summaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-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
12 files changed, 587 insertions, 358 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>