Split The code is public; the app answers only to the crew.
The match-over screen hides the scoreboard it was just showing: the final board, both sides, ten rows #237
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/195-the-match-over-screen-hides-the-scoreboa → main 1 commit fork
8 files
changed +162
−10
README.md +4 −1
| @@ -617,7 +617,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 617 | 617 | covered — one footfall per step of it, the same step the ear is counting — |
| 618 | 618 | a picture, with no reading of it in the simulation), |
| 619 | 619 | `summary` (the end of |
| 620 | the match as numbers), `ears` (a sound's pan and volume, as plain data), | |
| 620 | the match as numbers, and the final scoreboard both sides read from — the | |
| 621 | same kills-then-damage ranking the in-match one uses, marked with which row | |
| 622 | is yours, and the markup the `#summary` overlay drops in once, escaped the | |
| 623 | same way the server-rendered page is), `ears` (a sound's pan and volume, as plain data), | |
| 621 | 624 | `hearing` (what a sound is worth to someone standing somewhere else: how loud |
| 622 | 625 | it arrives through however many walls, and where they think it came from), |
| 623 | 626 | `earshot` (which of a tick's effects the person hears: the fixed ceilings a |
src/client/hud.ts +2 −1
| @@ -19,6 +19,7 @@ import { buildRadar } from "./radar.ts"; | ||
| 19 | 19 | import type { Camera } from "./renderer.ts"; |
| 20 | 20 | import { drawReport } from "./reportPanel.ts"; |
| 21 | 21 | import { deathNotice } from "./spectate.ts"; |
| 22 | import { rankPlayers } from "./summary.ts"; | |
| 22 | 23 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 23 | 24 | import { |
| 24 | 25 | canSee, causeName, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress, |
| @@ -638,7 +639,7 @@ function drawScoreboard( | ||
| 638 | 639 | for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries()) |
| 639 | 640 | ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2); |
| 640 | 641 | |
| 641 | const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage); | |
| 642 | const order = rankPlayers(world.players); | |
| 642 | 643 | order.forEach((p, row) => { |
| 643 | 644 | const ty = y + rowH * (row + 3.4); |
| 644 | 645 | ctx.textAlign = "left"; |
src/client/main.ts +6 −3
| @@ -32,7 +32,7 @@ import { fovFor } from "./zoom.ts"; | ||
| 32 | 32 | import { CALL_LIFE, isCall, type Call } from "./plan.ts"; |
| 33 | 33 | import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts"; |
| 34 | 34 | import { nextWatched, watched } from "./spectate.ts"; |
| 35 | import { summarize, type MatchSummary } from "./summary.ts"; | |
| 35 | import { finalBoard, finalBoardHtml, summarize, type FinalBoard, type MatchSummary } from "./summary.ts"; | |
| 36 | 36 | import { |
| 37 | 37 | buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, grenadeSlot, MAP_NAMES, mapFor, |
| 38 | 38 | radioCall, restoreWorld, setDifficulty, update, type Difficulty, type Player, type World, |
| @@ -698,11 +698,13 @@ function boot(): void { | ||
| 698 | 698 | * result has been sent — and a post that failed still puts it up, because a |
| 699 | 699 | * leaderboard that cannot be reached is not a reason to hide the scoreline. |
| 700 | 700 | */ |
| 701 | function showSummary(summary: MatchSummary): void { | |
| 701 | function showSummary(summary: MatchSummary, board: FinalBoard): void { | |
| 702 | 702 | $("summary-title").textContent = summary.headline; |
| 703 | 703 | $("summary-detail").textContent = summary.detail; |
| 704 | 704 | $("summary-score").textContent = summary.score; |
| 705 | 705 | $("summary-line").textContent = `${summary.name} — ${summary.line}`; |
| 706 | // Built once from board — already escaped — never patched frame by frame. | |
| 707 | $("summary-board").innerHTML = finalBoardHtml(board); | |
| 706 | 708 | show(summaryPanel, true); |
| 707 | 709 | $("again").focus(); |
| 708 | 710 | } |
| @@ -947,7 +949,8 @@ function boot(): void { | ||
| 947 | 949 | // The last post is the only one that says the match finished, and the |
| 948 | 950 | // score it carries is the one the overlay is about to show. |
| 949 | 951 | const summary = summarize(world); |
| 950 | void postResult(true).then(() => showSummary(summary), () => showSummary(summary)); | |
| 952 | const board = finalBoard(world); | |
| 953 | void postResult(true).then(() => showSummary(summary, board), () => showSummary(summary, board)); | |
| 951 | 954 | } |
| 952 | 955 | |
| 953 | 956 | const view = watched(world, watching); |
src/client/summary.test.ts +38 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { SHORT } from "./match.ts"; |
| 9 | import { summarize } from "./summary.ts"; | |
| 9 | import { finalBoard, finalBoardHtml, summarize } from "./summary.ts"; | |
| 10 | 10 | import { createWorld, finishRound, type World } from "./world.ts"; |
| 11 | 11 | |
| 12 | 12 | /** Play `mine` rounds for the person's side and `theirs` for the other one. */ |
| @@ -67,6 +67,43 @@ test("the line is what the person did, and the numbers are the ones posted", () | ||
| 67 | 67 | expect(summary.line).toBe("12 kills · 4 deaths · 5 headshots · 1637 damage"); |
| 68 | 68 | }); |
| 69 | 69 | |
| 70 | test("finalBoard: both sides complete and ranked by kills then damage, the person's row marked wherever it falls", () => { | |
| 71 | const world = createWorld(SHORT, "Vesper", "ct", 5, 5); | |
| 72 | const ct = world.players.filter((p) => p.team === "ct"); | |
| 73 | const t = world.players.filter((p) => p.team === "t"); | |
| 74 | const mates = ct.filter((p) => p !== world.me); | |
| 75 | mates[0]!.kills = 20; | |
| 76 | mates[1]!.kills = 15; | |
| 77 | mates[2]!.kills = 5; | |
| 78 | mates[3]!.kills = 0; | |
| 79 | world.me.kills = 10; // between the top two of the side and the bottom two | |
| 80 | const tKills = [6, 15, 3, 12, 9]; | |
| 81 | t.forEach((p, i) => (p.kills = tKills[i]!)); | |
| 82 | ||
| 83 | const board = finalBoard(world); | |
| 84 | ||
| 85 | expect(board.ct.map((r) => r.kills)).toEqual([20, 15, 10, 5, 0]); | |
| 86 | expect(board.t.map((r) => r.kills)).toEqual([15, 12, 9, 6, 3]); | |
| 87 | const mine = board.ct.filter((r) => r.mine); | |
| 88 | expect(mine).toEqual([{ | |
| 89 | name: "Vesper", kills: 10, deaths: 0, headshots: 0, damage: 0, money: world.me.money, mine: true, | |
| 90 | }]); | |
| 91 | const rank = board.ct.findIndex((r) => r.mine); | |
| 92 | expect(rank).toBeGreaterThan(0); | |
| 93 | expect(rank).toBeLessThan(board.ct.length - 1); | |
| 94 | expect(board.t.some((r) => r.mine)).toBe(false); | |
| 95 | }); | |
| 96 | ||
| 97 | test("finalBoardHtml escapes a name the way the server-rendered page does", () => { | |
| 98 | const world = createWorld(SHORT, "Vesper", "ct", 5, 5); | |
| 99 | world.me.name = "<script>alert(1)</script>"; | |
| 100 | const html = finalBoardHtml(finalBoard(world)); | |
| 101 | expect(html).not.toContain("<script>alert(1)</script>"); | |
| 102 | expect(html).toContain("<script>alert(1)</script>"); | |
| 103 | expect(html).toContain("<caption>Terrorists</caption>"); | |
| 104 | expect(html).toContain("<caption>Counter-Terrorists</caption>"); | |
| 105 | }); | |
| 106 | ||
| 70 | 107 | test("mid-match there is no winner yet, and it says the score rather than a result", () => { |
| 71 | 108 | const world = createWorld(SHORT, "Vesper", "ct", 5, 5); |
| 72 | 109 | play(world, 2, 1); |
src/client/summary.ts +68 −1
| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | * checked without a browser. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { escape } from "../escape.ts"; | |
| 8 | 9 | import { other, type Team } from "./match.ts"; |
| 9 | import type { World } from "./world.ts"; | |
| 10 | import type { Player, World } from "./world.ts"; | |
| 10 | 11 | |
| 11 | 12 | export type MatchSummary = { |
| 12 | 13 | winner: Team | null; // null while the match is still being played |
| @@ -55,3 +56,69 @@ export function summarize(world: World): MatchSummary { | ||
| 55 | 56 | seconds: Math.round(world.time), |
| 56 | 57 | }; |
| 57 | 58 | } |
| 59 | ||
| 60 | /** | |
| 61 | * Every player, best first: the one ordering the in-match scoreboard and the | |
| 62 | * match-over board both read, so there is only ever one answer to "who did | |
| 63 | * better" in the repo. Kills first, damage to split a tie — the same rule the | |
| 64 | * in-match scoreboard has always used. | |
| 65 | */ | |
| 66 | export function rankPlayers(players: Player[]): Player[] { | |
| 67 | return [...players].sort((a, b) => b.kills - a.kills || b.damage - a.damage); | |
| 68 | } | |
| 69 | ||
| 70 | export type BoardRow = { | |
| 71 | name: string; | |
| 72 | kills: number; | |
| 73 | deaths: number; | |
| 74 | headshots: number; | |
| 75 | damage: number; | |
| 76 | money: number; | |
| 77 | mine: boolean; | |
| 78 | }; | |
| 79 | ||
| 80 | export type FinalBoard = { t: BoardRow[]; ct: BoardRow[] }; | |
| 81 | ||
| 82 | /** | |
| 83 | * The final scoreboard, both sides, ranked the way the in-match one is. Pure | |
| 84 | * data: the overlay that shows it only builds markup from this. | |
| 85 | */ | |
| 86 | export function finalBoard(world: World): FinalBoard { | |
| 87 | const row = (p: Player): BoardRow => ({ | |
| 88 | name: p.name, | |
| 89 | kills: p.kills, | |
| 90 | deaths: p.deaths, | |
| 91 | headshots: p.headshots, | |
| 92 | damage: Math.round(p.damage), | |
| 93 | money: p.money, | |
| 94 | mine: p === world.me, | |
| 95 | }); | |
| 96 | const ranked = rankPlayers(world.players); | |
| 97 | return { | |
| 98 | t: ranked.filter((p) => p.team === "t").map(row), | |
| 99 | ct: ranked.filter((p) => p.team === "ct").map(row), | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * The final board as markup: two tables, one per side, each row's name | |
| 105 | * escaped with the same function the server-rendered page escapes with | |
| 106 | * (`../escape.ts`) — the leaderboard and this table can never disagree on | |
| 107 | * what "safe to print" means. Built once, when the match ends, and dropped in | |
| 108 | * with a single `innerHTML` — never rebuilt frame by frame the way the canvas | |
| 109 | * scoreboard is. | |
| 110 | */ | |
| 111 | export function finalBoardHtml(board: FinalBoard): string { | |
| 112 | const table = (label: string, rows: BoardRow[]): string => | |
| 113 | `<table class="final-board"><caption>${escape(label)}</caption>` + | |
| 114 | `<thead><tr><th scope="col">player</th><th scope="col">K</th><th scope="col">D</th>` + | |
| 115 | `<th scope="col">HS</th><th scope="col">DMG</th></tr></thead><tbody>${ | |
| 116 | rows | |
| 117 | .map( | |
| 118 | (p) => `<tr${p.mine ? ` class="mine"` : ""}><td>${escape(p.name)}</td><td>${p.kills}</td>` + | |
| 119 | `<td>${p.deaths}</td><td>${p.headshots}</td><td>${p.damage}</td></tr>`, | |
| 120 | ) | |
| 121 | .join("") | |
| 122 | }</tbody></table>`; | |
| 123 | return `<div class="board-wrap">${table(SIDE.t, board.t)}${table(SIDE.ct, board.ct)}</div>`; | |
| 124 | } |
src/escape.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | /** | |
| 2 | * HTML-escaping, alone: the one algorithm every place that builds markup from | |
| 3 | * untrusted text — the server-rendered page and the client's own overlays — | |
| 4 | * shares, so there is only ever one answer to "is this name safe to print". | |
| 5 | */ | |
| 6 | ||
| 7 | export const escape = (s: string): string => | |
| 8 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); |
src/pages/game.test.ts +16 −1
| @@ -9,7 +9,7 @@ import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts | ||
| 9 | 9 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| 10 | 10 | import { DEFAULT_MOTION, MOTIONS } from "../client/motion.ts"; |
| 11 | 11 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; |
| 12 | import { gamePage, statsHtml } from "./game.ts"; | |
| 12 | import { escape, gamePage, statsHtml } from "./game.ts"; | |
| 13 | 13 | |
| 14 | 14 | const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| 15 | 15 | // The page is always rendered for somebody: here, a guest, the way a visitor |
| @@ -75,6 +75,21 @@ test("the leaderboard says abandoned for a left match and in progress for a live | ||
| 75 | 75 | expect(html).toContain("in progress"); |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | test("escape turns markup characters a name could carry into entities", () => { | |
| 79 | expect(escape("<script>alert(1)</script>")).toBe("<script>alert(1)</script>"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the match-over overlay carries an empty final-board container the client fills in once", () => { | |
| 83 | const html = gamePage(db, options); | |
| 84 | const summary = element(html, "summary"); | |
| 85 | expect(summary).toContain(`<div id="summary-board"></div>`); | |
| 86 | // Between the person's line and the two buttons, not off on its own — the | |
| 87 | // buttons still come last so a phone reading top to bottom hits them after | |
| 88 | // the table, never before it. | |
| 89 | expect(summary.indexOf(`id="summary-line"`)).toBeLessThan(summary.indexOf(`id="summary-board"`)); | |
| 90 | expect(summary.indexOf(`id="summary-board"`)).toBeLessThan(summary.indexOf(`id="again"`)); | |
| 91 | }); | |
| 92 | ||
| 78 | 93 | test("an abandoned match still counts as a match and its kills, but never a win", () => { |
| 79 | 94 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| 80 | 95 | const session = "c".repeat(32); |
src/pages/game.ts +20 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | import { escape } from "../escape.ts"; | |
| 8 | 9 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | 10 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; |
| 10 | 11 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| @@ -14,8 +15,7 @@ import { isGuest, type Player } from "../player.ts"; | ||
| 14 | 15 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; |
| 15 | 16 | import { CARD } from "../client/weights.ts"; |
| 16 | 17 | |
| 17 | export const escape = (s: string): string => | |
| 18 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 18 | export { escape }; | |
| 19 | 19 | |
| 20 | 20 | /** |
| 21 | 21 | * The leaderboard block. Also what POST /api/match answers with, so the line |
| @@ -223,6 +223,23 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; } | ||
| 223 | 223 | #summary .score { font-size:2.2rem; line-height:1.1; color:var(--text); font-variant-numeric:tabular-nums; margin:.1rem 0; } |
| 224 | 224 | #summary h2 { font-size:1.8rem; } |
| 225 | 225 | #summary #summary-line { color:var(--text); font-size:.95rem; } |
| 226 | /* The final board: a fixed budget of height so filling it in never grows the | |
| 227 | overlay, and its own scroll so a phone in landscape — where there is no | |
| 228 | 30vh to give it — still keeps Play again and Back to menu on screen without | |
| 229 | a scroll past the table. table-layout:fixed plus a truncating name column is | |
| 230 | what keeps it inside 360px with no horizontal scroll. */ | |
| 231 | #summary-board { width:100%; } | |
| 232 | #summary-board .board-wrap { display:flex; flex-direction:column; gap:.4rem; width:min(26rem,100%); | |
| 233 | max-height:min(30vh,15rem); overflow-y:auto; margin:0 auto; } | |
| 234 | #summary-board .final-board { width:100%; table-layout:fixed; border-collapse:collapse; font-size:.78rem; } | |
| 235 | #summary-board .final-board caption { caption-side:top; text-align:left; font-weight:600; padding:.15rem 0; } | |
| 236 | #summary-board .final-board th, #summary-board .final-board td { padding:.1rem .3rem; text-align:right; | |
| 237 | font-variant-numeric:tabular-nums; } | |
| 238 | #summary-board .final-board th { color:var(--dim); font-weight:500; } | |
| 239 | #summary-board .final-board th:first-child, #summary-board .final-board td:first-child { | |
| 240 | width:44%; text-align:left; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; | |
| 241 | } | |
| 242 | #summary-board .final-board tr.mine td { color:var(--text); font-weight:700; } | |
| 226 | 243 | #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; } |
| 227 | 244 | /* The pad is under the view, not over it: the stage is 4:3 on a phone and the |
| 228 | 245 | HUD already owns all four of its corners, so a button on the glass would sit |
| @@ -398,6 +415,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 398 | 415 | <p id="summary-detail"></p> |
| 399 | 416 | <p class="score" id="summary-score"></p> |
| 400 | 417 | <p id="summary-line"></p> |
| 418 | <div id="summary-board"></div> | |
| 401 | 419 | <div class="row"> |
| 402 | 420 | <button type="button" class="primary" id="again">Play again</button> |
| 403 | 421 | <button type="button" id="summary-menu">Back to menu</button> |