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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/195-the-match-over-screen-hides-the-scoreboa → main 1 commit fork

  • Merged. scout/195-the-match-over-screen-hides-the-scoreboa was merged into main as 7513f8b.

Review

Sign in to review this change.
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.
617617 covered — one footfall per step of it, the same step the ear is counting —
618618 a picture, with no reading of it in the simulation),
619619 `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),
621624 `hearing` (what a sound is worth to someone standing somewhere else: how loud
622625 it arrives through however many walls, and where they think it came from),
623626 `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";
1919 import type { Camera } from "./renderer.ts";
2020 import { drawReport } from "./reportPanel.ts";
2121 import { deathNotice } from "./spectate.ts";
22 import { rankPlayers } from "./summary.ts";
2223 import { inaccuracy, WEAPONS } from "./weapons.ts";
2324 import {
2425 canSee, causeName, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
@@ -638,7 +639,7 @@ function drawScoreboard(
638639 for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries())
639640 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2);
640641
641 const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage);
642 const order = rankPlayers(world.players);
642643 order.forEach((p, row) => {
643644 const ty = y + rowH * (row + 3.4);
644645 ctx.textAlign = "left";
src/client/main.ts +6 −3
@@ -32,7 +32,7 @@ import { fovFor } from "./zoom.ts";
3232 import { CALL_LIFE, isCall, type Call } from "./plan.ts";
3333 import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts";
3434 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";
3636 import {
3737 buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, grenadeSlot, MAP_NAMES, mapFor,
3838 radioCall, restoreWorld, setDifficulty, update, type Difficulty, type Player, type World,
@@ -698,11 +698,13 @@ function boot(): void {
698698 * result has been sent — and a post that failed still puts it up, because a
699699 * leaderboard that cannot be reached is not a reason to hide the scoreline.
700700 */
701 function showSummary(summary: MatchSummary): void {
701 function showSummary(summary: MatchSummary, board: FinalBoard): void {
702702 $("summary-title").textContent = summary.headline;
703703 $("summary-detail").textContent = summary.detail;
704704 $("summary-score").textContent = summary.score;
705705 $("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);
706708 show(summaryPanel, true);
707709 $("again").focus();
708710 }
@@ -947,7 +949,8 @@ function boot(): void {
947949 // The last post is the only one that says the match finished, and the
948950 // score it carries is the one the overlay is about to show.
949951 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));
951954 }
952955
953956 const view = watched(world, watching);
src/client/summary.test.ts +38 −1
@@ -6,7 +6,7 @@
66
77 import { expect, test } from "bun:test";
88 import { SHORT } from "./match.ts";
9 import { summarize } from "./summary.ts";
9 import { finalBoard, finalBoardHtml, summarize } from "./summary.ts";
1010 import { createWorld, finishRound, type World } from "./world.ts";
1111
1212 /** 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", ()
6767 expect(summary.line).toBe("12 kills · 4 deaths · 5 headshots · 1637 damage");
6868 });
6969
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("&lt;script&gt;alert(1)&lt;/script&gt;");
103 expect(html).toContain("<caption>Terrorists</caption>");
104 expect(html).toContain("<caption>Counter-Terrorists</caption>");
105 });
106
70107 test("mid-match there is no winner yet, and it says the score rather than a result", () => {
71108 const world = createWorld(SHORT, "Vesper", "ct", 5, 5);
72109 play(world, 2, 1);
src/client/summary.ts +68 −1
@@ -5,8 +5,9 @@
55 * checked without a browser.
66 */
77
8 import { escape } from "../escape.ts";
89 import { other, type Team } from "./match.ts";
9 import type { World } from "./world.ts";
10 import type { Player, World } from "./world.ts";
1011
1112 export type MatchSummary = {
1213 winner: Team | null; // null while the match is still being played
@@ -55,3 +56,69 @@ export function summarize(world: World): MatchSummary {
5556 seconds: Math.round(world.time),
5657 };
5758 }
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
src/pages/game.test.ts +16 −1
@@ -9,7 +9,7 @@ import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts
99 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
1010 import { DEFAULT_MOTION, MOTIONS } from "../client/motion.ts";
1111 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";
1313
1414 const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
1515 // 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
7575 expect(html).toContain("in progress");
7676 });
7777
78 test("escape turns markup characters a name could carry into entities", () => {
79 expect(escape("<script>alert(1)</script>")).toBe("&lt;script&gt;alert(1)&lt;/script&gt;");
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
7893 test("an abandoned match still counts as a match and its kills, but never a win", () => {
7994 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
8095 const session = "c".repeat(32);
src/pages/game.ts +20 −2
@@ -5,6 +5,7 @@
55 */
66
77 import type { Database } from "bun:sqlite";
8 import { escape } from "../escape.ts";
89 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
910 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts";
1011 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
@@ -14,8 +15,7 @@ import { isGuest, type Player } from "../player.ts";
1415 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
1516 import { CARD } from "../client/weights.ts";
1617
17 export const escape = (s: string): string =>
18 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
18 export { escape };
1919
2020 /**
2121 * 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; }
223223 #summary .score { font-size:2.2rem; line-height:1.1; color:var(--text); font-variant-numeric:tabular-nums; margin:.1rem 0; }
224224 #summary h2 { font-size:1.8rem; }
225225 #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; }
226243 #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; }
227244 /* The pad is under the view, not over it: the stage is 4:3 on a phone and the
228245 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 {
398415 <p id="summary-detail"></p>
399416 <p class="score" id="summary-score"></p>
400417 <p id="summary-line"></p>
418 <div id="summary-board"></div>
401419 <div class="row">
402420 <button type="button" class="primary" id="again">Play again</button>
403421 <button type="button" id="summary-menu">Back to menu</button>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs