diff options
-rw-r--r-- | public/common/client.js | 702 | ||||
-rw-r--r-- | public/common/grid.css | 17 | ||||
-rw-r--r-- | public/join.js | 5 | ||||
-rw-r--r-- | server.js | 63 | ||||
-rw-r--r-- | views/head.pug | 13 |
5 files changed, 542 insertions, 258 deletions
diff --git a/public/common/client.js b/public/common/client.js index 01b6c05..d9a1f48 100644 --- a/public/common/client.js +++ b/public/common/client.js @@ -1,28 +1,25 @@ "use strict"; -/* global io, on_update */ +/* URL: /$title_id/(re)play:$game_id:$role */ -/* URL: /$title_id/play:$game_id:$role */ -if (!/\/[\w-]+\/play:\d+(:[\w-]+)?/.test(window.location.pathname)) { +if (!/\/[\w-]+\/(re)?play:\d+(:[\w-]+)?/.test(window.location.pathname)) { document.getElementById("prompt").textContent = "Invalid game ID."; throw Error("Invalid game ID."); } -const param_title_id = window.location.pathname.split("/")[1]; -const param_game_id = decodeURIComponent(window.location.pathname.split("/")[2]).split(":")[1] | 0; -const param_role = decodeURIComponent(window.location.pathname.split("/")[2]).split(":")[2] || "Observer"; +let params = { + mode: window.location.pathname.split("/")[2].split(":")[0], + title_id: window.location.pathname.split("/")[1], + game_id: decodeURIComponent(window.location.pathname.split("/")[2]).split(":")[1] | 0, + role: decodeURIComponent(window.location.pathname.split("/")[2]).split(":")[2] || "Observer", +} -let game = null; -let game_over = false; -let player = null; -let socket = null; +let roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})); -let chat_is_visible = false; -let chat_text = null; -let chat_key = null; -let chat_last_day = null; -let chat_log = 0; -let chat_seen = 0; +let view = null; +let player = "Observer"; +let socket = null; +let chat = null; function scroll_with_middle_mouse(panel_sel, multiplier) { let panel = document.querySelector(panel_sel); @@ -35,8 +32,8 @@ function scroll_with_middle_mouse(panel_sel, multiplier) { down_y = e.clientY; scroll_x = panel.scrollLeft; scroll_y = panel.scrollTop; - window.addEventListener('mousemove', mm); - window.addEventListener('mouseup', mu); + window.addEventListener("mousemove", mm); + window.addEventListener("mouseup", mu); e.preventDefault(); } } @@ -49,12 +46,12 @@ function scroll_with_middle_mouse(panel_sel, multiplier) { } function mu(e) { if (e.button === 1) { - window.removeEventListener('mousemove', mm); - window.removeEventListener('mouseup', mu); + window.removeEventListener("mousemove", mm); + window.removeEventListener("mouseup", mu); e.preventDefault(); } } - panel.addEventListener('mousedown', md); + panel.addEventListener("mousedown", md); } function drag_element_with_mouse(element_sel, grabber_sel) { @@ -65,8 +62,8 @@ function drag_element_with_mouse(element_sel, grabber_sel) { if (e.button === 0) { save_x = e.clientX; save_y = e.clientY; - window.addEventListener('mousemove', mm); - window.addEventListener('mouseup', mu); + window.addEventListener("mousemove", mm); + window.addEventListener("mouseup", mu); e.preventDefault(); } } @@ -81,12 +78,12 @@ function drag_element_with_mouse(element_sel, grabber_sel) { } function mu(e) { if (e.button === 0) { - window.removeEventListener('mousemove', mm); - window.removeEventListener('mouseup', mu); + window.removeEventListener("mousemove", mm); + window.removeEventListener("mouseup", mu); e.preventDefault(); } } - grabber.addEventListener('mousedown', md); + grabber.addEventListener("mousedown", md); } /* TITLE BLINKER */ @@ -115,7 +112,18 @@ function stop_blinker() { window.addEventListener("focus", stop_blinker); -function load_chat() { +/* CHAT */ + +function init_chat() { + // only fetch new messages when we reconnect! + if (chat !== null) { + console.log("RECONNECT CHAT"); + socket.emit("getchat", chat.log); + return; + } + + console.log("CONNECT CHAT"); + let chat_window = document.createElement("div"); chat_window.id = "chat_window"; chat_window.innerHTML = ` @@ -132,15 +140,50 @@ function load_chat() { chat_button.addEventListener("click", toggle_chat); document.querySelector("header").insertBefore(chat_button, document.getElementById("prompt")); - chat_key = "chat/" + param_game_id; - chat_text = document.getElementById("chat_text"); - chat_last_day = null; - chat_log = 0; - chat_seen = window.localStorage.getItem(chat_key) | 0; + chat = { + is_visible: false, + text_element: document.getElementById("chat_text"), + key: "chat/" + params.game_id, + last_day: null, + log: 0 + } + + chat.seen = window.localStorage.getItem(chat.key) | 0; + + drag_element_with_mouse("#chat_window", "#chat_header"); + + document.getElementById("chat_form").addEventListener("submit", e => { + let input = document.getElementById("chat_input"); + e.preventDefault(); + if (input.value) { + socket.emit("chat", input.value); + input.value = ""; + } else { + hide_chat(); + } + }); + + document.querySelector("body").addEventListener("keydown", e => { + if (e.key === "Escape") { + if (chat.is_visible) { + e.preventDefault(); + hide_chat(); + } + } + if (e.key === "Enter") { + let input = document.getElementById("chat_input"); + if (document.activeElement !== input) { + e.preventDefault(); + show_chat(); + } + } + }); + + socket.emit("getchat", 0); } function save_chat() { - window.localStorage.setItem(chat_key, chat_log); + window.localStorage.setItem(chat.key, chat.log); } function update_chat(chat_id, utc_date, user, message) { @@ -155,38 +198,70 @@ function update_chat(chat_id, utc_date, user, message) { let line = document.createElement("div"); line.className = "date"; line.textContent = "~ " + date + " ~"; - chat_text.appendChild(line); + chat.text_element.appendChild(line); } function add_chat_line(time, user, message) { let line = document.createElement("div"); line.textContent = "[" + time + "] " + user + " \xbb " + message; - chat_text.appendChild(line); - chat_text.scrollTop = chat_text.scrollHeight; + chat.text_element.appendChild(line); + chat.text_element.scrollTop = chat.text_element.scrollHeight; } - if (chat_id > chat_log) { - chat_log = chat_id; + if (chat_id > chat.log) { + chat.log = chat_id; let date = new Date(utc_date + "Z"); let day = date.toDateString(); - if (day !== chat_last_day) { + if (day !== chat.last_day) { add_date_line(day); - chat_last_day = day; + chat.last_day = day; } add_chat_line(format_time(date), user, message); } - if (chat_id > chat_seen) { + if (chat_id > chat.seen) { let button = document.getElementById("chat_button"); start_blinker("NEW MESSAGE"); - if (!chat_is_visible) + if (!chat.is_visible) button.classList.add("new"); else save_chat(); } } -function init_client(roles) { - game = null; - player = null; +function show_chat() { + if (!chat.is_visible) { + document.getElementById("chat_button").classList.remove("new"); + document.getElementById("chat_window").classList.add("show"); + document.getElementById("chat_input").focus(); + chat.is_visible = true; + save_chat(); + } +} + +function hide_chat() { + if (chat.is_visible) { + document.getElementById("chat_window").classList.remove("show"); + document.getElementById("chat_input").blur(); + chat.is_visible = false; + } +} + +function toggle_chat() { + if (chat.is_visible) + hide_chat(); + else + show_chat(); +} + +/* CONNECT TO GAME SERVER */ + +function init_player_names(players) { + for (let i = 0; i < roles.length; ++i) { + let sel = "#" + roles[i].id + " .role_user"; + let p = players.find(p => p.role === roles[i].role); + document.querySelector(sel).textContent = p ? p.name : "NONE"; + } +} +function init_play_client() { const ROLE_SEL = [ ".role.one", ".role.two", @@ -197,162 +272,119 @@ function init_client(roles) { ".role.seven", ]; - const USER_SEL = [ - ".role.one .role_user", - ".role.two .role_user", - ".role.three .role_user", - ".role.four .role_user", - ".role.five .role_user", - ".role.six .role_user", - ".role.seven .role_user", - ]; - - load_chat(); - - console.log("JOINING", param_title_id + "/" + param_game_id + "/" + param_role); + console.log("JOINING", params.title_id + "/" + params.game_id + "/" + params.role); socket = io({ - transports: ['websocket'], - query: { title: param_title_id, game: param_game_id, role: param_role }, + transports: ["websocket"], + query: { title: params.title_id, game: params.game_id, role: params.role }, }); - socket.on('connect', () => { + socket.on("connect", () => { console.log("CONNECTED"); - document.querySelector("header").classList.remove('disconnected'); - socket.emit('getchat', chat_log); // only send new messages when we reconnect! + document.querySelector("header").classList.remove("disconnected"); }); - socket.on('disconnect', () => { + socket.on("disconnect", () => { console.log("DISCONNECTED"); document.getElementById("prompt").textContent = "Disconnected from server!"; - document.querySelector("header").classList.add('disconnected'); + document.querySelector("header").classList.add("disconnected"); }); - socket.on('roles', (me, players) => { - console.log("ROLES", me, JSON.stringify(players)); - player = me.replace(/ /g, '_'); - if (player === "Observer") - document.getElementById("chat_button").style.display = "none"; - document.querySelector("body").classList.add(player); - for (let i = 0; i < roles.length; ++i) { - let pr = players.find(p => p.role === roles[i]); - document.querySelector(USER_SEL[i]).textContent = pr ? pr.name : "NONE"; - } + socket.on("roles", (me, players) => { + console.log("PLAYERS", me, JSON.stringify(players)); + player = me; + document.querySelector("body").classList.add(player.replace(/ /g, "_")); + if (player !== "Observer") + init_chat(); + init_player_names(players); }); - socket.on('presence', (presence) => { + socket.on("presence", (presence) => { console.log("PRESENCE", JSON.stringify(presence)); for (let i = 0; i < roles.length; ++i) { - let elt = document.querySelector(ROLE_SEL[i]); - if (roles[i] in presence) - elt.classList.add('present'); + let elt = document.getElementById(roles[i].id); + if (roles[i].role in presence) + elt.classList.add("present"); else - elt.classList.remove('present'); + elt.classList.remove("present"); } }); - socket.on('state', (new_game, new_game_over) => { - console.log("STATE", !!new_game.actions, new_game_over); - game = new_game; - game_over = new_game_over; - on_update_bar(); + socket.on("state", (new_view) => { + console.log("STATE"); + view = new_view; + on_update_header(); on_update(); - on_game_over(); on_update_log(); }); - socket.on('save', (msg) => { + socket.on("save", (msg) => { console.log("SAVE"); - window.localStorage[param_title_id + '/save'] = msg; + window.localStorage[params.title_id + "/save"] = msg; }); - socket.on('error', (msg) => { + socket.on("error", (msg) => { console.log("ERROR", msg); document.getElementById("prompt").textContent = msg; }); - socket.on('chat', function (item) { - console.log("CHAT", JSON.stringify(item)); + socket.on("chat", function (item) { update_chat(item[0], item[1], item[2], item[3]); }); - - document.getElementById("chat_form").addEventListener("submit", e => { - let input = document.getElementById("chat_input"); - e.preventDefault(); - if (input.value) { - socket.emit('chat', input.value); - input.value = ''; - } else { - hide_chat(); - } - }); - - document.querySelector("body").addEventListener("keydown", e => { - if (player && player !== "Observer") { - if (e.key === "Escape") { - if (chat_is_visible) { - e.preventDefault(); - hide_chat(); - } - } - if (e.key === "Enter") { - let input = document.getElementById("chat_input"); - if (document.activeElement !== input) { - e.preventDefault(); - show_chat(); - } - } - } - }); - - drag_element_with_mouse("#chat_window", "#chat_header"); } +/* HEADER */ + let is_your_turn = false; let old_active = null; -function on_update_bar() { - document.getElementById("prompt").textContent = game.prompt; - if (game.actions) { +function on_update_header() { + document.getElementById("prompt").textContent = view.prompt; + if (params.mode === "replay") + return; + if (view.actions) { document.querySelector("header").classList.add("your_turn"); - if (!is_your_turn || old_active !== game.active) + if (!is_your_turn || old_active !== view.active) start_blinker("YOUR TURN"); is_your_turn = true; } else { document.querySelector("header").classList.remove("your_turn"); is_your_turn = false; } - old_active = game.active; + old_active = view.active; } +/* LOG */ + let create_log_entry = function (text) { - let p = document.createElement("div"); - p.textContent = text; - return p; + let div = document.createElement("div"); + div.textContent = text; + return div; } -let log_scroller = document.getElementById("log"); - function on_update_log() { - let parent = document.getElementById("log"); - let to_delete = parent.children.length - game.log_start; + let div = document.getElementById("log"); + let to_delete = div.children.length - view.log_start; while (to_delete-- > 0) - parent.removeChild(parent.lastChild); - for (let entry of game.log) - parent.appendChild(create_log_entry(entry)); - log_scroller.scrollTop = log_scroller.scrollHeight; + div.removeChild(div.lastChild); + for (let entry of view.log) + div.appendChild(create_log_entry(entry)); + scroll_log_to_end(); +} + +function scroll_log_to_end() { + let div = document.getElementById("log"); + div.scrollTop = div.scrollHeight; } try { - new ResizeObserver(entries => { - log_scroller.scrollTop = log_scroller.scrollHeight; - }).observe(log_scroller); + new ResizeObserver(scroll_log_to_end).observe(document.getElementById("log")); } catch (err) { - window.addEventListener("resize", evt => { - log_scroller.scrollTop = log_scroller.scrollHeight; - }); + window.addEventListener("resize", scroll_log_to_end); } +/* MAP ZOOM */ + function toggle_fullscreen() { if (document.fullscreen) document.exitFullscreen(); @@ -360,78 +392,60 @@ function toggle_fullscreen() { document.documentElement.requestFullscreen(); } -function show_chat() { - if (!chat_is_visible) { - document.getElementById("chat_button").classList.remove("new"); - document.getElementById("chat_window").classList.add("show"); - document.getElementById("chat_input").focus(); - chat_is_visible = true; - save_chat(); - } +function toggle_log() { + document.querySelector("aside").classList.toggle("hide"); + zoom_map(); } -function hide_chat() { - if (chat_is_visible) { - document.getElementById("chat_window").classList.remove("show"); - document.getElementById("chat_input").blur(); - chat_is_visible = false; +function toggle_zoom() { + let mapwrap = document.getElementById("mapwrap"); + if (mapwrap) { + mapwrap.classList.toggle("fit"); + zoom_map(); } } -function toggle_chat() { - if (chat_is_visible) - hide_chat(); - else - show_chat(); -} - function zoom_map() { - let grid = document.querySelector("main"); let mapwrap = document.getElementById("mapwrap"); - let map = document.getElementById("map"); - map.style.transform = null; - mapwrap.style.width = null; - mapwrap.style.height = null; - if (mapwrap.classList.contains("fit")) { - let { width: gw, height: gh } = grid.getBoundingClientRect(); - let { width: ww, height: wh } = mapwrap.getBoundingClientRect(); - let { width: cw, height: ch } = map.getBoundingClientRect(); - let scale = Math.min(ww / cw, gh / ch); - if (scale < 1) { - map.style.transform = "scale(" + scale + ")"; - mapwrap.style.width = (cw * scale) + "px"; - mapwrap.style.height = (ch * scale) + "px"; + if (mapwrap) { + let main = document.querySelector("main"); + let map = document.getElementById("map"); + map.style.transform = null; + mapwrap.style.width = null; + mapwrap.style.height = null; + if (mapwrap.classList.contains("fit")) { + let { width: gw, height: gh } = main.getBoundingClientRect(); + let { width: ww, height: wh } = mapwrap.getBoundingClientRect(); + let { width: cw, height: ch } = map.getBoundingClientRect(); + let scale = Math.min(ww / cw, gh / ch); + if (scale < 1) { + map.style.transform = "scale(" + scale + ")"; + mapwrap.style.width = (cw * scale) + "px"; + mapwrap.style.height = (ch * scale) + "px"; + } } } } -function toggle_zoom() { - document.getElementById("mapwrap").classList.toggle('fit'); - zoom_map(); -} +zoom_map(); -function init_map_zoom() { - window.addEventListener('resize', zoom_map); - zoom_map(); -} +window.addEventListener("resize", zoom_map); -function init_shift_zoom() { - window.addEventListener("keydown", (evt) => { - if (evt.key === "Shift") - document.querySelector("body").classList.add("shift"); - }); - window.addEventListener("keyup", (evt) => { - if (evt.key === "Shift") - document.querySelector("body").classList.remove("shift"); - }); -} +window.addEventListener("keydown", (evt) => { + if (evt.key === "Shift") + document.querySelector("body").classList.add("shift"); +}); -function toggle_log() { - document.querySelector("aside").classList.toggle("hide"); - zoom_map(); -} +window.addEventListener("keyup", (evt) => { + if (evt.key === "Shift") + document.querySelector("body").classList.remove("shift"); +}); + +/* ACTIONS */ function action_button(action, label) { + if (params.mode === "replay") + return; let id = action + "_button"; let button = document.getElementById(id); if (!button) { @@ -441,11 +455,11 @@ function action_button(action, label) { button.addEventListener("click", evt => send_action(action)); document.getElementById("actions").appendChild(button); } - if (game.actions && action in game.actions) { + if (view.actions && action in view.actions) { button.classList.remove("hide"); - if (game.actions[action]) { + if (view.actions[action]) { if (label === undefined) - button.textContent = game.actions[action]; + button.textContent = view.actions[action]; button.disabled = false; } else { button.disabled = true; @@ -455,66 +469,282 @@ function action_button(action, label) { } } -function confirm_resign() { - if (window.confirm("Are you sure that you want to resign?")) - socket.emit('resign'); -} - function send_action(verb, noun) { + if (params.mode === "replay") + return; // Reset action list here so we don't send more than one action per server prompt! if (noun !== undefined) { - if (game.actions && game.actions[verb] && game.actions[verb].includes(noun)) { - game.actions = null; + if (view.actions && view.actions[verb] && view.actions[verb].includes(noun)) { + view.actions = null; console.log("ACTION", verb, JSON.stringify(noun)); - socket.emit('action', verb, noun); + socket.emit("action", verb, noun); return true; } } else { - if (game.actions && game.actions[verb]) { - game.actions = null; + if (view.actions && view.actions[verb]) { + view.actions = null; console.log("ACTION", verb); - socket.emit('action', verb); + socket.emit("action", verb); return true; } } return false; } +function confirm_resign() { + if (window.confirm("Are you sure that you want to resign?")) + socket.emit("resign"); +} + +/* DEBUGGING */ + function send_save() { - socket.emit('save'); + socket.emit("save"); } function send_restore() { - socket.emit('restore', window.localStorage[param_title_id + '/save']); + socket.emit("restore", window.localStorage[params.title_id + "/save"]); } function send_restart(scenario) { - socket.emit('restart', scenario); + socket.emit("restart", scenario); } -function on_game_over() { - if (player) { - let exit_button = document.getElementById("exit_button"); - if (exit_button) { - if (game_over || player === "Observer") - exit_button.classList.remove("hide"); - else - exit_button.classList.add("hide"); +/* REPLAY */ + +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_client() { + document.getElementById("prompt").textContent = "Loading replay..."; + document.querySelector("body").classList.add("replay"); + + console.log("LOADING RULES"); + let rules = await require("rules.js"); + + console.log("LOADING REPLAY"); + let response = await fetch("/replay/" + params.game_id); + let body = await response.json(); + replay = body.replay; + + init_player_names(body.players); + + let viewpoint = "Observer"; + let log_length = 0; + let p = 0; + let s = {}; + + function eval_action(item) { + switch (item.action) { + case "setup": + s = rules.setup(item.arguments[0], item.arguments[1], item.arguments[2]); + break; + case "resign": + s = rules.resign(s, item.role); + break; + default: + s = rules.action(s, item.role, item.action, item.arguments); + break; } - let rematch_button = document.getElementById("rematch_button"); - if (rematch_button) { - if (game_over && player !== "Observer") - rematch_button.classList.remove("hide"); + } + + let ss; + for (p = 0; p < replay.length; ++p) { + replay[p].arguments = JSON.parse(replay[p].arguments); + + if (rules.is_checkpoint) { + replay[p].is_checkpoint = (p > 0 && rules.is_checkpoint(ss, s)); + ss = Object.assign({}, s); + } + + eval_action(replay[p]); + + 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 + "#" + 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++]); + update_replay_view(); + } + + function update_replay_view() { + player = viewpoint; + + if (viewpoint === "Active") { + player = s.active; + if (player === "All" || player === "Both" || !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); + 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} / ${replay[p].action} ${replay[p].arguments}`; else - rematch_button.classList.add("hide"); + view.prompt = "[" + p + "/" + replay.length + "] " + view.prompt; } + if (log_length < view.log.length) + view.log_start = log_length; + else + view.log_start = view.log.length; + log_length = view.log.length; + view.log = view.log.slice(view.log_start); + + on_update_header(); + on_update(); + on_update_log(); } -} -function send_rematch() { - window.location = '/rematch/' + param_game_id + '/' + param_role; -} + function replay_button(div, label, fn) { + let button = document.createElement("button"); + button.addEventListener("click", fn); + button.className = "replay_button"; + button.textContent = label; + div.appendChild(button); + return button; + } -function send_exit() { - window.location = '/info/' + param_title_id; + function set_viewpoint(vp) { + viewpoint = vp; + update_replay_view(); + } + + if (replay.length > 0) { + console.log("REPLAY READY"); + + let div = document.createElement("div"); + div.className = "replay"; + replay_button(div, "Active", () => set_viewpoint("Active")); + for (let r of roles) + replay_button(div, r.role, () => set_viewpoint(r.role)); + replay_button(div, "Observer", () => set_viewpoint("Observer")); + document.querySelector("header").appendChild(div); + + div = document.createElement("div"); + div.className = "replay"; + replay_button(div, "<<<", () => goto_replay(1)); + replay_button(div, "<<", () => goto_replay(prev())); + replay_button(div, "<\xa0", () => goto_replay(p-1)); + replay_button(div, "\xa0>", () => goto_replay(p+1)); + replay_button(div, ">>", () => goto_replay(next())); + replay_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 = JSON.parse(body.state); + update_replay_view(); + } } + +if (params.mode === "play") + init_play_client(); +if (params.mode === "replay") + init_replay_client(); diff --git a/public/common/grid.css b/public/common/grid.css index 0532648..228c0aa 100644 --- a/public/common/grid.css +++ b/public/common/grid.css @@ -25,14 +25,16 @@ body:not(.shift) .debug { display: none; } -body.Observer .resign { +body.Observer .resign, body.replay .resign { display: none; } button { - font-size: 1rem; + box-sizing: border-box; + font-size: 16px; + height: 28px; margin: 0; - padding: 1px 12px; + padding: 1px 12px 1px 12px; background-color: gainsboro; } button:disabled { @@ -177,6 +179,15 @@ header button { margin: 0 10px; } +header .replay { + margin: 0 10px; + display: flex; +} + +header button.replay_button { + margin: 0; +} + #prompt { margin: 0 20px; font-size: 18px; diff --git a/public/join.js b/public/join.js index 24e10ab..eef3833 100644 --- a/public/join.js +++ b/public/join.js @@ -68,11 +68,14 @@ function start_event_source() { }); evtsrc.addEventListener("deleted", function (evt) { console.log("DELETED"); - window.location.href = '/info/' + game.title_id; + window.location.href = '/' + game.title_id; }); evtsrc.onerror = function (err) { window.message.innerHTML = "Disconnected from server..."; }; + window.addEventListener('beforeunload', function (evt) { + evtsrc.close(); + }); } } @@ -94,7 +94,7 @@ app.set('x-powered-by', false); app.set('etag', false); app.set('view engine', 'pug'); app.use(compression()); -app.use(express.static('public', { etag: false, cacheControl: false, setHeaders: set_static_headers })); +app.use(express.static('public', { redirect: false, etag: false, cacheControl: false, setHeaders: set_static_headers })); app.use(express.urlencoded({extended:false})); app.locals.SITE_NAME = SITE_NAME; @@ -910,6 +910,7 @@ 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,role,action,arguments) VALUES (?,?,?,?)"); +const SQL_SELECT_REPLAY = SQL("SELECT role,action,arguments FROM game_replay WHERE game_id=?"); const SQL_SELECT_GAME = SQL("SELECT * FROM games WHERE game_id=?"); const SQL_SELECT_GAME_VIEW = SQL("SELECT * FROM game_view WHERE game_id=?"); @@ -917,6 +918,8 @@ const SQL_SELECT_GAME_FULL_VIEW = SQL("SELECT * FROM game_full_view WHERE game_i const SQL_SELECT_GAME_TITLE = SQL("SELECT title_id FROM games WHERE game_id=?").pluck(); const SQL_SELECT_GAME_RANDOM = SQL("SELECT is_random FROM games WHERE game_id=?").pluck(); +const SQL_SELECT_GAME_HAS_TITLE_AND_STATUS = SQL("SELECT 1 FROM games WHERE game_id=? AND title_id=? AND status=?"); + const SQL_SELECT_PLAYERS = SQL("SELECT * FROM players NATURAL JOIN user_view WHERE game_id=?"); const SQL_SELECT_PLAYERS_JOIN = SQL("SELECT role, user_id, name FROM players NATURAL JOIN users WHERE game_id=?"); const SQL_SELECT_PLAYER_ROLE = SQL("SELECT role FROM players WHERE game_id=? AND user_id=?").pluck(); @@ -1062,8 +1065,11 @@ app.get('/profile', must_be_logged_in, function (req, res) { }); app.get('/info/:title_id', function (req, res) { - LOG(req, "GET /info/" + req.params.title_id); - let title_id = req.params.title_id; + return res.redirect('/' + req.params.title_id); +}); + +function get_title_page(req, res, title_id) { + LOG(req, "GET /" + title_id); let title = TITLES[title_id]; if (!title) return res.status(404).send("Invalid title."); @@ -1081,7 +1087,10 @@ app.get('/info/:title_id', function (req, res) { active_games: active_games, finished_games: finished_games, }); -}); +} + +for (let title_id in TITLES) + app.get('/' + title_id, (req, res) => get_title_page(req, res, title_id)); app.get('/create/:title_id', must_be_logged_in, function (req, res) { LOG(req, "GET /create/" + req.params.title_id); @@ -1137,7 +1146,7 @@ app.get('/delete/:game_id', must_be_logged_in, function (req, res) { return res.send("Not authorized to delete that game ID."); if (info.changes === 1) update_join_clients_deleted(game_id); - res.redirect('/info/'+title_id); + res.redirect('/'+title_id); }); function join_rematch(req, res, game_id, role) { @@ -1344,7 +1353,7 @@ app.get('/play/:game_id/:role', function (req, res) { let role = req.params.role; let title = SQL_SELECT_GAME_TITLE.get(game_id); if (!title) - return res.redirect('/join/'+game_id); + return res.status(404).send("Invalid game ID."); res.redirect('/'+title+'/play:'+game_id+':'+role); }); @@ -1354,7 +1363,7 @@ app.get('/play/:game_id', function (req, res) { let user_id = req.user ? req.user.user_id : 0; let title = SQL_SELECT_GAME_TITLE.get(game_id); if (!title) - return res.redirect('/join/'+game_id); + return res.status(404).send("Invalid game ID."); let role = SQL_SELECT_PLAYER_ROLE.get(game_id, user_id); if (role) res.redirect('/'+title+'/play:'+game_id+':'+role); @@ -1363,6 +1372,7 @@ app.get('/play/:game_id', function (req, res) { }); app.get('/:title_id/play\::game_id\::role', must_be_logged_in, function (req, res) { + LOG(req, "GET /" + req.params.title_id + "/play:" + req.params.game_id + ":" + req.params.role); let user_id = req.user ? req.user.user_id : 0; let title_id = req.params.title_id let game_id = req.params.game_id; @@ -1373,14 +1383,42 @@ app.get('/:title_id/play\::game_id\::role', must_be_logged_in, function (req, re }); app.get('/:title_id/play\::game_id', function (req, res) { + LOG(req, "GET /" + req.params.title_id + "/play:" + req.params.game_id); let title_id = req.params.title_id let game_id = req.params.game_id; let a_title = SQL_SELECT_GAME_TITLE.get(game_id); if (a_title !== title_id) - return res.send("Invalid game ID."); + return res.status(404).send("Invalid game ID."); return res.sendFile(__dirname + '/public/' + title_id + '/play.html'); }); +app.get('/:title_id/replay\::game_id', function (req, res) { + LOG(req, "GET /" + req.params.title_id + "/replay:" + req.params.game_id); + let title_id = req.params.title_id + let game_id = req.params.game_id; + let game = SQL_SELECT_GAME.get(game_id); + if (!game) + return res.status(404).send("Invalid game ID."); + if (game.title_id !== title_id) + return res.status(404).send("Invalid game ID."); + if (game.status < 2) + return res.status(404).send("Invalid game ID."); + return res.sendFile(__dirname + '/public/' + title_id + '/play.html'); +}); + +app.get('/replay/:game_id', function (req, res) { + let game_id = req.params.game_id; + let game = SQL_SELECT_GAME.get(game_id); + if (game.status < 2) + return res.status(404).send("Invalid game ID."); + let players = SQL_SELECT_PLAYERS_JOIN.all(game_id); + let replay = SQL_SELECT_REPLAY.all(game_id); + if (replay.length > 0) + return res.json({players, replay}); + let state = SQL_SELECT_GAME_STATE.get(game_id); + return res.json({players, state, replay}); +}); + /* * MAIL NOTIFICATIONS */ @@ -1452,7 +1490,6 @@ function mail_your_turn_notification(user, game_id, interval) { } } } -} function reset_your_turn_notification(user, game_id) { SQL_DELETE_NOTIFIED.run(game_id, user.user_id); @@ -1544,7 +1581,9 @@ function send_state(socket, state) { view.log_start = view.log.length; socket.log_length = view.log.length; view.log = view.log.slice(view.log_start); - socket.emit('state', view, state.state === 'game_over'); + if (state.state === 'game_over') + view.game_over = 1; + socket.emit('state', view); } catch (err) { console.log(err); return socket.emit('error', err.toString()); @@ -1580,7 +1619,7 @@ function on_action(socket, action, arg) { try { let state = get_game_state(socket.game_id); let old_active = state.active; - socket.rules.action(state, socket.role, action, arg); + 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); } catch (err) { @@ -1594,7 +1633,7 @@ function on_resign(socket) { try { let state = get_game_state(socket.game_id); let old_active = state.active; - socket.rules.resign(state, socket.role); + 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); } catch (err) { diff --git a/views/head.pug b/views/head.pug index 68fa904..df1b762 100644 --- a/views/head.pug +++ b/views/head.pug @@ -15,7 +15,7 @@ mixin social(title,description,game) meta(property="og:description" content=description) mixin gamecover(title_id) - a(href="/info/"+title_id) + a(href="/"+title_id) img(src=`/${title_id}/cover.1x.jpg` srcset=`/${title_id}/cover.2x.jpg 2x`) mixin forumpost(row,show_buttons) @@ -57,7 +57,7 @@ mixin gametable(status,table,hide_title=0) tr td= row.game_id unless hide_title - td.w: a(href="/info/"+row.title_id)= row.title_name + td.w: a(href="/"+row.title_id)= row.title_name td.w= row.scenario td!= row.player_names td= row.description @@ -76,15 +76,16 @@ mixin gametable(status,table,hide_title=0) td.command if status === 0 a(href="/join/"+row.game_id) Join - else - - let cmd = status === 1 ? "Play" : "View" + else if status === 1 if row.is_yours if row.is_shared - a(href="/join/"+row.game_id)= cmd + a(href="/join/"+row.game_id)= "Play" else - a(href=`/${row.title_id}/play:${row.game_id}:${row.your_role}`)= cmd + a(href=`/${row.title_id}/play:${row.game_id}:${row.your_role}`)= "Play" else a(href=`/${row.title_id}/play:${row.game_id}`) View + else if status >= 2 + a(href=`/${row.title_id}/replay:${row.game_id}`) View else tr case status |