Public: anyone can read this; the crew can change it.

forked from trevato/cs

A result says which map it was played on, so the leaderboard is comparing like with like #240

Merged opened this

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

Merged

torque/cs-engine:torque/150-a-result-says-which-map-it-was-played-on → main 1 commit fork

  • Merged. torque/150-a-result-says-which-map-it-was-played-on was merged into main as b45af3b.

Review

Sign in to review this change.
21 files changed +360 −141
README.md +8 −3
@@ -851,8 +851,11 @@ never checked itself.
851851 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
852852 schema and its queries, `src/result.ts` validates what a browser may claim
853853 — the scores add up to the rounds, a headshot is a kill, nobody dies more
854 often than there were rounds, and the clock is bounded by the rounds it is
855 spread over — with every one of those rules pinned in `src/result.test.ts`;
854 often than there were rounds, the clock is bounded by the rounds it is
855 spread over, and the map it names is a map (`de_forge` or `gen_<base36>`,
856 through `src/mapname.ts`; a body that names no map at all is an older client
857 and played `de_forge`) — with every one of those rules pinned in
858 `src/result.test.ts`;
856859 `bun run scripts/mutate-result.ts` breaks each line of it in turn and names
857860 the test that catches it, so a rule nothing tests is visible rather than
858861 assumed. `src/matchid.ts` is the one place that says what a match id is — the shape
@@ -1002,7 +1005,9 @@ It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
10021005 runs (the app is the login name, the forge is the host). It opens `src/tty.ts`
10031006 on a real pty over the same database the web page uses. Three tabs, and
10041007 <kbd>Tab</kbd> cycles them: **standings** (the leaderboard, one row a name),
1005 **matches** (the recent matches, newest first, a live one marked) and
1008 **matches** (the recent matches, newest first, the map each was played on, a
1009 live one marked, and under the cursor the id — and the map name, when it was a
1010 generated one — to paste back into the menu) and
10061011 **policy** (the card the bots were trained with — trained-at, simulation ticks
10071012 and throughput, PPO updates, duels and both win rates, and the command that
10081013 retrains it). <kbd>j</kbd>/<kbd>k</kbd> or the arrow keys move the cursor and
scripts/mutate-result.ts +5 −2
@@ -24,8 +24,11 @@ const BACKUP = "src/result.ts.original";
2424 */
2525 const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [
2626 { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" },
27 { rule: "matchId must be a string", find: `typeof body.matchId === "string" ? body.matchId : ""`, replace: "String(body.matchId ?? \"\")" },
28 { rule: "matchId must match the regex", find: "if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null;", replace: "" },
27 // The id's own shape lives in `src/matchid.ts` now; what this file can still
28 // break is asking it at all.
29 { rule: "matchId goes through the one door", find: "if (!isMatchId(matchId)) return null;", replace: "" },
30 { rule: "a map that is not a map is refused", find: "const map = body.map === undefined ? DEFAULT_MAP : playedOn(body.map);", replace: "const map = playedOn(body.map) ?? DEFAULT_MAP;" },
31 { rule: "a refused map refuses the result", find: "if (map === null) return null;", replace: "" },
2932 { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" },
3033 { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" },
3134 { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;" },
src/client/main.test.ts +21
@@ -1080,6 +1080,27 @@ test("a map by name is that map, and a name that is not one is de_forge and says
10801080 expect(stage.by("menu").hidden).toBe(true);
10811081 });
10821082
1083 test("what is posted says which map it was played on", async () => {
1084 // The leaderboard's rows used to mix layouts silently. The body the page
1085 // sends now names the map, and it is the map the world was carved from —
1086 // whatever the Map control was left on afterwards.
1087 const stage = painted(start(fakeStage()));
1088 stage.by("mapseed").value = MAP;
1089 stage.by("mapseed").fire("input");
1090 stage.by("play").fire("click");
1091 stage.lock(true);
1092 stage.step(FRAME);
1093 finish(stage, "ct");
1094 const named = await lastPost(stage);
1095 expect(named.map).toBe(MAP);
1096 expect(parseResult(named)?.map).toBe(MAP);
1097
1098 // And a match on de_forge says de_forge rather than nothing.
1099 const plain = await lastPost(ended().stage);
1100 expect(plain.map).toBe("de_forge");
1101 expect(parseResult(plain)?.map).toBe("de_forge");
1102 });
1103
10831104 test("copy this map is a link first and a clipboard second", async () => {
10841105 const LABEL = "Copy this map's link";
10851106 const stage = fakeStage();
src/client/summary.test.ts +8 −1
@@ -11,6 +11,7 @@
1111 import { expect, test } from "bun:test";
1212 import { headshotShare as leaderboardShare } from "../db.ts";
1313 import { parseResult } from "../result.ts";
14 import { generateLevel } from "./generate.ts";
1415 import { endRound, SHORT } from "./match.ts";
1516 import { DE_FORGE } from "./map.ts";
1617 import {
@@ -56,9 +57,15 @@ test("what is posted is what is shown: one object, and the server accepts it", (
5657 const result = resultOf(match, ID, "Vesper", true);
5758 expect(parseResult(result)).not.toBeNull();
5859 expect(result).toEqual({
59 matchId: ID, name: "Vesper", rounds: 7, scoreMine: 5, scoreTheirs: 2,
60 matchId: ID, name: "Vesper", map: "de_forge", rounds: 7, scoreMine: 5, scoreTheirs: 2,
6061 kills: 9, deaths: 4, headshots: 3, damage: 1610, finished: true, seconds: 614,
6162 });
63 // The map is the world's own level, not a field somebody set: a match on a
64 // generated layout posts that layout's name, and the server takes it.
65 const carved = createWorld(generateLevel(0x5eed), SHORT, "you", "ct", 5, 0x5eed);
66 const posted = resultOf(carved, ID, "Vesper", true);
67 expect(posted.map).toBe("gen_ir1");
68 expect(parseResult(posted)?.map).toBe("gen_ir1");
6269 // The score is read off the side the player is on now, which is what half
6370 // time swapping both the teams and the score means.
6471 match.me.team = "t";
src/client/summary.ts +8 −1
@@ -10,7 +10,8 @@
1010 * - `resultOf` is the one place a match is turned into numbers. The frame posts
1111 * exactly that object to `/api/match` and the screen below it reads the same
1212 * one, so what a player is shown and what the leaderboard was told cannot
13 * disagree — they are the same nine fields, built once.
13 * disagree — they are the same fields, built once, down to the map they were
14 * played on.
1415 * - `scoreLines` is the order and the figures the HUD's Tab scoreboard already
1516 * draws (`drawScoreboard` in `hud.ts` calls it), so the rows a player reads
1617 * after the match are the rows they read during it: sorted by kills, then by
@@ -28,6 +29,7 @@
2829 * exception is a rule somebody forgets.
2930 */
3031
32 import { DEFAULT_MAP, playedOn } from "../mapname.ts";
3133 import type { Result } from "../result.ts";
3234 import type { Team } from "./match.ts";
3335 import type { World } from "./world.ts";
@@ -83,6 +85,11 @@ export function resultOf(world: World, matchId: string, name: string, final: boo
8385 return {
8486 matchId,
8587 name,
88 // The map is the one the world is standing on, not the one a menu field
89 // says: what was played is what is posted. `playedOn` is the same door the
90 // server puts it through, so a level named something that is not a map name
91 // posts de_forge rather than a body the server would refuse.
92 map: playedOn(world.level.name) ?? DEFAULT_MAP,
8693 rounds: world.match.score.t + world.match.score.ct,
8794 scoreMine: world.match.score[me.team],
8895 scoreTheirs: world.match.score[me.team === "t" ? "ct" : "t"],
src/db.test.ts +52 −1
@@ -17,6 +17,7 @@ const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
1717 const result = (over: Partial<Result> = {}): Result => ({
1818 matchId: crypto.randomUUID(),
1919 name: "ignored — the player says the name",
20 map: "de_forge",
2021 rounds: 5, scoreMine: 3, scoreTheirs: 2,
2122 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
2223 ...over,
@@ -77,10 +78,60 @@ test("a database written before identities existed keeps its matches and calls t
7778 expect(standings(db).length).toBe(2);
7879 });
7980
81 test("a match records the map it was played on, and a generated one comes back whole", () => {
82 const db = openDatabase(dir());
83 expect(saveMatch(db, player("u-1", "kestrel"), result({ map: "gen_ir1" }))).toBe(true);
84 expect(recentMatches(db)[0]?.map).toBe("gen_ir1");
85 // The map moves with the rest of the row when a later round of the same
86 // match is posted — a player who swaps maps starts another match.
87 const id = crypto.randomUUID();
88 expect(saveMatch(db, guest("anon"), result({ matchId: id, map: "gen_1z141z3" }))).toBe(true);
89 expect(saveMatch(db, guest("anon"), result({ matchId: id, map: "gen_1z141z3", kills: 11 }))).toBe(true);
90 const row = recentMatches(db).find((m) => m.id === id);
91 expect(row?.map).toBe("gen_1z141z3");
92 expect(row?.kills).toBe(11);
93 // The standings are what they were: this issue adds the fact, it does not
94 // split the board by it.
95 expect(standings(db).length).toBe(2);
96 });
97
98 test("a database written before the map column keeps its rows and calls them de_forge", () => {
99 const home = dir();
100 // The schema as it was one migration ago: identities, but no map.
101 const old = new Database(join(home, "app.db"));
102 old.exec(`CREATE TABLE match (
103 id TEXT PRIMARY KEY, session TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
104 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL,
105 damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL,
106 score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, seconds INTEGER NOT NULL,
107 created_at TEXT NOT NULL DEFAULT (datetime('now')),
108 updated_at TEXT NOT NULL DEFAULT (datetime('now')))`);
109 old.exec(`INSERT INTO match (id, session, user_id, name, kills, deaths, headshots, damage, rounds,
110 score_mine, score_theirs, finished, seconds) VALUES ('m1', 's1', 'u-1', 'veteran', 9, 4, 3, 1200, 5, 3, 2, 1, 420)`);
111 old.close();
112
113 const db = openDatabase(home);
114 const rows = recentMatches(db);
115 expect(rows.length).toBe(1);
116 expect(rows[0]?.map).toBe("de_forge");
117 expect(rows[0]?.kills).toBe(9);
118 // And the migrated database takes a map like any other. The row is found by
119 // its id rather than by being first: both were written in the same second,
120 // and `updated_at` has no opinion inside one.
121 const fresh = crypto.randomUUID();
122 expect(saveMatch(db, player("u-2", "kestrel"), result({ matchId: fresh, map: "gen_ir1" }))).toBe(true);
123 expect(recentMatches(db).find((m) => m.id === fresh)?.map).toBe("gen_ir1");
124 });
125
80126 test("opening a database twice migrates once and breaks nothing", () => {
81127 const home = dir();
82128 openDatabase(home).close();
83129 const db = openDatabase(home);
84 expect(saveMatch(db, guest("anon"), result())).toBe(true);
130 expect(saveMatch(db, guest("anon"), result({ map: "gen_ir1" }))).toBe(true);
85131 expect(standings(db).length).toBe(1);
132 // The second open added no second map column and dropped no row's map: a
133 // migration that ran twice would have failed above, or answered null here.
134 expect(recentMatches(db)[0]?.map).toBe("gen_ir1");
135 const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name);
136 expect(columns.filter((c) => c === "map").length).toBe(1);
86137 });
src/db.ts +13 −5
@@ -21,6 +21,8 @@ export type MatchRow = {
2121 /** The forge identity that posted it, or "" for a guest. */
2222 user_id: string;
2323 name: string;
24 /** The map it was played on: `de_forge` or `gen_<base36>`, never empty. */
25 map: string;
2426 kills: number;
2527 deaths: number;
2628 headshots: number;
@@ -55,6 +57,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
5557 session TEXT NOT NULL,
5658 user_id TEXT NOT NULL DEFAULT '',
5759 name TEXT NOT NULL,
60 map TEXT NOT NULL DEFAULT 'de_forge',
5861 kills INTEGER NOT NULL,
5962 deaths INTEGER NOT NULL,
6063 headshots INTEGER NOT NULL,
@@ -72,6 +75,11 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
7275 // played as guests, which is what the default already says.
7376 const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name);
7477 if (!columns.includes("user_id")) db.exec("ALTER TABLE match ADD COLUMN user_id TEXT NOT NULL DEFAULT ''");
78 // And the same again for the map, in the same shape: every row written
79 // before a result carried one was played on de_forge, because de_forge was
80 // the only map there was. Additive and defaulted, so yesterday's database
81 // opens today and keeps every row it had.
82 if (!columns.includes("map")) db.exec("ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge'");
7583 db.exec("CREATE INDEX IF NOT EXISTS match_by_time ON match (updated_at DESC)");
7684 return db;
7785 }
@@ -110,10 +118,10 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean
110118 if (player.userId === "" && existing.session !== player.session) return false;
111119 }
112120 db.query(
113 `INSERT INTO match (id, session, user_id, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
114 VALUES (?1, ?2, ?13, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
121 `INSERT INTO match (id, session, user_id, name, map, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
122 VALUES (?1, ?2, ?13, ?3, ?14, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
115123 ON CONFLICT(id) DO UPDATE SET
116 name = excluded.name, kills = excluded.kills, deaths = excluded.deaths,
124 name = excluded.name, map = excluded.map, kills = excluded.kills, deaths = excluded.deaths,
117125 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
118126 score_mine = excluded.score_mine, score_theirs = excluded.score_theirs,
119127 finished = excluded.finished, seconds = excluded.seconds,
@@ -121,14 +129,14 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean
121129 ).run(
122130 result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots,
123131 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds,
124 player.userId,
132 player.userId, result.map,
125133 );
126134 return true;
127135 }
128136
129137 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
130138 db.query<MatchRow, [number]>(
131 `SELECT id, session, user_id, name, kills, deaths, headshots, damage, rounds,
139 `SELECT id, session, user_id, name, map, kills, deaths, headshots, damage, rounds,
132140 score_mine, score_theirs, finished, seconds, updated_at
133141 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
134142 ).all(limit);
src/mapname.test.ts +18 −1
@@ -7,7 +7,7 @@
77 */
88
99 import { expect, test } from "bun:test";
10 import { mapFrom, mapLink, nameOf, seedOf } from "./mapname.ts";
10 import { DEFAULT_MAP, mapFrom, mapLink, nameOf, playedOn, seedOf } from "./mapname.ts";
1111
1212 test("a name is a seed and a seed is a name, both ways round", () => {
1313 expect(nameOf(0)).toBe("gen_0");
@@ -45,3 +45,20 @@ test("the Map control's value is one of three things, and anything else is de_fo
4545 expect(mapLink(mapFrom("gen_ya"))).toBe("/?map=gen_ya");
4646 expect(mapLink(mapFrom("generated"))).toBe("/?map=generated");
4747 });
48
49 test("a map a match was played on is de_forge or one carving, and junk is refused", () => {
50 // `mapFrom` shrugs at a typo because a control is a person typing; `playedOn`
51 // refuses one because a record is a claim, and a row naming a layout nobody
52 // can open is worse than no row at all.
53 expect(playedOn("de_forge")).toBe("de_forge");
54 expect(playedOn("gen_ya")).toBe("gen_ya");
55 expect(playedOn(nameOf(0xffffffff))).toBe("gen_1z141z3");
56 for (const junk of [
57 // Everything `seedOf` refuses, plus the two `mapFrom` would have taken.
58 "generated", "de_dust2", "gen_0ya", "GEN_YA", "gen_zzzzzzzz", "de_forge ", "<script>",
59 null, undefined, 7, {}, ["gen_ya"],
60 ]) expect(playedOn(junk)).toBeNull();
61 // What a match that never said played on: the only map there was.
62 expect(DEFAULT_MAP).toBe("de_forge");
63 expect(playedOn(DEFAULT_MAP)).toBe(DEFAULT_MAP);
64 });
src/mapname.ts +26
@@ -67,3 +67,29 @@ export function mapFrom(value: unknown): MapChoice {
6767
6868 /** The link that plays this map: the one URL a person copies and hands over. */
6969 export const mapLink = (map: MapChoice): string => `/?map=${map}`;
70
71 /**
72 * A map somebody actually played on: de_forge, or one layout carved from a
73 * seed. `generated` is not one of these — it is a wish for a new map, and by
74 * the time a round has been played that wish has become a name.
75 */
76 export type PlayedMap = "de_forge" | `gen_${string}`;
77
78 /** What a match was played on when it never said: every match before #150. */
79 export const DEFAULT_MAP: PlayedMap = "de_forge";
80
81 /**
82 * The map a *record* claims it was played on, or null when that is not a map.
83 *
84 * This is `mapFrom`'s stricter twin and the difference is on purpose. A control
85 * with junk in it is a person who mistyped, so `mapFrom` shrugs and gives them
86 * de_forge; a stored result with junk in it is a claim nobody can check later,
87 * so it is refused at the door instead of quietly recorded as de_forge. A
88 * result asking for `generated` is refused for the same reason: the browser
89 * knows which layout it played by the time it posts one.
90 */
91 export function playedOn(value: unknown): PlayedMap | null {
92 if (value === "de_forge") return "de_forge";
93 const seed = seedOf(value);
94 return seed === null ? null : nameOf(seed);
95 }
src/pages/game.test.ts +21 −1
@@ -64,7 +64,7 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => {
6464 const home = db();
6565 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
6666 const result: Result = {
67 matchId: crypto.randomUUID(), name: "ignored", rounds: 5, scoreMine: 5, scoreTheirs: 2,
67 matchId: crypto.randomUUID(), name: "ignored", map: "de_forge", rounds: 5, scoreMine: 5, scoreTheirs: 2,
6868 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
6969 };
7070 expect(saveMatch(home, player, result)).toBe(true);
@@ -75,6 +75,26 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => {
7575 expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`);
7676 });
7777
78 test("a recent match names the map it was played on, and the name is a link to it", () => {
79 const home = db();
80 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
81 const result: Result = {
82 matchId: crypto.randomUUID(), name: "ignored", map: "gen_ir1", rounds: 5, scoreMine: 5, scoreTheirs: 2,
83 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
84 };
85 expect(saveMatch(home, player, result)).toBe(true);
86 expect(gamePage(home, options)).toContain(`<a class="map" href="/?map=gen_ir1">gen_ir1</a>`);
87
88 // A row whose map is not a map — which `parseResult` will not write, but a
89 // hand on the database could — is escaped where it is printed, and its link
90 // is this app's own string rather than the row's bytes.
91 home.query(`UPDATE match SET map = ?1`).run(`<script>alert(1)</script>`);
92 const html = gamePage(home, options);
93 expect(html).not.toContain("<script>alert(1)");
94 expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;</a>");
95 expect(html).toContain(`<a class="map" href="/?map=de_forge">`);
96 });
97
7898 test("the screen a match ends on is in the page, hidden, with the menu's own sentence on it", () => {
7999 const html = gamePage(db(), options);
80100 // An overlay like the menu and the pause, rendered hidden: the first paint is
src/pages/game.ts +12 −1
@@ -13,7 +13,7 @@ import type { Database } from "bun:sqlite";
1313 import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts";
1414 import { NOTHING_POSTED } from "../client/pending.ts";
1515 import { CARD, type Card } from "../client/weights.ts";
16 import { mapLink, type MapChoice } from "../mapname.ts";
16 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
1717 import type { Who } from "../who.ts";
1818 import { navFor, othersHtml } from "./games.ts";
1919 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -32,7 +32,12 @@ export function statsHtml(db: Database): string {
3232 .join("");
3333 const feed = recent
3434 .map(
35 // The map is a link to that map, and the href is never the stored bytes:
36 // `mapFrom` hands back this app's own string for a name it recognises and
37 // de_forge for anything else, so the row's text is escaped and its link
38 // is built rather than copied.
3539 (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ` +
40 `<a class="map" href="${mapLink(mapFrom(m.map))}">${escape(m.map)}</a> ` +
3641 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` +
3742 `<time>${escape(m.updated_at)}</time></li>`,
3843 )
@@ -132,6 +137,12 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under
132137 the one the browser measured off the level: the line changes, the box does not. */
133138 #planalt { min-height:2.5em; }
134139 #willplay { user-select:all; }
140 /* The map a recent match was played on, which is also the link that plays it.
141 Padded rather than bare so a thumb has something to land on in a list of
142 rows, and dim like the figures beside it until it is hovered or focused. */
143 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
144 border-radius:.3rem; background:#161b24; }
145 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
135146 `;
136147
137148 /** What the menu's hint under the id field says when the link carried junk. */
src/result.test.ts +37 −1
@@ -23,6 +23,7 @@ const MAX_SECONDS = MAX_ROUNDS * SECONDS_PER_ROUND; // 5400
2323 const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
2424 matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
2525 name: "tester",
26 map: "de_forge",
2627 rounds: 5, scoreMine: 3, scoreTheirs: 2,
2728 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
2829 ...over,
@@ -32,7 +33,7 @@ const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
3233 const parse = (over: Record<string, unknown> = {}): Result | null => parseResult(body(over));
3334
3435 const FIELDS = [
35 "matchId", "name", "rounds", "scoreMine", "scoreTheirs",
36 "matchId", "name", "map", "rounds", "scoreMine", "scoreTheirs",
3637 "kills", "deaths", "headshots", "damage", "seconds", "finished",
3738 ];
3839
@@ -41,6 +42,7 @@ test("a whole result comes back field for field, and nothing the body invented",
4142 expect(parsed).toEqual({
4243 matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
4344 name: "spaced",
45 map: "de_forge",
4446 rounds: 5, scoreMine: 3, scoreTheirs: 2,
4547 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
4648 });
@@ -259,6 +261,40 @@ test("the cut is by code unit, so an emoji at the boundary is cut in half", () =
259261 expect(cleanName("x".repeat(23) + " y")).toBe("x".repeat(23) + " ");
260262 });
261263
264 // --- the map it was played on
265
266 test("a result names the map it was played on, and a generated one round-trips", () => {
267 expect(parse({ map: "de_forge" })?.map).toBe("de_forge");
268 expect(parse({ map: "gen_ir1" })?.map).toBe("gen_ir1");
269 // The longest name there is, and the shortest.
270 expect(parse({ map: "gen_1z141z3" })?.map).toBe("gen_1z141z3");
271 expect(parse({ map: "gen_0" })?.map).toBe("gen_0");
272 });
273
274 test("a body with no map is still a result, and it was played on de_forge", () => {
275 // The client from before #150 posts no map at all. Its match is a match, and
276 // de_forge is the only map it could have been played on.
277 const { map, ...without } = body();
278 expect(map).toBe("de_forge"); // the fixture above does send one
279 expect("map" in without).toBe(false);
280 expect(parseResult(without)?.map).toBe("de_forge");
281 // Sent explicitly as undefined is the same thing: `JSON.stringify` drops it.
282 expect(parse({ map: undefined })?.map).toBe("de_forge");
283 });
284
285 test("a map that is not a map is refused, not quietly recorded as de_forge", () => {
286 for (const junk of [
287 // Absurd, which is what a client that lies about the map looks like.
288 "de_dust2", "gen_", "gen_0ir1", "GEN_IR1", "gen_zzzzzzzz", "gen_1z141z4", "de_forge ",
289 "<script>alert(1)</script>", "../../etc/passwd", "x".repeat(300),
290 // `generated` is a wish for a new map, not a map anybody played on: by the
291 // time a round is over, the browser knows the name.
292 "generated",
293 // And a map that is not a string is refused rather than stringified.
294 null, 7, true, {}, ["gen_ir1"], { toString: () => "gen_ir1" },
295 ]) expect(parse({ map: junk })).toBe(null);
296 });
297
262298 // --- won
263299
264300 test("a win is finished and ahead, and the other three ways are not", () => {
src/result.ts +16
@@ -6,10 +6,17 @@
66 */
77
88 import { isMatchId } from "./matchid.ts";
9 import { DEFAULT_MAP, type PlayedMap, playedOn } from "./mapname.ts";
910
1011 export type Result = {
1112 matchId: string;
1213 name: string;
14 /**
15 * Where it was played: `de_forge` or `gen_<base36>`. Like every number here
16 * it is the browser's word — a client can claim the wrong map exactly as it
17 * can claim the wrong kills — so it is checked for shape and nothing more.
18 */
19 map: PlayedMap;
1320 rounds: number;
1421 scoreMine: number;
1522 scoreTheirs: number;
@@ -57,6 +64,14 @@ export function parseResult(value: unknown): Result | null {
5764 // about what an id is because there is one of them.
5865 const matchId = body.matchId;
5966 if (!isMatchId(matchId)) return null;
67 // The map, through `src/mapname.ts` — the same door the server validates
68 // `?map=` with. A body that says nothing about the map is a client from
69 // before this field existed and still a result: it played de_forge, which is
70 // all there was. A body that says something that is not a map is refused,
71 // because a leaderboard row naming a layout nobody can load is worse than
72 // one naming none.
73 const map = body.map === undefined ? DEFAULT_MAP : playedOn(body.map);
74 if (map === null) return null;
6075 const rounds = integer(body.rounds, MAX_ROUNDS);
6176 const scoreMine = integer(body.scoreMine, MAX_ROUNDS);
6277 const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS);
@@ -82,6 +97,7 @@ export function parseResult(value: unknown): Result | null {
8297 return {
8398 matchId,
8499 name: cleanName(body.name),
100 map,
85101 rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds,
86102 finished: body.finished === true,
87103 };
src/server.test.ts +20
@@ -231,6 +231,26 @@ test("the row key one playing of a match is stored under is a row of its own", a
231231 expect(feed.match(/rematcher/g)?.length).toBeGreaterThanOrEqual(2);
232232 });
233233
234 test("a result says which map it was played on, and the feed names it", async () => {
235 const cookie = "cs_session=" + "b".repeat(32);
236 // A match on a generated layout: posted, stored, and said out loud on the
237 // page — with a link back to that exact layout rather than to a new one.
238 const body = await (await post(result({ name: "cartographer", map: "gen_ir1" }), cookie)).text();
239 expect(body).toContain(`href="/?map=gen_ir1">gen_ir1</a>`);
240 expect(await (await fetch(base + "/")).text()).toContain(`href="/?map=gen_ir1">gen_ir1</a>`);
241 // A client from before the field posts no map, and is still a result: it
242 // played de_forge, which is what the row says.
243 expect((await post(result({ name: "oldclient" }), cookie)).status).toBe(200);
244 const feed = await (await fetch(base + "/")).text();
245 expect(feed).toMatch(/oldclient \(guest\)<\/b>[^<]*<a class="map" href="\/\?map=de_forge">de_forge<\/a>/);
246 // And a map that is not one is the 422 every other bad field gets. The map
247 // is the browser's claim like every number beside it — checked for shape,
248 // never believed — so nothing that is not a map reaches the table.
249 for (const map of ["de_dust2", "generated", "<script>alert(1)</script>", 7, null])
250 expect((await post(result({ name: "liar", map }), cookie)).status).toBe(422);
251 expect(await (await fetch(base + "/")).text()).not.toContain("liar");
252 });
253
234254 test("serves the range on its own route, with its own page and its own bundle", async () => {
235255 const response = await fetch(base + "/range");
236256 expect(response.status).toBe(200);
src/terminal/keys.test.ts +1
@@ -18,6 +18,7 @@ const match = (id: string, name: string): MatchRow => ({
1818 session: "s",
1919 user_id: "u-1",
2020 name,
21 map: "de_forge",
2122 kills: 7,
2223 deaths: 4,
2324 headshots: 2,
src/terminal/screen.test.ts +18 −3
@@ -37,6 +37,7 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow
3737 session: "s",
3838 user_id: "u-1",
3939 name,
40 map: "de_forge",
4041 kills: 7,
4142 deaths: 4,
4243 headshots: 2,
@@ -101,9 +102,23 @@ test("a name wider than its column is cut with an ellipsis rather than pushing t
101102
102103 test("the match list keeps its columns and marks the ones still being played", () => {
103104 const lines = text(view({ tab: "matches" }));
104 expect(lines[4]).toBe(" player score k/d damage when");
105 expect(lines[5]).toBe("▸ ace 3-2 7/4 950 2026-09-16 10:00:00");
106 expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)");
105 expect(lines[4]).toBe(" player score k/d damage map when");
106 expect(lines[5]).toBe("▸ ace 3-2 7/4 950 de_forge 2026-09-16 10:00:00");
107 expect(lines[6]).toBe(" bo 3-2 7/4 950 de_forge 2026-09-16 10:00:00 (live)");
108 });
109
110 test("the map a match was played on is a column, generated names and all", () => {
111 // What a person can read on the page they can read here: the same fact, in
112 // the same words. The longest name there is fits the column it was cut for.
113 const lines = text(view({ tab: "matches" }), data({
114 matches: [match("m1", "ace", { map: "gen_ir1" }), match("m2", "bo", { map: "gen_1z141z3" })],
115 }));
116 expect(lines[5]).toBe("▸ ace 3-2 7/4 950 gen_ir1 2026-09-16 10:00:00");
117 expect(lines[6]).toBe(" bo 3-2 7/4 950 gen_1z141z3 2026-09-16 10:00:00");
118 // And the line under the cursor says how to play that layout again, because
119 // the id alone does not fetch it — the Map name field carves it.
120 expect(lines.find((l) => l.startsWith("play this match again")))
121 .toBe("play this match again: paste m1 into Match id, and gen_ir1 into Map name");
107122 });
108123
109124 test("the matches tab prints the id of the match under the cursor, ready to paste", () => {
src/terminal/screen.ts +17 −5
@@ -115,13 +115,16 @@ function standingsLines(table: Standing[], cursor: number): string[] {
115115
116116 function matchLines(matches: MatchRow[], cursor: number): string[] {
117117 const lines = [
118 `${esc}2m ${pad("player", 22)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`,
118 // The map column is 12 wide: `gen_1z141z3` is the longest name there is
119 // (`src/mapname.ts`), and a space after it. It sits before `when` so the
120 // columns a person already knows keep their order and their widths.
121 `${esc}2m ${pad("player", 22)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}${pad("map", 12)}when${esc}0m`,
119122 ];
120123 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
121124 matches.forEach((m, i) => {
122125 const line = `${pad(printable(displayName(m)), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
123 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${printable(m.updated_at)}` +
124 `${m.finished === 1 ? "" : " (live)"}`;
126 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${pad(printable(m.map), 12)}` +
127 `${printable(m.updated_at)}${m.finished === 1 ? "" : " (live)"}`;
125128 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
126129 });
127130 // The match the cursor is on, as the id that plays it again. A row is keyed
@@ -130,9 +133,18 @@ function matchLines(matches: MatchRow[], cursor: number): string[] {
130133 // of printing it: a match somebody played is a match you can play here too,
131134 // by pasting this into the menu. Never the raw column: `printable` guards
132135 // every line here, and an id that came from a browser is no exception.
136 // A generated map is named as well, because the id alone does not fetch it:
137 // the menu carves that layout from the name typed into Map name. de_forge
138 // needs nothing said — it is what the Map control is on already.
133139 const selected = matches[cursor];
134 if (selected !== undefined)
135 lines.push("", `${esc}2mplay this match again: paste ${printable(matchOf(selected.id))} into Match id${esc}0m`);
140 if (selected !== undefined) {
141 const map = printable(selected.map);
142 lines.push(
143 "",
144 `${esc}2mplay this match again: paste ${printable(matchOf(selected.id))} into Match id` +
145 `${map === "de_forge" ? "" : `, and ${map} into Map name`}${esc}0m`,
146 );
147 }
136148 return lines;
137149 }
138150
src/tty.test.ts +1
@@ -29,6 +29,7 @@ const CLEAR = "\x1b[2J\x1b[H";
2929 const result = (over: Partial<Result> = {}): Result => ({
3030 matchId: crypto.randomUUID(),
3131 name: "the player says the name",
32 map: "de_forge",
3233 rounds: 5, scoreMine: 3, scoreTheirs: 2,
3334 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
3435 ...over,
static/app.07fd7f4c9f.js deleted −58

Diff not shown — a line 73,606 characters wide (limit 2,000). −58

View file
static/app.8ec5c087a3.js added +58

Diff not shown — a line 75,773 characters wide (limit 2,000). +58

View file
static/app.d0901a691f.js deleted −58

Diff not shown — a line 71,841 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs