#!/usr/bin/env ruby
# coding: utf-8
require 'em-websocket'
require 'json'
require 'csv'
require 'sqlite3'
PORT = 8814
WARNMAX = 3
# 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 = '+36 hours'
@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"
UNION
SELECT user, team, max(score) hs, max(step), count(score),
cast(round(sum(time)/60) as INT), max(at)
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
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}
broadcast = lambda{|hash|
printf("sending %s\n", hash)
connections.keys.each do |c|
c.send(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
connections[ws_conn] = {}
ws_conn.send(JSON.generate({myid: connections.length}))
STDERR.printf("OP: %d clients\n", connections.length)
end
ws_conn.onclose do
connections.delete(ws_conn)
STDERR.printf("CL: %d clients\n", connections.length)
end
ws_conn.onmessage do |msg|
begin
json = JSON.parse(msg)
rescue
return
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)
if skey then
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
k.send(JSON.generate({"disable" => json["text"]}))
end
userinfo["text"] = json["text"].to_i
si = {"scoreinfo" => db.userScoreInfo(userinfo["user"])}
ws_conn.send(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)
ws_conn.send(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"]
p rank = db.ranking(text, mode, user)
ws_conn.send(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"])
ws_conn.send(JSON.generate({"yourtext" => r}))
end
else ########## Before authenticated
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("tmpley" => "notregistered")
end
p token
ws_conn.send(j)
elsif json["skey"]
user, skey = json["user"], json["skey"]
if countUser.call(user) >= WARNMAX
msg = "いったい何回繋いだら気が済むの?\n"+
"限度をわきまえなさい。"
ws_conn.send(JSON.generate({"fail" => "overlimit",
"message" => msg}))
next
end
skey = db.authKey(user, json["skey"])
STDERR.printf("SKey=%s", skey)
userinfo["skey"] = skey # Cache skey
STDERR.printf("CACHE skey for user %s\n", user)
userinfo["user"] = user # Cache User
if skey
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)
p textDB.textfiles
ws_conn.send(j) # Login success by skey
else
ws_conn.send(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)
ws_conn.send(j) # Login success by token+tmpkey
else
j = JSON.generate({"user" => user, "fail" => "fail"})
ws_conn.send(j) # Login failure by token
end
else
ws_conn.send("No such user")
ws_conn.close # Force guest out
end
# broadcast.call({"message" => msg})
end
end
end