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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/150-a-result-says-which-map-it-was-played-on → main 1 commit fork
21 files
changed +360
−141
README.md +8 −3
| @@ -851,8 +851,11 @@ never checked itself. | ||
| 851 | 851 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 852 | 852 | schema and its queries, `src/result.ts` validates what a browser may claim |
| 853 | 853 | — 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`; | |
| 856 | 859 | `bun run scripts/mutate-result.ts` breaks each line of it in turn and names |
| 857 | 860 | the test that catches it, so a rule nothing tests is visible rather than |
| 858 | 861 | 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 | ||
| 1002 | 1005 | runs (the app is the login name, the forge is the host). It opens `src/tty.ts` |
| 1003 | 1006 | on a real pty over the same database the web page uses. Three tabs, and |
| 1004 | 1007 | <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 | |
| 1006 | 1011 | **policy** (the card the bots were trained with — trained-at, simulation ticks |
| 1007 | 1012 | and throughput, PPO updates, duels and both win rates, and the command that |
| 1008 | 1013 | 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"; | ||
| 24 | 24 | */ |
| 25 | 25 | const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [ |
| 26 | 26 | { 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: "" }, | |
| 29 | 32 | { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" }, |
| 30 | 33 | { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" }, |
| 31 | 34 | { 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 | ||
| 1080 | 1080 | expect(stage.by("menu").hidden).toBe(true); |
| 1081 | 1081 | }); |
| 1082 | 1082 | |
| 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 | ||
| 1083 | 1104 | test("copy this map is a link first and a clipboard second", async () => { |
| 1084 | 1105 | const LABEL = "Copy this map's link"; |
| 1085 | 1106 | const stage = fakeStage(); |
src/client/summary.test.ts +8 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { expect, test } from "bun:test"; |
| 12 | 12 | import { headshotShare as leaderboardShare } from "../db.ts"; |
| 13 | 13 | import { parseResult } from "../result.ts"; |
| 14 | import { generateLevel } from "./generate.ts"; | |
| 14 | 15 | import { endRound, SHORT } from "./match.ts"; |
| 15 | 16 | import { DE_FORGE } from "./map.ts"; |
| 16 | 17 | import { |
| @@ -56,9 +57,15 @@ test("what is posted is what is shown: one object, and the server accepts it", ( | ||
| 56 | 57 | const result = resultOf(match, ID, "Vesper", true); |
| 57 | 58 | expect(parseResult(result)).not.toBeNull(); |
| 58 | 59 | 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, | |
| 60 | 61 | kills: 9, deaths: 4, headshots: 3, damage: 1610, finished: true, seconds: 614, |
| 61 | 62 | }); |
| 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"); | |
| 62 | 69 | // The score is read off the side the player is on now, which is what half |
| 63 | 70 | // time swapping both the teams and the score means. |
| 64 | 71 | match.me.team = "t"; |
src/client/summary.ts +8 −1
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | * - `resultOf` is the one place a match is turned into numbers. The frame posts |
| 11 | 11 | * exactly that object to `/api/match` and the screen below it reads the same |
| 12 | 12 | * 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. | |
| 14 | 15 | * - `scoreLines` is the order and the figures the HUD's Tab scoreboard already |
| 15 | 16 | * draws (`drawScoreboard` in `hud.ts` calls it), so the rows a player reads |
| 16 | 17 | * after the match are the rows they read during it: sorted by kills, then by |
| @@ -28,6 +29,7 @@ | ||
| 28 | 29 | * exception is a rule somebody forgets. |
| 29 | 30 | */ |
| 30 | 31 | |
| 32 | import { DEFAULT_MAP, playedOn } from "../mapname.ts"; | |
| 31 | 33 | import type { Result } from "../result.ts"; |
| 32 | 34 | import type { Team } from "./match.ts"; |
| 33 | 35 | import type { World } from "./world.ts"; |
| @@ -83,6 +85,11 @@ export function resultOf(world: World, matchId: string, name: string, final: boo | ||
| 83 | 85 | return { |
| 84 | 86 | matchId, |
| 85 | 87 | 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, | |
| 86 | 93 | rounds: world.match.score.t + world.match.score.ct, |
| 87 | 94 | scoreMine: world.match.score[me.team], |
| 88 | 95 | 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-")); | ||
| 17 | 17 | const result = (over: Partial<Result> = {}): Result => ({ |
| 18 | 18 | matchId: crypto.randomUUID(), |
| 19 | 19 | name: "ignored — the player says the name", |
| 20 | map: "de_forge", | |
| 20 | 21 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 21 | 22 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 22 | 23 | ...over, |
| @@ -77,10 +78,60 @@ test("a database written before identities existed keeps its matches and calls t | ||
| 77 | 78 | expect(standings(db).length).toBe(2); |
| 78 | 79 | }); |
| 79 | 80 | |
| 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 | ||
| 80 | 126 | test("opening a database twice migrates once and breaks nothing", () => { |
| 81 | 127 | const home = dir(); |
| 82 | 128 | openDatabase(home).close(); |
| 83 | 129 | 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); | |
| 85 | 131 | 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); | |
| 86 | 137 | }); |
src/db.ts +13 −5
| @@ -21,6 +21,8 @@ export type MatchRow = { | ||
| 21 | 21 | /** The forge identity that posted it, or "" for a guest. */ |
| 22 | 22 | user_id: string; |
| 23 | 23 | name: string; |
| 24 | /** The map it was played on: `de_forge` or `gen_<base36>`, never empty. */ | |
| 25 | map: string; | |
| 24 | 26 | kills: number; |
| 25 | 27 | deaths: number; |
| 26 | 28 | headshots: number; |
| @@ -55,6 +57,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 55 | 57 | session TEXT NOT NULL, |
| 56 | 58 | user_id TEXT NOT NULL DEFAULT '', |
| 57 | 59 | name TEXT NOT NULL, |
| 60 | map TEXT NOT NULL DEFAULT 'de_forge', | |
| 58 | 61 | kills INTEGER NOT NULL, |
| 59 | 62 | deaths INTEGER NOT NULL, |
| 60 | 63 | headshots INTEGER NOT NULL, |
| @@ -72,6 +75,11 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 72 | 75 | // played as guests, which is what the default already says. |
| 73 | 76 | const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name); |
| 74 | 77 | 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'"); | |
| 75 | 83 | db.exec("CREATE INDEX IF NOT EXISTS match_by_time ON match (updated_at DESC)"); |
| 76 | 84 | return db; |
| 77 | 85 | } |
| @@ -110,10 +118,10 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean | ||
| 110 | 118 | if (player.userId === "" && existing.session !== player.session) return false; |
| 111 | 119 | } |
| 112 | 120 | 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) | |
| 115 | 123 | 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, | |
| 117 | 125 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, |
| 118 | 126 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, |
| 119 | 127 | finished = excluded.finished, seconds = excluded.seconds, |
| @@ -121,14 +129,14 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean | ||
| 121 | 129 | ).run( |
| 122 | 130 | result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots, |
| 123 | 131 | result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds, |
| 124 | player.userId, | |
| 132 | player.userId, result.map, | |
| 125 | 133 | ); |
| 126 | 134 | return true; |
| 127 | 135 | } |
| 128 | 136 | |
| 129 | 137 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| 130 | 138 | 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, | |
| 132 | 140 | score_mine, score_theirs, finished, seconds, updated_at |
| 133 | 141 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 134 | 142 | ).all(limit); |
src/mapname.test.ts +18 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | |
| 12 | 12 | test("a name is a seed and a seed is a name, both ways round", () => { |
| 13 | 13 | 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 | ||
| 45 | 45 | expect(mapLink(mapFrom("gen_ya"))).toBe("/?map=gen_ya"); |
| 46 | 46 | expect(mapLink(mapFrom("generated"))).toBe("/?map=generated"); |
| 47 | 47 | }); |
| 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 { | ||
| 67 | 67 | |
| 68 | 68 | /** The link that plays this map: the one URL a person copies and hands over. */ |
| 69 | 69 | 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", () => { | ||
| 64 | 64 | const home = db(); |
| 65 | 65 | const player: Player = { userId: "u-1", session: "s1", name: "kestrel" }; |
| 66 | 66 | 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, | |
| 68 | 68 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 69 | 69 | }; |
| 70 | 70 | expect(saveMatch(home, player, result)).toBe(true); |
| @@ -75,6 +75,26 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => { | ||
| 75 | 75 | expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`); |
| 76 | 76 | }); |
| 77 | 77 | |
| 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("<script>alert(1)</script></a>"); | |
| 95 | expect(html).toContain(`<a class="map" href="/?map=de_forge">`); | |
| 96 | }); | |
| 97 | ||
| 78 | 98 | test("the screen a match ends on is in the page, hidden, with the menu's own sentence on it", () => { |
| 79 | 99 | const html = gamePage(db(), options); |
| 80 | 100 | // 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"; | ||
| 13 | 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 14 | 14 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import type { Who } from "../who.ts"; |
| 18 | 18 | import { navFor, othersHtml } from "./games.ts"; |
| 19 | 19 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| @@ -32,7 +32,12 @@ export function statsHtml(db: Database): string { | ||
| 32 | 32 | .join(""); |
| 33 | 33 | const feed = recent |
| 34 | 34 | .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. | |
| 35 | 39 | (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> ` + | |
| 36 | 41 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` + |
| 37 | 42 | `<time>${escape(m.updated_at)}</time></li>`, |
| 38 | 43 | ) |
| @@ -132,6 +137,12 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under | ||
| 132 | 137 | the one the browser measured off the level: the line changes, the box does not. */ |
| 133 | 138 | #planalt { min-height:2.5em; } |
| 134 | 139 | #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; } | |
| 135 | 146 | `; |
| 136 | 147 | |
| 137 | 148 | /** 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 | ||
| 23 | 23 | const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ |
| 24 | 24 | matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", |
| 25 | 25 | name: "tester", |
| 26 | map: "de_forge", | |
| 26 | 27 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 27 | 28 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 28 | 29 | ...over, |
| @@ -32,7 +33,7 @@ const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ | ||
| 32 | 33 | const parse = (over: Record<string, unknown> = {}): Result | null => parseResult(body(over)); |
| 33 | 34 | |
| 34 | 35 | const FIELDS = [ |
| 35 | "matchId", "name", "rounds", "scoreMine", "scoreTheirs", | |
| 36 | "matchId", "name", "map", "rounds", "scoreMine", "scoreTheirs", | |
| 36 | 37 | "kills", "deaths", "headshots", "damage", "seconds", "finished", |
| 37 | 38 | ]; |
| 38 | 39 | |
| @@ -41,6 +42,7 @@ test("a whole result comes back field for field, and nothing the body invented", | ||
| 41 | 42 | expect(parsed).toEqual({ |
| 42 | 43 | matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", |
| 43 | 44 | name: "spaced", |
| 45 | map: "de_forge", | |
| 44 | 46 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 45 | 47 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 46 | 48 | }); |
| @@ -259,6 +261,40 @@ test("the cut is by code unit, so an emoji at the boundary is cut in half", () = | ||
| 259 | 261 | expect(cleanName("x".repeat(23) + " y")).toBe("x".repeat(23) + " "); |
| 260 | 262 | }); |
| 261 | 263 | |
| 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 | ||
| 262 | 298 | // --- won |
| 263 | 299 | |
| 264 | 300 | test("a win is finished and ahead, and the other three ways are not", () => { |
src/result.ts +16
| @@ -6,10 +6,17 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { isMatchId } from "./matchid.ts"; |
| 9 | import { DEFAULT_MAP, type PlayedMap, playedOn } from "./mapname.ts"; | |
| 9 | 10 | |
| 10 | 11 | export type Result = { |
| 11 | 12 | matchId: string; |
| 12 | 13 | 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; | |
| 13 | 20 | rounds: number; |
| 14 | 21 | scoreMine: number; |
| 15 | 22 | scoreTheirs: number; |
| @@ -57,6 +64,14 @@ export function parseResult(value: unknown): Result | null { | ||
| 57 | 64 | // about what an id is because there is one of them. |
| 58 | 65 | const matchId = body.matchId; |
| 59 | 66 | 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; | |
| 60 | 75 | const rounds = integer(body.rounds, MAX_ROUNDS); |
| 61 | 76 | const scoreMine = integer(body.scoreMine, MAX_ROUNDS); |
| 62 | 77 | const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS); |
| @@ -82,6 +97,7 @@ export function parseResult(value: unknown): Result | null { | ||
| 82 | 97 | return { |
| 83 | 98 | matchId, |
| 84 | 99 | name: cleanName(body.name), |
| 100 | map, | |
| 85 | 101 | rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds, |
| 86 | 102 | finished: body.finished === true, |
| 87 | 103 | }; |
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 | ||
| 231 | 231 | expect(feed.match(/rematcher/g)?.length).toBeGreaterThanOrEqual(2); |
| 232 | 232 | }); |
| 233 | 233 | |
| 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 | ||
| 234 | 254 | test("serves the range on its own route, with its own page and its own bundle", async () => { |
| 235 | 255 | const response = await fetch(base + "/range"); |
| 236 | 256 | expect(response.status).toBe(200); |
src/terminal/keys.test.ts +1
| @@ -18,6 +18,7 @@ const match = (id: string, name: string): MatchRow => ({ | ||
| 18 | 18 | session: "s", |
| 19 | 19 | user_id: "u-1", |
| 20 | 20 | name, |
| 21 | map: "de_forge", | |
| 21 | 22 | kills: 7, |
| 22 | 23 | deaths: 4, |
| 23 | 24 | headshots: 2, |
src/terminal/screen.test.ts +18 −3
| @@ -37,6 +37,7 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow | ||
| 37 | 37 | session: "s", |
| 38 | 38 | user_id: "u-1", |
| 39 | 39 | name, |
| 40 | map: "de_forge", | |
| 40 | 41 | kills: 7, |
| 41 | 42 | deaths: 4, |
| 42 | 43 | headshots: 2, |
| @@ -101,9 +102,23 @@ test("a name wider than its column is cut with an ellipsis rather than pushing t | ||
| 101 | 102 | |
| 102 | 103 | test("the match list keeps its columns and marks the ones still being played", () => { |
| 103 | 104 | 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"); | |
| 107 | 122 | }); |
| 108 | 123 | |
| 109 | 124 | 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[] { | ||
| 115 | 115 | |
| 116 | 116 | function matchLines(matches: MatchRow[], cursor: number): string[] { |
| 117 | 117 | 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`, | |
| 119 | 122 | ]; |
| 120 | 123 | if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); |
| 121 | 124 | matches.forEach((m, i) => { |
| 122 | 125 | 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)"}`; | |
| 125 | 128 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 126 | 129 | }); |
| 127 | 130 | // 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[] { | ||
| 130 | 133 | // of printing it: a match somebody played is a match you can play here too, |
| 131 | 134 | // by pasting this into the menu. Never the raw column: `printable` guards |
| 132 | 135 | // 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. | |
| 133 | 139 | 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 | } | |
| 136 | 148 | return lines; |
| 137 | 149 | } |
| 138 | 150 |
src/tty.test.ts +1
| @@ -29,6 +29,7 @@ const CLEAR = "\x1b[2J\x1b[H"; | ||
| 29 | 29 | const result = (over: Partial<Result> = {}): Result => ({ |
| 30 | 30 | matchId: crypto.randomUUID(), |
| 31 | 31 | name: "the player says the name", |
| 32 | map: "de_forge", | |
| 32 | 33 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 33 | 34 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 34 | 35 | ...over, |
static/app.07fd7f4c9f.js deleted −58
Diff not shown — a line 73,606 characters wide (limit 2,000). −58
View filestatic/app.8ec5c087a3.js added +58
Diff not shown — a line 75,773 characters wide (limit 2,000). +58
View filestatic/app.d0901a691f.js deleted −58
Diff not shown — a line 71,841 characters wide (limit 2,000). −58
View file
new above
offline