summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--public/common/play.css61
-rw-r--r--public/common/play.js515
-rw-r--r--public/common/replay.js342
-rw-r--r--public/images/gui_arrow_down.svg3
-rw-r--r--public/images/gui_arrow_left.svg3
-rw-r--r--public/images/gui_arrow_right.svg3
-rw-r--r--public/images/gui_arrow_right_stop.svg3
-rw-r--r--public/images/gui_arrow_up.svg3
-rw-r--r--public/images/gui_chevron_left.svg3
-rw-r--r--public/images/gui_chevron_right.svg3
-rw-r--r--public/images/gui_play.svg3
-rw-r--r--public/images/gui_stop.svg3
-rw-r--r--schema.sql10
-rw-r--r--server.js224
-rwxr-xr-x[-rw-r--r--]tools/patchgame.js263
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>
diff --git a/schema.sql b/schema.sql
index 95e2690..f959720 100644
--- a/schema.sql
+++ b/schema.sql
@@ -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;
diff --git a/server.js b/server.js
index 078a5c6..31a7def 100644
--- a/server.js
+++ b/server.js
@@ -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)