diff options
-rw-r--r-- | designs.js | 3 | ||||
-rw-r--r-- | public/common/client.css | 19 | ||||
-rw-r--r-- | public/common/client.js | 28 | ||||
-rw-r--r-- | public/common/replay.js | 33 | ||||
-rw-r--r-- | public/common/util.js | 2 | ||||
-rw-r--r-- | public/docs/tournaments.html | 2 | ||||
-rw-r--r-- | public/style.css | 2 | ||||
-rw-r--r-- | schema.sql | 275 | ||||
-rw-r--r-- | server.js | 464 | ||||
-rw-r--r-- | tools/elo.js | 2 | ||||
-rwxr-xr-x | tools/import-game.js | 4 | ||||
-rw-r--r-- | tools/lift-bans.sh | 30 | ||||
-rwxr-xr-x | tools/patchgame.js | 66 | ||||
-rw-r--r-- | tools/purge.sql | 6 | ||||
-rw-r--r-- | views/create-index.pug | 2 | ||||
-rw-r--r-- | views/create.pug | 25 | ||||
-rw-r--r-- | views/games_finished.pug | 5 | ||||
-rw-r--r-- | views/games_public.pug | 2 | ||||
-rw-r--r-- | views/head.pug | 11 | ||||
-rw-r--r-- | views/profile.pug | 4 | ||||
-rw-r--r-- | views/tm_active.pug | 4 | ||||
-rw-r--r-- | views/tm_finished.pug | 5 | ||||
-rw-r--r-- | views/tm_list.pug | 4 | ||||
-rw-r--r-- | views/tm_pool.pug | 30 | ||||
-rw-r--r-- | views/tm_seed.pug | 4 | ||||
-rw-r--r-- | views/user.pug | 32 |
26 files changed, 773 insertions, 291 deletions
@@ -197,6 +197,7 @@ designs.resolvable_bibd = function (v, k) { switch (k) { case 3: switch (v) { + case 6: return designs.social_golfer_6_3_1 case 9: return designs.resolvable_bibd_9_3_1 case 12: return designs.social_golfer_12_3_1 case 15: return designs.resolvable_bibd_15_3_1 @@ -233,6 +234,7 @@ designs.youden_square = function (v, k) { case 3: return designs.youden_square_3_3_3 case 4: return designs.youden_square_4_3_2 case 7: return designs.youden_square_7_3_1 + case 13: return designs.bibd_13_3_1 // sit twice } break case 4: @@ -240,6 +242,7 @@ designs.youden_square = function (v, k) { case 4: return designs.youden_square_4_4_4 case 5: return designs.youden_square_5_4_3 case 7: return designs.youden_square_7_4_2 + case 9: return designs.bibd_9_4_3 // sit twice case 13: return designs.youden_square_13_4_1 } break diff --git a/public/common/client.css b/public/common/client.css index 53b6a38..f328d12 100644 --- a/public/common/client.css +++ b/public/common/client.css @@ -12,7 +12,7 @@ html { -webkit-tap-highlight-color: transparent; /* disable blue flashes when tapping on chrome mobile */ } -summary::-webkit-details-marker { +header summary::-webkit-details-marker { display: none; } @@ -24,7 +24,7 @@ html { user-select: text; } -summary img, button img, menu img { +header summary img, button img, menu img { pointer-events: none; } @@ -270,12 +270,12 @@ header.replay { /* MENUS AND ICONS */ -details menu { +header details menu { display: block; min-width: 140px; } -summary img, #toolbar button img { +header summary img, #toolbar button img { display: block; height: 36px; padding: 4px; @@ -292,13 +292,13 @@ summary img, #toolbar button img { margin: 0; } -details[open] > summary { background-color: #0004; } +header details[open] > summary { background-color: #0004; } @media (hover: hover) { - summary:hover, #toolbar button:hover { background-color: #0004; } + header summary:hover, #toolbar button:hover { background-color: #0004; } } -summary:active, #toolbar button:active { background-color: #0008; } +header summary:active, #toolbar button:active { background-color: #0008; } -summary { +header summary { cursor: pointer; list-style: none; } @@ -323,6 +323,9 @@ menu li { cursor: pointer; } +menu li.checked::before { content: "\2714 " } +menu li.unchecked::before { content: "\2714 "; color:transparent; } + menu li a { display: block; margin: -4px -8px; diff --git a/public/common/client.js b/public/common/client.js index 393eb0a..f8a5e23 100644 --- a/public/common/client.js +++ b/public/common/client.js @@ -361,9 +361,9 @@ function toggle_notepad() { show_notepad() } -/* REMATCH & REPLAY BUTTONS WHEN GAME OVER */ +/* REMATCH & REPLAY BUTTONS WHEN GAME IS FINISHED */ -function on_game_over() { +function on_finished() { remove_resign_menu() add_icon_button(1, "replay_button", "sherlock-holmes-mirror", @@ -547,8 +547,10 @@ function connect_play() { if (typeof on_update === "function") on_update() on_update_log(view.log_start, game_log.length) - if (view.game_over) - on_game_over() + break + + case "finished": + on_finished() break case "snapsize": @@ -569,10 +571,6 @@ function connect_play() { if (typeof on_reply === "function") on_reply(arg[0], arg[1]) break - - case "save": - window.localStorage[params.title_id + "/save"] = arg - break } } } @@ -580,7 +578,6 @@ function connect_play() { /* HEADER */ let is_your_turn = false -let old_active = null function on_update_header() { if (typeof on_prompt === "function") @@ -600,14 +597,15 @@ function on_update_header() { document.querySelector("header").classList.remove("your_turn") is_your_turn = false } - old_active = view.active update_title() } function on_update_roles() { if (view.active !== undefined) for (let role in roles) - roles[role].element.classList.toggle("active", view.active === role) + roles[role].element.classList.toggle("active", + view.active === role || view.active === "Both" || view.active.includes(role) + ) } /* LOG */ @@ -743,14 +741,6 @@ function send_query(q, param) { send_message("query", [ q, param ]) } -function send_save() { - send_message("save") -} - -function send_restore() { - send_message("restore", window.localStorage[params.title_id + "/save"]) -} - /* REPLAY */ function init_replay() { diff --git a/public/common/replay.js b/public/common/replay.js index b6c5e84..950b244 100644 --- a/public/common/replay.js +++ b/public/common/replay.js @@ -92,6 +92,20 @@ function snap_from_state(state) { return snap } +function finish_game_state(state, result, message) { + if (typeof rules.finish === "function") { + state = rules.finish(state, result, message) + } else { + state.state = "game_over" + state.active = "None" + state.result = result + state.victory = message + state.log.push("") + state.log.push(message) + } + return state +} + function eval_action(s, item, p) { let [ item_role, item_action, item_arguments ] = item switch (item_action) { @@ -101,11 +115,15 @@ function eval_action(s, item, p) { if (params.mode === "debug") s.log.push([p, item_role.substring(0,2), item_action, null]) - s.state = "game_over" - s.active = "None" - s.victory = item_role + " resigned." - s.log.push("") - s.log.push(s.victory) + let result = "None" + if (roles.length === 2) { + for (let r of roles) + if (r !== item_role) + result = r + } + + s = finish_game_state(s, result, role + " resigned.") + return s default: if (params.mode === "debug") @@ -151,11 +169,6 @@ function update_replay_view() { if (params.mode !== "debug") view.actions = null - if (viewpoint === "Observer") - view.game_over = 1 - if (replay_state.state === "game_over") - view.game_over = 1 - if (replay.length > 0) { if (document.body.classList.contains("shift")) { view.prompt = `[${replay_this}/${replay.length}] ${replay_state.active} / ${replay_state.state}` diff --git a/public/common/util.js b/public/common/util.js index 7f5459e..b8f1b6c 100644 --- a/public/common/util.js +++ b/public/common/util.js @@ -1,4 +1,4 @@ -// === COMMON LIBRARY === +/* COMMON LIBRARY */ function clear_undo() { if (game.undo) { diff --git a/public/docs/tournaments.html b/public/docs/tournaments.html index 6122194..e8f08f8 100644 --- a/public/docs/tournaments.html +++ b/public/docs/tournaments.html @@ -52,7 +52,7 @@ score is used to break ties. <p> Some tournaments may have multiple levels. If you win a tournament at one -level, you will automatically be queued for the next level up. +level, you may play in the next level (once for each victory). <hr> diff --git a/public/style.css b/public/style.css index 2707345..486b309 100644 --- a/public/style.css +++ b/public/style.css @@ -87,7 +87,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: currentcolor; } -input[type="text"], input[type="password"], input[type="email"], textarea, select[size] { +input[type="text"], input[type="password"], input[type="email"], input[type="number"], textarea, select[size] { background-color: var(--color-text); color: var(--color-black); border: var(--thin-border); @@ -38,24 +38,53 @@ create table if not exists users ( mail text unique collate nocase, notify integer default 0, is_verified boolean default 0, - is_banned boolean default 0, - ctime datetime default current_timestamp, - password text, - salt text, - about text + is_banned boolean default 0 ); insert or ignore into - users (user_id, name, mail, ctime) - values (0, 'Deleted', 'deleted@nowhere', null) + users (user_id, name, mail) + values (0, 'Deleted', 'deleted@nowhere') ; +create table if not exists user_password ( + user_id integer primary key, + password text, + salt text +); + +create table if not exists user_about ( + user_id integer primary key, + about text +); + +create table if not exists user_first_seen ( + user_id integer primary key, + ctime datetime, + ip text +); + create table if not exists user_last_seen ( user_id integer primary key, atime datetime, ip text ); +create table if not exists user_timeout ( + user_id integer, + game_id integer, + time datetime default current_timestamp, + primary key (user_id, game_id) +); + +create index if not exists user_timeout_idx on user_timeout(user_id, time); + +create table if not exists user_move_hist ( + user_id integer, + minutes integer, + frequency integer default 1, + primary key (user_id, minutes) +) without rowid; + create table if not exists tokens ( user_id integer primary key, token text, @@ -84,15 +113,101 @@ create view user_login_view as user_id, name, mail, notify, password, salt from users + left join user_password using(user_id) + ; + +drop view if exists user_move_iqr; +create view user_move_iqr as + with + aa as ( + select + user_id, + sum(frequency) as total + from user_move_hist + group by user_id + ), + bb as ( + select + user_id, + minutes, + 4 * sum(frequency) over (partition by user_id order by minutes) / (total+1) as quartile + from aa join user_move_hist using(user_id) + ), + cc as ( + select + user_id, + quartile, + last_value(minutes) over (partition by user_id order by quartile) as minutes + from bb + where quartile < 3 + group by user_id, quartile + ) + select + user_id, + sum(minutes) filter (where quartile = 0) as q1, + sum(minutes) filter (where quartile = 1) as q2, + sum(minutes) filter (where quartile = 2) as q3 + from cc + group by user_id ; drop view if exists user_profile_view; create view user_profile_view as - select - user_id, name, mail, notify, ctime, atime, about, is_banned - from - users - left join user_last_seen using(user_id) + with + timeout as ( + select + user_id, + count(1) as timeout_total, + max(time) as timeout_last + from + user_timeout + group by + user_id + ), + user_move_mean as ( + select + user_id, + sum(minutes * frequency) / sum(frequency) as move_time_mean + from + user_move_hist + group by + user_id + ), + profile as ( + select + user_id, name, mail, notify, ctime, atime, about, is_banned, + move_time_mean, + coalesce(q1, q2, q3) as move_time_q1, + coalesce(q2, q3) as move_time_q2, + q3 as move_time_q3, + coalesce(timeout_total, 0) as timeout_total, + coalesce(timeout_last, 0) as timeout_last + from + users + left join user_first_seen using(user_id) + left join user_last_seen using(user_id) + left join user_about using(user_id) + left join timeout using(user_id) + left join user_move_mean using(user_id) + left join user_move_iqr using(user_id) + ) + select + profile.*, + ( + select + count(1) + from + players + join games using(game_id) + where + players.user_id = profile.user_id + and games.is_opposed + and games.status > 1 + and games.result != 'None' + and games.mtime > timeout_last + ) as games_since_timeout + from + profile ; drop view if exists user_dynamic_view; @@ -119,16 +234,17 @@ create view user_dynamic_view as join games using(game_id) where status = 1 - and user_count = player_count + and is_opposed + and is_active and players.user_id = users.user_id - and active in ( players.role, 'Both' ) ) + ( select count(*) from players where - players.user_id = users.user_id and players.is_invite + is_invite + and players.user_id = users.user_id ) + ( select count(*) @@ -157,6 +273,7 @@ create view rated_games_view as and moves >= player_count * 3 and user_count = player_count and player_count > 1 + and result != 'None' and not exists ( select 1 from players where players.game_id = games.game_id and user_id = 0 ) @@ -427,6 +544,8 @@ create table if not exists players ( role text, user_id integer, is_invite integer, + is_active boolean, + active_time real, -- julianday clock real, score integer, primary key (game_id, role) @@ -471,19 +590,11 @@ create view player_view as name, role, is_invite, + is_active, ( - case status - when 0 then - owner_id = user_id - when 1 then - active in ( 'Both', role ) - else - 0 - end - ) as is_active, - ( - case when active in ( 'Both', role ) then - clock - (julianday() - julianday(mtime)) + case when is_active + then + clock - (julianday() - julianday(active_time)) else clock end @@ -509,8 +620,8 @@ create view time_control_view as join players using(game_id) where status = 1 - and active in ( 'Both', role ) - and clock - (julianday() - julianday(mtime)) < 0 + and is_active + and clock - (julianday() - julianday(players.active_time)) < 0 ; -- Export game state as JSON @@ -590,12 +701,6 @@ create table if not exists tm_banned ( time datetime default current_timestamp ); -create table if not exists tm_timeout ( - user_id integer, - game_id integer, - time datetime default current_timestamp -); - create table if not exists tm_queue ( user_id integer, seed_id integer, @@ -704,6 +809,7 @@ begin set score = ( case when new.result is null then null + when new.result = 'None' then null when new.result = role then 2 when new.result = 'Draw' then 1 when instr(new.result, role) then 1 @@ -776,7 +882,7 @@ begin with tt as ( select - round_count as threshold + (2 * round_count) / player_count as threshold from tm_seeds where @@ -851,15 +957,91 @@ begin games.game_id = old.game_id; end; --- Trigger to track time spent! +-- Triggers to track is_active and time spent! -drop trigger if exists trigger_time_used_update; -create trigger trigger_time_used_update before update of active on games +drop trigger if exists trigger_game_started; +create trigger trigger_game_started after update of status on games + when old.status = 0 and new.status = 1 begin - update players - set clock = clock - (julianday() - julianday(old.mtime)) + update + players + set + clock = ( + case old.pace + when 1 then 1 + when 2 then 3 + when 3 then 3 + else 21 + end + ) where - players.game_id = old.game_id and players.role in ( 'Both', old.active ); + players.game_id = old.game_id + ; +end; + +drop trigger if exists trigger_active_changed; +create trigger trigger_active_changed after update of active on games +begin + update + players + set + is_active = ( new.active = 'Both' or instr(new.active, players.role) ) + where + players.game_id = old.game_id + ; +end; + +drop trigger if exists trigger_player_to_active; +create trigger trigger_player_to_active after update of is_active on players + when old.is_active is not true and new.is_active +begin + update + players + set + active_time = julianday() + where + players.game_id = old.game_id and players.role = old.role + ; +end; + +drop trigger if exists trigger_player_to_inactive; +create trigger trigger_player_to_inactive after update of is_active on players + when old.is_active and (not new.is_active) +begin + update + players + set + active_time = null, + clock = ( + case (select pace from games where games.game_id = players.game_id) + when 1 then min(clock - (julianday() - julianday(old.active_time)) + 4 / 24.0, 3) + when 2 then min(clock - (julianday() - julianday(old.active_time)) + 12 / 24.0, 5) + when 3 then min(clock - (julianday() - julianday(old.active_time)) + 36 / 24.0, 10) + else 21 + end + ) + where + players.game_id = old.game_id and players.role = old.role + ; + insert into user_move_hist (user_id, minutes) + select + old.user_id, + case + when minutes < 60 then ceil(minutes) + when minutes < 720 then round(minutes / 5) * 5 + when minutes < 4320 then round(minutes / 15) * 15 + when minutes < 7200 then round(minutes / 60) * 60 + else round(minutes / 360) * 360 + end as minutes + from ( + select (julianday() - julianday(old.active_time)) * 1440 as minutes + ) + where ( + select is_opposed from games where games.game_id = old.game_id + ) + on conflict do update + set frequency = frequency + 1 + ; end; -- Trigger to remove game data when filing a game as archived @@ -892,10 +1074,15 @@ end; drop trigger if exists trigger_delete_on_users; create trigger trigger_delete_on_users after delete on users begin + delete from user_password where user_id = old.user_id; + delete from user_first_seen where user_id = old.user_id; + delete from user_last_seen where user_id = old.user_id; + delete from user_about where user_id = old.user_id; + delete from user_move_hist where user_id = old.user_id; + delete from user_timeout where user_id = old.user_id; + delete from webhooks where user_id = old.user_id; delete from logins where user_id = old.user_id; delete from tokens where user_id = old.user_id; - delete from webhooks where user_id = old.user_id; - delete from user_last_seen where user_id = old.user_id; delete from read_threads where user_id = old.user_id; delete from unread_chats where user_id = old.user_id; delete from contacts where me = old.user_id or you = old.user_id; @@ -66,7 +66,13 @@ var game_cookies = {} let db = new sqlite3(process.env.DATABASE || "./db") db.pragma("synchronous = NORMAL") -const SQL_BEGIN = db.prepare("begin") +let ENABLE_ARCHIVE = process.env.ARCHIVE | 0 +if (ENABLE_ARCHIVE) { + console.log("Attached to archive database.") + db.exec("attach database 'archive.db' as archive") +} + +const SQL_BEGIN = db.prepare("begin immediate") const SQL_COMMIT = db.prepare("commit") const SQL_ROLLBACK = db.prepare("rollback") @@ -200,6 +206,7 @@ app.locals.ENABLE_MAIL = !!mailer app.locals.ENABLE_WEBHOOKS = !!WEBHOOKS app.locals.ENABLE_FORUM = process.env.FORUM | 0 app.locals.ENABLE_TOURNAMENTS = process.env.TOURNAMENTS | 0 +app.locals.ENABLE_ARCHIVE = ENABLE_ARCHIVE app.locals.EMOJI_PRIVATE = "\u{1F512}" // or 512 app.locals.EMOJI_MATCH = "\u{1f3c6}" @@ -225,6 +232,9 @@ app.locals.PACE_TEXT = [ app.locals.human_date = human_date app.locals.format_options = format_options +app.locals.format_minutes = format_minutes + +app.locals.may_join_seed_level = may_join_seed_level app.set("x-powered-by", false) app.set("etag", false) @@ -313,6 +323,15 @@ function human_date(date) { return new Date(epoch_from_julianday(date)).toISOString().substring(0,10) } +function format_minutes(mins) { + if (mins > 59) { + var hh = mins / 60 | 0 + var mm = mins % 60 + return `${hh} hours ${mm} minutes` + } + return mins + " minutes" +} + function is_valid_email(email) { return REGEX_MAIL.test(email) } @@ -407,7 +426,7 @@ function must_pass_altcha(req, res, next) { return next() } -app.get("/altcha-challenge", async (req, res) => { +app.get("/altcha-challenge", function (_req, res) { return res.json(altcha_create_challenge()) }) @@ -421,14 +440,15 @@ const SQL_BLACKLIST_NAME = SQL("select exists ( select 1 from blacklist_name whe const SQL_EXISTS_USER_NAME = SQL("SELECT EXISTS ( SELECT 1 FROM users WHERE name=? )").pluck() const SQL_EXISTS_USER_MAIL = SQL("SELECT EXISTS ( SELECT 1 FROM users WHERE mail=? )").pluck() -const SQL_INSERT_USER = SQL("INSERT INTO users (name,mail,password,salt) VALUES (?,?,?,?) RETURNING user_id,name,mail") +const SQL_INSERT_USER = SQL("INSERT INTO users (name,mail) VALUES (?,?) RETURNING user_id,name,mail") const SQL_DELETE_USER = SQL("DELETE FROM users WHERE user_id = ?") const SQL_SELECT_LOGIN = SQL("SELECT * FROM user_login_view WHERE user_id=?") -const SQL_SELECT_USER_VIEW = SQL("SELECT * FROM user_view WHERE user_id=?") -const SQL_SELECT_USER_BY_NAME = SQL("SELECT * FROM user_view WHERE name=?") const SQL_SELECT_LOGIN_BY_MAIL = SQL("SELECT * FROM user_login_view WHERE mail=?") const SQL_SELECT_LOGIN_BY_NAME = SQL("SELECT * FROM user_login_view WHERE name=?") + +const SQL_SELECT_USER_VIEW = SQL("SELECT * FROM user_view WHERE user_id=?") +const SQL_SELECT_USER_BY_NAME = SQL("SELECT * FROM user_view WHERE name=?") const SQL_SELECT_USER_PROFILE = SQL("SELECT * FROM user_profile_view WHERE name=?") const SQL_SELECT_USER_DYNAMIC = SQL("select * from user_dynamic_view where user_id=?") const SQL_SELECT_USER_ID = SQL("SELECT user_id FROM users WHERE name=?").pluck() @@ -440,9 +460,11 @@ const SQL_UPDATE_USER_NOTIFY = SQL("UPDATE users SET notify=? WHERE user_id=?") const SQL_UPDATE_USER_NAME = SQL("UPDATE users SET name=? WHERE user_id=?") const SQL_UPDATE_USER_MAIL = SQL("UPDATE users SET mail=? WHERE user_id=?") const SQL_UPDATE_USER_VERIFIED = SQL("UPDATE users SET is_verified=? WHERE user_id=?") -const SQL_UPDATE_USER_ABOUT = SQL("UPDATE users SET about=? WHERE user_id=?") -const SQL_UPDATE_USER_PASSWORD = SQL("UPDATE users SET password=?, salt=? WHERE user_id=?") -const SQL_UPDATE_USER_LAST_SEEN = SQL("INSERT OR REPLACE INTO user_last_seen (user_id,atime,ip) VALUES (?,datetime(),?)") + +const SQL_UPDATE_USER_ABOUT = SQL("insert or replace into user_about (user_id,about) values (?,?)") +const SQL_UPDATE_USER_PASSWORD = SQL("insert or replace into user_password (user_id,password,salt) values (?,?,?)") +const SQL_UPDATE_USER_FIRST_SEEN = SQL("insert or replace into user_first_seen (user_id,ctime,ip) values (?,datetime(),?)") +const SQL_UPDATE_USER_LAST_SEEN = SQL("insert or replace into user_last_seen (user_id,atime,ip) values (?,datetime(),?)") const SQL_UPDATE_USER_IS_BANNED = SQL("update users set is_banned=? where name=?") const SQL_SELECT_WEBHOOK = SQL("SELECT * FROM webhooks WHERE user_id=?") @@ -543,6 +565,7 @@ app.post("/signup", must_pass_altcha, function (req, res) { function err(msg) { res.render("signup.pug", { flash: msg }) } + let ip = req.headers["x-real-ip"] || req.ip || req.connection.remoteAddress || "0.0.0.0" let name = req.body.username let mail = req.body.mail let password = req.body.password @@ -561,7 +584,9 @@ app.post("/signup", must_pass_altcha, function (req, res) { return err("Password is too long!") let salt = crypto.randomBytes(32).toString("hex") let hash = hash_password(password, salt) - let user = SQL_INSERT_USER.get(name, mail, hash, salt) + let user = SQL_INSERT_USER.get(name, mail) + SQL_UPDATE_USER_FIRST_SEEN.run(user.user_id, ip) + SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt) login_insert(res, user.user_id) res.redirect("/profile") }) @@ -653,7 +678,7 @@ app.post("/reset-password", function (req, res) { return err("Invalid or expired token!") let salt = crypto.randomBytes(32).toString("hex") let hash = hash_password(password, salt) - SQL_UPDATE_USER_PASSWORD.run(hash, salt, user.user_id) + SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt) SQL_UPDATE_USER_VERIFIED.run(1, user.user_id) login_insert(res, user.user_id) return res.redirect("/profile") @@ -677,7 +702,7 @@ app.post("/change-password", must_be_logged_in, function (req, res) { return res.render("change_password.pug", { user: req.user, flash: "Wrong password!" }) let salt = crypto.randomBytes(32).toString("hex") let hash = hash_password(newpass, salt) - SQL_UPDATE_USER_PASSWORD.run(hash, salt, user.user_id) + SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt) return res.redirect("/profile") }) @@ -805,7 +830,7 @@ app.get("/change-about", must_be_logged_in, function (req, res) { }) app.post("/change-about", must_be_logged_in, function (req, res) { - SQL_UPDATE_USER_ABOUT.run(req.body.about, req.user.user_id) + SQL_UPDATE_USER_ABOUT.run(req.user.user_id, req.body.about) return res.redirect("/profile") }) @@ -813,13 +838,22 @@ app.get("/user/:who_name", function (req, res) { let who = SQL_SELECT_USER_PROFILE.get(req.params.who_name) if (who) { let games = QUERY_LIST_PUBLIC_GAMES_OF_USER.all({ user_id: who.user_id }) + let ratings = SQL_USER_RATINGS.all(who.user_id) annotate_games(games, 0, null) let active_pools = TM_POOL_LIST_USER_ACTIVE.all(who.user_id) let finished_pools = TM_POOL_LIST_USER_RECENT_FINISHED.all(who.user_id) let relation = 0 if (req.user) relation = SQL_SELECT_RELATION.get(req.user.user_id, who.user_id) | 0 - res.render("user.pug", { user: req.user, who, relation, games, active_pools, finished_pools }) + res.render("user.pug", { + user: req.user, + who, + relation, + games, + active_pools, + finished_pools, + ratings, + }) } else { return res.status(404).send("User not found.") } @@ -1270,10 +1304,11 @@ function format_options(options_json) { function get_game_roles(title_id, scenario, options) { let roles = RULES[title_id].roles - if (typeof options === "string") - options = parse_game_options(options) - if (typeof roles === "function") + if (typeof roles === "function") { + if (typeof options === "string") + options = parse_game_options(options) return roles(scenario, options) + } return roles } @@ -1379,6 +1414,8 @@ const SQL_SELECT_REWIND = SQL("select snap_id, state->>'$.active' as active, sta const SQL_UPDATE_GAME_ACTIVE = SQL("update games set active=?,mtime=datetime(),moves=moves+1 where game_id=?") const SQL_UPDATE_GAME_SCENARIO = SQL("update games set scenario=? where game_id=?") +const ARCHIVE_SELECT_GAME_STATE = ENABLE_ARCHIVE ? SQL("select state from archive.game_state where game_id=?").pluck() : null + const SQL_SELECT_GAME_STATE = SQL("select state from game_state where game_id=?").pluck() const SQL_INSERT_GAME_STATE = SQL("insert or replace into game_state (game_id,state) values (?,?)") @@ -1436,6 +1473,78 @@ const SQL_SELECT_REPLAY = SQL(` where game_id = ? `).pluck() +const ARCHIVE_SELECT_REPLAY = ENABLE_ARCHIVE ? SQL(` + select + json_object( + 'players', + (select json_group_array( + json_object('role', role, 'name', name) + ) + from players + left join users using(user_id) + where game_id = outer.game_id + ), + 'state', + (select json(state) + from archive.game_state + where game_id = outer.game_id + ), + 'replay', + (select json_group_array( + case when arguments is null then + json_array(role, action) + else + json_array(role, action, json(arguments)) + end + ) + from archive.game_replay + where game_id = outer.game_id + ) + ) as export + from games as outer + where game_id = ? +`).pluck() : null + +const ARCHIVE_SELECT_EXPORT = ENABLE_ARCHIVE ? SQL(` + select + game_id, + json_object( + 'setup', json_object( + 'game_id', game_id, + 'title_id', title_id, + 'scenario', scenario, + 'options', json(options), + 'player_count', player_count, + 'notice', notice + ), + 'players', + (select json_group_array( + json_object('role', role, 'name', name) + ) + from players + left join users using(user_id) + where game_id = outer.game_id + ), + 'state', + (select json(state) + from archive.game_state + where game_id = outer.game_id + ), + 'replay', + (select json_group_array( + case when arguments is null then + json_array(role, action) + else + json_array(role, action, json(arguments)) + end + ) + from archive.game_replay + where game_id = outer.game_id + ) + ) as export + from games as outer +`).pluck() : null + const SQL_SELECT_EXPORT = SQL("select export from game_export_view where game_id=?").pluck() const SQL_SELECT_GAME = SQL("SELECT * FROM games WHERE game_id=?") @@ -1531,7 +1640,8 @@ const QUERY_NEXT_GAME_OF_USER = SQL(` join players using(game_id) where status = ${STATUS_ACTIVE} - and active in (role, 'Both') + -- and active in (role, 'Both') + and ( active = 'Both' or instr(active, role) > 0 ) and user_id = ? and is_opposed order by mtime @@ -1801,7 +1911,7 @@ app.get("/create/:title_id", function (req, res) { user: req.user, title: title, limit: req.user ? check_create_game_limit(req.user) : null, - scenarios: RULES[title_id].scenarios, + rules: RULES[title_id], }) }) @@ -1987,6 +2097,11 @@ app.get("/join/:game_id", function (req, res) { if (!game) return res.status(404).send("Invalid game ID.") + if (ENABLE_ARCHIVE) { + if (game.status === STATUS_ARCHIVED && game.moves >= game.player_count * 3) + game.status = STATUS_FINISHED + } + let roles = get_game_roles(game.title_id, game.scenario, game.options) let players = SQL_SELECT_PLAYER_VIEW.all(game_id) @@ -2189,13 +2304,11 @@ function start_game(game) { state = RULES[game.title_id].setup(seed, game.scenario, options) - SQL_START_GAME.run(state.active, game.game_id) + SQL_START_GAME.run(String(state.active), game.game_id) let replay_id = put_replay(game.game_id, null, ".setup", [ seed, game.scenario, options ]) put_snap(game.game_id, replay_id, state) SQL_INSERT_GAME_STATE.run(game.game_id, JSON.stringify(state)) - SQL_UPDATE_PLAYERS_INIT_TIME.run(game.game_id) - SQL_COMMIT.run() } finally { if (db.inTransaction) @@ -2214,6 +2327,10 @@ app.get("/api/replay/:game_id", function (req, res) { return res.status(404).send("Invalid game ID.") if (game.status < STATUS_FINISHED && (!req.user || req.user.user_id !== 1)) return res.status(401).send("Not authorized to debug.") + if (ENABLE_ARCHIVE) { + if (game.status === STATUS_ARCHIVED) + return res.type("application/json").send(ARCHIVE_SELECT_REPLAY.get(game_id)) + } return res.type("application/json").send(SQL_SELECT_REPLAY.get(game_id)) }) @@ -2224,6 +2341,10 @@ app.get("/api/export/:game_id", function (req, res) { return res.status(404).send("Invalid game ID.") if (game.status < STATUS_FINISHED && (!req.user || req.user.user_id !== 1)) return res.status(401).send("Not authorized to debug.") + if (ENABLE_ARCHIVE) { + if (game.status === STATUS_ARCHIVED) + return res.type("application/json").send(ARCHIVE_SELECT_EXPORT.get(game_id)) + } return res.type("application/json").send(SQL_SELECT_EXPORT.get(game_id)) }) @@ -2240,7 +2361,7 @@ function rewind_game_to_snap(game_id, snap_id) { SQL_DELETE_GAME_REPLAY.run(game_id, snap.replay_id) SQL_INSERT_GAME_STATE.run(game_id, JSON.stringify(snap_state)) - SQL_REWIND_GAME.run(snap_id - 1, snap_state.active, game_id) + SQL_REWIND_GAME.run(snap_id - 1, String(snap_state.active), game_id) SQL_REWIND_GAME_CLOCK.run(game_id) update_join_clients(game_id) @@ -2292,7 +2413,7 @@ const SQL_CLONE_1 = SQL(` `).pluck() const SQL_CLONE_2 = [ - SQL(`insert into players(game_id,role,user_id) select $new_game_id,role,user_id from players where game_id=$old_game_id`), + SQL(`insert into players(game_id,role,user_id,is_active) select $new_game_id,role,user_id,is_active from players where game_id=$old_game_id`), SQL(`insert into game_state(game_id,state) select $new_game_id,state from game_state where game_id=$old_game_id`), SQL(`insert into game_replay(game_id,replay_id,role,action,arguments) select $new_game_id,replay_id,role,action,arguments from game_replay where game_id=$old_game_id`), SQL(`insert into game_snap(game_id,snap_id,replay_id,state) select $new_game_id,snap_id,replay_id,state from game_snap where game_id=$old_game_id`), @@ -2336,8 +2457,8 @@ function is_winner(role, result) { return (result === "Draw" || result === role || result.includes(role)) } -function elo_k(a) { - return a.count < 10 ? 60 : 30 +function elo_k(_) { + return 30 } function elo_ev(a, players) { @@ -2492,6 +2613,10 @@ function message_link(msg_id) { return SITE_URL + "/message/read/" + msg_id } +function tour_pool_link(pool_id) { + return SITE_URL + "/tm/pool/" + pool_id +} + function send_notification(user, link, message) { if (WEBHOOKS) { let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id) @@ -2526,6 +2651,10 @@ function send_play_notification(user, game_id, message) { send_notification(user, game_play_link(game_id, title_id, user), `${title_name} #${game_id} (${user.role}) - ${message}`) } +function send_tour_notification(user, pool_name, message) { + send_notification(user, tour_pool_link(pool_name), `${pool_name} - ${message}`) +} + function send_chat_activity_notification(game_id, p) { send_play_notification(p, game_id, "Chat activity") } @@ -2533,7 +2662,7 @@ function send_chat_activity_notification(game_id, p) { function send_game_started_notification(game_id, active) { let players = SQL_SELECT_PLAYERS.all(game_id) for (let p of players) { - let p_is_active = active === p.role || active === "Both" + let p_is_active = is_role_active(active, p.role) if (p_is_active) send_play_notification(p, game_id, "Started - Your turn") else @@ -2541,15 +2670,15 @@ function send_game_started_notification(game_id, active) { } } -function send_your_turn_notification_to_offline_users(game_id, old_active, active) { +function send_your_turn_notification_to_offline_users(game_id, old_active, new_active) { // Only send notifications when the active player changes. - if (old_active === active) + if (!is_changed_active(old_active, new_active)) return let players = SQL_SELECT_PLAYERS.all(game_id) for (let p of players) { - let p_was_active = old_active === p.role || old_active === "Both" - let p_is_active = active === p.role || active === "Both" + let p_was_active = is_role_active(old_active, p.role) + let p_is_active = is_role_active(new_active, p.role) if (!p_was_active && p_is_active) { if (!is_player_online(game_id, p.user_id)) send_play_notification(p, game_id, "Your turn") @@ -2672,46 +2801,22 @@ setTimeout(purge_game_ticker, 89 * 1000) * TIME CONTROL */ -const SQL_UPDATE_PLAYERS_INIT_TIME = SQL(` - update players - set clock = ( - case (select pace from games where games.game_id = players.game_id) - when 1 then 1 - when 2 then 3 - when 3 then 3 - else 21 - end - ) - where - players.game_id = ? -`) - -const SQL_UPDATE_PLAYERS_ADD_TIME = SQL(` - update players - set clock = ( - case (select pace from games where games.game_id = players.game_id) - when 1 then min(clock + ${4 / 24}, 3) - when 2 then min(clock + ${12 / 24}, 5) - when 3 then min(clock + ${36 / 24}, 10) - else 21 - end - ) - where - players.game_id = ? and players.role = ? -`) - +// SQL_UPDATE_PLAYERS_INIT_TIME is handled by trigger +// SQL_UPDATE_PLAYERS_ADD_TIME is handled by trigger // SQL_UPDATE_PLAYERS_USE_TIME is handled by trigger const SQL_SELECT_TIME_CONTROL = SQL("select * from time_control_view") +const SQL_INSERT_TIMEOUT = SQL("insert into user_timeout (user_id, game_id) values (?, ?)") + function time_control_ticker() { for (let item of SQL_SELECT_TIME_CONTROL.all()) { if (item.is_opposed) { console.log("TIMED OUT GAME:", item.game_id, item.role) - do_resign(item.game_id, item.role, "timed out") + do_timeout(item.game_id, item.role, item.role + " timed out.") + SQL_INSERT_TIMEOUT.run(item.user_id, item.game_id) if (item.is_match) { console.log("BANNED FROM TOURNAMENTS:", item.user_id) - TM_INSERT_TIMEOUT.run(item.user_id, item.game_id) TM_INSERT_BANNED.run(item.user_id) TM_DELETE_QUEUE_ALL.run(item.user_id) } @@ -2736,7 +2841,6 @@ const designs = require("./designs.js") const TM_SELECT_BANNED = SQL("select exists ( select 1 from tm_banned where user_id=? )").pluck() const TM_INSERT_BANNED = SQL("insert or ignore into tm_banned (user_id, time) values (?, datetime())") -const TM_INSERT_TIMEOUT = SQL("insert into tm_timeout (user_id, game_id) values (?, ?)") const TM_DELETE_QUEUE_ALL = SQL("delete from tm_queue where user_id=?") @@ -2762,6 +2866,33 @@ const TM_MAY_JOIN_SEED = SQL(` where seed_id=? `).pluck() +const TM_MAY_JOIN_SEED_LEVEL = SQL(` + with + win_cte as ( + select + count(1) as n_win + from + tm_winners + join tm_pools using(pool_id) + where + level = @level - 1 and user_id = @user_id and seed_id = @seed_id + ), + play_cte as ( + select + count(distinct pool_id) as n_play + from + tm_rounds + join tm_pools using(pool_id) + join players using(game_id) + where + level = @level and user_id = @user_id and seed_id = @seed_id + ) + select + coalesce(n_win, 0) > coalesce(n_play, 0) as may_join + from + win_cte, play_cte +`).pluck() + function is_banned_from_tournaments(user_id) { return TM_SELECT_BANNED.get(user_id) } @@ -2774,6 +2905,14 @@ function may_join_seed(seed_id) { return TM_MAY_JOIN_SEED.get(seed_id) } +function may_join_seed_level(user_id, seed_id, level) { + if (level === 1) + return true + if (level >= 2) + return TM_MAY_JOIN_SEED_LEVEL.get({ level, user_id, seed_id }) + return false +} + const TM_SEED_LIST_ALL = SQL(` select tm_seeds.*, @@ -2810,8 +2949,6 @@ const TM_SEED_LIST_USER = SQL(` order by seed_name `) -const TM_POOL_LIST_ACTIVE = SQL("select * from tm_pool_active_view") - const TM_POOL_LIST_USER_ACTIVE = SQL(` select * from tm_pool_active_view where not is_finished and pool_id in ( @@ -2862,9 +2999,11 @@ const TM_SELECT_QUEUE_BLACKLIST = SQL(` with qq as ( select user_id from tm_queue_view where seed_id=? and level=? ) - select me, you + select me, you, u_me.name as me_name, u_you.name as you_name from contacts join qq on qq.user_id = me + join users u_me on u_me.user_id=me + join users u_you on u_you.user_id=you where relation < 0 and exists (select 1 from qq where user_id = you) `) @@ -2890,6 +3029,7 @@ const TM_SELECT_GAMES = SQL(` tm_rounds.*, games.status, games.moves, + games.status > 1 and games.result = 'None' as is_abandoned, json_group_object(role, coalesce(name, 'null')) as role_names, json_group_object(role, score) as role_scores from @@ -2903,7 +3043,16 @@ const TM_SELECT_GAMES = SQL(` game_id `) -const TM_SELECT_WINNERS = SQL("select user_id from tm_winners where pool_id = ?").pluck() +const TM_SELECT_PLAYERS_IN_POOL = SQL(` + select + user_view.* + from + tm_rounds + join players using(game_id) + join user_view using(user_id) + group by + user_id +`) const TM_SELECT_PLAYERS_2P = SQL(` with @@ -3022,10 +3171,9 @@ const TM_FIND_NEXT_GAME_TO_START = SQL(` const TM_SELECT_ENDED_POOLS = SQL(` select - pool_id, seed_id, level, pool_name, level_count + pool_id, pool_name from tm_pools - join tm_seeds using(seed_id) join tm_rounds using(pool_id) join games using(game_id) where @@ -3053,10 +3201,7 @@ const TM_SELECT_SEED_READY_MINI_CUP = SQL(` app.get("/tm/list", function (req, res) { let seeds = TM_SEED_LIST_ALL.all(req.user ? req.user.user_id : 0) - let seeds_by_title = object_group_by(seeds, "title_id") - let active_pools = TM_POOL_LIST_ACTIVE.all() - let pools_by_seed = object_group_by(active_pools, "seed_name") - res.render("tm_list.pug", { user: req.user, seeds, seeds_by_title, active_pools, pools_by_seed }) + res.render("tm_list.pug", { user: req.user, seeds }) }) app.get("/tm/seed/:seed_name", function (req, res) { @@ -3103,11 +3248,12 @@ app.get("/tm/pool/:pool_name", function (req, res) { players = TM_SELECT_PLAYERS_MP.all(pool_id) let games = TM_SELECT_GAMES.all(pool_id) let games_by_round = object_group_by(games, "round") - res.render("tm_pool.pug", { user: req.user, seed, pool, roles, players, games_by_round }) + res.render("tm_pool.pug", { user: req.user, seed, pool, roles, players, games, games_by_round }) }) -app.post("/api/tm/register/:seed_id", must_be_logged_in, function (req, res) { +app.post("/api/tm/register/:seed_id/:level", must_be_logged_in, function (req, res) { let seed_id = req.params.seed_id | 0 + let level = req.params.level | 0 let user_id = req.user.user_id if (is_banned_from_tournaments(req.user.user_id)) return res.status(401).send("You may not join any tournaments.") @@ -3115,7 +3261,9 @@ app.post("/api/tm/register/:seed_id", must_be_logged_in, function (req, res) { return res.status(401).send("You may not join any tournaments right now.") if (!may_join_seed(seed_id)) return res.status(401).send("This tournament is closed.") - TM_INSERT_QUEUE.run(user_id, seed_id, 1) + if (!may_join_seed_level(req.user.user_id, seed_id, level)) + return res.status(401).send("You may not join this tournament.") + TM_INSERT_QUEUE.run(user_id, seed_id, level) return res.redirect(req.headers.referer) }) @@ -3261,7 +3409,7 @@ function make_concurrent_rounds(v, k, n) { let rbibd = designs.resolvable_bibd(v, k) if (rbibd) - return rbibd.slice(0, n).flat() + return [ rbibd.slice(0, n).flat() ] throw new Error("cannot create rounds for this configuration") } @@ -3374,6 +3522,7 @@ function start_tournament_seed_mc(seed_id, level) { let blacklist = TM_SELECT_QUEUE_BLACKLIST.all(seed_id, level) console.log("TM SPAWN SEED (MC)", seed.seed_name, level, queue.length) + console.log("TM BLACKLIST", blacklist) let players = filter_queue_through_blacklist(queue, seed.pool_size, blacklist) if (!players) { @@ -3416,23 +3565,15 @@ function start_tournament_seed(seed_id, level) { } function tm_reap_pools() { - // reap pools that are finished (and promote winners) + // reap pools that are finished (and notify players) let ended = TM_SELECT_ENDED_POOLS.all() for (let item of ended) { - console.log("TM POOL - END", item.pool_name) - SQL_BEGIN.run() - try { - TM_UPDATE_POOL_FINISHED.run(item.pool_id) - if (item.level < item.level_count) { - let winners = TM_SELECT_WINNERS.all(item.pool_id) - for (let user_id of winners) - TM_INSERT_QUEUE.run(user_id, item.seed_id, item.level + 1) - } - SQL_COMMIT.run() - } finally { - if (db.inTransaction) - SQL_ROLLBACK.run() - } + console.log("TM POOL FINISHED", item.pool_name) + TM_UPDATE_POOL_FINISHED.run(item.pool_id) + + let players = TM_SELECT_PLAYERS_IN_POOL.all(item.pool_id) + for (let user of players) + send_tour_notification(user, item.pool_name, "Finished") } } @@ -3472,6 +3613,26 @@ if (app.locals.ENABLE_TOURNAMENTS) { * GAME SERVER */ +function is_role_active(active, role) { + return active === role || active === "Both" || active.includes(role) +} + +function is_nobody_active(active) { + return !active || active === "None" +} + +function is_multi_active(active) { + if (!active) + return false + if (Array.isArray(active)) + return true + return active === "Both" || active.includes(",") +} + +function is_changed_active(old_active, new_active) { + return String(old_active) !== String(new_active) +} + function is_player_online(game_id, user_id) { if (game_clients[game_id]) for (let other of game_clients[game_id]) @@ -3493,13 +3654,14 @@ function send_state(socket, state) { view.log_start = view.log.length socket.seen = view.log.length view.log = view.log.slice(view.log_start) - if (state.state === "game_over") - view.game_over = 1 let this_view = JSON.stringify(view) if (view.actions || socket.last_view !== this_view) { socket.send('["state",' + this_view + "," + game_cookies[socket.game_id] + "]") socket.last_view = this_view } + if (is_nobody_active(state.active)) { + socket.send('["finished"]') + } } catch (err) { console.log(err) return send_message(socket, "error", err.toString()) @@ -3508,6 +3670,10 @@ function send_state(socket, state) { function get_game_state(game_id) { let game_state = SQL_SELECT_GAME_STATE.get(game_id) + if (ENABLE_ARCHIVE) { + if (!game_state) + game_state = ARCHIVE_SELECT_GAME_STATE.get(game_id) + } if (!game_state) throw new Error("No game with that ID") return JSON.parse(game_state) @@ -3539,9 +3705,11 @@ function put_replay(game_id, role, action, args) { } function dont_snap(rules, state, old_active) { - if (state.active === old_active) + if (is_nobody_active(state.active)) return true - if (state.state === "game_over") + if (is_multi_active(old_active) && is_multi_active(state.active)) + return true + if (!is_changed_active(old_active, state.active)) return true if (rules.dont_snap && rules.dont_snap(state)) return true @@ -3555,19 +3723,14 @@ function put_snap(game_id, replay_id, state) { send_message(other, "snapsize", snap_id) } -function put_game_state(game_id, state, old_active, current_role) { +function put_game_state(game_id, state, old_active) { // TODO: separate state, undo, and log entries (and reuse "snap" json stringifaction?) - SQL_INSERT_GAME_STATE.run(game_id, JSON.stringify(state)) - if (state.active !== old_active) { - SQL_UPDATE_GAME_ACTIVE.run(state.active, game_id) + if (is_changed_active(old_active, state.active)) + SQL_UPDATE_GAME_ACTIVE.run(String(state.active), game_id) - // add time for the player who took the current action - SQL_UPDATE_PLAYERS_ADD_TIME.run(game_id, current_role) - } - - if (state.state === "game_over") { + if (is_nobody_active(state.active)) { SQL_FINISH_GAME.run(state.result, game_id) if (state.result && state.result !== "None") update_elo_ratings(game_id) @@ -3582,15 +3745,16 @@ function put_new_state(title_id, game_id, state, old_active, role, action, args) if (!dont_snap(RULES[title_id], state, old_active)) put_snap(game_id, replay_id, state) - put_game_state(game_id, state, old_active, role) + put_game_state(game_id, state, old_active) - if (state.active !== old_active) + if (is_changed_active(old_active, state.active)) update_join_clients(game_id) + if (game_clients[game_id]) for (let other of game_clients[game_id]) send_state(other, state) - if (state.state === "game_over") + if (is_nobody_active(state.active)) send_game_finished_notification_to_offline_users(game_id, state.result) else send_your_turn_notification_to_offline_users(game_id, old_active, state.active) @@ -3616,11 +3780,11 @@ function on_action(socket, action, args, cookie) { try { let state = get_game_state(socket.game_id) - let old_active = state.active + let old_active = String(state.active) // Don't update cookie during simultaneous turns, as it results // in many in-flight collisions. - if (old_active !== "Both") + if (!is_multi_active(old_active)) game_cookies[socket.game_id] ++ state = RULES[socket.title_id].action(state, socket.role, action, args) @@ -3634,73 +3798,51 @@ function on_action(socket, action, args, cookie) { function on_resign(socket) { SLOG(socket, "RESIGN") try { - do_resign(socket.game_id, socket.role, "resigned") + do_resign(socket.game_id, socket.role) } catch (err) { console.log(err) return send_message(socket, "error", err.toString()) } } -function do_resign(game_id, role, how) { +function do_timeout(game_id, role) { let game = SQL_SELECT_GAME.get(game_id) let state = get_game_state(game_id) - let old_active = state.active + let old_active = String(state.active) + state = finish_game_state(game.title_id, state, "None", role + " timed out.") + put_new_state(game.title_id, game_id, state, old_active, role, ".timeout", null) +} - let result = "None" +function do_resign(game_id, role) { + let game = SQL_SELECT_GAME.get(game_id) + let state = get_game_state(game_id) + let old_active = String(state.active) - let roles = get_game_roles(game.title_id, game.scenario, game.options) + let result = "None" if (game.player_count === 2) { + let roles = get_game_roles(game.title_id, game.scenario, game.options) for (let r of roles) if (r !== role) result = r - } else { - result = roles.filter(r => r !== role).join(", ") } - state.state = "game_over" - state.active = "None" - state.result = result - state.victory = role + " " + how + "." - state.log.push("") - state.log.push(state.victory) + state = finish_game_state(game.title_id, state, result, role + " resigned.") - put_new_state(game.title_id, game_id, state, old_active, role, ".resign", null) + put_new_state(game.title_id, game_id, state, old_active, role, ".resign", result) } -function on_restore(socket, state_text) { - if (!DEBUG) - send_message(socket, "error", "Debugging is not enabled on this server.") - SLOG(socket, "RESTORE") - try { - let state = JSON.parse(state_text) - - // reseed! - state.seed = random_seed() - - // resend full log! - for (let other of game_clients[socket.game_id]) - other.seen = 0 - - put_new_state(socket.title_id, socket.game_id, state, null, null, "$restore", state) - } catch (err) { - console.log(err) - return send_message(socket, "error", err.toString()) - } -} - -function on_save(socket) { - if (!DEBUG) - send_message(socket, "error", "Debugging is not enabled on this server.") - SLOG(socket, "SAVE") - try { - let game_state = SQL_SELECT_GAME_STATE.get(socket.game_id) - if (!game_state) - return send_message(socket, "error", "No game with that ID.") - send_message(socket, "save", game_state) - } catch (err) { - console.log(err) - return send_message(socket, "error", err.toString()) +function finish_game_state(title_id, state, result, message) { + if (typeof RULES[title_id].finish === "function") { + state = RULES[title_id].finish(state, result, message) + } else { + state.state = "game_over" + state.active = "None" + state.result = result + state.victory = message + state.log.push("") + state.log.push(message) } + return state } function on_query(socket, q, params) { @@ -3861,12 +4003,6 @@ function handle_player_message(socket, cmd, arg) { case "querysnap": on_query_snap(socket, arg[0], arg[1], arg[2]) break - case "save": - on_save(socket) - break - case "restore": - on_restore(socket, arg) - break default: send_message(socket, "error", "Invalid server command: " + cmd) break @@ -4080,12 +4216,12 @@ const SQL_USER_STATS = SQL(` `) const SQL_USER_RATINGS = SQL(` - select title_name, rating, count, date(last) as last + select title_id, title_name, rating, count, date(last) as last from ratings join titles using(title_id) where user_id = ? - and count >= 5 - order by rating desc + and count >= 3 + order by count desc `) const SQL_GAME_RATINGS = SQL(` diff --git a/tools/elo.js b/tools/elo.js index ad0dbad..f6064f1 100644 --- a/tools/elo.js +++ b/tools/elo.js @@ -14,7 +14,7 @@ function is_winner(role, result) { } function elo_k(n) { - return n < 10 ? 60 : 30 + return 30 } function elo_ev(a, players) { diff --git a/tools/import-game.js b/tools/import-game.js index 59b26a7..42391e1 100755 --- a/tools/import-game.js +++ b/tools/import-game.js @@ -40,8 +40,9 @@ for (let file of input) { let db = new sqlite3("db") let insert_game = db.prepare("insert into games(status,owner_id,title_id,scenario,options,player_count,active,moves,notice) values (1,1,:title_id,:scenario,:options,:player_count,:active,:moves,:notice) returning game_id").pluck() - let insert_player = db.prepare("insert into players(game_id,role,user_id) values (?,?,?)") + let insert_player = db.prepare("insert into players(game_id,role,user_id,clock) values (?,?,?,21)") let insert_state = db.prepare("insert into game_state(game_id,state) values (?,?)") + let update_active_trigger = db.prepare("update games set active=active where game_id=?") let select_user = db.prepare("select user_id from users where name=?").pluck() @@ -57,6 +58,7 @@ for (let file of input) { for (let p of game.players) insert_player.run(game_id, p.role, find_user(p.name)) insert_state.run(game_id, JSON.stringify(game.state)) + update_active_trigger.run(game_id) if (game.replay) { let insert_replay = db.prepare("insert into game_replay(game_id,replay_id,role,action,arguments) values (?,?,?,?,?)") diff --git a/tools/lift-bans.sh b/tools/lift-bans.sh new file mode 100644 index 0000000..e76f621 --- /dev/null +++ b/tools/lift-bans.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +sqlite3 db <<EOF + +begin immediate; + +.mode column + +create temporary view tm_lift_ban_view as + select + user_id, + name, + date(timeout_last), + timeout_total, + games_since_timeout, + (games_since_timeout > timeout_total) and (julianday() > julianday(timeout_last)+14) as lift_ban + from + user_profile_view + where + user_id in (select user_id from tm_banned) + order by lift_ban desc, timeout_last asc +; + +select * from tm_lift_ban_view; + +delete from tm_banned where user_id in (select user_id from tm_lift_ban_view where lift_ban) returning user_id; + +commit; + +EOF diff --git a/tools/patchgame.js b/tools/patchgame.js index fef07a0..de8721e 100755 --- a/tools/patchgame.js +++ b/tools/patchgame.js @@ -71,10 +71,30 @@ function snapshot(state) { return snap } +function is_role_active(active, role) { + return active === role || active === "Both" || active.includes(role) +} + +function is_nobody_active(active) { + return !active || active === "None" +} + +function is_multi_active(active) { + if (!active) + return false + if (Array.isArray(active)) + return true + return active === "Both" || active.includes(",") +} + +function is_changed_active(old_active, new_active) { + return String(old_active) !== String(new_active) +} + function is_valid_action(rules, state, role, action, arg) { if (action === "undo") // for jc, hots, r3, and cr compatibility return true - if (state.active !== role && state.active !== "Both") + if (!is_role_active(state.active, role)) return false let view = rules.view(state, role) let va = view.actions[action] @@ -88,15 +108,45 @@ function is_valid_action(rules, state, role, action, arg) { } function dont_snap(rules, state, old_active) { - if (state.state === "game_over") + if (is_nobody_active(state.active)) return true - if (state.active === old_active) + if (is_multi_active(old_active) && is_multi_active(state.active)) + return true + if (!is_changed_active(old_active, state.active)) return true if (rules.dont_snap && rules.dont_snap(state)) return true return false } +function get_game_roles(rules, scenario, options) { + let roles = rules.roles + if (typeof roles === "function") { + if (typeof options === "string") + options = JSON.parse(options) + return roles(scenario, options) + } + return roles +} + +function get_resign_result(roles, role) { + return roles.filter(r => r !== role).join(", ") +} + +function finish_game(rules, state, result, message) { + if (typeof rules.finish === "function") { + state = RULES[title_id].finish(state, result, message) + } else { + state.state = "game_over" + state.active = "None" + state.result = result + state.victory = message + state.log.push("") + state.log.push(message) + } + return state +} + function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_undo=false, delete_invalid=false}, verbose) { let game = select_game.get(game_id) if (!game) { @@ -106,6 +156,7 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und let title_id = game.title_id let rules = require("../public/" + title_id + "/rules.js") + let roles = get_game_roles(rules, game.scenario, game.options) let replay = select_replay.all(game_id) if (replay.length === 0) @@ -129,8 +180,13 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und case ".setup": state = rules.setup(...args) break + case ".timeout": + finish_game(rules, state, "None", item.role + " timed out.") + break + case ".abandon": + finish_game(rules, state, "None", item.role + " abandoned the game.") case ".resign": - state = rules.resign(state, item.role) + finish_game(rules, state, get_resign_result(roles, item.role), item.role + " resigned.") break default: if (validate_actions) { @@ -185,7 +241,7 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und insert_snap.run(game_id, ++snap_id, item.replay_id, item.state) } - update_active.run(state.active, game_id) + update_active.run(String(state.active), game_id) update_state.run(JSON.stringify(state), game_id) if (state.state === "game_over") diff --git a/tools/purge.sql b/tools/purge.sql index 3e0c898..2a95f01 100644 --- a/tools/purge.sql +++ b/tools/purge.sql @@ -2,6 +2,8 @@ attach database 'db' as live; +pragma live.busy_timeout=10000; + create temporary view prune_snap_list as select distinct game_id @@ -26,12 +28,8 @@ create temporary view prune_all_list as ) ; -begin; - select 'PURGE SNAPS FROM ' || count(1) from prune_snap_list; delete from live.game_snap where game_id in (select game_id from prune_snap_list); select 'PURGE ALL FROM ' || count(1) from prune_all_list; update live.games set status = 3 where game_id in (select game_id from prune_all_list); - -commit; diff --git a/views/create-index.pug b/views/create-index.pug index d038810..c508be7 100644 --- a/views/create-index.pug +++ b/views/create-index.pug @@ -3,7 +3,7 @@ doctype html html head include head - title= SITE_NAME + title Create game body include header article diff --git a/views/create.pug b/views/create.pug index d45903e..c4c5c2a 100644 --- a/views/create.pug +++ b/views/create.pug @@ -19,27 +19,36 @@ html p.error You are not logged in! form(method="post" action="/create/"+title.title_id) - if Array.isArray(scenarios) - if scenarios.length > 1 + if Array.isArray(rules.scenarios) + if rules.scenarios.length > 1 p Scenario: br select(name="scenario") - each scenario in scenarios - option(value=scenario)= scenario + each scenario in rules.scenarios + if scenario === rules.default_scenario + option(value=scenario selected)= scenario + else + option(value=scenario)= scenario else - input(type="hidden" name="scenario" value=scenarios[0]) + input(type="hidden" name="scenario" value=rules.scenarios[0]) else p Scenario: br select(name="scenario") - each list, name in scenarios + each list, name in rules.scenarios if name === "" each scenario in list - option(value=scenario)= scenario + if scenario === rules.default_scenario + option(value=scenario selected)= scenario + else + option(value=scenario)= scenario else optgroup(label=name) each scenario in list - option(value=scenario)= scenario + if scenario === rules.default_scenario + option(value=scenario selected)= scenario + else + option(value=scenario)= scenario | !{ title.create_html } diff --git a/views/games_finished.pug b/views/games_finished.pug index 8d71717..90ec9a2 100644 --- a/views/games_finished.pug +++ b/views/games_finished.pug @@ -3,7 +3,10 @@ doctype html html head include head - title= SITE_NAME + if user && user.user_id === who.user_id + title Your finished games + else + title #{who.name}’s finished games body include header article.wide diff --git a/views/games_public.pug b/views/games_public.pug index 54591cd..8cfd541 100644 --- a/views/games_public.pug +++ b/views/games_public.pug @@ -3,7 +3,7 @@ doctype html html head include head - title= SITE_NAME + title Public room if user meta(http-equiv="refresh" content=900) body diff --git a/views/head.pug b/views/head.pug index 61ca688..75e1135 100644 --- a/views/head.pug +++ b/views/head.pug @@ -109,7 +109,16 @@ mixin gamelist(list,hide_title=0) else a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}`) Review when 3 - | Archived + if ENABLE_ARCHIVE + if item.is_yours + if item.your_role + a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=${encodeURIComponent(item.your_role)}`) Archived + else + a(class="command" href="/join/"+item.game_id) Archived + else + a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=Observer`) Archived + else + | Archived div.game_main div.game_info diff --git a/views/profile.pug b/views/profile.pug index 391fca0..4ecf289 100644 --- a/views/profile.pug +++ b/views/profile.pug @@ -3,13 +3,13 @@ doctype html html head include head - title= SITE_NAME + title Profile body include header article h1= SITE_NAME - p Welcome, #{user.name}! + p Welcome, <a class="black" href="/user/#{user.name}">#{user.name}</a>! p Your mail address is #{user.mail} if ENABLE_MAIL diff --git a/views/tm_active.pug b/views/tm_active.pug index cc821b2..625eac2 100644 --- a/views/tm_active.pug +++ b/views/tm_active.pug @@ -3,11 +3,11 @@ doctype html html head include head - title= SITE_NAME + title Your tournaments body include header article.wide - h1 Your Tournaments + h1 Your tournaments if seeds && seeds.length > 0 +seedlist(seeds, "Registrations") diff --git a/views/tm_finished.pug b/views/tm_finished.pug index efe193d..5d077c6 100644 --- a/views/tm_finished.pug +++ b/views/tm_finished.pug @@ -3,7 +3,10 @@ doctype html html head include head - title= SITE_NAME + if user && user.user_id === who.user_id + title Your finished tournaments + else + title #{who.name}’s finished tournaments body include header article.wide diff --git a/views/tm_list.pug b/views/tm_list.pug index bc7fd83..f5efa26 100644 --- a/views/tm_list.pug +++ b/views/tm_list.pug @@ -12,7 +12,3 @@ html p See <a href="/docs/tournaments.html">tournament information</a>. +seedlist(seeds, "Mini Cup") - - h2 Active - each pools, seed_name in pools_by_seed - +poollist(pools, `<a href="/tm/seed/${seed_name}">${seed_name}</a>`) diff --git a/views/tm_pool.pug b/views/tm_pool.pug index 1690535..e6c7715 100644 --- a/views/tm_pool.pug +++ b/views/tm_pool.pug @@ -49,9 +49,9 @@ html tr td Started td= human_date(pool.start_date) - tr - td Finished - if pool.finish_date + if pool.finish_date + tr + td Finished td= human_date(pool.finish_date) if seed.player_count === 2 @@ -84,7 +84,10 @@ html if ix > 0 | if gs[1] === null - a.black(href="/join/" + gs[0]) − + if games.find(game => game.game_id === gs[0]).is_abandoned + a.black(href="/join/" + gs[0]) × + else + a.black(href="/join/" + gs[0]) − else a.black(href="/join/" + gs[0])= gs[1] td.r= row.points @@ -116,7 +119,10 @@ html each gs in result td.c if gs[1] === null - a.black(href="/join/" + gs[0]) − + if games.find(game => game.game_id === gs[0]).is_abandoned + a.black(href="/join/" + gs[0]) × + else + a.black(href="/join/" + gs[0]) − else a.black(href="/join/" + gs[0])= gs[1] td.r= row.points @@ -150,10 +156,16 @@ html if game.status > 1 td.w.r - each role, ix in roles - if ix > 0 - | : - | #{role_scores[role]} + if game.is_abandoned + | None + else + each role, ix in roles + if ix > 0 + | : + if role_scores[role] === null + | × + else + | #{role_scores[role]} else td.r if game.status > 0 diff --git a/views/tm_seed.pug b/views/tm_seed.pug index 9902f4f..ea31a41 100644 --- a/views/tm_seed.pug +++ b/views/tm_seed.pug @@ -70,8 +70,8 @@ html form(method="post" action="/api/tm/withdraw/" + seed.seed_id + "/" + (ix+1)) button(disabled) Register button(type="submit") Withdraw - else if ix === 0 && may_register - form(method="post" action="/api/tm/register/" + seed.seed_id) + else if may_register && may_join_seed_level(user.user_id, seed.seed_id, ix+1) + form(method="post" action="/api/tm/register/" + seed.seed_id + "/" + (ix+1)) button(type="submit") Register button(disabled) Withdraw else diff --git a/views/user.pug b/views/user.pug index fc55b42..c3e8925 100644 --- a/views/user.pug +++ b/views/user.pug @@ -42,6 +42,38 @@ html br a(href="/contacts/add-enemy/"+who.name) Add to blacklist + if (who.move_time_mean !== null) + h3 Response time + div Average response time: #{format_minutes(who.move_time_mean)} + if (who.move_time_q2 !== null) + div Median response time: #{format_minutes(who.move_time_q2)} + if (who.move_time_q1 !== null && who.move_time_q2 !== null) + div Middle half of response times: #{format_minutes(who.move_time_q1)} to #{format_minutes(who.move_time_q3)} + + h3 Timeouts + div Total number of timeouts: #{who.timeout_total} + div Games completed since last timeout: #{who.games_since_timeout} + + if ratings.length > 0 + h3 Most played games + table + thead + tr + th Title + th Count + th Last played + if user && user.user_id === 1 + th Elo + tbody + each row in ratings + tr + td + a.black(href="/" + row.title_id)= row.title_name + td.r= row.count + td.r= row.last + if user && user.user_id === 1 + td.r= row.rating + +tourlist(null, active_pools, finished_pools) if open_games.length > 0 |