diff options
author | Tor Andersson <tor@ccxvii.net> | 2023-07-01 15:02:17 +0200 |
---|---|---|
committer | Tor Andersson <tor@ccxvii.net> | 2023-07-01 15:02:17 +0200 |
commit | 758043c4275498c94eeed26213536052349dd449 (patch) | |
tree | 7f0364552138b2fd148b4140f0faeb10b2681612 | |
parent | a07c8e521a06ec7228edba8ca65c6ec9b81a1f7e (diff) | |
download | server-758043c4275498c94eeed26213536052349dd449.tar.gz |
Add "snapshot" replay view during play.
Snapshots store game state without undo and only log length.
Combined with the final game state's log we can recreate the view
from any snapshot quickly.
Move replay code into separate script file, loaded only when used.
Prefix system "setup", "resign", and "restore" actions with a period.
-rw-r--r-- | public/common/play.css | 61 | ||||
-rw-r--r-- | public/common/play.js | 515 | ||||
-rw-r--r-- | public/common/replay.js | 342 | ||||
-rw-r--r-- | public/images/gui_arrow_down.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_left.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_right.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_right_stop.svg | 3 | ||||
-rw-r--r-- | public/images/gui_arrow_up.svg | 3 | ||||
-rw-r--r-- | public/images/gui_chevron_left.svg | 3 | ||||
-rw-r--r-- | public/images/gui_chevron_right.svg | 3 | ||||
-rw-r--r-- | public/images/gui_play.svg | 3 | ||||
-rw-r--r-- | public/images/gui_stop.svg | 3 | ||||
-rw-r--r-- | schema.sql | 10 | ||||
-rw-r--r-- | server.js | 224 | ||||
-rwxr-xr-x[-rw-r--r--] | tools/patchgame.js | 263 |
15 files changed, 934 insertions, 508 deletions
diff --git a/public/common/play.css b/public/common/play.css index 81bd358..5a145f4 100644 --- a/public/common/play.css +++ b/public/common/play.css @@ -77,7 +77,6 @@ header { align-items: center; border-bottom: 1px solid black; background-color: gainsboro; - padding-right: 8px; } #toolbar { @@ -93,6 +92,10 @@ header.your_turn { background-color: orange; } +header.replay { + background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px); +} + main { grid-column: 1; grid-row: 2; @@ -105,7 +108,7 @@ aside { grid-row: 2; display: grid; overflow: clip; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; width: 212px; border-left: 1px solid black; } @@ -230,22 +233,31 @@ header #actions { flex-wrap: wrap; justify-content: end; gap: 8px; - padding-left: 8px; + padding: 0 8px; margin: 4px 0; } -header .replay { +header #viewpoint_panel { display: flex; flex-wrap: wrap; justify-content: end; - padding-left: 8px; + padding: 0 8px; margin: 4px 0; } -header .replay button { +header .viewpoint_button { margin-right: 0; } +header .viewpoint_button.selected { + background-color: hsl(51,100%,60%); + border-color: hsl(51,100%,80%) hsl(51,100%,40%) hsl(51,100%,40%) hsl(51,100%,80%); +} + +header .viewpoint_button.selected:active:hover { + border-color: hsl(51,100%,40%) hsl(51,100%,80%) hsl(51,100%,80%) hsl(51,100%,40%); +} + #prompt { padding-left: 20px; font-size: 18px; @@ -255,6 +267,35 @@ header .replay button { overflow: hidden; } +#replay_panel { + display: flex; + height: 24px; + border-top: 1px solid black; + background-color: darkgray; +} + +.replay_button { + height: 24px; + flex-grow: 1; + background-repeat: no-repeat; + background-size: 16px 16px; + background-position: center; + opacity: 60%; +} + +.replay_button:hover { + background-color: lightgray; +} + +#replay_first { background-image: url(/images/gui_arrow_up.svg) } +#replay_prev { background-image: url(/images/gui_arrow_left.svg) } +#replay_step_prev { background-image: url(/images/gui_chevron_left.svg) } +#replay_step_next { background-image: url(/images/gui_chevron_right.svg) } +#replay_next { background-image: url(/images/gui_arrow_right.svg) } +#replay_last { background-image: url(/images/gui_arrow_down.svg) } +#replay_play { background-image: url(/images/gui_play.svg) } +#replay_stop { background-image: url(/images/gui_stop.svg) } + /* ROLES */ .role_info { @@ -431,6 +472,13 @@ header .replay button { white-space: normal; } + #replay_panel { + position: fixed; + z-index: 500; + bottom: 0; + width: 100%; + } + #chat_window, #notepad_window { position: static; grid-column: 1; @@ -456,6 +504,7 @@ header .replay button { width: auto !important; border-left: none; border-top: 1px solid black; + margin-bottom: 25px; /* space for replay panel */ } #log { height: 50vh; diff --git a/public/common/play.js b/public/common/play.js index 37708c0..e117dae 100644 --- a/public/common/play.js +++ b/public/common/play.js @@ -25,6 +25,12 @@ let chat = null let game_log = [] +let snap_active = [] +let snap_cache = [] +let snap_count = 0 +let snap_this = 0 +let snap_view = null + function scroll_with_middle_mouse(panel_sel, multiplier) { let panel = document.querySelector(panel_sel) let down_x, down_y, scroll_x, scroll_y @@ -331,6 +337,20 @@ function toggle_notepad() { show_notepad() } +function add_icon_button(parent, id, img, title, fn) { + let button = document.getElementById(id) + if (!button) { + button = document.createElement("div") + button.id = id + button.title = title + button.className = "icon_button" + button.innerHTML = '<img src="/images/' + img + '.svg">' + button.addEventListener("click", fn) + parent.appendChild(button) + } + return button +} + /* REMATCH BUTTON */ function remove_resign_menu() { @@ -349,20 +369,9 @@ function goto_replay() { } function on_game_over() { - function icon_button(id, img, title, fn) { - if (!document.getElementById(id)) { - let button = document.createElement("div") - button.id = id - button.title = title - button.className = "icon_button" - button.innerHTML = '<img src="/images/' + img + '.svg">' - button.addEventListener("click", fn) - document.querySelector("header").appendChild(button) - } - } - icon_button("replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) + add_icon_button(document.querySelector("header"), "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) if (player !== "Observer") - icon_button("rematch_button", "cycle", "Propose a rematch!", goto_rematch) + add_icon_button(document.querySelector("header"), "rematch_button", "cycle", "Propose a rematch!", goto_rematch) remove_resign_menu() } @@ -376,7 +385,7 @@ function init_player_names(players) { } function send_message(cmd, arg) { - let data = JSON.stringify([cmd, arg]) + let data = JSON.stringify([ cmd, arg ]) console.log("SEND %s %s", cmd, arg) socket.send(data) } @@ -400,7 +409,7 @@ function connect_play() { socket = new WebSocket(url) - window.addEventListener('beforeunload', function () { + window.addEventListener("beforeunload", function () { socket.close(1000) }) @@ -429,19 +438,19 @@ function connect_play() { let [ cmd, arg ] = JSON.parse(evt.data) console.log("MESSAGE %s", cmd) switch (cmd) { - case 'error': + case "error": document.getElementById("prompt").textContent = arg break - case 'chat': + case "chat": update_chat(arg[0], arg[1], arg[2], arg[3]) break - case 'note': + case "note": update_notepad(arg) break - case 'players': + case "players": player = arg[0] document.querySelector("body").classList.add(player.replace(/ /g, "_")) if (player !== "Observer") { @@ -453,15 +462,20 @@ function connect_play() { init_player_names(arg[1]) break - case 'presence': - let list = Array.isArray(arg) ? arg : Object.keys(arg) - for (let i = 0; i < roles.length; ++i) { - let elt = document.getElementById(roles[i].id) - elt.classList.toggle("present", list.includes(roles[i].role)) + case "presence": + { + let list = Array.isArray(arg) ? arg : Object.keys(arg) + for (let i = 0; i < roles.length; ++i) { + let elt = document.getElementById(roles[i].id) + elt.classList.toggle("present", list.includes(roles[i].role)) + } } break - case 'state': + case "state": + if (snap_view) + on_snap_stop() + view = arg game_log.length = view.log_start @@ -469,19 +483,35 @@ function connect_play() { game_log.push(line) on_update_header() - if (typeof on_update === 'function') + 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 'reply': - if (typeof on_reply === 'function') + case "snapsize": + snap_count = arg + if (snap_count === 0) + replay_panel.remove() + else + document.querySelector("aside").appendChild(replay_panel) + console.log("SNAPSIZE", snap_count) + break + + case "snap": + console.log("SNAP", arg[0]) + snap_active[arg[0]] = arg[1] + snap_cache[arg[0]] = arg[2] + show_snap(arg[0]) + break + + case "reply": + if (typeof on_reply === "function") on_reply(arg[0], arg[1]) break - case 'save': + case "save": window.localStorage[params.title_id + "/save"] = arg break } @@ -497,6 +527,10 @@ function on_update_header() { document.getElementById("prompt").textContent = view.prompt if (params.mode === "replay") return + if (snap_view) + document.querySelector("header").classList.add("replay") + else + document.querySelector("header").classList.remove("replay") if (view.actions) { document.querySelector("header").classList.add("your_turn") if (!is_your_turn || old_active !== view.active) @@ -649,13 +683,13 @@ function send_action(verb, noun) { let realnoun = Array.isArray(noun) ? noun[0] : noun if (view.actions && view.actions[verb] && view.actions[verb].includes(realnoun)) { view.actions = null - send_message("action", [verb, noun]) + send_message("action", [ verb, noun ]) return true } } else { if (view.actions && view.actions[verb]) { view.actions = null - send_message("action", [verb]) + send_message("action", [ verb ]) return true } } @@ -667,20 +701,13 @@ function confirm_action(message, verb, noun) { send_action(verb, noun) } -let replay_query = null - function send_query(q, param) { - if (param !== undefined) { - if (replay_query) - replay_query(q, param) - else - send_message("query", [q, param]) - } else { - if (replay_query) - replay_query(q, undefined) - else - send_message("query", q) - } + if (typeof replay_query === "function") + replay_query(q, param) + else if (snap_view) + send_message("querysnap", [ snap_this, q, param ]) + else + send_message("query", [ q, param ]) } function confirm_resign() { @@ -688,6 +715,14 @@ function confirm_resign() { send_message("resign") } +function send_save() { + send_message("save") +} + +function send_restore() { + send_message("restore", window.localStorage[params.title_id + "/save"]) +} + /* MOBILE PHONE LAYOUT */ let mobile_scroll_header = document.querySelector("header") @@ -706,316 +741,12 @@ window.addEventListener("scroll", function scroll_mobile_fix (evt) { } }) -/* DEBUGGING */ - -function send_save() { - send_message("save") -} - -function send_restore() { - send_message("restore", window.localStorage[params.title_id + "/save"]) -} - -function send_restart(scenario) { - send_message("restart", scenario) -} - /* REPLAY */ -function object_copy(original) { - if (Array.isArray(original)) { - let n = original.length - let copy = new Array(n) - for (let i = 0; i < n; ++i) { - let v = original[i] - if (typeof v === "object" && v !== null) - copy[i] = object_copy(v) - else - copy[i] = v - } - return copy - } else { - let copy = {} - for (let i in original) { - let v = original[i] - if (typeof v === "object" && v !== null) - copy[i] = object_copy(v) - else - copy[i] = v - } - return copy - } -} - -function adler32(data) { - let a = 1, b = 0 - for (let i = 0, n = data.length; i < n; ++i) { - a = (a + data.charCodeAt(i)) % 65521 - b = (b + a) % 65521 - } - return (b << 16) | a -} - -async function require(path) { - let cache = {} - - if (!path.endsWith(".js")) - path = path + ".js" - if (path.startsWith("./")) - path = path.substring(2) - - console.log("REQUIRE", path) - - let response = await fetch(path) - let source = await response.text() - - for (let [_, subpath] of source.matchAll(/require\(['"]([^)]*)['"]\)/g)) - if (cache[subpath] === undefined) - cache[subpath] = await require(subpath) - - let module = { exports: {} } - Function("module", "exports", "require", source)(module, module.exports, path => cache[path]) - return module.exports -} - -let replay = null - -async function init_replay() { - remove_resign_menu() - - document.getElementById("prompt").textContent = "Loading replay..." - - console.log("LOADING RULES") - let rules = await require("rules.js") - - console.log("LOADING REPLAY") - let response = await fetch("/api/replay/" + params.game_id) - if (!response.ok) { - let text = await response.text() - document.getElementById("prompt").textContent = "ERROR " + response.status + ": " + text - return - } - - let body = await response.json() - replay = body.replay - - init_player_names(body.players) - - let viewpoint = "Observer" - let p = 0 - let s = {} - - function eval_action(item, p) { - let [ item_role, item_action, item_arguments ] = item - switch (item_action) { - case "setup": - s = rules.setup(item_arguments[0], item_arguments[1], item_arguments[2]) - break - case "resign": - if (params.mode === "debug") - s.log.push([p, item_role.substring(0,2), item_action, null]) - s = rules.resign(s, item_role) - break - default: - if (params.mode === "debug") - s.log.push([p, item_role.substring(0,2), item_action, item_arguments]) - s = rules.action(s, item_role, item_action, item_arguments) - break - } - } - - replay_query = function (query, params) { - let reply = rules.query(s, player, query, params) - on_reply(query, reply) - } - - let ss - for (p = 0; p < replay.length; ++p) { - if (rules.is_checkpoint) { - replay[p].is_checkpoint = p > 1 && rules.is_checkpoint(ss, s) - ss = object_copy(s) - } - - try { - eval_action(replay[p], p) - } catch (err) { - console.log("ERROR IN REPLAY %d %s %s/%s/%s", p, s.state, replay[p][0], replay[p][1], replay[p][2]) - console.log(err) - if (params.mode === "debug") - replay.length = p - else - replay.length = 0 - break - } - - if (params.mode !== "debug") { - replay[p].digest = adler32(JSON.stringify(s)) - for (let k = p-1; k > 0; --k) { - if (replay[k].digest === replay[p].digest && !replay[k].is_undone) { - for (let a = k+1; a <= p; ++a) - if (!replay[a].is_undone) - replay[a].is_undone = true - break - } - } - } - } - - replay = replay.filter(x => !x.is_undone) - - function set_hash(n) { - history.replaceState(null, "", window.location.pathname + window.location.search + "#" + n) - } - - let timer = 0 - function play_pause_replay(evt) { - if (timer === 0) { - evt.target.textContent = "Stop" - timer = setInterval(() => { - if (p < replay.length) - goto_replay(p+1) - else - play_pause_replay(evt) - }, 1000) - } else { - evt.target.textContent = "Run" - clearInterval(timer) - timer = 0 - } - } - - function prev() { - for (let i = p - 1; i > 1; --i) - if (replay[i].is_checkpoint) - return i - return 1 - } - - function next() { - for (let i = p + 1; i < replay.length; ++i) - if (replay[i].is_checkpoint) - return i - return replay.length - } - - function on_hash_change() { - goto_replay(parseInt(window.location.hash.slice(1)) || 1) - } - - function goto_replay(np) { - if (np < 1) - np = 1 - if (np > replay.length) - np = replay.length - set_hash(np) - if (p > np) - p = 0, s = {} - while (p < np) { - eval_action(replay[p], p) - ++p; - } - update_replay_view() - } - - function update_replay_view() { - player = viewpoint - - if (viewpoint === "Active") { - player = s.active - if (player === "All" || player === "Both" || player === "None" || !player) - player = "Observer" - } - - let body = document.querySelector("body") - body.classList.remove("Observer") - for (let i = 0; i < roles.length; ++i) - body.classList.remove(roles[i].role.replace(/ /g, "_")) - body.classList.add(player.replace(/ /g, "_")) - - view = rules.view(s, player) - if (params.mode !== "debug") - view.actions = null - - if (viewpoint === "Observer") - view.game_over = 1 - if (s.state === "game_over") - view.game_over = 1 - - if (replay.length > 0) { - if (document.querySelector("body").classList.contains("shift")) { - view.prompt = `[${p}/${replay.length}] ${s.active} / ${s.state}` - if (p < replay.length) - view.prompt += ` / ${replay[p][1]} ${replay[p][2]}` - } else { - view.prompt = "[" + p + "/" + replay.length + "] " + view.prompt - } - } - - if (game_log.length > view.log.length) - game_log.length = view.log.length - let log_start = game_log.length - for (let i = log_start; i < view.log.length; ++i) - game_log.push(view.log[i]) - - on_update_header() - on_update() - on_update_log(log_start, game_log.length) - } - - function text_button(div, txt, fn) { - let button = document.createElement("button") - button.addEventListener("click", fn) - button.textContent = txt - div.appendChild(button) - return button - } - - function set_viewpoint(vp) { - viewpoint = vp - update_replay_view() - } - - function short_role(name) { - return name.split(" ").map(n => n[0]).join("") - } - - let div = document.createElement("div") - div.className = "replay" - if (replay.length > 0) - text_button(div, "Active", () => set_viewpoint("Active")) - if (roles.length > 2) - for (let r of roles) - text_button(div, short_role(r.role), () => set_viewpoint(r.role)) - else - for (let r of roles) - text_button(div, r.role, () => set_viewpoint(r.role)) - text_button(div, "Observer", () => set_viewpoint("Observer")) - document.querySelector("header").appendChild(div) - - if (replay.length > 0) { - console.log("REPLAY READY") - - div = document.createElement("div") - div.className = "replay" - text_button(div, "<<<", () => goto_replay(1)) - text_button(div, "<<", () => goto_replay(prev())) - text_button(div, "<\xa0", () => goto_replay(p-1)) - text_button(div, "\xa0>", () => goto_replay(p+1)) - text_button(div, ">>", () => goto_replay(next())) - text_button(div, "Run", play_pause_replay).style.width = "65px" - document.querySelector("header").appendChild(div) - - if (window.location.hash === "") - set_hash(replay.length) - - on_hash_change() - - window.addEventListener("hashchange", on_hash_change) - } else { - console.log("REPLAY NOT AVAILABLE") - s = body.state - update_replay_view() - } +function init_replay() { + let script = document.createElement("script") + script.src = "/common/replay.js" + document.body.appendChild(script) } window.addEventListener("load", function () { @@ -1030,6 +761,8 @@ window.addEventListener("load", function () { document.getElementById("prompt").textContent = "Invalid mode: " + params.mode }) +/* MAIN MENU */ + function init_main_menu() { let popup = document.querySelector(".menu_popup") let sep = document.createElement("div") @@ -1065,3 +798,81 @@ if (params.mode === "play" && params.role !== "Observer") { } else { add_main_menu_item_link("Go home", "/") } + +/* SNAPSHOT VIEW */ + +var replay_panel = null + +function add_replay_button(parent, id, callback) { + let button = document.createElement("div") + button.className = "replay_button" + button.id = id + button.onclick = callback + parent.appendChild(button) + return button +} + +function init_snap() { + replay_panel = document.createElement("div") + replay_panel.id = "replay_panel" + add_replay_button(replay_panel, "replay_first", on_snap_first) + add_replay_button(replay_panel, "replay_prev", on_snap_prev) + add_replay_button(replay_panel, "replay_step_prev", null).classList.add("hide") + add_replay_button(replay_panel, "replay_step_next", null).classList.add("hide") + add_replay_button(replay_panel, "replay_next", on_snap_next) + add_replay_button(replay_panel, "replay_last", null).classList.add("hide") + add_replay_button(replay_panel, "replay_play", on_snap_stop) + add_replay_button(replay_panel, "replay_stop", null).classList.add("hide") +} + +init_snap() + +function request_snap(snap_id) { + if (snap_id >= 1 && snap_id <= snap_count) { + snap_this = snap_id + if (snap_cache[snap_id]) + show_snap(snap_id) + else + send_message("getsnap", snap_id) + } +} + +function show_snap(snap_id) { + if (snap_view === null) + snap_view = view + view = snap_cache[snap_id] + view.prompt = "Replay " + snap_id + " / " + snap_count + " \u2013 " + snap_active[snap_id] + on_update_header() + on_update() + on_update_log(view.log, view.log) +} + +function on_snap_first() { + request_snap(1) +} + +function on_snap_prev() { + if (!snap_view) + request_snap(snap_count) + else if (snap_this > 1) + request_snap(snap_this - 1) +} + +function on_snap_next() { + if (!snap_view) + on_snap_stop() + else if (snap_this < snap_count) + request_snap(snap_this + 1) + else + on_snap_stop() +} + +function on_snap_stop() { + if (snap_view) { + view = snap_view + snap_view = null + on_update_header() + on_update() + on_update_log(game_log.length, game_log.length) + } +} diff --git a/public/common/replay.js b/public/common/replay.js new file mode 100644 index 0000000..91170bf --- /dev/null +++ b/public/common/replay.js @@ -0,0 +1,342 @@ +/* POST-GAME REPLAY & DEBUG MODE */ + +"use strict" + +;(function () { + +/* global view, player, params, roles, game_log, replay_panel */ + +var rules = null +var replay = null +var viewpoint = params.role +var viewpoint_buttons = [] + +var replay_this = 0 +var replay_state = {} + +const CRC32C_TABLE = new Int32Array([ + 0x00000000, 0xf26b8303, 0xe13b70f7, 0x1350f3f4, 0xc79a971f, 0x35f1141c, 0x26a1e7e8, 0xd4ca64eb, + 0x8ad958cf, 0x78b2dbcc, 0x6be22838, 0x9989ab3b, 0x4d43cfd0, 0xbf284cd3, 0xac78bf27, 0x5e133c24, + 0x105ec76f, 0xe235446c, 0xf165b798, 0x030e349b, 0xd7c45070, 0x25afd373, 0x36ff2087, 0xc494a384, + 0x9a879fa0, 0x68ec1ca3, 0x7bbcef57, 0x89d76c54, 0x5d1d08bf, 0xaf768bbc, 0xbc267848, 0x4e4dfb4b, + 0x20bd8ede, 0xd2d60ddd, 0xc186fe29, 0x33ed7d2a, 0xe72719c1, 0x154c9ac2, 0x061c6936, 0xf477ea35, + 0xaa64d611, 0x580f5512, 0x4b5fa6e6, 0xb93425e5, 0x6dfe410e, 0x9f95c20d, 0x8cc531f9, 0x7eaeb2fa, + 0x30e349b1, 0xc288cab2, 0xd1d83946, 0x23b3ba45, 0xf779deae, 0x05125dad, 0x1642ae59, 0xe4292d5a, + 0xba3a117e, 0x4851927d, 0x5b016189, 0xa96ae28a, 0x7da08661, 0x8fcb0562, 0x9c9bf696, 0x6ef07595, + 0x417b1dbc, 0xb3109ebf, 0xa0406d4b, 0x522bee48, 0x86e18aa3, 0x748a09a0, 0x67dafa54, 0x95b17957, + 0xcba24573, 0x39c9c670, 0x2a993584, 0xd8f2b687, 0x0c38d26c, 0xfe53516f, 0xed03a29b, 0x1f682198, + 0x5125dad3, 0xa34e59d0, 0xb01eaa24, 0x42752927, 0x96bf4dcc, 0x64d4cecf, 0x77843d3b, 0x85efbe38, + 0xdbfc821c, 0x2997011f, 0x3ac7f2eb, 0xc8ac71e8, 0x1c661503, 0xee0d9600, 0xfd5d65f4, 0x0f36e6f7, + 0x61c69362, 0x93ad1061, 0x80fde395, 0x72966096, 0xa65c047d, 0x5437877e, 0x4767748a, 0xb50cf789, + 0xeb1fcbad, 0x197448ae, 0x0a24bb5a, 0xf84f3859, 0x2c855cb2, 0xdeeedfb1, 0xcdbe2c45, 0x3fd5af46, + 0x7198540d, 0x83f3d70e, 0x90a324fa, 0x62c8a7f9, 0xb602c312, 0x44694011, 0x5739b3e5, 0xa55230e6, + 0xfb410cc2, 0x092a8fc1, 0x1a7a7c35, 0xe811ff36, 0x3cdb9bdd, 0xceb018de, 0xdde0eb2a, 0x2f8b6829, + 0x82f63b78, 0x709db87b, 0x63cd4b8f, 0x91a6c88c, 0x456cac67, 0xb7072f64, 0xa457dc90, 0x563c5f93, + 0x082f63b7, 0xfa44e0b4, 0xe9141340, 0x1b7f9043, 0xcfb5f4a8, 0x3dde77ab, 0x2e8e845f, 0xdce5075c, + 0x92a8fc17, 0x60c37f14, 0x73938ce0, 0x81f80fe3, 0x55326b08, 0xa759e80b, 0xb4091bff, 0x466298fc, + 0x1871a4d8, 0xea1a27db, 0xf94ad42f, 0x0b21572c, 0xdfeb33c7, 0x2d80b0c4, 0x3ed04330, 0xccbbc033, + 0xa24bb5a6, 0x502036a5, 0x4370c551, 0xb11b4652, 0x65d122b9, 0x97baa1ba, 0x84ea524e, 0x7681d14d, + 0x2892ed69, 0xdaf96e6a, 0xc9a99d9e, 0x3bc21e9d, 0xef087a76, 0x1d63f975, 0x0e330a81, 0xfc588982, + 0xb21572c9, 0x407ef1ca, 0x532e023e, 0xa145813d, 0x758fe5d6, 0x87e466d5, 0x94b49521, 0x66df1622, + 0x38cc2a06, 0xcaa7a905, 0xd9f75af1, 0x2b9cd9f2, 0xff56bd19, 0x0d3d3e1a, 0x1e6dcdee, 0xec064eed, + 0xc38d26c4, 0x31e6a5c7, 0x22b65633, 0xd0ddd530, 0x0417b1db, 0xf67c32d8, 0xe52cc12c, 0x1747422f, + 0x49547e0b, 0xbb3ffd08, 0xa86f0efc, 0x5a048dff, 0x8ecee914, 0x7ca56a17, 0x6ff599e3, 0x9d9e1ae0, + 0xd3d3e1ab, 0x21b862a8, 0x32e8915c, 0xc083125f, 0x144976b4, 0xe622f5b7, 0xf5720643, 0x07198540, + 0x590ab964, 0xab613a67, 0xb831c993, 0x4a5a4a90, 0x9e902e7b, 0x6cfbad78, 0x7fab5e8c, 0x8dc0dd8f, + 0xe330a81a, 0x115b2b19, 0x020bd8ed, 0xf0605bee, 0x24aa3f05, 0xd6c1bc06, 0xc5914ff2, 0x37faccf1, + 0x69e9f0d5, 0x9b8273d6, 0x88d28022, 0x7ab90321, 0xae7367ca, 0x5c18e4c9, 0x4f48173d, 0xbd23943e, + 0xf36e6f75, 0x0105ec76, 0x12551f82, 0xe03e9c81, 0x34f4f86a, 0xc69f7b69, 0xd5cf889d, 0x27a40b9e, + 0x79b737ba, 0x8bdcb4b9, 0x988c474d, 0x6ae7c44e, 0xbe2da0a5, 0x4c4623a6, 0x5f16d052, 0xad7d5351 +]) + +function crc32c(data) { + let x = 0 + for (let i = 0, n = data.length; i < n; ++i) + x = CRC32C_TABLE[(x ^ data.charCodeAt(i)) & 0xff] ^ (x >>> 8) + return x ^ -1 +} + +async function require(path) { + let cache = {} + + if (!path.endsWith(".js")) + path = path + ".js" + if (path.startsWith("./")) + path = path.substring(2) + + console.log("REQUIRE", path) + + let response = await fetch(path) + let source = await response.text() + + for (let [_, subpath] of source.matchAll(/require\(['"]([^)]*)['"]\)/g)) + if (cache[subpath] === undefined) + cache[subpath] = await require(subpath) + + let module = { exports: {} } + Function("module", "exports", "require", source)(module, module.exports, path => cache[path]) + return module.exports +} + +function snap_from_state(state) { + // return JSON of game state without undo and with log replaced by log length + let save_undo = state.undo + let save_log = state.log + state.undo = undefined + state.log = save_log.length + let snap = JSON.stringify(state) + state.undo = save_undo + state.log = save_log + return snap +} + +function eval_action(s, item, p) { + let [ item_role, item_action, item_arguments ] = item + switch (item_action) { + case ".setup": + return rules.setup(item_arguments[0], item_arguments[1], item_arguments[2]) + case ".resign": + if (params.mode === "debug") + s.log.push([p, item_role.substring(0,2), item_action, null]) + return rules.resign(s, item_role) + default: + if (params.mode === "debug") + s.log.push([p, item_role.substring(0,2), item_action, item_arguments]) + return rules.action(s, item_role, item_action, item_arguments) + } +} + +function on_click_viewpoint(evt) { + for (let button of viewpoint_buttons) + button.classList.toggle("selected", button === evt.target) + viewpoint = evt.target.my_role + update_replay_view() +} + +function create_viewpoint_button(parent, text, role) { + let button = document.createElement("button") + if (role === viewpoint) + button.className = "viewpoint_button selected" + else + button.className = "viewpoint_button" + button.onclick = on_click_viewpoint + button.textContent = text + button.my_role = role + parent.appendChild(button) + viewpoint_buttons.push(button) +} + +function update_replay_view() { + player = viewpoint + + if (viewpoint === "Active") { + player = replay_state.active + if (player === "All" || player === "Both" || player === "None" || !player) + player = "Observer" + } + + let body = document.querySelector("body") + body.classList.remove("Observer") + for (let i = 0; i < roles.length; ++i) + body.classList.remove(roles[i].role.replace(/ /g, "_")) + body.classList.add(player.replace(/ /g, "_")) + + view = rules.view(replay_state, player) + 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.querySelector("body").classList.contains("shift")) { + view.prompt = `[${replay_this}/${replay.length}] ${replay_state.active} / ${replay_state.state}` + if (replay_this < replay.length) + view.prompt += ` / ${replay[replay_this][1]} ${replay[replay_this][2]}` + } else { + view.prompt = "[" + replay_this + "/" + replay.length + "] " + view.prompt + } + } + + if (game_log.length > view.log.length) + game_log.length = view.log.length + let log_start = game_log.length + for (let i = log_start; i < view.log.length; ++i) + game_log.push(view.log[i]) + + on_update_header() + on_update() + on_update_log(log_start, game_log.length) +} + +function replay_query(query, params) { + on_reply(query, rules.query(replay_state, player, query, params)) +} + +function goto_replay(np) { + if (np < 1) + np = 1 + if (np > replay.length) + np = replay.length + set_hash(np) + if (replay_this > np) + replay_this = 0, replay_state = {} + while (replay_this < np) { + replay_state = eval_action(replay_state, replay[replay_this], replay_this) + ++replay_this; + } + update_replay_view() +} + +function set_hash(n) { + history.replaceState(null, "", window.location.pathname + window.location.search + "#" + n) +} + +function on_hash_change() { + goto_replay(parseInt(window.location.hash.slice(1)) || 1) +} + +function on_replay_first() { + goto_replay(1) +} + +function on_replay_last() { + goto_replay(replay.length) +} + +function on_replay_step_prev() { + goto_replay(replay_this-1) +} + +function on_replay_step_next() { + goto_replay(replay_this+1) +} + +function on_replay_jump_prev() { + for (let i = replay_this - 1; i > 1; --i) + if (replay[i].is_checkpoint) + return goto_replay(i) + goto_replay(1) +} + +function on_replay_jump_next() { + for (let i = replay_this + 1; i < replay.length; ++i) + if (replay[i].is_checkpoint) + return goto_replay(i) + goto_replay(replay.length) +} + +let replay_timer = 0 +function on_replay_play_pause() { + if (replay_timer === 0) { + document.getElementById("replay_stop").classList.remove("hide") + document.getElementById("replay_play").classList.add("hide") + replay_timer = setInterval(() => { + if (replay_this < replay.length) + on_replay_step_next() + else + on_replay_play_pause() + }, 1000) + } else { + document.getElementById("replay_stop").classList.add("hide") + document.getElementById("replay_play").classList.remove("hide") + clearInterval(replay_timer) + replay_timer = 0 + } +} + +async function load_replay() { + document.getElementById("prompt").textContent = "Loading replay..." + + remove_resign_menu() + + console.log("LOADING RULES") + rules = await require("rules.js") + + console.log("LOADING REPLAY") + let response = await fetch("/api/replay/" + params.game_id) + if (!response.ok) { + let text = await response.text() + document.getElementById("prompt").textContent = "ERROR " + response.status + ": " + text + return + } + let body = await response.json() + replay = body.replay + + init_player_names(body.players) + + console.log("PROCESSING REPLAY") + let old_active = null + let s = null + for (let p = 0; p < replay.length; ++p) { + try { + s = eval_action(s, replay[p], p) + if (p + 1 < replay.length) + replay[p+1].is_checkpoint = (old_active !== s.active) + old_active = s.active + } catch (err) { + console.log("ERROR IN REPLAY", JSON.stringify(replay[p])) + console.log(err) + if (params.mode === "debug") + replay.length = p + else + replay.length = 0 + break + } + + if (params.mode !== "debug") { + replay[p].digest = crc32c(snap_from_state(s)) + for (let k = p - 1; k > 0; --k) { + if (replay[k].digest === replay[p].digest && !replay[k].remove) { + for (let a = k + 1; a <= p; ++a) + replay[a].remove = true + break + } + } + } + } + + replay = replay.filter(x => !x.remove) + + if (replay.length > 0) { + console.log("REPLAY READY") + if (window.location.hash === "") + set_hash(replay.length) + on_hash_change() + window.addEventListener("hashchange", on_hash_change) + document.querySelector("aside").appendChild(replay_panel) + } else { + console.log("REPLAY NOT AVAILABLE") + replay_state = body.state + update_replay_view() + } + + // Build viewpoint panel + let viewpoint_panel = document.createElement("div") + viewpoint_panel.id = "viewpoint_panel" + create_viewpoint_button(viewpoint_panel, "Active", "Active") + for (let r of roles) + create_viewpoint_button(viewpoint_panel, r.role, r.role) + create_viewpoint_button(viewpoint_panel, "Observer", "Observer") + document.querySelector("header").appendChild(viewpoint_panel) + + // Adjust replay panel + document.getElementById("replay_step_prev").classList.remove("hide") + document.getElementById("replay_step_next").classList.remove("hide") + document.getElementById("replay_last").classList.remove("hide") + + document.getElementById("replay_first").onclick = on_replay_first + document.getElementById("replay_prev").onclick = on_replay_jump_prev + document.getElementById("replay_step_prev").onclick = on_replay_step_prev + document.getElementById("replay_step_next").onclick = on_replay_step_next + document.getElementById("replay_next").onclick = on_replay_jump_next + document.getElementById("replay_last").onclick = on_replay_last + document.getElementById("replay_stop").onclick = on_replay_play_pause + document.getElementById("replay_play").onclick = on_replay_play_pause +} + +load_replay() + +})() diff --git a/public/images/gui_arrow_down.svg b/public/images/gui_arrow_down.svg new file mode 100644 index 0000000..b02c8ac --- /dev/null +++ b/public/images/gui_arrow_down.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 2 8 L 8 14 L 14 8 M 8 2 L 8 14" /> +</svg> diff --git a/public/images/gui_arrow_left.svg b/public/images/gui_arrow_left.svg new file mode 100644 index 0000000..cf11f54 --- /dev/null +++ b/public/images/gui_arrow_left.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8 14 L 2 8 L 8 2 M 2 8 L 14 8" /> +</svg> diff --git a/public/images/gui_arrow_right.svg b/public/images/gui_arrow_right.svg new file mode 100644 index 0000000..011f314 --- /dev/null +++ b/public/images/gui_arrow_right.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8 14 L 14 8 L 8 2 M 2 8 L 14 8" /> +</svg> diff --git a/public/images/gui_arrow_right_stop.svg b/public/images/gui_arrow_right_stop.svg new file mode 100644 index 0000000..c97fe76 --- /dev/null +++ b/public/images/gui_arrow_right_stop.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 8,14 14,8 8,2 M 4,8 14,8 M 15,1 15,15" /> +</svg> diff --git a/public/images/gui_arrow_up.svg b/public/images/gui_arrow_up.svg new file mode 100644 index 0000000..8e50d19 --- /dev/null +++ b/public/images/gui_arrow_up.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 2 8 L 8 2 L 14 8 M 8 2 L 8 14" /> +</svg> diff --git a/public/images/gui_chevron_left.svg b/public/images/gui_chevron_left.svg new file mode 100644 index 0000000..512f417 --- /dev/null +++ b/public/images/gui_chevron_left.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 10 14 L 4 8 L 10 2" /> +</svg> diff --git a/public/images/gui_chevron_right.svg b/public/images/gui_chevron_right.svg new file mode 100644 index 0000000..3a5bddc --- /dev/null +++ b/public/images/gui_chevron_right.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 6 14 L 12 8 L 6 2" /> +</svg> diff --git a/public/images/gui_play.svg b/public/images/gui_play.svg new file mode 100644 index 0000000..20d917b --- /dev/null +++ b/public/images/gui_play.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 12,8 l -10,6 0,-12z"/> +</svg> diff --git a/public/images/gui_stop.svg b/public/images/gui_stop.svg new file mode 100644 index 0000000..648de6e --- /dev/null +++ b/public/images/gui_stop.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> +<path stroke-width="2" stroke="#000" fill="none" d="M 5,2 v 12 M 11,2 v 12"/> +</svg> @@ -322,10 +322,17 @@ create table if not exists game_replay ( replay_id integer, role text, action text, - arguments json, -- numeric affinity is more compact for numbers + arguments json, primary key (game_id, replay_id) ) without rowid; +create table if not exists game_snap ( + game_id integer, + snap_id integer, + state text, + primary key (game_id, snap_id) +); + create table if not exists game_notes ( game_id integer, role text, @@ -440,6 +447,7 @@ begin delete from game_state where game_id = old.game_id; delete from game_chat where game_id = old.game_id; delete from game_replay where game_id = old.game_id; + delete from game_snap where game_id = old.game_id; delete from game_notes where game_id = old.game_id; delete from last_notified where game_id = old.game_id; delete from unread_chats where game_id = old.game_id; @@ -1059,8 +1059,11 @@ const SQL_UPDATE_GAME_STATE = SQL("INSERT OR REPLACE INTO game_state (game_id,st const SQL_UPDATE_GAME_RESULT = SQL("UPDATE games SET status=?, result=? WHERE game_id=?") const SQL_UPDATE_GAME_PRIVATE = SQL("UPDATE games SET is_private=1 WHERE game_id=?") -const SQL_INSERT_REPLAY = SQL("insert into game_replay (game_id,replay_id,role,action,arguments) values (?, (select count(1) + 1 from game_replay where game_id=?), ?,?,?)") -const SQL_DELETE_REPLAY = SQL("delete from game_replay where game_id=?") +const SQL_INSERT_REPLAY = SQL("insert into game_replay (game_id,replay_id,role,action,arguments) values (?, (select coalesce(max(replay_id), 0) + 1 from game_replay where game_id=?) ,?,?,?) returning replay_id").pluck() + +const SQL_INSERT_SNAP = SQL("insert into game_snap (game_id,snap_id,state) values (?, (select coalesce(max(snap_id), 0) + 1 from game_snap where game_id=?), ?) returning snap_id").pluck() +const SQL_SELECT_SNAP = SQL("select state from game_snap where game_id = ? and snap_id = ?").pluck() +const SQL_SELECT_SNAP_COUNT = SQL("select max(snap_id) from game_snap where game_id=?").pluck() const SQL_SELECT_REPLAY = SQL(` select json_object( @@ -1629,14 +1632,14 @@ app.post('/start/:game_id', must_be_logged_in, function (req, res) { let options = game.options ? JSON.parse(game.options) : {} let seed = random_seed() let state = RULES[game.title_id].setup(seed, game.scenario, options) - put_replay(game_id, null, 'setup', [seed, game.scenario, options]) + SQL_UPDATE_GAME_RESULT.run(1, null, game_id) - SQL_UPDATE_GAME_STATE.run(game_id, JSON.stringify(state), state.active) if (is_solo(players)) SQL_UPDATE_GAME_PRIVATE.run(game_id) - update_join_clients_game(game_id) mail_game_started_notification_to_offline_users(game_id) - mail_your_turn_notification_to_offline_users(game_id, null, state.active) + + put_new_state(game_id, state, null, null, ".setup", [seed, game.scenario, options]) + res.send("SUCCESS") }) @@ -2007,52 +2010,121 @@ function get_game_state(game_id) { return JSON.parse(game_state) } +function snap_from_state(state) { + // return JSON of game state without undo and with log replaced by log length + let save_undo = state.undo + let save_log = state.log + state.undo = undefined + state.log = save_log.length + let snap = JSON.stringify(state) + state.undo = save_undo + state.log = save_log + return snap +} + +function put_replay(game_id, role, action, args) { + if (args !== undefined && args !== null && typeof args !== "number") + args = JSON.stringify(args) + return SQL_INSERT_REPLAY.get(game_id, game_id, role, action, args) +} + +function put_snap(game_id, state) { + let snap_id = SQL_INSERT_SNAP.get(game_id, game_id, snap_from_state(state)) + if (game_clients[game_id]) + for (let other of game_clients[game_id]) + send_message(other, "snapsize", snap_id) +} + function put_game_state(game_id, state, old_active) { + // TODO: separate state, undo, and log entries to reuse "snap" json stringifaction? if (state.state === "game_over") { SQL_UPDATE_GAME_RESULT.run(2, state.result, game_id) SQL_DELETE_NOTIFIED_ALL.run(game_id) mail_game_finished_notification_to_offline_users(game_id, state.result) } SQL_UPDATE_GAME_STATE.run(game_id, JSON.stringify(state), state.active) - for (let other of game_clients[game_id]) - send_state(other, state) + if (game_clients[game_id]) + for (let other of game_clients[game_id]) + send_state(other, state) update_join_clients_game(game_id) mail_your_turn_notification_to_offline_users(game_id, old_active, state.active) } -function put_replay(game_id, role, action, args) { - if (args !== undefined && args !== null) - args = JSON.stringify(args) - SQL_INSERT_REPLAY.run(game_id, game_id, role, action, args) +function put_new_state(game_id, state, old_active, role, action, args) { + let replay_id = put_replay(game_id, role, action, args) + if (state.active !== old_active) + put_snap(game_id, state) + put_game_state(game_id, state, old_active) } -function on_action(socket, action, arg) { - if (arg !== undefined) - SLOG(socket, "ACTION", action, JSON.stringify(arg)) +function on_action(socket, action, args) { + if (args !== undefined) + SLOG(socket, "ACTION", action, JSON.stringify(args)) else SLOG(socket, "ACTION", action) try { let state = get_game_state(socket.game_id) let old_active = state.active - state = socket.rules.action(state, socket.role, action, arg) - put_game_state(socket.game_id, state, old_active) - put_replay(socket.game_id, socket.role, action, arg) + state = socket.rules.action(state, socket.role, action, args) + put_new_state(socket.game_id, state, old_active, socket.role, action, args) } catch (err) { console.log(err) return send_message(socket, 'error', err.toString()) } } -function on_query(socket, q) { - let params = undefined - if (Array.isArray(q)) { - params = q[1] - q = q[0] +function on_resign(socket) { + SLOG(socket, "RESIGN") + try { + // TODO: shared "resign" function + let state = get_game_state(socket.game_id) + let old_active = state.active + state = socket.rules.resign(state, socket.role) + put_new_state(socket.game_id, state, old_active, socket.role, ".resign", null) + } catch (err) { + console.log(err) + return send_message(socket, 'error', err.toString()) } - if (params !== undefined) - SLOG(socket, "QUERY", q, JSON.stringify(params)) - else - SLOG(socket, "QUERY", q) +} + +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.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 on_query(socket, q, params) { + SLOG(socket, "QUERY", q, JSON.stringify(params)) try { if (socket.rules.query) { let state = get_game_state(socket.game_id) @@ -2065,15 +2137,14 @@ function on_query(socket, q) { } } -function on_resign(socket) { - SLOG(socket, "RESIGN") +function on_query_snap(socket, snap_id, q, params) { + SLOG(socket, "QUERYSNAP", snap_id, JSON.stringify(params)) try { - let state = get_game_state(socket.game_id) - let old_active = state.active - // TODO: shared "resign" function - state = socket.rules.resign(state, socket.role) - put_game_state(socket.game_id, state, old_active) - put_replay(socket.game_id, socket.role, 'resign', null) + if (socket.rules.query) { + let state = JSON.parse(SQL_SELECT_SNAP.get(socket.game_id, snap_id)) + let reply = socket.rules.query(state, socket.role, q, params) + send_message(socket, 'reply', [q, reply]) + } } catch (err) { console.log(err) return send_message(socket, 'error', err.toString()) @@ -2148,35 +2219,17 @@ function on_chat(socket, message) { } } -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 on_restore(socket, state_text) { - if (!DEBUG) - send_message(socket, 'error', "Debugging is not enabled on this server.") - SLOG(socket, "RESTORE") +function on_snap(socket, snap_id) { + SLOG(socket, "SNAP", snap_id) try { - let state = JSON.parse(state_text) - state.seed = random_seed() // reseed! - state_text = JSON.stringify(state) - SQL_UPDATE_GAME_RESULT.run(1, null, socket.game_id) - SQL_UPDATE_GAME_STATE.run(socket.game_id, state_text, state.active) - put_replay(socket.game_id, null, 'debug-restore', state_text) - for (let other of game_clients[socket.game_id]) { - other.seen = 0 - send_state(other, state) + let snap_state = SQL_SELECT_SNAP.get(socket.game_id, snap_id) + if (snap_state) { + let state = JSON.parse(snap_state) + let view = socket.rules.view(state, socket.role) + view.prompt = undefined + view.actions = undefined + view.log = state.log + send_message(socket, "snap", [snap_id, state.active, view]) } } catch (err) { console.log(err) @@ -2193,34 +2246,13 @@ function broadcast_presence(game_id) { send_message(socket, 'presence', presence) } -function on_restart(socket, scenario) { - if (!DEBUG) - send_message(socket, 'error', "Debugging is not enabled on this server.") - try { - let seed = random_seed() - let options = JSON.parse(SQL_SELECT_GAME.get(socket.game_id).options) - let state = socket.rules.setup(seed, scenario, options) - put_replay(socket.game_id, null, 'setup', [seed, scenario, options]) - for (let other of game_clients[socket.game_id]) { - other.seen = 0 - send_state(other, state) - } - let state_text = JSON.stringify(state) - SQL_UPDATE_GAME_RESULT.run(1, null, socket.game_id) - SQL_UPDATE_GAME_STATE.run(socket.game_id, state_text, state.active) - } catch (err) { - console.log(err) - return send_message(socket, 'error', err.toString()) - } -} - function handle_player_message(socket, cmd, arg) { switch (cmd) { case "action": on_action(socket, arg[0], arg[1]) break case "query": - on_query(socket, arg) + on_query(socket, arg[0], arg[1]) break case "resign": on_resign(socket) @@ -2237,22 +2269,31 @@ function handle_player_message(socket, cmd, arg) { case "chat": on_chat(socket, arg) break + case "getsnap": + on_snap(socket, arg | 0) + break + 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 - case "restart": - on_restart(socket, arg) - break } } function handle_observer_message(socket, cmd, arg) { switch (cmd) { + case "getsnap": + on_snap(socket, arg) + break + case "querysnap": + on_query_snap(socket, arg[0], arg[1], arg[2]) + break case 'query': - on_query(socket, arg) + on_query(socket, arg[0], arg[1]) break } } @@ -2331,6 +2372,11 @@ wss.on('connection', (socket, req) => { }) broadcast_presence(socket.game_id) + + let snapsize = SQL_SELECT_SNAP_COUNT.get(socket.game_id) + if (snapsize > 0) + send_message(socket, "snapsize", snapsize) + send_state(socket, get_game_state(socket.game_id)) } catch (err) { console.log(err) diff --git a/tools/patchgame.js b/tools/patchgame.js index 42b6c57..9751d26 100644..100755 --- a/tools/patchgame.js +++ b/tools/patchgame.js @@ -1,76 +1,219 @@ -#!/usr/bin/env node +#!/usr/bin/env -S node -const VERIFY = true +const sqlite3 = require("better-sqlite3") -const fs = require('fs') -const sqlite3 = require('better-sqlite3') +let db = new sqlite3("db") -if (process.argv.length !== 3) { - process.stderr.write("usage: ./tools/patchgame.js <game_id>\n") - process.exit(1) -} +let select_game = db.prepare("select * from games where game_id=?") -let db = new sqlite3("./db") +let select_replay = db.prepare("select * from game_replay where game_id=?") +let delete_replay = db.prepare("delete from game_replay where game_id=?") +let insert_replay = db.prepare("insert into game_replay (game_id,replay_id,role,action,arguments) values (?,?,?,?,?)") -let game_id = process.argv[2] -let title_id = db.prepare("select title_id from games where game_id=?").pluck().get(game_id) -let rules = require("../public/" + title_id + "/rules.js") -let log = db.prepare("select * from game_replay where game_id=?").all(game_id) +let delete_snap = db.prepare("delete from game_snap where game_id=?") +let insert_snap = db.prepare("insert into game_snap(game_id,snap_id,state) values (?,?,?)") -let save = db.prepare("select state from game_state where game_id=?").pluck().get(game_id) -fs.writeFileSync("backup-" + game_id + ".txt", save) +let update_state = db.prepare("update game_state set active=?, state=? where game_id=?") -function is_valid_action(rules, game, role, action, a) { - if (action !== 'undo') - if (game.active !== role && game.active !== "Both" && game.active !== "All") - return false - let view = rules.view(game, role) - let va = view.actions[action] - if (va === undefined) +const CRC32C_TABLE = new Int32Array([ + 0x00000000, 0xf26b8303, 0xe13b70f7, 0x1350f3f4, 0xc79a971f, 0x35f1141c, 0x26a1e7e8, 0xd4ca64eb, + 0x8ad958cf, 0x78b2dbcc, 0x6be22838, 0x9989ab3b, 0x4d43cfd0, 0xbf284cd3, 0xac78bf27, 0x5e133c24, + 0x105ec76f, 0xe235446c, 0xf165b798, 0x030e349b, 0xd7c45070, 0x25afd373, 0x36ff2087, 0xc494a384, + 0x9a879fa0, 0x68ec1ca3, 0x7bbcef57, 0x89d76c54, 0x5d1d08bf, 0xaf768bbc, 0xbc267848, 0x4e4dfb4b, + 0x20bd8ede, 0xd2d60ddd, 0xc186fe29, 0x33ed7d2a, 0xe72719c1, 0x154c9ac2, 0x061c6936, 0xf477ea35, + 0xaa64d611, 0x580f5512, 0x4b5fa6e6, 0xb93425e5, 0x6dfe410e, 0x9f95c20d, 0x8cc531f9, 0x7eaeb2fa, + 0x30e349b1, 0xc288cab2, 0xd1d83946, 0x23b3ba45, 0xf779deae, 0x05125dad, 0x1642ae59, 0xe4292d5a, + 0xba3a117e, 0x4851927d, 0x5b016189, 0xa96ae28a, 0x7da08661, 0x8fcb0562, 0x9c9bf696, 0x6ef07595, + 0x417b1dbc, 0xb3109ebf, 0xa0406d4b, 0x522bee48, 0x86e18aa3, 0x748a09a0, 0x67dafa54, 0x95b17957, + 0xcba24573, 0x39c9c670, 0x2a993584, 0xd8f2b687, 0x0c38d26c, 0xfe53516f, 0xed03a29b, 0x1f682198, + 0x5125dad3, 0xa34e59d0, 0xb01eaa24, 0x42752927, 0x96bf4dcc, 0x64d4cecf, 0x77843d3b, 0x85efbe38, + 0xdbfc821c, 0x2997011f, 0x3ac7f2eb, 0xc8ac71e8, 0x1c661503, 0xee0d9600, 0xfd5d65f4, 0x0f36e6f7, + 0x61c69362, 0x93ad1061, 0x80fde395, 0x72966096, 0xa65c047d, 0x5437877e, 0x4767748a, 0xb50cf789, + 0xeb1fcbad, 0x197448ae, 0x0a24bb5a, 0xf84f3859, 0x2c855cb2, 0xdeeedfb1, 0xcdbe2c45, 0x3fd5af46, + 0x7198540d, 0x83f3d70e, 0x90a324fa, 0x62c8a7f9, 0xb602c312, 0x44694011, 0x5739b3e5, 0xa55230e6, + 0xfb410cc2, 0x092a8fc1, 0x1a7a7c35, 0xe811ff36, 0x3cdb9bdd, 0xceb018de, 0xdde0eb2a, 0x2f8b6829, + 0x82f63b78, 0x709db87b, 0x63cd4b8f, 0x91a6c88c, 0x456cac67, 0xb7072f64, 0xa457dc90, 0x563c5f93, + 0x082f63b7, 0xfa44e0b4, 0xe9141340, 0x1b7f9043, 0xcfb5f4a8, 0x3dde77ab, 0x2e8e845f, 0xdce5075c, + 0x92a8fc17, 0x60c37f14, 0x73938ce0, 0x81f80fe3, 0x55326b08, 0xa759e80b, 0xb4091bff, 0x466298fc, + 0x1871a4d8, 0xea1a27db, 0xf94ad42f, 0x0b21572c, 0xdfeb33c7, 0x2d80b0c4, 0x3ed04330, 0xccbbc033, + 0xa24bb5a6, 0x502036a5, 0x4370c551, 0xb11b4652, 0x65d122b9, 0x97baa1ba, 0x84ea524e, 0x7681d14d, + 0x2892ed69, 0xdaf96e6a, 0xc9a99d9e, 0x3bc21e9d, 0xef087a76, 0x1d63f975, 0x0e330a81, 0xfc588982, + 0xb21572c9, 0x407ef1ca, 0x532e023e, 0xa145813d, 0x758fe5d6, 0x87e466d5, 0x94b49521, 0x66df1622, + 0x38cc2a06, 0xcaa7a905, 0xd9f75af1, 0x2b9cd9f2, 0xff56bd19, 0x0d3d3e1a, 0x1e6dcdee, 0xec064eed, + 0xc38d26c4, 0x31e6a5c7, 0x22b65633, 0xd0ddd530, 0x0417b1db, 0xf67c32d8, 0xe52cc12c, 0x1747422f, + 0x49547e0b, 0xbb3ffd08, 0xa86f0efc, 0x5a048dff, 0x8ecee914, 0x7ca56a17, 0x6ff599e3, 0x9d9e1ae0, + 0xd3d3e1ab, 0x21b862a8, 0x32e8915c, 0xc083125f, 0x144976b4, 0xe622f5b7, 0xf5720643, 0x07198540, + 0x590ab964, 0xab613a67, 0xb831c993, 0x4a5a4a90, 0x9e902e7b, 0x6cfbad78, 0x7fab5e8c, 0x8dc0dd8f, + 0xe330a81a, 0x115b2b19, 0x020bd8ed, 0xf0605bee, 0x24aa3f05, 0xd6c1bc06, 0xc5914ff2, 0x37faccf1, + 0x69e9f0d5, 0x9b8273d6, 0x88d28022, 0x7ab90321, 0xae7367ca, 0x5c18e4c9, 0x4f48173d, 0xbd23943e, + 0xf36e6f75, 0x0105ec76, 0x12551f82, 0xe03e9c81, 0x34f4f86a, 0xc69f7b69, 0xd5cf889d, 0x27a40b9e, + 0x79b737ba, 0x8bdcb4b9, 0x988c474d, 0x6ae7c44e, 0xbe2da0a5, 0x4c4623a6, 0x5f16d052, 0xad7d5351 +]) + +function crc32c(data) { + let x = 0 + for (let i = 0, n = data.length; i < n; ++i) + x = CRC32C_TABLE[(x ^ data.charCodeAt(i)) & 0xff] ^ (x >>> 8) + return x ^ -1 +} + +function snapshot(state) { + let save_undo = state.undo + let save_log = state.log + state.undo = undefined + state.log = save_log.length + let snap = JSON.stringify(state) + state.undo = save_undo + state.log = save_log + return snap +} + +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") return false - if (a === undefined || a === null) - return (va === 1) || (typeof va === 'string') - if (Array.isArray(a)) - a = a[0] - if (!Array.isArray(va)) - throw new Error("action list not array:" + JSON.stringify(view.actions)) - return va.includes(a) + let view = rules.view(state, role) + let va = view.actions[action] + if (va) { + if (Array.isArray(arg)) + arg = arg[0] + if (arg === undefined || arg === null) + return (va === 1 || va === true || typeof va === "string") + if (Array.isArray(va) && va.includes(arg)) + return true + } + return false } -let game = { state: null, active: null } -let view = null -let i = 0 -try { - log.forEach(item => { - let args = JSON.parse(item.arguments) - if (item.action === 'setup') - game = rules.setup(args[0], args[1], args[2]) - else if (item.action === 'resign') - game = rules.resign(game, item.role) - else { - console.log("ACTION", i, game.state, game.active, ">", item.role, item.action, item.arguments) - if (VERIFY) { - if (!is_valid_action(rules, game, item.role, item.action, args)) { - console.log(`invalid action: ${item.role} ${item.action} ${item.arguments}`) - console.log("\t", game.state, game.active, JSON.stringify(rules.view(game, item.role).actions)) - throw "invalid action" +function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_undo=true, delete_invalid=false}, verbose) { + let game = select_game.get(game_id) + if (!game) { + console.error("game not found:", game_id) + return + } + + let title_id = game.title_id + let rules = require("../public/" + title_id + "/rules.js") + + let replay = select_replay.all(game_id) + if (replay.length === 0) + return + + console.log("processing", game_id, title_id) + + try { + let state = null + let old_active = null + let need_to_rewrite = false + + for (let i = 0; i < replay.length; ++i) { + let item = replay[i] + + if (verbose) + console.log(item.replay_id, item.role, item.action, item.arguments) + + let args = JSON.parse(item.arguments) + switch (item.action) { + case ".setup": + state = rules.setup(...args) + break + case ".resign": + state = rules.resign(state, item.role) + break + default: + if (validate_actions) { + if (!is_valid_action(rules, state, item.role, item.action, args)) { + console.error(`invalid action: ${item.role} ${item.action} ${item.arguments}`) + console.error("\t", state.state, state.active, JSON.stringify(rules.view(state, item.role).actions)) + if (i < replay.length) { + console.log("BROKEN ENTRIES: %d", replay.length-i) + console.log(`sqlite3 db "delete from game_replay where game_id=${game_id} and replay_id>=${replay[i].replay_id}"`) + } + throw "invalid action" + } + } + state = rules.action(state, item.role, item.action, args) + break + } + + item.state = snapshot(state) + item.checksum = crc32c(item.state) + if (old_active !== state.active) + item.save = 1 + old_active = state.active + + if (delete_undo) { + if (item.action === "undo") { + for (let k = i-1; k >= 0; --k) { + if (replay[k].checksum === item.checksum) { + need_to_rewrite = true + for (let z = k+1; z <= i; ++z) + replay[z].remove = 1 + break + } + } } } - game = rules.action(game, item.role, item.action, args) } - ++i - }) - console.log("SUCCESS %d", log.length) - db.prepare("update game_state set active=?, state=? where game_id=?").run(game.active, JSON.stringify(game), game_id) -} catch (err) { - console.log("FAILED %d/%d", i+1, log.length) - console.log(err) - delete game.log - delete game.undo - console.log(game) + + db.exec("begin") + + if (need_to_rewrite) { + delete_replay.run(game_id) + let replay_id = 0 + for (item of replay) + if (!item.remove) + insert_replay.run(game_id, ++replay_id, item.role, item.action, item.arguments) + } + + if (save_snaps) { + delete_snap.run(game_id) + let snap_id = 0 + for (item of replay) + if (item.save) + insert_snap.run(game_id, ++snap_id, item.state) + } + + update_state.run(state.active, JSON.stringify(state), game_id) + + db.exec("commit") + + } catch (err) { + if (err !== "invalid action") + console.error("ERROR", game_id, title_id, err.message) + if (delete_invalid) { + delete_replay.run(game_id) + delete_snap.run(game_id) + } + } +} + +function patch_all(options) { + for (let game_id of db.prepare("select game_id from games where status=1").pluck().all()) + patch_game(game_id, options, false) } -if (i < log.length) { - console.log("BROKEN ENTRIES: %d", log.length-i) - console.log(`sqlite3 db "delete from game_replay where game_id=${game_id} and replay_id>=${log[i].replay_id}"`) +function patch_title(title_id, options) { + for (let game_id of db.prepare("select game_id from games where status=1 and title_id=?").pluck().all(title_id)) + patch_game(game_id, options, false) } + +if (process.argv.length < 3) { + process.stderr.write("usage: ./tools/patchgame.js <game_id> '{options}'\n") + process.stderr.write(" or: ./tools/patchgame.js <title_id> '{options}'\n") + process.stderr.write(" or: ./tools/patchgame.js all '{options}'\n") + process.stderr.write('options: { "validate_actions":true, "delete_invalid":false, "save_snaps":true, "delete_undo":true }\n') + process.exit(1) +} + +let options = {} +if (process.argv.length === 4) + options = JSON.parse(process.argv[3]) + +if (process.argv[2] === 'all') + patch_all(options) +else if (isNaN(process.argv[2])) + patch_title(process.argv[2], options) +else + patch_game(parseInt(process.argv[2]), options, true) |