diff options
-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) |