#!/usr/bin/env ruby # coding: utf-8 require 'em-websocket' require 'json' require 'csv' require 'sqlite3' PORT = (ENV["JTSV_PORT"]||8814).to_i WARNMAX = 3 LOCALCSS = ENV["JTSV_LOCALCSS"]||"local.css" TIMEOUT = (ENV["JTSV_TIMEOUT"]||60*5).to_i # Interchange of Information is done in JSON form. # (IN) {"cmd": Command, OtherArgs...} # (OUT) # # Command is one of: # team # text class UserDB def initialize() sq3 = ENV["JTSV_DB"]||"users.sq3" @db = SQLite3::Database.new(sq3) ### @db.results_as_hash = true @expireskey = '+5 days' @expiretmpkey = '+1 hour' @from = 'postmaster@koeki-prj.org' @rank = %w(必須 標準 目標 一流 超一流 頂点) File.chmod(0600, sq3) File.chmod(0700, File.dirname(sq3)) dbinit end def dbinit() # "at" field is like: YYYY-MM-DDThh:mm:ss at finish time @db.execute_batch(<<~EOF) -- DROP TABLE score; CREATE TABLE IF NOT EXISTS score( user, at, text, step INTEGER, score INTEGER, types INTEGER, miss INTEGER, time INTEGER, UNIQUE(user, at), FOREIGN KEY(user) REFERENCES users(user) ON DELETE CASCADE ON UPDATE CASCADE ); EOF end def email(user) r = @db.execute("SELECT email FROM users WHERE user = ?", user) if r[0] && /.+@.+/ =~ r[0][0] then r[0][0] else nil end end def sendmail(rcpt, subj, body) require 'nkf' rcpt = rcpt.join(" ") if rcpt.is_a?(Array) sj = NKF.nkf('-jM', subj) body = NKF.nkf('-j', body) open("| sendmail -f#{@from} #{rcpt}", "w") do |m| m.puts(<<~EOF) Subject: #{sj} From: jsTrr Admin <#{@from}> Mime-Version: 1.0 Content-type: text/plain; charset=iso-2022-jp EOF m.puts(body) end end def genToken(user, email) d1 = ("a".."z").to_a.sample d2 = ("a".."z").to_a.sample token = d1 + d2 + sprintf("%02d", rand(100)) sendmail(email, 'jsTrr: passcode', "Your(%s) passcode = %s\n" % [user, token]) token end def expire @db.execute("DELETE FROM skey WHERE expire < datetime('now','localtime')") end def genKey(n=50) rand(10**n).to_s(36) end def genSkey(user) # First, erase expired session keys expire sKey = genKey @db.execute("INSERT INTO skey VALUES(?, ?, datetime('now', 'localtime', '#{@expireskey}'));", user, sKey) sKey end def storeTmpKey(user, token) # First, erase expired session keys @db.execute("DELETE FROM tmpkey WHERE expire < datetime('now','localtime')") tmpkey = genKey @db.execute("INSERT INTO tmpkey VALUES(?, ?, ?, datetime('now', 'localtime', '#{@expiretmpkey}'));", user, tmpkey, token) tmpkey end def authTmpKey(user, tmpkey, token) r = @db.execute("SELECT user FROM tmpkey WHERE user=? AND tmpkey=? AND token=?", user, tmpkey, token) p token p r[0] if r && r[0] && r[0].length==1 # Auth OK! genSkey(user) end end def updateSkey(user, skey) @db.execute("REPLACE INTO skey VALUES(?, ?, datetime('now', 'localtime', '#{@expireskey}'));", user, skey) skey end def authKey(user, skey) expire r = @db.execute("SELECT user FROM skey WHERE user=? AND skey=?", user, skey) if r && r[0] && r[0].length==1 # Auth OK! updateSkey(user, skey) end end def userScoreInfo(user) rval = Hash.new @db.execute("SELECT text, max(score), max(step), count(user) FROM score WHERE user=? GROUP BY user, text;", user) do |row| rval[row[0]] = {"highscore" => row[1].to_i, "step" => row[2].to_i, "trial" => row[3].to_i} end if r=@db.execute("SELECT gecos FROM users WHERE user=?", user) rval["gecos"] = r[0][0] end steptrial = Hash.new{|k, v| k[v]=Hash.new} @db.execute("SELECT text, step, count(step) FROM score WHERE user=? GROUP BY user, text, step;", user) do |row| txt, step, trial = row steptrial[txt][step] = trial end rval["steptrial"] = steptrial p rval rval end ################### Trr STEP Calculation ################### def msgSecret(s) if s > 300 "こんな高い得点を出す方がどうして秘密にしておくの?" elsif s > 200 "業界標準を越えてるわ。秘密にする必要は全くないわよ。" elsif s > 120 "恥ずかしくない点だわ。秘密にするのはもうやめましょう。" else "公開するとちょっと恥ずかしい点だわ。しばらく秘密で続けましょう。" end end def msgBeginner(s) if s <= 0 "0点というのは問題だわ。これからかなりの努力が必要よ。道のりは長いけど頑張りましょう。" elsif s <40 "少なくとも英文字ぐらいは絶対覚えること。業界必須の100点に向けてこれから頑張りましょう。" elsif s < 80 "キー配置も大分覚えたようだけどまだまだだわ。業界必須の100点に向けてこれから頑張りましょう。" elsif s < 130 "基礎的な技術は身に付けているようだけどまだまだだわ。業界標準の200点に向けてこれから頑張りましょう。" elsif s < 180 "なかなかの実力ね。でもスピードと正確さが少し足りないわ。業界標準の200点に向けてもう少し頑張りましょう。" elsif s < 280 "なかなかやるわね。もう少し頑張れば業界目標の300点をきっと突破できるわ。" elsif s < 380 "すごいわね。初めてでこれぐらい出せれば十分だわ。でも業界一流の400点に向けてもう少し頑張りましょう。" elsif s < 480 "すっごい!こんな点を出す人は滅多にいないわよ。ひょっとしてプロではないかしら?" else "あまりにも超人的だわ。きっとギネスブックに載るわよ。" end end def msgForRecordBreaker(s) rank = (s/100).to_i sxx = s%100 STDERR.printf("s=%d, rank=%d, sxx=%d\n", s, rank, sxx) if s < 600 sprintf("業界%sの%d点%s。", @rank[rank], rank*100+100, sxx < 67 ? "目指して頑張って" : "までもうすぐよ") else "よくここまでやるわね。あなたの目標は一体何なの?" end end def msgSpecialForRecordBreaker(s) rank = (s/100).to_i sprintf("ついに業界%sの%d点突破ね!これからは業界%sの%d点を目指して頑張りましょう。", @rank[rank], rank*100+100, @rank[rank+1], rank*100+200) end def msgSuccess(user, step, highscore, score) diff = [highscore - score, 0].max trial = 0 if r=@db.execute("SELECT count(score) FROM score WHERE user=? AND step=? GROUP BY user, step", user, step) trial = r[0][0].to_i end STDERR.printf("Trial=%d\n", trial) if (score-step*10) > 100 "あなたには簡単すぎたようね。" else m = %w(軽く突破したわね。 わりと簡単に突破したわね。 ちょっと手こずったようね。 だいぶ手こずったようね。 よく頑張ったわね。 随分苦労したようね。 苦しみ抜いたわね。) m[[Math.log2(trial)-1, m.length-1].min] end end def msgFail(diff, highscore, score, missratio) if diff<10 "あとほんの少しだったのに...本当に惜しかったわね。" elsif diff<20 "惜しかったわね。" elsif diff<30 "その調子よ。" elsif diff<40 "もう一息だわ。でも息抜きはだめよ。。" elsif diff<50 "頑張ればきっとできるわ。" elsif diff < 60 "努力あるのみよ。" elsif diff < 100 || missratio > 30 if missratio > 60 "練習に練習を重ねなさい。" elsif missratio > 40 "ミスが多過ぎるわ。初心に帰って一つ一つ慎重に打つ練習をしなさい。" elsif missratio > 24 "ミスが多いわ。正確に打つ練習をしなさい。" elsif missratio > 8 "練習に練習を重ねなさい。" else "正確に打ってるようだけどスピードが遅すぎるわ。速く打つ練習に励みなさい。" end elsif diff < 110 "「trrの道は一日にしてならず」" elsif diff < 120 "「trrに王道なし」" elsif diff < 130 "あらまぁ。%d点を出した人がたったの%d点なんていったいどうしたのよ。" \ % [highscore, score] elsif diff < 140 "%d点はまぐれだったの?" % [highscore] else "あなたの実力ってこの程度だったのね。" end end def evalStep(user, h) # h == Hash from client # Return the Hash whose keys are "step", "message", "time" highscore = step = 0 update = nil # Break Hiscore pass = nil # Go to next step beginner = nil # Beginner flag secret = (/secret/i =~ h["trrmode"].to_s) time = h["finish"] - h["start"] types = h["types"].to_i miss = h["miss"].to_i text = h["text"] speed = (60*types/time).to_i score = [(60*(types-miss)/time).to_i, 0].max missratio = 1000*h["miss"].to_i/h["types"].to_i # 1/1000!!! r = @db.execute("SELECT max(score) ms,max(step) mst FROM score WHERE user=? AND text=? GROUP BY user, text", user, text) if r && r[0] && r[0][0] highscore, step = r[0] if score >= step*10 # Qualify current step step += 1; pass = true end update = (score > highscore) else step = score/10; beginner = true end if time <= 1 return {"step" => h["step"], "message" => "ごめんなさい。時間計るの忘れてたわ。"} end # p score # user, at, text, step, score, types, miss, time secret or @db.execute(<<~EOF, user, text, step, score, types, miss, time) REPLACE INTO score VALUES( ?, datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?); EOF diff = step*10 - score recbreaker = (score > highscore && highscore/100 != score/100) msg = if score > 750 "そんなことでいいの? 恥を知りなさい。" elsif secret msgSecret(score) elsif beginner msgBeginner(score) elsif pass # STEP clear! sprintf("ステップ%d突破%sおめでとう!\n%s", step, update ? "そして記録更新" : "", recbreaker ? msgSpecialForRecordBreaker(highscore) : msgForRecordBreaker(highscore) ) elsif update "記録更新おめでとう!\n"+msgSuccess(user, step, highscore, score) else msgFail(diff, highscore, score, missratio) end rv = {"score" => score, "step" => step, "time" => time.round(1), "speed" => speed, "message" => msg, "types"=>types, "highscore" => [highscore, score].max} if false # Peek trial count of this level r = @db.execute("SELECT count(*) FROM score WHERE user=? AND text=? AND step=? GROUP BY user, text, step;", user, text, step) rv["strial"] = r[0][0].to_i r = @db.execute("SELECT count(*) FROM score WHERE user=? AND text=? GROUP by user, text", user, text) rv["trial"] = r[0][0].to_i else rv["scoreinfo"] = userScoreInfo(user) end return rv end def ranking(text, mode=nil, user=nil) rankbase = <<~EOF SELECT "User", "Team", "Score", "Step", "Try", "Minutes", "Time", "Gecos" UNION SELECT user, team, max(score) hs, max(step), count(score), cast(round(sum(time)/60) as INT), max(at), gecos FROM score NATURAL LEFT JOIN users WHERE text=? %s GROUP BY user ORDER BY hs DESC; EOF teambase = <<~EOF WITH hs AS ( SELECT user, gecos, team, max(score) hs FROM score NATURAL LEFT JOIN users WHERE text=? %s GROUP BY USER ) SELECT team, round(cast(sum(hs) as real)/count(team), 1) avg, count(team) n FROM hs GROUP BY team ORDER BY avg DESC; EOF limit = if mode && /(\d+)([hdw])/ =~ mode n = $1.to_i t = 3600*[1, 24, 24*7]["hdw".index($2.downcase)] (Time.now-t*n).strftime("%F %T") end p limit case mode when "today", /^(\d+)[hdw]/i limit = Time.now.strftime("%F") if mode == "today" STDERR.printf("limit=%s\n", limit) @db.execute(sprintf(rankbase, "AND at > ?"), text, limit) when /^byteam/ [["Team", "Average", "n"]] + if limit @db.execute(sprintf(teambase, "AND at > ?"), text, limit) else @db.execute(sprintf(teambase, ""), text) end when /^inteam/ return unless user cond2 = " AND team=(SELECT team FROM users WHERE user=?)" if limit @db.execute(sprintf(rankbase, cond2 + " AND at > ?"), text, user, limit) else @db.execute(sprintf(rankbase, cond2), text, user) end else @db.execute(sprintf(rankbase, ""), text) end end end class Text def initialize() @textlist = {} IO.readlines("TEXT").each do |tx| file, title = tx.split(",") @textlist[file] = {"title" => title, "text" => IO.readlines(file)} end end def getFilledText(file, lines, fill) text = @textlist[file]["text"] array = [] return nil unless text buffer = 5; n=text.length-lines-buffer text = text[rand(n), lines+buffer] if /^[0-9A-Z]/ !~ text[0] text[0].sub!(/.*?\. */, "") end subtxt = text.join(" ") thisline = "" subtxt.gsub(/\s+/, " ").split(" ").each do |w| if thisline.length + w.length < fill w += " " if /\.$/ =~ w thisline += (w + " ") else array << thisline.strip thisline = "" break if array.length >= lines end end array.join("\n")+"\n" end def textfiles @textlist.collect{|file,v| {"file" => file, "title" => v["title"]}} end end db = UserDB.new textDB = Text.new connections = {} team = Hash.new{|h,k| h[k]=Hash.new} socksend = lambda{|sock, msg| begin sock.send(msg) rescue begin connections.close # Try to close rescue end connections.delete(sock) end } broadcast = lambda{|hash| printf("sending %s\n", hash) connections.keys.each do |c| socksend.call(c, JSON.generate(hash)) end } countUser = lambda{|user| connections.values.select{|c| c["user"] == user}.length } EM::WebSocket.start({:host => "0.0.0.0", :port => PORT}) do |ws_conn| Thread.new { while cmd = gets case cmd.chop when 's' broadcast.call({start: Time.now.to_f}) end end } ws_conn.onopen do # First, clean up idling connections connections.each do |conn, info| STDERR.printf("OPENat: %s\n", info.inspect) next if !info || !info["openat"] # For avoidance of exception.. if TIMEOUT < Time.now - (info["stamp"]||info["openat"]) STDERR.printf("Force Close client %s after long idle\n", conn) conn.close end end # Start to accept a new client connections[ws_conn] = {"openat" => Time.now} ws_conn.send(JSON.generate({myid: connections.length})) if test(?e, File.expand_path("..", LOCALCSS)) ws_conn.send(JSON.generate({css: LOCALCSS})) end STDERR.printf("OP: %d clients [%s]\n", connections.length, connections.values.collect{|c| c["user"]}.join(", ")) end ws_conn.onclose do connections.delete(ws_conn) STDERR.printf("CL: %d clients\n", connections.length) end ## ## Main Loop ## ws_conn.onmessage do |msg| begin json = JSON.parse(msg) rescue next ## Skip client that sends invalid JSON end userinfo = connections[ws_conn] user, skey = userinfo['user'], userinfo['skey'] STDERR.printf("RECV%s: [%s]\n", user ? "("+user+")" : "", json.reject{|k,v|k=="Passcode"}.to_json) userinfo["stamp"] = Time.now # Remember time stamp if skey then userinfo["trial"] = 0 # Reset auth count if json["team"] then #team[json["team"]][json["name"]] ||= nil userinfo = {"team" => json["team"], "name" => json["name"]} elsif json["settext"] # {"松" => {"name1"=>1, "name2" => 2}} tm = userinfo["team"] nm = userinfo["name"] team[tm][nm] = json["text"] connections.each do |k, v| p v["team"] next unless tm == v["team"] # Skip other team next if ws_conn==k # Skip myself begin k.send(JSON.generate({"disable" => json["text"]})) rescue begin k.close rescue end connections.delete(k) end end userinfo["text"] = json["text"].to_i si = {"scoreinfo" => db.userScoreInfo(userinfo["user"])} socksend.call(ws_conn, JSON.generate(si)) elsif json["finish"] user = userinfo["user"] if user && user > "" if false ## start = # json["start"].to_i start = userinfo["start"] ## finish = json["finish"].to_i finish = Time.now.to_f types = json["types"].to_i STDERR.printf("types=%d", types) miss = json["miss"].to_i text = json["text"] time = finish-start score = 60*(types - 10*miss)/time end json["start"] = userinfo["start"] json["finish"] = Time.now.to_f STDERR.printf("Start=%.2f, Finish=%.2f\n", json["start"], json["finish"]) eval = db.evalStep(user, json) socksend.call(ws_conn, JSON.generate(eval)) userinfo.delete("typelist") end elsif json["types"] unless userinfo["typelist"] p "START!" userinfo["start"] = Time.now.to_f userinfo["typelist"] = Hash.new end userinfo["typelist"][json["types"]] = Time.now.to_f elsif json["ranking"] text = json["ranking"] mode = json["mode"] rank = db.ranking(text, mode, user) socksend.call(ws_conn, JSON.generate({"ranking": rank, "mode": mode})) elsif json["gettext"] db.updateSkey(user, skey) userinfo.delete("typelist") # Reset running typing information r = textDB.getFilledText(json["gettext"], json["lines"], json["fill"]) socksend.call(ws_conn, JSON.generate({"yourtext" => r})) end else ########## Before authenticated userinfo["trial"] ||= 0 p userinfo["trial"] if json["Login"] user = json["Login"] email = db.email(user) if email token = db.genToken(user, email) tmpkey = db.storeTmpKey(user, token) j = JSON.generate({"email" => email, "tmpkey" => tmpkey, "user" => user}) else j = JSON.generate("tmpkey" => "notregistered") end p token socksend.call(ws_conn, j) elsif json["skey"] user, skey = json["user"], json["skey"] if countUser.call(user) >= WARNMAX msg = "いったい何回繋いだら気が済むの?\n"+ "限度をわきまえなさい。" socksend.call(ws_conn, JSON.generate({"fail" => "overlimit", "message" => msg})) next end skey = db.authKey(user, json["skey"]) STDERR.printf("SKey=%s", skey) if skey userinfo["skey"] = skey # Cache skey STDERR.printf("CACHE skey for user %s\n", user) userinfo["user"] = user # Cache User si = db.userScoreInfo(user) STDERR.printf("scoreinfo = %s\n", si) r = {"user"=>user, "skey"=>skey, "scoreinfo"=>si} r["textfiles"] = textDB.textfiles j = JSON.generate(r) socksend.call(ws_conn, j) # Login success by skey else ## Need too frequent trial barrier here? socksend.call(ws_conn, JSON.generate({"fail" => "nokey"})) end elsif json["Passcode"] token = json["Passcode"] tmpkey = json["tmpkey"] user = json["user"] authkey = db.authTmpKey(user, tmpkey, token) STDERR.printf("authkey=%s", authkey) if authkey userinfo["skey"] = authkey # Cache skey STDERR.printf("Cache skey for user %s\n", user) userinfo["user"] = user # Cache User si = db.userScoreInfo(user) r = {"user"=>user, "skey"=>authkey, "scoreinfo"=>si} r["textfiles"] = textDB.textfiles j = JSON.generate(r) socksend.call(ws_conn, j) # Login success by token+tmpkey else case userinfo["trial"]+=1 when 0..3 when 4..8 sleep 3 else socksend.call(ws_conn, JSON.generate({"user"=>user, "fail"=>"byebye"})) ws_conn.close next end j = JSON.generate({"user" => user, "fail" => "fail"}) socksend.call(ws_conn, j) # Login failure by token end else socksend(ws_conn, "No such user") ws_conn.close # Force guest out end # broadcast.call({"message" => msg}) end end end