From 36d047d337db428b54c1d7663ca0e29ee16e5c6a Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Thu, 6 Jan 2022 12:40:14 +0100 Subject: Use WebSockets instead of socket.io. --- package.json | 6 +- public/common/battle_abc.css | 94 ---- public/common/client.js | 750 ---------------------------- public/common/columbia.css | 100 ++++ public/common/grid.css | 355 -------------- public/common/play.css | 354 ++++++++++++++ public/common/play.js | 807 +++++++++++++++++++++++++++++++ public/images/sherlock-holmes-mirror.svg | 1 + server.js | 205 ++++---- 9 files changed, 1374 insertions(+), 1298 deletions(-) delete mode 100644 public/common/battle_abc.css delete mode 100644 public/common/client.js create mode 100644 public/common/columbia.css delete mode 100644 public/common/grid.css create mode 100644 public/common/play.css create mode 100644 public/common/play.js create mode 100644 public/images/sherlock-holmes-mirror.svg diff --git a/package.json b/package.json index 2cb73c2..2258084 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "express": "^4.17.1", "nodemailer": "^6.7.0", "pug": "^3.0.2", - "socket.io": "^4.3.1" + "ws": "^8.4.0" + }, + "optionalDependencies": { + "bufferutil": "^4.0.6", + "utf-8-validate": "^5.0.8" } } diff --git a/public/common/battle_abc.css b/public/common/battle_abc.css deleted file mode 100644 index 3a211a1..0000000 --- a/public/common/battle_abc.css +++ /dev/null @@ -1,94 +0,0 @@ -/* ABC BATTLE DISPLAY FOR COLUMBIA BLOCK GAMES */ - -#battle { - position: absolute; - left: 100px; - top: 100px; - z-index: 50; - border-collapse: collapse; - border: 1px solid black; - background-color: white; - box-shadow: 0px 5px 10px 0px rgba(0,0,0,0.5); - visibility: hidden; -} -#battle.show { - visibility: visible; -} -#battle th { - padding: 0; - border: 1px solid black; -} -#battle td { - padding: 0; - border: 1px solid black; - vertical-align: top; -} -#battle_header { - font-weight: bold; - height: 2em; - line-height: 2em; - font-weight: bold; - user-select: none; - cursor: move; -} -#battle_message { - font-weight: normal; - height: 2em; - line-height: 2em; - padding: 0 8px; -} -#FA, #FB, #FC, #FD, #FF, #FR, #EA, #EB, #EC, #ED, #EF, #ER { - display: flex; - flex-wrap: wrap; - justify-content: center; - margin: 10px; -} - -.battle_separator { - height: 15px; -} - -.battle_menu .block { - margin: 0 auto; -} - -.battle_menu { - width: 80px; -} - -.battle_menu .action { - width: 20px; - height: 20px; - border-radius: 5px; - padding: 3px; - display: none; -} - -.battle_menu .action:hover { background-color: red; } -.battle_menu .action.retreat:hover { background-color: #eee; } -.battle_menu .action.withdraw:hover { background-color: #eee; } -.battle_menu .action.pass:hover { background-color: gray; } - -.battle_menu_list { - margin-top: 5px; - min-height: 26px; - width: 80px; - text-align: center; - font-size: 1px; - line-height: 1px; -} - -.battle_reserves .battle_menu_list { - display: none; -} - -.battle_menu.fire .action.fire { display: inline; } -.battle_menu.retreat .action.retreat { display: inline; } -.battle_menu.pass .action.pass { display: inline; } -.battle_menu.charge .action.charge { display: inline; } -.battle_menu.treachery .action.treachery { display: inline; } -.battle_menu.harry .action.harry { display: inline; } -.battle_menu.withdraw .action.withdraw { display: inline; } -.battle_menu.storm .action.storm { display: inline; } -.battle_menu.sally .action.sally { display: inline; } -.battle_menu.hit .action.hit { display: inline; } diff --git a/public/common/client.js b/public/common/client.js deleted file mode 100644 index d9a1f48..0000000 --- a/public/common/client.js +++ /dev/null @@ -1,750 +0,0 @@ -"use strict"; - -/* URL: /$title_id/(re)play:$game_id:$role */ - -if (!/\/[\w-]+\/(re)?play:\d+(:[\w-]+)?/.test(window.location.pathname)) { - document.getElementById("prompt").textContent = "Invalid game ID."; - throw Error("Invalid game ID."); -} - -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 roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})); - -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); - let down_x, down_y, scroll_x, scroll_y; - if (!multiplier) - multiplier = 1; - function md(e) { - if (e.button === 1) { - down_x = e.clientX; - down_y = e.clientY; - scroll_x = panel.scrollLeft; - scroll_y = panel.scrollTop; - window.addEventListener("mousemove", mm); - window.addEventListener("mouseup", mu); - e.preventDefault(); - } - } - function mm(e) { - let dx = down_x - e.clientX; - let dy = down_y - e.clientY; - panel.scrollLeft = scroll_x + dx * multiplier; - panel.scrollTop = scroll_y + dy * multiplier; - e.preventDefault(); - } - function mu(e) { - if (e.button === 1) { - window.removeEventListener("mousemove", mm); - window.removeEventListener("mouseup", mu); - e.preventDefault(); - } - } - panel.addEventListener("mousedown", md); -} - -function drag_element_with_mouse(element_sel, grabber_sel) { - let element = document.querySelector(element_sel); - let grabber = document.querySelector(grabber_sel) || element; - let save_x, save_y; - function md(e) { - if (e.button === 0) { - save_x = e.clientX; - save_y = e.clientY; - window.addEventListener("mousemove", mm); - window.addEventListener("mouseup", mu); - e.preventDefault(); - } - } - function mm(e) { - let dx = save_x - e.clientX; - let dy = save_y - e.clientY; - save_x = e.clientX; - save_y = e.clientY; - element.style.left = (element.offsetLeft - dx) + "px"; - element.style.top = (element.offsetTop - dy) + "px"; - e.preventDefault(); - } - function mu(e) { - if (e.button === 0) { - window.removeEventListener("mousemove", mm); - window.removeEventListener("mouseup", mu); - e.preventDefault(); - } - } - grabber.addEventListener("mousedown", md); -} - -/* TITLE BLINKER */ - -let blink_title = document.title; -let blink_timer = 0; - -function start_blinker(message) { - let tick = false; - if (blink_timer) - stop_blinker(); - if (!document.hasFocus()) { - document.title = message; - blink_timer = setInterval(function () { - document.title = tick ? message : blink_title; - tick = !tick; - }, 1000); - } -} - -function stop_blinker() { - document.title = blink_title; - clearInterval(blink_timer); - blink_timer = 0; -} - -window.addEventListener("focus", stop_blinker); - -/* 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 = ` -
Chat
-
-
- `; - document.querySelector("body").appendChild(chat_window); - - let chat_button = document.createElement("div"); - chat_button.id = "chat_button"; - chat_button.className = "icon_button"; - chat_button.innerHTML = ''; - chat_button.addEventListener("click", toggle_chat); - document.querySelector("header").insertBefore(chat_button, document.getElementById("prompt")); - - 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); -} - -function update_chat(chat_id, utc_date, user, message) { - function format_time(date) { - let mm = date.getMinutes(); - let hh = date.getHours(); - if (mm < 10) mm = "0" + mm; - if (hh < 10) hh = "0" + hh; - return hh + ":" + mm; - } - function add_date_line(date) { - let line = document.createElement("div"); - line.className = "date"; - line.textContent = "~ " + date + " ~"; - 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_element.appendChild(line); - chat.text_element.scrollTop = chat.text_element.scrollHeight; - } - 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) { - add_date_line(day); - chat.last_day = day; - } - add_chat_line(format_time(date), user, message); - } - if (chat_id > chat.seen) { - let button = document.getElementById("chat_button"); - start_blinker("NEW MESSAGE"); - if (!chat.is_visible) - button.classList.add("new"); - else - save_chat(); - } -} - -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", - ".role.three", - ".role.four", - ".role.five", - ".role.six", - ".role.seven", - ]; - - console.log("JOINING", params.title_id + "/" + params.game_id + "/" + params.role); - - socket = io({ - transports: ["websocket"], - query: { title: params.title_id, game: params.game_id, role: params.role }, - }); - - socket.on("connect", () => { - console.log("CONNECTED"); - document.querySelector("header").classList.remove("disconnected"); - }); - - socket.on("disconnect", () => { - console.log("DISCONNECTED"); - document.getElementById("prompt").textContent = "Disconnected from server!"; - document.querySelector("header").classList.add("disconnected"); - }); - - 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) => { - console.log("PRESENCE", JSON.stringify(presence)); - for (let i = 0; i < roles.length; ++i) { - let elt = document.getElementById(roles[i].id); - if (roles[i].role in presence) - elt.classList.add("present"); - else - elt.classList.remove("present"); - } - }); - - socket.on("state", (new_view) => { - console.log("STATE"); - view = new_view; - on_update_header(); - on_update(); - on_update_log(); - }); - - socket.on("save", (msg) => { - console.log("SAVE"); - window.localStorage[params.title_id + "/save"] = msg; - }); - - socket.on("error", (msg) => { - console.log("ERROR", msg); - document.getElementById("prompt").textContent = msg; - }); - - socket.on("chat", function (item) { - update_chat(item[0], item[1], item[2], item[3]); - }); -} - -/* HEADER */ - -let is_your_turn = false; -let old_active = null; - -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 !== view.active) - start_blinker("YOUR TURN"); - is_your_turn = true; - } else { - document.querySelector("header").classList.remove("your_turn"); - is_your_turn = false; - } - old_active = view.active; -} - -/* LOG */ - -let create_log_entry = function (text) { - let div = document.createElement("div"); - div.textContent = text; - return div; -} - -function on_update_log() { - let div = document.getElementById("log"); - let to_delete = div.children.length - view.log_start; - while (to_delete-- > 0) - 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(scroll_log_to_end).observe(document.getElementById("log")); -} catch (err) { - window.addEventListener("resize", scroll_log_to_end); -} - -/* MAP ZOOM */ - -function toggle_fullscreen() { - if (document.fullscreen) - document.exitFullscreen(); - else - document.documentElement.requestFullscreen(); -} - -function toggle_log() { - document.querySelector("aside").classList.toggle("hide"); - zoom_map(); -} - -function toggle_zoom() { - let mapwrap = document.getElementById("mapwrap"); - if (mapwrap) { - mapwrap.classList.toggle("fit"); - zoom_map(); - } -} - -function zoom_map() { - let mapwrap = document.getElementById("mapwrap"); - 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"; - } - } - } -} - -zoom_map(); - -window.addEventListener("resize", zoom_map); - -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"); -}); - -/* ACTIONS */ - -function action_button(action, label) { - if (params.mode === "replay") - return; - let id = action + "_button"; - let button = document.getElementById(id); - if (!button) { - button = document.createElement("button"); - button.id = id; - button.textContent = label; - button.addEventListener("click", evt => send_action(action)); - document.getElementById("actions").appendChild(button); - } - if (view.actions && action in view.actions) { - button.classList.remove("hide"); - if (view.actions[action]) { - if (label === undefined) - button.textContent = view.actions[action]; - button.disabled = false; - } else { - button.disabled = true; - } - } else { - button.classList.add("hide"); - } -} - -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 (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); - return true; - } - } else { - if (view.actions && view.actions[verb]) { - view.actions = null; - console.log("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"); -} - -function send_restore() { - socket.emit("restore", window.localStorage[params.title_id + "/save"]); -} - -function send_restart(scenario) { - socket.emit("restart", scenario); -} - -/* 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 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 - 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 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 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/columbia.css b/public/common/columbia.css new file mode 100644 index 0000000..1a646b6 --- /dev/null +++ b/public/common/columbia.css @@ -0,0 +1,100 @@ +/* BATTLE DIALOG FOR COLUMBIA BLOCK GAMES */ + +#battle { + position: absolute; + left: 100px; + top: 100px; + z-index: 50; + border-collapse: collapse; + border: 1px solid black; + background-color: white; + box-shadow: 0px 5px 10px 0px rgba(0,0,0,0.5); + visibility: hidden; +} + +#battle.show { + visibility: visible; +} + +#battle th { + padding: 0; + border: 1px solid black; +} + +#battle td { + padding: 0; + border: 1px solid black; + vertical-align: top; +} + +#battle_header { + font-weight: bold; + height: 2em; + line-height: 2em; + font-weight: bold; + user-select: none; + cursor: move; +} + +#battle_message { + font-weight: normal; + height: 2em; + line-height: 2em; + padding: 0 8px; +} + +#FA, #FB, #FC, #FD, #FF, #FR, #EA, #EB, #EC, #ED, #EF, #ER { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 10px; +} + +.battle_separator { + height: 15px; +} + +.battle_menu .block { + margin: 0 auto; +} + +.battle_menu { + width: 80px; +} + +.battle_menu .action { + width: 20px; + height: 20px; + border-radius: 5px; + padding: 3px; + display: none; +} + +.battle_menu_list { + margin-top: 5px; + min-height: 26px; + width: 80px; + text-align: center; + font-size: 1px; + line-height: 1px; +} + +.battle_reserves .battle_menu_list { + display: none; +} + +.battle_menu .action:hover { background-color: red; } +.battle_menu .action.retreat:hover { background-color: #eee; } +.battle_menu .action.withdraw:hover { background-color: #eee; } +.battle_menu .action.pass:hover { background-color: gray; } + +.battle_menu.fire .action.fire { display: inline; } +.battle_menu.retreat .action.retreat { display: inline; } +.battle_menu.pass .action.pass { display: inline; } +.battle_menu.charge .action.charge { display: inline; } +.battle_menu.treachery .action.treachery { display: inline; } +.battle_menu.harry .action.harry { display: inline; } +.battle_menu.withdraw .action.withdraw { display: inline; } +.battle_menu.storm .action.storm { display: inline; } +.battle_menu.sally .action.sally { display: inline; } +.battle_menu.hit .action.hit { display: inline; } diff --git a/public/common/grid.css b/public/common/grid.css deleted file mode 100644 index 228c0aa..0000000 --- a/public/common/grid.css +++ /dev/null @@ -1,355 +0,0 @@ -/* COMMON GRID LAYOUT */ - -html { - image-rendering: -webkit-optimize-contrast; /* try to fix chromium's terrible image rescaling */ -} - -html, button, input, select { - font-family: "Source Sans", "Circled Numbers", "Dingbats", "Noto Emoji", "Verdana", sans-serif; - font-size: 16px; -} - -#chat_text, #chat_input { - font-family: "Source Serif", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; -} - -#log, #turn_info { - font-family: "Source Serif SmText", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; -} - -.hide { - display: none; -} - -body:not(.shift) .debug { - display: none; -} - -body.Observer .resign, body.replay .resign { - display: none; -} - -button { - box-sizing: border-box; - font-size: 16px; - height: 28px; - margin: 0; - padding: 1px 12px 1px 12px; - background-color: gainsboro; -} -button:disabled { - color: gray; - border: 2px solid gainsboro; - outline: 1px solid gray; -} -button:enabled { - border: 2px outset white; - outline: 1px solid black; -} -button:enabled:active:hover { - border: 2px inset white; - padding: 2px 11px 0px 13px; -} - -/* MAIN GRID */ - -body { - margin: 0; - padding: 0; - display: grid; - overflow: clip; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: auto minmax(0, 1fr); - width: 100vw; - height: 100vh; -} - -header { - grid-column: 1/3; - grid-row: 1; - display: flex; - align-items: center; - border-bottom: 1px solid black; -} - -header.disconnected { - background-color: red !important; -} - -header.your_turn { - background-color: orange; -} - -main { - grid-column: 1; - grid-row: 2; - overflow: auto; - scrollbar-width: none; -} - -aside { - grid-column: 2; - grid-row: 2; - display: grid; - overflow: clip; - grid-template-rows: auto minmax(0, 1fr); - width: 210px; - border-left: 1px solid black; -} - -#roles { - grid-column: 1; - grid-row: 1; -} - -#log { - grid-column: 1; - grid-row: 2; - overflow-y: scroll; - scrollbar-width: thin; -} - -footer { - position:absolute; - z-index: 100; - bottom: 0; - background-color: white; - padding: 0 8px; -} - -/* MENU */ - -.menu { - user-select: none; -} -.menu_title img { - display: block; - height: 35px; - padding: 5px; -} -.menu:hover .menu_title { - background-color: black; - color: white; -} -.menu:hover .menu_title img { - filter: invert(100%); -} -.menu_popup { - display: none; - position: absolute; - min-width: 20ex; - white-space: nowrap; - border: 1px solid black; - background-color: white; - z-index: 100; -} -.menu:hover .menu_popup { - display: block; -} -.menu_separator { - border-top: 1px solid black; -} -.menu_item { - padding: 5px 10px; -} -.menu_item:hover { - background-color: black; - color: white; -} - -/* TOOL BAR */ - -.icon_button { - user-select: none; -} -.icon_button img { - display: block; - height: 35px; - padding: 5px; -} -.icon_button:hover { - background-color: black; - color: white; -} -.icon_button:hover img { - filter: invert(100%); -} - -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; - flex: 1 1 0; - width: 0; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -/* ROLES */ - -.role_name { - border-bottom: 1px solid black; - padding-top: 3px; - padding-bottom: 3px; - padding-left: 5px; - padding-right: 5px; -} - -.role_info, .card_info, #turn_info { - border-bottom: 1px solid black; - overflow: clip; /* clip dropshadow from filter:grayscale() stacking context */ -} - -.role_vp { - float: right; -} - -.role_user { - font-style: italic; - text-align: right; - overflow: clip; - text-overflow: "..."; - white-space: nowrap; -} - -.role .role_name::before { content: "\25cb "; opacity: 0.6; } -.role.present .role_name::before { content: "\25cf "; opacity: 0.6; } - -#turn_info { - padding: 8px 0px 8px 8px; - white-space: pre-line; - font-style: italic; - font-size: 12px; - line-height: 18px; -} - -/* LOG */ - -#log { - padding: 12px 0; - font-size: 12px; - line-height: 18px; - white-space: pre-wrap; -} - -#log > * { - padding-left: 20px; - padding-right: 4px; - text-indent: -12px; - min-height: 9px; -} - -/* MAP */ - -#mapwrap { - position: relative; - margin: 0 auto; -} - -#mapwrap.fit { - max-width: 100%; -} - -#map { - position: absolute; - isolation: isolate; - transform-origin: 0 0; -} - -/* CARDS */ - -.card { - background-size: cover; - background-repeat: no-repeat; -} - -.card.enabled { - cursor: pointer; -} - -.card.disabled { - filter: grayscale(100%); -} - -.hand { - margin: 15px; - display: flex; - flex-wrap: wrap; - justify-content: center; - min-height: 370px; -} - -.hand .card { - margin: 10px; -} - -/* CHAT WINDOW */ - -#chat_button.new { - filter: invert(100%); -} - -#chat_window { - position: absolute; - left: 10px; - top: 55px; - width: 40rem; - z-index: 60; - border: 1px solid black; - background-color: white; - box-shadow: 0px 5px 10px 0px rgba(0,0,0,0.5); - display: none; - grid-template-rows: auto minmax(0, 1fr) auto; -} - -#chat_window.show { - display: grid; -} - -#chat_header { - cursor: move; - background-color: gainsboro; - border-bottom: 1px solid black; - padding: 5px 10px; -} - -#chat_text { - font-size: 16px; - line-height: 24px; - height: 216px; - padding: 0px 5px; - overflow-y: scroll; -} - -#chat_text .date { - font-weight: bold; -} - -#chat_form { - display: block; - margin: 0; - padding: 0; - border-top: 1px solid black; -} - -#chat_input { - box-sizing: border-box; - width: 100%; - outline: none; - border: none; - padding: 5px; - font-size: 1rem; -} diff --git a/public/common/play.css b/public/common/play.css new file mode 100644 index 0000000..64e9202 --- /dev/null +++ b/public/common/play.css @@ -0,0 +1,354 @@ +/* COMMON GRID LAYOUT */ + +html { + image-rendering: -webkit-optimize-contrast; /* try to fix chromium's terrible image rescaling */ +} + +html, button, input, select { + font-family: "Source Sans", "Circled Numbers", "Dingbats", "Noto Emoji", "Verdana", sans-serif; + font-size: 16px; +} + +#chat_text, #chat_input { + font-family: "Source Serif", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; +} + +#log, #turn_info { + font-family: "Source Serif SmText", "Circled Numbers", "Dingbats", "Noto Emoji", "Georgia", serif; +} + +.hide { + display: none; +} + +body:not(.shift) .debug { + display: none; +} + +body.Observer .resign { + display: none; +} + +button { + box-sizing: border-box; + font-size: 16px; + height: 28px; + margin: 0; + padding: 1px 12px 1px 12px; + background-color: gainsboro; +} +button:disabled { + color: gray; + border: 2px solid gainsboro; + outline: 1px solid gray; +} +button:enabled { + border: 2px outset white; + outline: 1px solid black; +} +button:enabled:active:hover { + border: 2px inset white; + padding: 2px 11px 0px 13px; +} + +/* MAIN GRID */ + +body { + margin: 0; + padding: 0; + display: grid; + overflow: clip; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr); + width: 100vw; + height: 100vh; +} + +header { + grid-column: 1/3; + grid-row: 1; + display: flex; + align-items: center; + border-bottom: 1px solid black; +} + +header.disconnected { + background-color: red !important; +} + +header.your_turn { + background-color: orange; +} + +main { + grid-column: 1; + grid-row: 2; + overflow: auto; + scrollbar-width: none; +} + +aside { + grid-column: 2; + grid-row: 2; + display: grid; + overflow: clip; + grid-template-rows: auto minmax(0, 1fr); + width: 210px; + border-left: 1px solid black; +} + +#roles { + grid-column: 1; + grid-row: 1; +} + +#log { + grid-column: 1; + grid-row: 2; + overflow-y: scroll; + scrollbar-width: thin; +} + +footer { + position:absolute; + z-index: 100; + bottom: 0; + background-color: white; + padding: 0 8px; +} + +/* MENU */ + +.menu { + user-select: none; +} +.menu_title img { + display: block; + height: 35px; + padding: 5px; +} +.menu:hover .menu_title { + background-color: black; + color: white; +} +.menu:hover .menu_title img { + filter: invert(100%); +} +.menu_popup { + display: none; + position: absolute; + min-width: 20ex; + white-space: nowrap; + border: 1px solid black; + background-color: white; + z-index: 100; +} +.menu:hover .menu_popup { + display: block; +} +.menu_separator { + border-top: 1px solid black; +} +.menu_item { + padding: 5px 10px; +} +.menu_item:hover { + background-color: black; + color: white; +} + +/* TOOL BAR */ + +.icon_button { + user-select: none; +} +.icon_button img { + display: block; + height: 35px; + padding: 5px; +} +.icon_button:hover { + background-color: black; + color: white; +} +.icon_button:hover img { + filter: invert(100%); +} + +header button { + margin: 0 10px; +} + +header .replay { + margin: 0 10px; + display: flex; +} + +header .replay button { + margin: 0; +} + +#prompt { + margin: 0 20px; + font-size: 18px; + flex: 1 1 0; + width: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +/* ROLES */ + +.role_name { + border-bottom: 1px solid black; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 5px; + padding-right: 5px; +} + +.role_info, .card_info, #turn_info { + border-bottom: 1px solid black; + overflow: clip; /* clip dropshadow from filter:grayscale() stacking context */ +} + +.role_vp { + float: right; +} + +.role_user { + font-style: italic; + text-align: right; + overflow: clip; + text-overflow: "..."; + white-space: nowrap; +} + +.role .role_name::before { content: "\25cb "; opacity: 0.6; } +.role.present .role_name::before { content: "\25cf "; opacity: 0.6; } + +#turn_info { + padding: 8px 0px 8px 8px; + white-space: pre-line; + font-style: italic; + font-size: 12px; + line-height: 18px; +} + +/* LOG */ + +#log { + padding: 12px 0; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +#log > * { + padding-left: 20px; + padding-right: 4px; + text-indent: -12px; + min-height: 9px; +} + +/* MAP */ + +#mapwrap { + position: relative; + margin: 0 auto; +} + +#mapwrap.fit { + max-width: 100%; +} + +#map { + position: absolute; + isolation: isolate; + transform-origin: 0 0; +} + +/* CARDS */ + +.card { + background-size: cover; + background-repeat: no-repeat; +} + +.card.enabled { + cursor: pointer; +} + +.card.disabled { + filter: grayscale(100%); +} + +.hand { + margin: 15px; + display: flex; + flex-wrap: wrap; + justify-content: center; + min-height: 370px; +} + +.hand .card { + margin: 10px; +} + +/* CHAT WINDOW */ + +#chat_button.new { + filter: invert(100%); +} + +#chat_window { + position: absolute; + left: 10px; + top: 55px; + width: 40rem; + z-index: 60; + border: 1px solid black; + background-color: white; + box-shadow: 0px 5px 10px 0px rgba(0,0,0,0.5); + visibility: hidden; +} + +#chat_window.show { + visibility: visible; +} + +#chat_header { + cursor: move; + background-color: gainsboro; + border-bottom: 1px solid black; + padding: 5px 10px; +} + +#chat_text { + font-size: 16px; + line-height: 24px; + height: 216px; + padding: 0px 5px; + overflow-y: scroll; +} + +#chat_text .date { + font-weight: bold; +} + +#chat_form { + display: block; + margin: 0; + padding: 0; + border-top: 1px solid black; +} + +#chat_input { + box-sizing: border-box; + width: 100%; + outline: none; + border: none; + padding: 5px; + font-size: 1rem; +} diff --git a/public/common/play.js b/public/common/play.js new file mode 100644 index 0000000..ede3a5b --- /dev/null +++ b/public/common/play.js @@ -0,0 +1,807 @@ +"use strict"; + +/* URL: /$title_id/(re)play:$game_id:$role */ + +if (!/\/[\w-]+\/(re)?play:\d+(:[\w-]+)?/.test(window.location.pathname)) { + document.getElementById("prompt").textContent = "Invalid game ID."; + throw Error("Invalid game ID."); +} + +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 roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})); + +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); + let down_x, down_y, scroll_x, scroll_y; + if (!multiplier) + multiplier = 1; + function md(e) { + if (e.button === 1) { + down_x = e.clientX; + down_y = e.clientY; + scroll_x = panel.scrollLeft; + scroll_y = panel.scrollTop; + window.addEventListener("mousemove", mm); + window.addEventListener("mouseup", mu); + e.preventDefault(); + } + } + function mm(e) { + let dx = down_x - e.clientX; + let dy = down_y - e.clientY; + panel.scrollLeft = scroll_x + dx * multiplier; + panel.scrollTop = scroll_y + dy * multiplier; + e.preventDefault(); + } + function mu(e) { + if (e.button === 1) { + window.removeEventListener("mousemove", mm); + window.removeEventListener("mouseup", mu); + e.preventDefault(); + } + } + panel.addEventListener("mousedown", md); +} + +function drag_element_with_mouse(element_sel, grabber_sel) { + let element = document.querySelector(element_sel); + let grabber = document.querySelector(grabber_sel) || element; + let save_x, save_y; + function md(e) { + if (e.button === 0) { + save_x = e.clientX; + save_y = e.clientY; + window.addEventListener("mousemove", mm); + window.addEventListener("mouseup", mu); + e.preventDefault(); + } + } + function mm(e) { + let dx = save_x - e.clientX; + let dy = save_y - e.clientY; + save_x = e.clientX; + save_y = e.clientY; + element.style.left = (element.offsetLeft - dx) + "px"; + element.style.top = (element.offsetTop - dy) + "px"; + e.preventDefault(); + } + function mu(e) { + if (e.button === 0) { + window.removeEventListener("mousemove", mm); + window.removeEventListener("mouseup", mu); + e.preventDefault(); + } + } + grabber.addEventListener("mousedown", md); +} + +/* TITLE BLINKER */ + +let blink_title = document.title; +let blink_timer = 0; + +function start_blinker(message) { + let tick = false; + if (blink_timer) + stop_blinker(); + if (!document.hasFocus()) { + document.title = message; + blink_timer = setInterval(function () { + document.title = tick ? message : blink_title; + tick = !tick; + }, 1000); + } +} + +function stop_blinker() { + document.title = blink_title; + clearInterval(blink_timer); + blink_timer = 0; +} + +window.addEventListener("focus", stop_blinker); + +/* CHAT */ + +function init_chat() { + // only fetch new messages when we reconnect! + if (chat !== null) { + send_message("getchat", chat.log); + return; + } + + let chat_window = document.createElement("div"); + chat_window.id = "chat_window"; + chat_window.innerHTML = ` +
Chat
+
+
+ `; + document.querySelector("body").appendChild(chat_window); + + let chat_button = document.createElement("div"); + chat_button.id = "chat_button"; + chat_button.className = "icon_button"; + chat_button.innerHTML = ''; + chat_button.addEventListener("click", toggle_chat); + document.querySelector("header").insertBefore(chat_button, document.getElementById("prompt")); + + 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) { + send_message("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(); + } + } + }); + + send_message("getchat", 0); +} + +function save_chat() { + window.localStorage.setItem(chat.key, chat.log); +} + +function update_chat(chat_id, utc_date, user, message) { + function format_time(date) { + let mm = date.getMinutes(); + let hh = date.getHours(); + if (mm < 10) mm = "0" + mm; + if (hh < 10) hh = "0" + hh; + return hh + ":" + mm; + } + function add_date_line(date) { + let line = document.createElement("div"); + line.className = "date"; + line.textContent = "~ " + date + " ~"; + 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_element.appendChild(line); + chat.text_element.scrollTop = chat.text_element.scrollHeight; + } + 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) { + add_date_line(day); + chat.last_day = day; + } + add_chat_line(format_time(date), user, message); + } + if (chat_id > chat.seen) { + let button = document.getElementById("chat_button"); + start_blinker("NEW MESSAGE"); + if (!chat.is_visible) + button.classList.add("new"); + else + save_chat(); + } +} + +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(); +} + +/* REMATCH BUTTON */ + +function remove_resign_menu() { + document.querySelectorAll(".resign").forEach(x => x.remove()); +} + +function goto_rematch() { + window.location = "/rematch/" + params.game_id + "/" + params.role; +} + +function goto_replay() { + window.location = "/" + params.title_id + "/replay:" + params.game_id; +} + +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 = ''; + button.addEventListener("click", fn); + document.querySelector("header").appendChild(button); + } + } + icon_button("replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay); + if (player !== "Observer") + icon_button("rematch_button", "cycle", "Propose a rematch!", goto_rematch); + remove_resign_menu(); +} + +/* 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 send_message(cmd, arg) { + let data = JSON.stringify([cmd, arg]); + console.log("SEND %s %s", cmd, arg); + socket.send(data); +} + +let reconnect_count = 0; +let reconnect_max = 10; + +function connect_play() { + if (reconnect_count >= reconnect_max) { + document.title = "DISCONNECTED"; + document.getElementById("prompt").textContent = "Disconnected."; + return; + } + + let protocol = (window.location.protocol === "http:") ? "ws" : "wss"; + let seen = document.getElementById("log").children.length; + let url = `${protocol}://${window.location.host}/play-socket?title=${params.title_id}&game=${params.game_id}&role=${params.role}&seen=${seen}`; + + console.log("CONNECTING", url); + document.getElementById("prompt").textContent = "Connecting... "; + + socket = new WebSocket(url); + + window.addEventListener('beforeunload', function () { + socket.close(1000); + }); + + socket.onopen = function (evt) { + console.log("OPEN"); + document.querySelector("header").classList.remove("disconnected"); + reconnect_count = 0; + } + + socket.onclose = function (evt) { + console.log("CLOSE %d", evt.code); + if (evt.code === 1000 && evt.reason !== "") { + document.getElementById("prompt").textContent = "Disconnected: " + evt.reason; + document.title = "DISCONNECTED"; + } + if (evt.code !== 1000) { + document.querySelector("header").classList.add("disconnected"); + document.getElementById("prompt").textContent = `Reconnecting soon... (${reconnect_count+1}/${reconnect_max})`; + let wait = 1000 * (Math.random() + 0.5) * Math.pow(2, reconnect_count++); + console.log("WAITING %.1f TO RECONNECT", wait/1000); + setTimeout(connect_play, wait); + } + } + + socket.onmessage = function (evt) { + let [ cmd, arg ] = JSON.parse(evt.data); + console.log("MESSAGE %s", cmd); + switch (cmd) { + case 'error': + document.getElementById("prompt").textContent = arg; + break; + + case 'chat': + update_chat(arg[0], arg[1], arg[2], arg[3]); + break; + + case 'players': + player = arg[0]; + document.querySelector("body").classList.add(player.replace(/ /g, "_")); + if (player !== "Observer") + init_chat(); + else + remove_resign_menu(); + init_player_names(arg[1]); + break; + + case 'presence': + for (let i = 0; i < roles.length; ++i) { + let elt = document.getElementById(roles[i].id); + if (roles[i].role in arg) + elt.classList.add("present"); + else + elt.classList.remove("present"); + } + break; + + case 'state': + view = arg; + on_update_header(); + on_update(); + on_update_log(); + if (view.game_over) + on_game_over(); + break; + + case 'save': + window.localStorage[params.title_id + "/save"] = msg; + break; + } + } +} + +/* HEADER */ + +let is_your_turn = false; +let old_active = null; + +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 !== view.active) + start_blinker("YOUR TURN"); + is_your_turn = true; + } else { + document.querySelector("header").classList.remove("your_turn"); + is_your_turn = false; + } + old_active = view.active; +} + +/* LOG */ + +let create_log_entry = function (text) { + let div = document.createElement("div"); + div.textContent = text; + return div; +} + +function on_update_log() { + let div = document.getElementById("log"); + let to_delete = div.children.length - view.log_start; + while (to_delete-- > 0) + 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(scroll_log_to_end).observe(document.getElementById("log")); +} catch (err) { + window.addEventListener("resize", scroll_log_to_end); +} + +/* MAP ZOOM */ + +function toggle_fullscreen() { + if (document.fullscreen) + document.exitFullscreen(); + else + document.documentElement.requestFullscreen(); +} + +function toggle_log() { + document.querySelector("aside").classList.toggle("hide"); + zoom_map(); +} + +function toggle_zoom() { + let mapwrap = document.getElementById("mapwrap"); + if (mapwrap) { + mapwrap.classList.toggle("fit"); + zoom_map(); + } +} + +function zoom_map() { + let mapwrap = document.getElementById("mapwrap"); + 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"; + } + } + } +} + +zoom_map(); + +window.addEventListener("resize", zoom_map); + +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"); +}); + +/* ACTIONS */ + +function action_button(action, label) { + if (params.mode === "replay") + return; + let id = action + "_button"; + let button = document.getElementById(id); + if (!button) { + button = document.createElement("button"); + button.id = id; + button.textContent = label; + button.addEventListener("click", evt => send_action(action)); + document.getElementById("actions").appendChild(button); + } + if (view.actions && action in view.actions) { + button.classList.remove("hide"); + if (view.actions[action]) { + if (label === undefined) + button.textContent = view.actions[action]; + button.disabled = false; + } else { + button.disabled = true; + } + } else { + button.classList.add("hide"); + } +} + +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) { + 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]); + return true; + } + } else { + if (view.actions && view.actions[verb]) { + view.actions = null; + send_message("action", [verb]); + return true; + } + } + return false; +} + +function confirm_resign() { + if (window.confirm("Are you sure that you want to resign?")) + send_message("resign"); +} + +/* 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 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("/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 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 === "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); + 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].action} ${replay[p].arguments}`; + } else { + 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 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(); + } + + if (replay.length > 0) { + console.log("REPLAY READY"); + + let div = document.createElement("div"); + div.className = "replay"; + text_button(div, "Active", () => set_viewpoint("Active")); + 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); + + 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 = JSON.parse(body.state); + update_replay_view(); + } +} + +if (params.mode === "replay") + init_replay(); +if (params.mode === "play") + connect_play(); diff --git a/public/images/sherlock-holmes-mirror.svg b/public/images/sherlock-holmes-mirror.svg new file mode 100644 index 0000000..f2f0f19 --- /dev/null +++ b/public/images/sherlock-holmes-mirror.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server.js b/server.js index 6c4caee..be67657 100644 --- a/server.js +++ b/server.js @@ -4,8 +4,9 @@ const fs = require('fs'); const crypto = require('crypto'); const http = require('http'); const https = require('https'); -const socket_io = require('socket.io'); +const { WebSocketServer } = require('ws'); const express = require('express'); +const url = require('url'); const compression = require('compression'); const sqlite3 = require('better-sqlite3'); @@ -100,10 +101,10 @@ app.locals.SITE_NAME = SITE_NAME; let http_port = process.env.HTTP_PORT || 8080; let http_server = http.createServer(app); -let http_io = socket_io(http_server); +let http_wss = new WebSocketServer({server: http_server}); http_server.keepAliveTimeout = 0; http_server.listen(http_port, '0.0.0.0', () => console.log('listening HTTP on *:' + http_port)); -let io = http_io; +let wss = http_wss; let https_port = process.env.HTTPS_PORT; if (https_port) { @@ -111,13 +112,10 @@ if (https_port) { key: fs.readFileSync(process.env.SSL_KEY || "key.pem"), cert: fs.readFileSync(process.env.SSL_CERT || "cert.pem") }, app); - let https_io = socket_io(https_server); + let https_wss = new WebSocketServer({server: https_server}); https_server.listen(https_port, '0.0.0.0', () => console.log('listening HTTPS on *:' + https_port)); - http_server.keepAliveTimeout=0; - io = { - use: function (fn) { http_io.use(fn); https_io.use(fn); }, - on: function (ev,fn) { http_io.on(ev,fn); https_io.on(ev,fn); }, - }; + https_server.keepAliveTimeout = 0; + wss = { on: function (ev,fn) { http_wss.on(ev,fn); https_wss.on(ev,fn); } }; } /* @@ -129,19 +127,16 @@ function random_seed() { } function LOG(req, ...msg) { - let name; - if (req.user) - name = `"${req.user.name}" <${req.user.mail}>`; - else - name = "guest"; let time = new Date().toISOString().substring(0,19).replace("T", " "); + let name = req.user ? `"${req.user.name}" <${req.user.mail}>` : "guest"; console.log(time, req.connection.remoteAddress, name, ...msg); } function SLOG(socket, ...msg) { let time = new Date().toISOString().substring(0,19).replace("T", " "); - console.log(time, socket.request.connection.remoteAddress, socket.user_name, - socket.title_id + "/" + socket.game_id + "/" + socket.role, ...msg); + let name = socket.user ? `"${socket.user.name}" <${socket.user.mail}>` : "guest"; + console.log(time, socket.ip, name, + "WS /" + socket.title_id + "/" + socket.game_id + "/" + socket.role, ...msg); } function human_date(time) { @@ -287,19 +282,6 @@ app.use(function (req, res, next) { return next(); }); -io.use(function (socket, next) { - let sid = login_cookie(socket.request); - if (sid) - socket.user_id = login_sql_select.get(sid); - else - socket.user_id = 0; - if (socket.user_id) - socket.user_name = SQL_SELECT_USER_NAME.get(socket.user_id); - else - socket.user_name = "guest"; - return next(); -}); - function must_be_logged_in(req, res, next) { if (!req.user) return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl)); @@ -1378,7 +1360,7 @@ app.get('/:title_id/play\::game_id\::role', must_be_logged_in, function (req, re let game_id = req.params.game_id; let role = req.params.role; if (!SQL_AUTHORIZE_GAME_ROLE.get(title_id, game_id, role, user_id)) - return res.send("You are not assigned that role."); + return res.status(404).send("Invalid game ID."); return res.sendFile(__dirname + '/public/' + title_id + '/play.html'); }); @@ -1515,7 +1497,7 @@ function mail_ready_to_start_notification(user, game_id, interval) { function mail_your_turn_notification_to_offline_users(game_id, old_active, active) { function is_online(game_id, user_id) { for (let other of clients[game_id]) - if (other.user_id === user_id) + if (other.user && other.user.user_id === user_id) return true; return false; } @@ -1572,21 +1554,25 @@ setInterval(notify_ready_to_start_reminder, 5 * 60 * 1000); let clients = {}; +function send_message(socket, cmd, arg) { + socket.send(JSON.stringify([cmd, arg])); +} + function send_state(socket, state) { try { let view = socket.rules.view(state, socket.role); - if (socket.log_length < view.log.length) - view.log_start = socket.log_length; + if (socket.seen < view.log.length) + view.log_start = socket.seen; else view.log_start = view.log.length; - socket.log_length = view.log.length; + socket.seen = view.log.length; view.log = view.log.slice(view.log_start); if (state.state === 'game_over') view.game_over = 1; - socket.emit('state', view); + send_message(socket, 'state', view); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1624,7 +1610,7 @@ function on_action(socket, action, arg) { put_replay(socket.game_id, socket.role, action, arg); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1638,7 +1624,7 @@ function on_resign(socket) { put_replay(socket.game_id, socket.role, 'resign', null); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1648,25 +1634,25 @@ function on_getchat(socket, seen) { if (chat.length > 0) SLOG(socket, "GETCHAT", seen, chat.length); for (let i = 0; i < chat.length; ++i) - socket.emit('chat', chat[i]); + send_message(socket, 'chat', chat[i]); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } function on_chat(socket, message) { message = message.substring(0,4000); try { - let chat = SQL_INSERT_GAME_CHAT.get(socket.game_id, socket.user_id, message); - chat[2] = socket.user_name; + let chat = SQL_INSERT_GAME_CHAT.get(socket.game_id, socket.user.user_id, message); + chat[2] = socket.user.name; SLOG(socket, "CHAT"); for (let other of clients[socket.game_id]) if (other.role !== "Observer") - other.emit('chat', chat); + send_message(other, 'chat', chat); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1675,11 +1661,11 @@ function on_debug(socket) { try { let game_state = SQL_SELECT_GAME_STATE.get(socket.game_id); if (!game_state) - return socket.emit('error', "No game with that ID."); - socket.emit('debug', game_state); + return send_message(socket, 'error', "No game with that ID."); + send_message(socket, 'debug', game_state); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1688,16 +1674,16 @@ function on_save(socket) { try { let game_state = SQL_SELECT_GAME_STATE.get(socket.game_id); if (!game_state) - return socket.emit('error', "No game with that ID."); - socket.emit('save', game_state); + return send_message(socket, 'error', "No game with that ID."); + send_message(socket, 'save', game_state); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } function on_restore(socket, state_text) { - SLOG(socket, 'RESTORE'); + SLOG(socket, "RESTORE"); try { let state = JSON.parse(state_text); state.seed = random_seed(); // reseed! @@ -1709,7 +1695,7 @@ function on_restore(socket, state_text) { send_state(other, state); } catch (err) { console.log(err); - return socket.emit('error', err.toString()); + return send_message(socket, 'error', err.toString()); } } @@ -1718,89 +1704,112 @@ function broadcast_presence(game_id) { for (let socket of clients[game_id]) presence[socket.role] = true; for (let socket of clients[game_id]) - socket.emit('presence', presence); + send_message(socket, 'presence', presence); +} + +function on_restart(socket, scenario) { + try { + let seed = random_seed(); + let state = socket.rules.setup(seed, scenario, {}, socket.players); + put_replay(socket.game_id, null, 'setup', [seed, scenario, null, socket.players]); + for (let other of 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()); + } } -io.on('connection', (socket) => { - socket.title_id = socket.handshake.query.title || "unknown"; - socket.game_id = socket.handshake.query.game | 0; - socket.role = socket.handshake.query.role; - socket.log_length = 0; +function handle_message(socket, cmd, arg) { + switch (cmd) { + case 'action': on_action(socket, arg[0], arg[1]); break; + case 'resign': on_resign(socket); break; + case 'getchat': on_getchat(socket, arg); break; + case 'chat': on_chat(socket, arg); break; + case 'debug': on_debug(socket); break; + case 'save': on_save(socket); break; + case 'restore': on_restore(socket, arg); break; + case 'restart': on_restart(socket, arg); break; + } +} + +wss.on('connection', (socket, req, client) => { + let u = url.parse(req.url, true); + if (u.pathname !== '/play-socket') + return setTimeout(() => socket.close(1000, "Invalid request."), 30000); + req.query = u.query; + + let user_id = 0; + let sid = login_cookie(req); + if (sid) + user_id = login_sql_select.get(sid); + if (user_id) + socket.user = SQL_SELECT_USER_INFO.get(user_id); + + socket.ip = req.connection.remoteAddress; + socket.title_id = req.query.title || "unknown"; + socket.game_id = req.query.game | 0; + socket.role = req.query.role; + socket.seen = req.query.seen | 0; socket.rules = RULES[socket.title_id]; - SLOG(socket, "CONNECT"); + SLOG(socket, "OPEN " + socket.seen); try { let title_id = SQL_SELECT_GAME_TITLE.get(socket.game_id); if (title_id !== socket.title_id) - return socket.emit('error', "Invalid game ID."); + return socket.close(1000, "Invalid game ID."); - let players = SQL_SELECT_PLAYERS_JOIN.all(socket.game_id); + let players = socket.players = SQL_SELECT_PLAYERS_JOIN.all(socket.game_id); if (socket.role !== "Observer") { - if (!socket.user_id) - return socket.emit('error', "You are not logged in!"); + if (!socket.user) + return socket.close(1000, "You are not logged in!"); if (socket.role && socket.role !== 'undefined' && socket.role !== 'null') { - let me = players.find(p => p.user_id === socket.user_id && p.role === socket.role); - if (!me) { - socket.role = "Observer"; - return socket.emit('error', "You aren't assigned that role!"); - } + let me = players.find(p => p.user_id === socket.user.user_id && p.role === socket.role); + if (!me) + return socket.close(1000, "You aren't assigned that role!"); } else { - let me = players.find(p => p.user_id === socket.user_id); + let me = players.find(p => p.user_id === socket.user.user_id); socket.role = me ? me.role : "Observer"; } } - socket.emit('roles', socket.role, players); + if (socket.seen === 0) + send_message(socket, 'players', [socket.role, players]); if (clients[socket.game_id]) clients[socket.game_id].push(socket); else clients[socket.game_id] = [ socket ]; - socket.on('disconnect', () => { - SLOG(socket, "DISCONNECT"); + socket.on('close', (code, reason) => { + SLOG(socket, "CLOSE " + code); clients[socket.game_id].splice(clients[socket.game_id].indexOf(socket), 1); - if (socket.role !== "Observer") - broadcast_presence(socket.game_id); + broadcast_presence(socket.game_id); }); if (socket.role !== "Observer") { - socket.on('action', (action, arg) => on_action(socket, action, arg)); - socket.on('resign', () => on_resign(socket)); - socket.on('getchat', (seen) => on_getchat(socket, seen)); - socket.on('chat', (message) => on_chat(socket, message)); - - socket.on('debug', () => on_debug(socket)); - socket.on('save', () => on_save(socket)); - socket.on('restore', (state) => on_restore(socket, state)); - socket.on('restart', (scenario) => { + socket.on('message', (data) => { try { - let seed = random_seed(); - let state = socket.rules.setup(seed, scenario, {}, players); - put_replay(socket.game_id, null, 'setup', [seed, scenario, null, players]); - for (let other of clients[socket.game_id]) { - other.log_length = 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); + let [ cmd, arg ] = JSON.parse(data); + handle_message(socket, cmd, arg); } catch (err) { - console.log(err); - return socket.emit('error', err.toString()); + send_message(socket, 'error', err); } }); } broadcast_presence(socket.game_id); - send_state(socket, get_game_state(socket.game_id)); - } catch (err) { console.log(err); - socket.emit('error', err.message); + socket.close(1000, err.message); } }); -- cgit v1.2.3