From 34487c1b1920c6a52108a7ed7b390d4b08728ff9 Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Tue, 23 Jul 2024 13:48:12 +0200 Subject: New join page! * Remove explicit (and redundant) role=Observer parameter. * Remove old /play/:id redirects. * Show "private" badge on game boxes. * Forbid leave/kick in public games. * Allow "rewind" by owner in public games. --- public/join.js | 603 +++++++++++++++++++++++++++++++++---------------------- public/style.css | 3 +- schema.sql | 16 +- server.js | 240 ++++++++++------------ views/create.pug | 6 +- views/head.pug | 23 ++- views/info.pug | 4 - views/join.pug | 84 +++----- 8 files changed, 533 insertions(+), 446 deletions(-) diff --git a/public/join.js b/public/join.js index f3279ad..f852b95 100644 --- a/public/join.js +++ b/public/join.js @@ -1,66 +1,232 @@ "use strict" -let start_status = game.status +/* global game, roles, players, blacklist, user_id */ + +const pace_text = [ + "", + "Live!", + "Fast \u2013 many moves per day", + "Slow \u2013 one move per day", +] + +let start_status = 0 let evtsrc = null -let timer = 0 let invite_role = null +function is_game_ready() { + if (game.player_count !== players.length) + return false + for (let p of players) + if (p.is_invite) + return false + return true +} + +function is_blacklist(p) { + return blacklist && blacklist.includes(p.user_id) +} + +function has_already_joined() { + for (let p of players) + if (p.user_id === user_id) + return true + return false +} + +function has_other_players() { + for (let p of players) + if (p.user_id !== user_id) + return true + return false +} + +function may_join() { + if (game.is_match || game.status > 1) + return false + if (has_already_joined()) { + if (user_id !== game.owner_id) + return false + if (has_other_players()) + return false + } + return true +} + +function may_part() { + if (game.is_match || game.status > 1) + return false + if (game.status > 0) { + if (!game.is_private) + return false + } + return true +} + +function may_kick() { + if (game.owner_id !== user_id) + return false + return may_part() +} + +function may_start() { + if (game.owner_id !== user_id || game.is_match || game.status !== 0) + return false + if (!is_game_ready()) + return false + return true +} + +function may_delete() { + if (game.owner_id !== user_id || game.is_match || game.status >= 2) + return false + if (game.status > 0 && game.user_count > 0) + return false + return true +} + +function may_rewind() { + if (game.owner_id !== user_id || game.is_match || game.status !== 1) + return false + if (!game.is_private) + return false + return true +} + +function option_to_english(k) { + if (k === true || k === 1) + return "yes" + if (k === false) + return "no" + if (typeof k === "string") + return k.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()) + return k +} + +function format_options(options) { + if (options === "{}") + return "" + return Object.entries(JSON.parse(options)) + .map(([ k, v ]) => { + if (k === "players") + return v + " Player" + if (v === true || v === 1) + return option_to_english(k) + return option_to_english(k) + "=" + option_to_english(v) + }) + .join(", ") +} + +function format_time_left(time) { + if (time <= 0) + return "no time left" + if (time <= 2 / 24) + return Math.floor(time * 24 * 60) + " minutes left" + if (time <= 2) + return Math.floor(time * 24) + " hours left" + return Math.floor(time) + " days left" +} + +function epoch_from_julianday(x) { + return (x - 2440587.5) * 86400000 +} + +function julianday_from_epoch(x) { + return x / 86400000 + 2440587.5 +} + +function human_date(date) { + if (typeof date === "string") + date = julianday_from_epoch(Date.parse(date + "Z")) + if (typeof date !== "number") + return "never" + var days = julianday_from_epoch(Date.now()) - date + var seconds = days * 86400 + if (days < 1) { + if (seconds < 60) return "now" + if (seconds < 120) return "1 minute ago" + if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago" + if (seconds < 7200) return "1 hour ago" + if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago" + } + if (days < 2) return "yesterday" + if (days < 14) return Math.floor(days) + " days ago" + if (days < 31) return Math.floor(days / 7) + " weeks ago" + return new Date(epoch_from_julianday(date)).toISOString().substring(0,10) +} + function confirm_delete() { - let warning = `Are you sure you want to DELETE this game?` + let warning = "Are you sure you want to DELETE this game?" + if (window.confirm(warning)) + post("/api/delete/" + game.game_id) +} + +function confirm_rewind() { + let warning = "Are you sure you want to REWIND this game to the last move?\n\nMake sure you have the consent of all the players." if (window.confirm(warning)) - window.location.href = "/delete/" + game.game_id + post("/api/rewind/" + game.game_id) } -function post(url) { - fetch(url, { method: "POST" }) - .then(r => r.text()) - .then(t => window.error.textContent = (t === "SUCCESS") ? "" : t) - .catch(e => window.error.textContent = e) +async function post(url) { + window.error.textContent = "" + let res = await fetch(url, { method: "POST" }) + if (!res.ok) { + window.error.textContent = res.status + " " + res.statusText + return + } + let text = await res.text() + if (text !== "SUCCESS") { + window.error.textContent = text + return + } start_event_source() } function start() { - post(`/start/${game.game_id}`) + post(`/api/start/${game.game_id}`) } function join(role) { - post(`/join/${game.game_id}/${encodeURIComponent(role)}`) + post(`/api/join/${game.game_id}/${encodeURIComponent(role)}`) +} + +function accept(role) { + post(`/api/accept/${game.game_id}/${encodeURIComponent(role)}`) +} + +function decline(role) { + post(`/api/part/${game.game_id}/${encodeURIComponent(role)}`) } function part(role) { let warning = "Are you sure you want to LEAVE this game?" if (game.status === 0 || window.confirm(warning)) - post(`/part/${game.game_id}/${encodeURIComponent(role)}`) + post(`/api/part/${game.game_id}/${encodeURIComponent(role)}`) } function kick(role) { let player = players.find(p => p.role === role) let warning = `Are you sure you want to KICK player ${player.name} (${role}) from this game?` if (game.status === 0 || window.confirm(warning)) - post(`/part/${game.game_id}/${encodeURIComponent(role)}`) + post(`/api/part/${game.game_id}/${encodeURIComponent(role)}`) } -function accept(role) { - post(`/accept/${game.game_id}/${encodeURIComponent(role)}`) +function invite(role) { + invite_role = role + document.getElementById("invite").showModal() +} + +function hide_invite() { + document.getElementById("invite").close() } function send_invite() { let invite_user = document.getElementById("invite_user").value if (invite_user) { - post(`/invite/${game.game_id}/${encodeURIComponent(invite_role)}/${encodeURIComponent(invite_user)}`) document.getElementById("invite").close() + post(`/api/invite/${game.game_id}/${encodeURIComponent(invite_role)}/${encodeURIComponent(invite_user)}`) } } -function show_invite(role) { - invite_role = role - document.getElementById("invite").showModal() -} - -function hide_invite() { - document.getElementById("invite").close() -} - let blink_title = document.title let blink_timer = 0 @@ -86,261 +252,225 @@ function stop_blinker() { window.addEventListener("focus", stop_blinker) function start_event_source() { + if (!game) + return if (!evtsrc || evtsrc.readyState === 2) { console.log("STARTING EVENT SOURCE") evtsrc = new EventSource("/join-events/" + game.game_id) - evtsrc.addEventListener("players", function (evt) { - console.log("PLAYERS:", evt.data) - players = JSON.parse(evt.data) - update() - }) - evtsrc.addEventListener("ready", function (evt) { - console.log("READY:", evt.data) - ready = JSON.parse(evt.data) - update() + evtsrc.addEventListener("hello", function (evt) { + console.log("HELLO", evt.data) + window.disconnected.textContent = "" }) - evtsrc.addEventListener("game", function (evt) { - console.log("GAME:", evt.data) - game = JSON.parse(evt.data) - if (game.status > 1) { - console.log("CLOSED EVENT SOURCE") - clearInterval(timer) - evtsrc.close() - } + evtsrc.addEventListener("updated", function (evt) { + console.log("UPDATED", evt.data) + let data = JSON.parse(evt.data) + game = data.game + roles = data.roles + players = data.players update() }) evtsrc.addEventListener("deleted", function (evt) { - console.log("DELETED") - window.location.href = '/' + game.title_id + console.log("DELETED", evt.data) + game = null + roles = null + players = null + update() + evtsrc.close() }) - evtsrc.onerror = function (err) { - window.message.innerHTML = "Disconnected from server..." + evtsrc.onerror = function (evt) { + console.log("ERROR", evt) + window.disconnected.textContent = "Disconnected from server..." } - window.addEventListener('beforeunload', function (evt) { + window.addEventListener('beforeunload', function (_evt) { evtsrc.close() }) } } -function is_friend(p) { - return whitelist && whitelist.includes(p.user_id) +function user_link(user_name) { + return `${user_name}` } -function is_enemy(p) { - return blacklist && blacklist.includes(p.user_id) +function player_link(player) { + let link = user_link(player.name) + if (player.is_invite) + link = "" + link + " ?" + if (player.user_id === user_id) + link = "\xbb " + link + return link } -function user_link(player) { - return `${player.name}` +function play_link(parent, player) { + let e = document.createElement("a") + e.setAttribute("href", `/${game.title_id}/play.html?game=${game.game_id}&role=${encodeURIComponent(player.role)}`) + e.textContent = "Play" + parent.appendChild(e) } -function play_link(player) { - return `\xbb ${player.name}` +function action_link(parent, text, action, arg) { + let e = document.createElement("a") + e.setAttribute("href", `javascript:${action.name}('${arg}')`) + e.textContent = text + parent.appendChild(e) } -function action_link(player, action, color, text) { - return `${text}` +function create_element(parent, tag, classList) { + let e = document.createElement(tag) + if (classList) + e.classList = classList + parent.appendChild(e) + return e } -function update() { - update_common() - if (user_id) - update_login() - else - update_no_login() +function create_button(text, action) { + let e = create_element(window.game_actions, "button") + e.textContent = text + e.onclick = action +} + +function create_game_list_item(parent, key, val) { + if (val) { + let tr = create_element(parent, "tr") + let e_key = create_element(tr, "td") + let e_val = create_element(tr, "td") + e_key.innerHTML = key + e_val.innerHTML = val + } } -function update_common() { +function create_game_list() { + let table = create_element(window.game_info, "table") + let list = create_element(table, "tbody") + if (game.scenario !== "Standard") - document.querySelector("h1").textContent = "#" + game.game_id + " - " + game.title_name + " - " + game.scenario + create_game_list_item(list, "Scenario", game.scenario) + create_game_list_item(list, "Options", format_options(game.options)) + create_game_list_item(list, "Pace", pace_text[game.pace]) + create_game_list_item(list, "Notice", game.notice) + + if (game.owner_id) + create_game_list_item(list, "Created", human_date(game.ctime) + " by " + user_link(game.owner_name)) else - document.querySelector("h1").textContent = "#" + game.game_id + " - " + game.title_name + create_game_list_item(list, "Created", human_date(game.ctime)) - let message = window.message - if (game.status === 0) { - if (ready) - message.innerHTML = "Waiting to start..." - else - message.innerHTML = "Waiting for players to join..." - } else if (game.status === 1) { - message.innerHTML = `Observe` - } else if (game.status === 2) { - message.innerHTML = `Review` - } else if (game.status === 3) { - message.innerHTML = "Archived" + + create_game_list_item(list, "Moves", game.moves) + + if (game.status === 1) { + create_game_list_item(list, "Last move", human_date(game.mtime)) } -} -function update_no_login() { - for (let i = 0; i < roles.length; ++i) { - let role = roles[i] - let role_id = "role_" + role.replace(/ /g, "_") - if (game.is_random && game.status === 0) - role = "Random " + (i+1) - document.getElementById(role_id + "_name").textContent = role - let player = players.find(p => p.role === role) - let element = document.getElementById(role_id) - - if (game.is_match) { - if (player) { - if (game.status === 1) - element.classList.toggle("is_active", player.is_active) - element.innerHTML = user_link(player) - } else { - element.innerHTML = `Empty` - } - continue - } + if (game.status > 1) { + create_game_list_item(list, "Finished", human_date(game.mtime)) + create_game_list_item(list, "Result", game.result) + } +} - if (player) { - element.classList.remove("is_invite") - switch (game.status) { - case 3: - element.innerHTML = player.name - break - case 2: - element.innerHTML = user_link(player) - break - case 1: - if (player.is_invite) { - element.classList.add("is_invite") - element.innerHTML = user_link(player) + " ?" - } else { - element.classList.toggle("is_active", player.is_active) - element.innerHTML = user_link(player) - } - break - case 0: +function create_player_box(role, player) { + let box = create_element(window.game_players, "table") + let thead = create_element(box, "thead") + let tbody = create_element(box, "tbody") + let tr_role = create_element(thead, "tr") + let tr_player = create_element(tbody, "tr", "p") + let tr_actions = create_element(tbody, "tr", "a") + + let td_role_name = create_element(tr_role, "td") + let td_role_time = create_element(tr_role, "td", "r") + let td_player_name = create_element(tr_player, "td") + let td_player_seen = create_element(tr_player, "td", "r") + let td_actions = create_element(tr_actions, "td", "a r") + td_actions.setAttribute("colspan", 2) + td_actions.textContent = "\u200b" + + td_role_name.textContent = role + + if (player) { + if (player.is_active && game.status === 1) + box.classList = "active" + if (player.is_invite) + box.classList = "invite" + + if (game.status > 0 && (game.pace > 0 || player.time_left < 3)) + td_role_time.textContent = format_time_left(player.time_left) + + td_player_name.innerHTML = player_link(player) + if (player.user_id !== user_id && game.status <= 1) + td_player_seen.innerHTML = "seen " + human_date(player.atime) + "" + + if (user_id) { + if (is_blacklist(player)) + td_player_name.classList.add("blacklist") + + if (player.user_id === user_id) { if (player.is_invite) { - element.classList.add("is_invite") - element.innerHTML = user_link(player) + " ?" + action_link(td_actions, "Decline", decline, role) + action_link(td_actions, "Accept", accept, role) } else { - element.innerHTML = user_link(player) + if (may_part()) + action_link(td_actions, "Leave", part, role) + if (game.status === 1 || game.status === 2) + play_link(td_actions, player) } - break + } else { + if (may_kick()) + action_link(td_actions, "Kick", kick, role) + } + } + } else { + td_player_name.innerHTML = "Empty" + + if (user_id) { + if (!game.is_match) { + if (game.owner_id === user_id) + action_link(td_actions, "Invite", invite, role) + if (may_join()) + action_link(td_actions, "Join", join, role) } - } else { - element.classList.remove("is_invite") - element.innerHTML = `Empty` } } } -function update_login() { +function update() { + window.error.textContent = "" + window.game_info.replaceChildren() + window.game_players.replaceChildren() + window.game_actions.replaceChildren() + + if (!game) { + window.game_enter.textContent = "Game deleted!" + stop_blinker() + return + } + + if (game.status === 0) { + if (user_id) + window.game_enter.textContent = "Waiting for players to join." + else + window.game_enter.innerHTML = `Login or sign up to join.` + } else if (game.status === 1) + window.game_enter.innerHTML = `Watch` + else if (game.status === 2) + window.game_enter.innerHTML = `Review` + else + window.game_enter.innerHTML = "Archived." + + create_game_list() + for (let i = 0; i < roles.length; ++i) { let role = roles[i] - let role_id = "role_" + role.replace(/ /g, "_") if (game.is_random && game.status === 0) role = "Random " + (i+1) - document.getElementById(role_id + "_name").textContent = role - let player = players.find(p => p.role === role) - let element = document.getElementById(role_id) - - if (game.is_match) { - if (player) { - if (game.status === 1) - element.classList.toggle("is_active", player.is_active) - if (player.user_id === user_id && (game.status === 1 || game.status === 2)) - element.innerHTML = play_link(player) - else - element.innerHTML = user_link(player) - } else { - element.innerHTML = `Empty` - } - continue - } - - if (player) { - element.classList.remove("is_invite") - switch (game.status) { - case 3: - element.innerHTML = player.name - break - case 2: - if (player.user_id === user_id) - element.innerHTML = play_link(player) - else - element.innerHTML = user_link(player) - break - case 1: - if (player.is_invite) { - element.classList.add("is_invite") - if (player.user_id === user_id) - element.innerHTML = player.name + " ?" + - action_link(player, "part", "red", "\u274c") + - action_link(player, "accept", "green", "\u2714") - else if (game.owner_id === user_id) - element.innerHTML = user_link(player) + " ?" + action_link(player, "kick", "red", "\u274c") - else - element.innerHTML = user_link(player) + " ?" - } else { - element.classList.toggle("is_active", player.is_active) - if (player.user_id === user_id) - element.innerHTML = play_link(player) + action_link(player, "part", "red", "\u274c") - else if (game.owner_id === user_id) - element.innerHTML = user_link(player) + action_link(player, "kick", "red", "\u274c") - else - element.innerHTML = user_link(player) - } - break - case 0: - if (player.is_invite) { - element.classList.add("is_invite") - if (player.user_id === user_id) - element.innerHTML = player.name + " ?" + - action_link(player, "part", "red", "\u274c") + - action_link(player, "accept", "green", "\u2714") - else if (game.owner_id === user_id) - element.innerHTML = user_link(player) + " ?" + action_link(player, "kick", "red", "\u274c") - else - element.innerHTML = user_link(player) + " ?" - } else { - if (player.user_id === user_id) - element.innerHTML = player.name + action_link(player, "part", "red", "\u274c") - else if (game.owner_id === user_id) - element.innerHTML = user_link(player) + action_link(player, "kick", "red", "\u274c") - else - element.innerHTML = user_link(player) - } - break - } - element.classList.toggle("friend", is_friend(player)) - element.classList.toggle("enemy", is_enemy(player)) - if (is_enemy(player)) - element.title = "You have blacklisted this user!" - else - element.title = "" - } else { - element.classList.remove("is_invite") - switch (game.status) { - case 2: - element.innerHTML = `Empty` - break - case 1: - case 0: - if (limit) - element.innerHTML = `Empty` - else if (game.owner_id === user_id) - element.innerHTML = `\xbb Join\u{2795}` - else - element.innerHTML = `\xbb Join` - break - } - element.classList.remove("friend") - element.classList.remove("enemy") - element.title = "" - } + create_player_box(role, players.find(p => p.role === role)) } - if (game.owner_id === user_id) { - window.start_button.disabled = !ready - window.start_button.classList = (game.status === 0) ? "" : "hide" - window.delete_button.classList = (game.status === 0 || game.user_count <= 1) ? "" : "hide" - if (game.status === 0 && ready) - start_blinker("READY TO START") - else - stop_blinker() - } else { + if (user_id) { + if (may_rewind()) + create_button("Rewind", confirm_rewind) + if (may_delete()) + create_button("Delete", confirm_delete) + if (may_start()) + create_button("Start", start) + if (start_status === 0 && game.status === 1) start_blinker("STARTED") else @@ -350,8 +480,9 @@ function update_login() { window.onload = function () { update() - if (user_id && game.status <= 1 && !game.is_match) { + if (user_id && game.status <= 1) { start_event_source() - timer = setInterval(start_event_source, 15000) + setInterval(start_event_source, 15000) + setInterval(update, 60000) } } diff --git a/public/style.css b/public/style.css index caa2feb..7928f86 100644 --- a/public/style.css +++ b/public/style.css @@ -21,7 +21,6 @@ html { --color-table-active: hsla(55, 100%, 50%, 0.15); --color-table-invite: hsla(120, 100%, 50%, 0.15); - --color-table-danger: hsla(350, 100%, 50%, 0.15); } /* light gray */ @@ -141,6 +140,8 @@ h1 { margin: 16px 0 16px -1px; font-size: 24px; } h2 { margin: 16px 0 16px -1px; font-size: 20px; } h3 { margin: 16px 0 8px -1px; font-size: 16px; } +h1 .icon { font-weight: normal; } + html { overflow-y: scroll; } diff --git a/schema.sql b/schema.sql index 9fe8a76..1d3641c 100644 --- a/schema.sql +++ b/schema.sql @@ -474,6 +474,18 @@ create view game_view as on owner.user_id = games.owner_id ; +drop view if exists game_view_public; +create view game_view_public as + select + * + from + game_view + where + not is_private + and join_count > 0 + and join_count = user_count + ; + drop view if exists player_view; create view player_view as select @@ -502,11 +514,13 @@ create view player_view as 10.0 - (time_used - time_added) end end - ) as time_left + ) as time_left, + atime from games join players using(game_id) join users using(user_id) + left join user_last_seen using(user_id) ; drop view if exists time_control_view; diff --git a/server.js b/server.js index 86a4c68..52507e4 100644 --- a/server.js +++ b/server.js @@ -176,11 +176,19 @@ app.locals.ENABLE_MAIL = !!mailer app.locals.ENABLE_WEBHOOKS = !!WEBHOOKS app.locals.ENABLE_FORUM = process.env.FORUM | 0 +app.locals.EMOJI_PRIVATE = "\u{1F512}" // or 512 app.locals.EMOJI_MATCH = "\u{1f3c6}" app.locals.EMOJI_LIVE = "\u{1f465}" app.locals.EMOJI_FAST = "\u{1f3c1}" app.locals.EMOJI_SLOW = "\u{1f40c}" +const PACE_ICON = [ + "", + app.locals.EMOJI_LIVE, + app.locals.EMOJI_FAST, + app.locals.EMOJI_SLOW +] + app.set("x-powered-by", false) app.set("etag", false) app.set("view engine", "pug") @@ -1146,9 +1154,10 @@ function option_to_english(k) { return k } -function format_options(options_json, options) { +function format_options(options_json) { if (options_json in HUMAN_OPTIONS_CACHE) return HUMAN_OPTIONS_CACHE[options_json] + let options = parse_game_options(options_json) let text = Object.entries(options) .map(([ k, v ]) => { if (k === "players") @@ -1163,6 +1172,8 @@ function format_options(options_json, options) { function get_game_roles(title_id, scenario, options) { let roles = RULES[title_id].roles + if (typeof options === "string") + options = parse_game_options(options) if (typeof roles === "function") return roles(scenario, options) return roles @@ -1198,7 +1209,7 @@ function load_rules(rules_dir, rules_file, title) { else setup.setup_name = title.title_name } - setup.roles = get_game_roles(setup.title_id, setup.scenario, parse_game_options(setup.options)) + setup.roles = get_game_roles(setup.title_id, setup.scenario, setup.options) } title.about_html = fs.readFileSync(rules_dir + "/about.html") @@ -1399,54 +1410,49 @@ const SQL_INSERT_REMATCH = SQL(` `).pluck() const QUERY_LIST_PUBLIC_GAMES_OPEN = SQL(` - select * from game_view where status=0 and not is_private and join_count > 0 and join_count < player_count + select * from game_view_public where status = 0 and join_count < player_count and not exists ( select 1 from contacts where me = owner_id and you = ? and relation < 0 ) order by mtime desc, ctime desc `) const QUERY_LIST_PUBLIC_GAMES_REPLACEMENT = SQL(` - select * from game_view where status=1 and not is_private and join_count > 0 and join_count < player_count + select * from game_view_public where status = 1 and join_count < player_count and not exists ( select 1 from contacts where me = owner_id and you = ? and relation < 0 ) order by mtime desc, ctime desc `) const QUERY_LIST_PUBLIC_GAMES_ACTIVE = SQL(` - select * from game_view where status=1 and not is_private and join_count = player_count + select * from game_view_public where status = 1 and join_count = player_count order by mtime desc, ctime desc limit 12 `) const QUERY_LIST_PUBLIC_GAMES_FINISHED = SQL(` - select * from game_view where status=2 and not is_private + select * from game_view_public where status = 2 order by mtime desc, ctime desc limit 12 `) const QUERY_LIST_GAMES_OF_TITLE_OPEN = SQL(` - select * from game_view where title_id=? and not is_private and status=0 and join_count > 0 and join_count < player_count + select * from game_view_public where title_id=? and status = 0 and join_count < player_count and not exists ( select 1 from contacts where me = owner_id and you = ? and relation < 0 ) order by mtime desc, ctime desc `) -const QUERY_LIST_GAMES_OF_TITLE_READY = SQL(` - select * from game_view where title_id=? and not is_private and status=0 and join_count = player_count - order by mtime desc, ctime desc - `) - const QUERY_LIST_GAMES_OF_TITLE_REPLACEMENT = SQL(` - select * from game_view where title_id=? and not is_private and status=1 and join_count > 0 and join_count < player_count + select * from game_view_public where title_id=? and status = 1 and join_count < player_count and not exists ( select 1 from contacts where me = owner_id and you = ? and relation < 0 ) order by mtime desc, ctime desc `) const QUERY_LIST_GAMES_OF_TITLE_ACTIVE = SQL(` - select * from game_view where title_id=? and not is_private and status=1 and join_count = player_count + select * from game_view_public where title_id=? and status = 1 and join_count = player_count order by mtime desc, ctime desc limit 12 `) const QUERY_LIST_GAMES_OF_TITLE_FINISHED = SQL(` - select * from game_view where title_id=? and not is_private and status=2 + select * from game_view_public where title_id=? and status = 2 order by mtime desc, ctime desc limit 12 `) @@ -1511,8 +1517,7 @@ function check_join_game_limit(user) { } function annotate_game_info(game, user_id, unread) { - let options = parse_game_options(game.options) - game.human_options = format_options(game.options, options) + game.human_options = format_options(game.options) game.is_unread = set_has(unread, game.game_id) @@ -1520,7 +1525,7 @@ function annotate_game_info(game, user_id, unread) { let your_role = null let time_left = Infinity - let roles = get_game_roles(game.title_id, game.scenario, options) + let roles = get_game_roles(game.title_id, game.scenario, game.options) game.players = SQL_SELECT_PLAYER_VIEW.all(game.game_id) for (let p of game.players) @@ -1676,13 +1681,11 @@ function get_title_page(req, res, title_id) { let user_id = req.user ? req.user.user_id : 0 let open_games = QUERY_LIST_GAMES_OF_TITLE_OPEN.all(title_id, user_id) - let ready_games = QUERY_LIST_GAMES_OF_TITLE_READY.all(title_id) let replacement_games = QUERY_LIST_GAMES_OF_TITLE_REPLACEMENT.all(title_id, user_id) let active_games = QUERY_LIST_GAMES_OF_TITLE_ACTIVE.all(title_id) let finished_games = QUERY_LIST_GAMES_OF_TITLE_FINISHED.all(title_id) annotate_games(open_games, user_id, unread) - annotate_games(ready_games, user_id, unread) annotate_games(replacement_games, user_id, unread) annotate_games(active_games, user_id, unread) annotate_games(finished_games, user_id, unread) @@ -1691,7 +1694,6 @@ function get_title_page(req, res, title_id) { user: req.user, title: title, open_games, - ready_games, replacement_games, active_games, finished_games, @@ -1761,13 +1763,13 @@ app.post("/create/:title_id", must_be_logged_in, function (req, res) { if (is_random_scenario(title_id, scenario)) rand = 1 - let player_count = get_game_roles(title_id, scenario, parse_game_options(options)).length + let player_count = get_game_roles(title_id, scenario, options).length let game_id = SQL_INSERT_GAME.get(user_id, title_id, scenario, options, player_count, pace, priv, rand, notice, 0) res.redirect("/join/" + game_id) }) -app.get("/delete/:game_id", must_be_logged_in, function (req, res) { +app.post("/api/delete/:game_id", must_be_logged_in, function (req, res) { let game_id = req.params.game_id let title_id = SQL_SELECT_GAME_TITLE.get(game_id) let info = SQL_DELETE_GAME_BY_OWNER.run(game_id, req.user.user_id) @@ -1775,13 +1777,13 @@ 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("/" + title_id) + res.send("SUCCESS") }) function insert_rematch_players(old_game_id, new_game_id, req_user_id, order) { let game = SQL_SELECT_GAME.get(old_game_id) let players = SQL_SELECT_PLAYERS.all(old_game_id) - let roles = get_game_roles(game.title_id, game.scenario, parse_game_options(game.options)) + let roles = get_game_roles(game.title_id, game.scenario, game.options) let n = roles.length if (players.length !== n) @@ -1861,39 +1863,27 @@ app.post("/rematch/:old_game_id", must_be_logged_in, function (req, res) { function update_join_clients_deleted(game_id) { let list = join_clients[game_id] - if (list && list.length > 0) { - for (let { res } of list) { - res.write("retry: 15000\n") - res.write("event: deleted\n") - res.write("data: The game doesn't exist.\n\n") - } - } + if (list && list.length > 0) + for (let res of list) + res.write("event: deleted\ndata: null\n\n") delete join_clients[game_id] } -function update_join_clients_game(game_id) { +function update_join_clients(game_id) { let list = join_clients[game_id] if (list && list.length > 0) { let game = SQL_SELECT_GAME_VIEW.get(game_id) - for (let { res } of list) { - res.write("retry: 15000\n") - res.write("event: game\n") - res.write("data: " + JSON.stringify(game) + "\n\n") - } - } -} - -function update_join_clients_players(game_id) { - let list = join_clients[game_id] - if (list && list.length > 0) { - let players = SQL_SELECT_PLAYER_VIEW.all(game_id) - let ready = is_game_ready(list.player_count, players) - for (let { res } of list) { - res.write("retry: 15000\n") - res.write("event: players\n") - res.write("data: " + JSON.stringify(players) + "\n\n") - res.write("event: ready\n") - res.write("data: " + ready + "\n\n") + if (game) { + let players = SQL_SELECT_PLAYER_VIEW.all(game_id) + let roles = null + if (game) + roles = get_game_roles(game.title_id, game.scenario, game.options) + let data = "event: updated\ndata: " + JSON.stringify({game,roles,players}) + "\n\n" + for (let res of list) + res.write(data) + } else { + for (let res of list) + res.write("event: deleted\ndata: null\n\n") } } } @@ -1904,10 +1894,7 @@ app.get("/join/:game_id", function (req, res) { if (!game) return res.status(404).send("Invalid game ID.") - let options = parse_game_options(game.options) - game.human_options = format_options(game.options, options) - - let roles = get_game_roles(game.title_id, game.scenario, options) + let roles = get_game_roles(game.title_id, game.scenario, game.options) let players = SQL_SELECT_PLAYER_VIEW.all(game_id) let whitelist = null @@ -1924,15 +1911,20 @@ app.get("/join/:game_id", function (req, res) { rewind = SQL_SELECT_REWIND.all(game_id) } - let ready = (game.status === STATUS_OPEN) && is_game_ready(game.player_count, players) - game.ctime = human_date(game.ctime) - game.mtime = human_date(game.mtime) + let icon = "" + if (game.is_private) + icon += app.locals.EMOJI_PRIVATE + if (game.is_match) + icon += app.locals.EMOJI_MATCH + if (game.pace) + icon += PACE_ICON[game.pace] + res.render("join.pug", { user: req.user, + icon, game, roles, players, - ready, whitelist, blacklist, friends, @@ -1950,34 +1942,30 @@ app.get("/join-events/:game_id", must_be_logged_in, function (req, res) { res.setHeader("Connection", "keep-alive") res.setHeader("X-Accel-Buffering", "no") - if (!game) { - return res.send("event: deleted\ndata: The game doesn't exist.\n\n") - } + if (!game) + return res.send("data: null\n\n") + if (!(game_id in join_clients)) { join_clients[game_id] = [] join_clients[game_id].player_count = game.player_count } - join_clients[game_id].push({ res: res, user_id: req.user.user_id }) + join_clients[game_id].push(res) res.on("close", () => { let list = join_clients[game_id] if (list) { - let i = list.findIndex(item => item.res === res) + let i = list.indexOf(res) if (i >= 0) list.splice(i, 1) } }) - res.write("retry: 15000\n\n") - res.write("event: game\n") - res.write("data: " + JSON.stringify(game) + "\n\n") - res.write("event: players\n") - res.write("data: " + JSON.stringify(players) + "\n\n") + res.write("retry: 15000\nevent: hello\ndata: null\n\n") }) function do_join(res, game_id, role, user_id, user_name, is_invite) { let game = SQL_SELECT_GAME.get(game_id) - let roles = get_game_roles(game.title_id, game.scenario, parse_game_options(game.options)) + let roles = get_game_roles(game.title_id, game.scenario, game.options) if (game.is_random && game.status === STATUS_OPEN) { let m = role.match(/^Random (\d+)$/) if (!m || Number(m[1]) < 1 || Number(m[1]) > roles.length) @@ -1988,7 +1976,7 @@ function do_join(res, game_id, role, user_id, user_name, is_invite) { } let info = SQL_INSERT_PLAYER_ROLE.run(game_id, role, user_id, is_invite) if (info.changes === 1) { - update_join_clients_players(game_id) + update_join_clients(game_id) res.send("SUCCESS") // send chat message about player joining a game in progress @@ -2003,7 +1991,7 @@ function do_join(res, game_id, role, user_id, user_name, is_invite) { } } -app.post("/join/:game_id/:role", must_be_logged_in, function (req, res) { +app.post("/api/join/:game_id/:role", must_be_logged_in, function (req, res) { let game_id = req.params.game_id | 0 let role = req.params.role let limit = check_join_game_limit(req.user) @@ -2012,24 +2000,26 @@ app.post("/join/:game_id/:role", must_be_logged_in, function (req, res) { do_join(res, game_id, role, req.user.user_id, req.user.name, 0) }) -app.post("/invite/:game_id/:role/:user", must_be_logged_in, function (req, res) { +app.post("/api/invite/:game_id/:role/:user", must_be_logged_in, function (req, res) { let game_id = req.params.game_id | 0 let role = req.params.role let user_id = SQL_SELECT_USER_ID.get(req.params.user) - if (user_id) - do_join(res, game_id, role, user_id, null, 1) - else + if (!user_id) res.send("User not found.") + else if (user_id === req.user.user_id) + res.send("You cannot invite yourself!") + else + do_join(res, game_id, role, user_id, null, 1) }) -app.post("/accept/:game_id/:role", must_be_logged_in, function (req, res) { +app.post("/api/accept/:game_id/:role", must_be_logged_in, function (req, res) { // TODO: check join game limit if inviting self... let game_id = req.params.game_id | 0 let game = SQL_SELECT_GAME.get(game_id) let role = req.params.role let info = SQL_UPDATE_PLAYER_ACCEPT.run(game_id, role, req.user.user_id) if (info.changes === 1) { - update_join_clients_players(game_id) + update_join_clients(game_id) res.send("SUCCESS") // send chat message about player joining a game in progress @@ -2040,13 +2030,13 @@ app.post("/accept/:game_id/:role", must_be_logged_in, function (req, res) { } }) -app.post("/part/:game_id/:role", must_be_logged_in, function (req, res) { +app.post("/api/part/:game_id/:role", must_be_logged_in, function (req, res) { let game_id = req.params.game_id | 0 let role = req.params.role let user_name = SQL_SELECT_PLAYER_NAME.get(game_id, role) let game = SQL_SELECT_GAME.get(game_id) SQL_DELETE_PLAYER_ROLE.run(game_id, role) - update_join_clients_players(game_id) + update_join_clients(game_id) res.send("SUCCESS") // send chat message about player leaving a game in progress @@ -2074,7 +2064,7 @@ function assign_random_roles(game, options, players) { } } -app.post("/start/:game_id", must_be_logged_in, function (req, res) { +app.post("/api/start/:game_id", must_be_logged_in, function (req, res) { let game_id = req.params.game_id | 0 let game = SQL_SELECT_GAME.get(game_id) if (game.owner_id !== req.user.user_id) @@ -2124,35 +2114,12 @@ function start_game(game) { SQL_ROLLBACK.run() } - update_join_clients_players(game.game_id) - update_join_clients_game(game.game_id) + update_join_clients(game.game_id) send_game_started_notification_to_offline_users(game.game_id) send_your_turn_notification_to_offline_users(game.game_id, null, state.active) } -app.get("/play/:game_id/:role", function (req, res) { - let game_id = req.params.game_id | 0 - let role = req.params.role - let title = SQL_SELECT_GAME_TITLE.get(game_id) - if (!title) - return res.status(404).send("Invalid game ID.") - res.redirect(play_url(title, game_id, role)) -}) - -app.get("/play/:game_id", function (req, res) { - let game_id = req.params.game_id | 0 - let user_id = req.user ? req.user.user_id : 0 - let title = SQL_SELECT_GAME_TITLE.get(game_id) - if (!title) - return res.status(404).send("Invalid game ID.") - let role = SQL_SELECT_PLAYER_ROLE.get(game_id, user_id) - if (role) - res.redirect(play_url(title, game_id, role)) - else - res.redirect(play_url(title, game_id, "Observer")) -}) - app.get("/api/replay/:game_id", function (req, res) { let game_id = req.params.game_id | 0 let game = SQL_SELECT_GAME.get(game_id) @@ -2173,9 +2140,7 @@ app.get("/api/export/:game_id", function (req, res) { return res.type("application/json").send(SQL_SELECT_EXPORT.get(game_id)) }) -app.get("/admin/rewind/:game_id/:snap_id", must_be_administrator, function (req, res) { - let game_id = req.params.game_id | 0 - let snap_id = req.params.snap_id | 0 +function rewind_game_to_snap(game_id, snap_id, res) { let snap = SQL_SELECT_SNAP.get(game_id, snap_id) let game_state = JSON.parse(SQL_SELECT_GAME_STATE.get(game_id)) let snap_state = JSON.parse(snap.state) @@ -2190,19 +2155,45 @@ app.get("/admin/rewind/:game_id/:snap_id", must_be_administrator, function (req, SQL_REWIND_GAME.run(snap_id - 1, snap_state.active, game_id) - update_join_clients_game(game_id) + update_join_clients(game_id) if (game_clients[game_id]) for (let other of game_clients[game_id]) send_state(other, snap_state) SQL_COMMIT.run() - } catch (err) { - return res.send(err.toString()) } finally { if (db.inTransaction) SQL_ROLLBACK.run() } - res.redirect("/join/" + game_id) +} + +const SQL_SELECT_REWIND_AUTH = SQL("select 1 from games where game_id=? and owner_id=? and is_private").pluck() +const SQL_SELECT_REWIND_ONCE_1 = SQL("select max(replay_id) from game_replay where game_id=?").pluck() +const SQL_SELECT_REWIND_ONCE_2 = SQL("select max(snap_id) from game_snap where game_id=? and replay_id { 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.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.user_id) - socket.role = me ? me.role : "Observer" - } + if (!players.find(p => p.user_id === socket.user.user_id && p.role === socket.role)) + return socket.close(1000, "You aren't assigned that role!") let new_chat = SQL_SELECT_UNREAD_CHAT.get(socket.user.user_id, socket.game_id) send_message(socket, "newchat", new_chat) } if (socket.seen === 0) { - let roles = get_game_roles(game.title_id, game.scenario, parse_game_options(game.options)) + let roles = get_game_roles(game.title_id, game.scenario, game.options) send_message(socket, "players", [ socket.role, roles.map(r => ({ role: r, name: players.find(p => p.role === r)?.name })) diff --git a/views/create.pug b/views/create.pug index 4e52da4..912156c 100644 --- a/views/create.pug +++ b/views/create.pug @@ -13,6 +13,8 @@ html div.logo +gamecover(title.title_id) + if limit + p.error= limit if !user p.error You are not logged in! @@ -77,9 +79,7 @@ html input(type="checkbox" name="is_private" value="true") | Private - if limit - p.error= limit - else + if !limit p button(type="submit") Create diff --git a/views/head.pug b/views/head.pug index 3deb614..e4ff857 100644 --- a/views/head.pug +++ b/views/head.pug @@ -61,10 +61,11 @@ mixin gamelist(list,hide_title=0) else if (item.status === 2) className += " finished" else if (item.status === 3) className += " archived" if (item.is_unread) chat_icon = "\u{1f4dd}" - if (item.is_match) pace_icon = EMOJI_MATCH - else if (item.pace === 1) pace_icon = EMOJI_LIVE, pace_text = "Live!" - else if (item.pace === 2) pace_icon = EMOJI_FAST, pace_text = "Fast - many moves per day" - else if (item.pace === 3) pace_icon = EMOJI_SLOW, pace_text = "Slow - one move per day" + if (item.is_private) pace_icon += EMOJI_PRIVATE + if (item.is_match) pace_icon += EMOJI_MATCH + else if (item.pace === 1) pace_icon += EMOJI_LIVE, pace_text = "Live!" + else if (item.pace === 2) pace_icon += EMOJI_FAST, pace_text = "Fast - many moves per day" + else if (item.pace === 3) pace_icon += EMOJI_SLOW, pace_text = "Slow - one move per day" div(class=className) div.game_head @@ -77,25 +78,25 @@ mixin gamelist(list,hide_title=0) case item.status when 0 - a(class="command" href=`/join/${item.game_id}`) Enter + a(class="command" href=`/join/${item.game_id}`) Join when 1 if !item.is_ready - a(class="command" href="/join/"+item.game_id) Enter + a(class="command" href="/join/"+item.game_id) Join else if item.is_yours if item.your_role a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=${encodeURIComponent(item.your_role)}`) Play else - a(class="command" href="/join/"+item.game_id) Enter + a(class="command" href="/join/"+item.game_id) Play else - a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=Observer`) View + a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}`) Watch when 2 if item.is_yours if item.your_role - a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=${encodeURIComponent(item.your_role)}`) Play + a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=${encodeURIComponent(item.your_role)}`) Review else - a(class="command" href="/join/"+item.game_id) Enter + a(class="command" href="/join/"+item.game_id) Review else - a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=Observer`) View + a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}`) Review when 3 | Archived diff --git a/views/info.pug b/views/info.pug index 369cf8a..5c8346b 100644 --- a/views/info.pug +++ b/views/info.pug @@ -30,10 +30,6 @@ html p a(href="/create/"+title.title_id) Create a new game - if ready_games.length > 0 - h2 Open (waiting to start) - +gamelist(ready_games, true) - if active_games.length > 0 h2 Recently active +gamelist(active_games, true) diff --git a/views/join.pug b/views/join.pug index 1f00995..257e4a8 100644 --- a/views/join.pug +++ b/views/join.pug @@ -8,71 +8,51 @@ html game.title_id) title ##{game.game_id} - #{game.title_name} style. - th,td { border: var(--table-border); } - a.red { text-decoration: none; color: var(--color-red); font-size: 15px; float: right; } - a.green { text-decoration: none; color: var(--color-green); font-size: 15px; float: right; } - td, th { border: var(--thin-border); } - td { width: 180px; } - .hide { display: none; } - td.is_invite { background-color: var(--color-table-invite); } - td.is_active { background-color: var(--color-table-active); } - td.enemy { background-color: var(--color-table-danger); } - td.enemy::before { content: "\1f6ab "; font-size: 15px; } + table { width: clamp(240px, 100%, 400px); } + table.invite tr.p { background-color: var(--color-table-invite) } + table.active tr.p { background-color: var(--color-table-active) } + #game_info td { padding: 2px 10px } + #game_info td:first-child { width: 80px } + #game_info tr:first-child td { padding-top: 5px } + #game_info tr:last-child td { padding-bottom: 5px } + td.r a { margin-left: 18px; } + tr.p i { color: #0008; } + td.blacklist::before { color: brown; content: "\1f6ab "; font-size: 15px; } script. let game = !{ JSON.stringify(game) } - let roles = !{ JSON.stringify(roles) } let players = !{ JSON.stringify(players) } + let roles = !{ JSON.stringify(roles) } let user_id = !{ user ? user.user_id : 0 } - let whitelist = !{ JSON.stringify(whitelist) } let blacklist = !{ JSON.stringify(blacklist) } let friends = !{ JSON.stringify(friends) } - let limit = !{ !!limit } - let ready = !{ ready } script(src="/join.js") body include header article - if game.scenario === "Standard" - h1 ##{game.game_id} - #{game.title_name} + if icon + h1 #{icon} ##{game.game_id} - #{game.title_name} else - h1 ##{game.game_id} - #{game.title_name} - #{game.scenario} + h1 ##{game.game_id} - #{game.title_name} div.logo +gamecover(game.title_id) + p.error#error + if limit && game.status < 1 p.error= limit if !user p.error You are not logged in! - p.error#error - if game.owner_id - if game.is_private - p Created #{game.ctime} by #{game.owner_name} (private). - else - p Created #{game.ctime} by #{game.owner_name}. - else - p Created #{game.ctime}. + div#game_info - if game.status > 1 - p Finished #{game.mtime}. - else if game.status > 0 - p Last move #{game.mtime}. + p#game_enter - unless game.human_options === "None" - p Options: #{game.human_options}. + div#game_players - case game.pace - when 1 - p #{EMOJI_LIVE} Live! - when 2 - p #{EMOJI_FAST} Fast – many moves per day. - when 3 - p #{EMOJI_SLOW} Slow – one move per day. + p#game_actions - if game.notice - p - i= game.notice + p.error#disconnected if user dialog(id="invite") @@ -87,30 +67,12 @@ html button(onclick="send_invite()") Invite button(onclick="hide_invite()") Cancel - p - table - tbody - each role in roles - tr - th.w(id="role_"+role.replace(/ /g, "_")+"_name")= role - td(id="role_"+role.replace(/ /g, "_")) - - tfoot - tr - td#message(colspan=2) - - - p - button.hide#delete_button(onclick="confirm_delete()") Delete - button.hide#start_button(onclick="start()" disabled) Start - - if !user && !ready - p Login or sign up to play. - if user && user.user_id === 1 hr div: p each snap in rewind - REWIND #{snap.snap_id} - #{snap.state} - #{snap.active} + REWIND #{snap.snap_id} - #{snap.state} - #{snap.active} br if DEBUG - CLONE + CLONE br -- cgit v1.2.3