Split The code is public; the app answers only to the crew.

The map catalogue: a validated `map` setting and a Map select beside Difficulty #136

Merged opened this

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

Merged

scout/cs:scout/131-the-map-catalogue-a-validated-map-settin → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/131-the-map-catalogue-a-validated-map-settin was merged into main as 7c0aef2.

Review

Sign in to review this change.
16 files changed +207 −20
README.md +4 −1
@@ -362,7 +362,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
362362 - `src/client/` — the game, in modules named for one thing each: `math`, `map`
363363 (a `MapDef` is a value — grid, boxes, spawns, sites — that `buildMap` makes
364364 from a layout's areas and crates; `DE_FORGE` is the one the game plays, and
365 everything that reads a grid takes the map rather than reaching for one),
365 everything that reads a grid takes the map rather than reaching for one; the
366 catalogue — `MAPS`, `MAP_NAMES`, `DEFAULT_MAP`, `mapFor`, built once each —
367 is the seam a player chooses through, offered as a Map select beside
368 Difficulty and validated the same way settings.ts validates difficulty),
366369 `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity,
367370 bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation),
368371 `buy` (the buy panel's rows — what a side can afford, what it already owns
src/client/main.ts +21 −3
@@ -19,6 +19,7 @@ import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action }
1919 import { advance, TICK, waiting } from "./loop.ts";
2020 import { clamp } from "./math.ts";
2121 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
22 import type { MapName } from "./map.ts";
2223 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2324 import { createRenderer, type Camera } from "./renderer.ts";
2425 import type { CrosshairStyle } from "./crosshair.ts";
@@ -29,8 +30,8 @@ import { CALL_LIFE, isCall, type Call } from "./plan.ts";
2930 import { nextWatched, watched } from "./spectate.ts";
3031 import { summarize, type MatchSummary } from "./summary.ts";
3132 import {
32 buy, canBuy, createWorld, DIFFICULTIES, eyePos, flinch, radioCall, setDifficulty, update,
33 type Difficulty, type Player, type World,
33 buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, MAP_NAMES, mapFor, radioCall,
34 setDifficulty, update, type Difficulty, type Player, type World,
3435 } from "./world.ts";
3536
3637 /**
@@ -60,6 +61,8 @@ function boot(): void {
6061 const sideField = $<HTMLSelectElement>("side");
6162 const difficultyField = $<HTMLSelectElement>("difficulty");
6263 const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty");
64 const mapField = $<HTMLSelectElement>("map");
65 const pauseMapLabel = $("pause-map");
6366 const sensField = $<HTMLInputElement>("sens");
6467 const pauseSensField = $<HTMLInputElement>("pause-sens");
6568 const volumeField = $<HTMLInputElement>("volume");
@@ -124,6 +127,17 @@ function boot(): void {
124127 saveSettings(settings);
125128 if (world !== null) setDifficulty(world, next);
126129 };
130 // The map is chosen once, in the menu, before a world exists to change it —
131 // unlike difficulty there is nowhere mid-match this can take hold, so there
132 // is no pause-overlay twin to keep in step, only the label that says which
133 // map the running match is on.
134 const syncMap = (value: string): void => {
135 const next: MapName = (MAP_NAMES as readonly string[]).includes(value)
136 ? (value as MapName) : DEFAULT_MAP;
137 settings = { ...settings, map: next };
138 mapField.value = next;
139 saveSettings(settings);
140 };
127141 const updateMuteButton = (): void => {
128142 muteButton.textContent = settings.muted ? "Unmute" : "Mute";
129143 muteButton.setAttribute("aria-pressed", String(settings.muted));
@@ -133,6 +147,7 @@ function boot(): void {
133147 volumeField.value = String(Math.round(settings.volume * 100));
134148 difficultyField.value = settings.difficulty;
135149 pauseDifficultyField.value = settings.difficulty;
150 mapField.value = settings.map;
136151 updateMuteButton();
137152
138153 // "change" rather than "input": a number field mid-edit (cleared, or "0.")
@@ -141,6 +156,7 @@ function boot(): void {
141156 pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value)));
142157 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
143158 pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value));
159 mapField.addEventListener("change", () => syncMap(mapField.value));
144160 volumeField.addEventListener("input", () => {
145161 const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100));
146162 settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume };
@@ -546,6 +562,7 @@ function boot(): void {
546562 const me = world.me;
547563 const body = {
548564 matchId,
565 map: world.map.name,
549566 rounds: world.match.score.t + world.match.score.ct,
550567 scoreMine: world.match.score[me.team],
551568 scoreTheirs: world.match.score[me.team === "t" ? "ct" : "t"],
@@ -592,7 +609,8 @@ function boot(): void {
592609 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
593610 const team = sideField.value === "t" ? "t" : "ct";
594611 const name = nameField.value.trim().slice(0, 24) || "you";
595 world = createWorld(rules, name, team, 5, undefined, settings.difficulty);
612 world = createWorld(rules, name, team, 5, undefined, settings.difficulty, mapFor(settings.map));
613 pauseMapLabel.textContent = world.map.name;
596614 matchId = crypto.randomUUID();
597615 posted = -1;
598616 watching = world.me.id;
src/client/map.test.ts +26 −1
@@ -1,6 +1,7 @@
11 import { expect, test } from "bun:test";
22 import {
3 buildMap, cellAt, colOf, DE_FORGE, findPath, rowOf, siteAt, walkable, worldOf, type Layout,
3 buildMap, cellAt, colOf, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS, rowOf, siteAt, walkable,
4 worldOf, type Layout,
45 } from "./map.ts";
56
67 test("the map has two sites and two spawns", () => {
@@ -104,6 +105,30 @@ test("buildMap turns a layout into the cells, spawns and sites it describes", ()
104105 expect(siteAt(toy, toy.sites.B)).toBe("B");
105106 });
106107
108 test("the catalogue holds de_forge, and nothing builds it twice", () => {
109 expect(MAP_NAMES).toEqual(["de_forge"]);
110 expect(DEFAULT_MAP).toBe("de_forge");
111 expect(MAPS.de_forge).toBe(DE_FORGE);
112 });
113
114 // A key and its map's own `name` diverging is a silent 422 on every post — a
115 // second layout with a key that does not match `MapDef.name` would still
116 // build, still show up in the menu, and only fail the moment a match on it
117 // is saved.
118 test("every catalogue key names the map it holds", () => {
119 for (const name of MAP_NAMES) expect(MAPS[name].name).toBe(name);
120 });
121
122 test("mapFor returns the map a valid name asks for", () => {
123 expect(mapFor("de_forge")).toBe(DE_FORGE);
124 });
125
126 test("mapFor falls back to the default map for anything unrecognised", () => {
127 expect(mapFor("nonsense")).toBe(MAPS[DEFAULT_MAP]);
128 expect(mapFor("")).toBe(MAPS[DEFAULT_MAP]);
129 expect(mapFor("DE_FORGE")).toBe(MAPS[DEFAULT_MAP]); // case matters: this is not a stored name
130 });
131
107132 test("two maps built from two layouts hold no state in common", () => {
108133 const a = buildMap({ ...TOY, name: "a" });
109134 const b = buildMap({ ...TOY, gridW: 8, gridH: 8, name: "b", crates: [] });
src/client/map.ts +17
@@ -210,6 +210,23 @@ export function buildMap(layout: Layout): MapDef {
210210 /** The map de_forge plays on, built once from today's tables. */
211211 export const DE_FORGE: MapDef = buildMap(DE_FORGE_LAYOUT);
212212
213 // --- the catalogue ----------------------------------------------------------
214
215 /** Every map the game can be played on. One entry today; a second layout is a second key. */
216 export type MapName = "de_forge";
217
218 /** Name to built map, each one built exactly once, above. */
219 export const MAPS: Record<MapName, MapDef> = { de_forge: DE_FORGE };
220
221 export const MAP_NAMES: readonly MapName[] = Object.keys(MAPS) as MapName[];
222
223 export const DEFAULT_MAP: MapName = "de_forge";
224
225 /** Whatever a select, a stored setting or a request body says, the map it names or the default. */
226 export function mapFor(name: string): MapDef {
227 return Object.hasOwn(MAPS, name) ? MAPS[name as MapName] : MAPS[DEFAULT_MAP];
228 }
229
213230 // --- places ---------------------------------------------------------------
214231
215232 /** Which bombsite a point stands in, if any. */
src/client/settings.test.ts +21 −3
@@ -26,11 +26,11 @@ test("a JSON value that isn't an object is the default", () => {
2626
2727 test("out-of-range numbers are clamped, not rejected", () => {
2828 expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({
29 muted: false, volume: 1, sensitivity: 12, difficulty: "normal",
29 muted: false, volume: 1, sensitivity: 12, difficulty: "normal", map: "de_forge",
3030 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3131 });
3232 expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({
33 muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal",
33 muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", map: "de_forge",
3434 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3535 });
3636 });
@@ -50,7 +50,7 @@ test("a whole, valid settings object round-trips", () => {
5050 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" };
5151 const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const;
5252 const stored: Settings = {
53 muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", crosshair, bindings,
53 muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", map: "de_forge", crosshair, bindings,
5454 };
5555 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
5656 });
@@ -126,6 +126,24 @@ test("a valid difficulty is kept exactly", () => {
126126 expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard");
127127 });
128128
129 test("a missing map is the default", () => {
130 expect(parseSettings('{"muted":true}').map).toBe("de_forge");
131 });
132
133 test("a garbage map is the default", () => {
134 expect(parseSettings('{"map":123}').map).toBe("de_forge");
135 expect(parseSettings('{"map":null}').map).toBe("de_forge");
136 expect(parseSettings('{"map":["de_forge"]}').map).toBe("de_forge");
137 });
138
139 test("a map name that was valid last week but is not in today's catalogue is the default", () => {
140 expect(parseSettings('{"map":"de_dust3"}').map).toBe("de_forge");
141 });
142
143 test("a valid map is kept exactly", () => {
144 expect(parseSettings('{"map":"de_forge"}').map).toBe("de_forge");
145 });
146
129147 test("no stored bindings at all is precisely today's map", () => {
130148 expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS);
131149 expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS);
src/client/settings.ts +7 −1
@@ -15,18 +15,20 @@ import {
1515 type CrosshairColour, type CrosshairStyle,
1616 } from "./crosshair.ts";
1717 import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts";
18 import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts";
1819
1920 export type Settings = {
2021 muted: boolean;
2122 volume: number;
2223 sensitivity: number;
2324 difficulty: Difficulty;
25 map: MapName;
2426 crosshair: CrosshairStyle;
2527 bindings: Record<Action, string>;
2628 };
2729
2830 export const DEFAULT_SETTINGS: Settings = {
29 muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY,
31 muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP,
3032 crosshair: DEFAULT_CROSSHAIR,
3133 bindings: DEFAULT_BINDINGS,
3234 };
@@ -36,6 +38,9 @@ export const SENSITIVITY_MAX = 12;
3638 const isDifficulty = (value: unknown): value is Difficulty =>
3739 typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value);
3840
41 const isMapName = (value: unknown): value is MapName =>
42 typeof value === "string" && (MAP_NAMES as readonly string[]).includes(value);
43
3944 /**
4045 * Every action ends up with a code, and no two actions ever end up with the
4146 * same one. An action's stored code is kept if it is a non-empty string and
@@ -119,6 +124,7 @@ export function parseSettings(raw: string | null): Settings {
119124 volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume),
120125 sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity),
121126 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
127 map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map,
122128 crosshair: parseCrosshair(obj.crosshair),
123129 bindings: parseBindings(obj.bindings),
124130 };
src/client/world.ts +1
@@ -15,6 +15,7 @@ import {
1515 } from "./grenades.ts";
1616 import { pose, remember, rememberPos, type Pose } from "./interpolate.ts";
1717 import { DE_FORGE, siteAt, type MapDef } from "./map.ts";
18 export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts";
1819 import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts";
1920 import * as rules from "./match.ts";
2021 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
src/db.test.ts +28
@@ -15,6 +15,7 @@ const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
1515
1616 const result = (over: Partial<Result> = {}): Result => ({
1717 matchId: crypto.randomUUID(),
18 map: "de_forge",
1819 rounds: 5, scoreMine: 3, scoreTheirs: 2,
1920 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false,
2021 ...over,
@@ -98,6 +99,33 @@ test("a row written under the old column name `left` keeps reading as abandoned"
9899 expect(recentMatches(db).find((m) => m.id === "m1")?.state).toBe("abandoned");
99100 });
100101
102 test("a row written before the map catalogue existed reads as de_forge, and no existing row is rewritten", () => {
103 const where = dir();
104 const old = new Database(join(where, "app.db"));
105 old.exec(`CREATE TABLE match (
106 id TEXT PRIMARY KEY, identity TEXT NOT NULL DEFAULT '', session TEXT NOT NULL, name TEXT NOT NULL,
107 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL,
108 damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL,
109 score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, walked_out INTEGER NOT NULL DEFAULT 0,
110 seconds INTEGER NOT NULL,
111 created_at TEXT NOT NULL DEFAULT (datetime('now')),
112 updated_at TEXT NOT NULL DEFAULT (datetime('now')))`);
113 old.query(
114 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
115 VALUES ('m1', 'user:u_1', 'deadbeefdeadbeefdeadbeefdeadbeef', 'Vesper', 9, 4, 3, 1200, 5, 3, 2, 1, 0, 420)`,
116 ).run();
117 old.close();
118
119 const db = openDatabase(where);
120 expect(recentMatches(db).find((m) => m.id === "m1")?.map).toBe("de_forge");
121 });
122
123 test("a saved match records the map it was played on", () => {
124 const db = openDatabase(dir());
125 expect(saveMatch(db, writer("user:u_1", "ada"), result({ map: "de_forge" }))).toBe(true);
126 expect(recentMatches(db)[0]?.map).toBe("de_forge");
127 });
128
101129 test("a match round-trips its state: finished, left, or still live", () => {
102130 const db = openDatabase(dir());
103131 const finished = result({ finished: true, left: false });
src/db.ts +12 −6
@@ -24,6 +24,7 @@ export type MatchRow = {
2424 identity: string;
2525 session: string;
2626 name: string;
27 map: string;
2728 kills: number;
2829 deaths: number;
2930 headshots: number;
@@ -81,6 +82,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
8182 identity TEXT NOT NULL DEFAULT '',
8283 session TEXT NOT NULL,
8384 name TEXT NOT NULL,
85 map TEXT NOT NULL DEFAULT 'de_forge',
8486 kills INTEGER NOT NULL,
8587 deaths INTEGER NOT NULL,
8688 headshots INTEGER NOT NULL,
@@ -110,6 +112,10 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
110112 db.exec("ALTER TABLE match ADD COLUMN walked_out INTEGER NOT NULL DEFAULT 0");
111113 if (columns.includes("left")) db.exec("UPDATE match SET walked_out = left");
112114 }
115 // A row written before the catalogue existed was, in fact, played on
116 // de_forge — that was the only map there was — so the default reads true
117 // for every row this adds it to, and nothing already there is rewritten.
118 if (!columns.includes("map")) db.exec("ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge'");
113119 db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)");
114120 return db;
115121 }
@@ -124,10 +130,10 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
124130 const existing = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(result.matchId);
125131 if (existing !== null && existing.identity !== writer.identity) return false;
126132 db.query(
127 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
133 `INSERT INTO match (id, identity, session, name, map, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
134 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
129135 ON CONFLICT(id) DO UPDATE SET
130 name = excluded.name, kills = excluded.kills, deaths = excluded.deaths,
136 name = excluded.name, map = excluded.map, kills = excluded.kills, deaths = excluded.deaths,
131137 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
132138 score_mine = excluded.score_mine, score_theirs = excluded.score_theirs,
133139 -- Posts race: a periodic post already in flight when the match ends
@@ -137,8 +143,8 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
137143 finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out),
138144 seconds = excluded.seconds, updated_at = datetime('now')`,
139145 ).run(
140 result.matchId, writer.identity, writer.session, writer.name, result.kills, result.deaths, result.headshots,
141 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0,
146 result.matchId, writer.identity, writer.session, writer.name, result.map, result.kills, result.deaths,
147 result.headshots, result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0,
142148 result.left ? 1 : 0, result.seconds,
143149 );
144150 return true;
@@ -146,7 +152,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
146152
147153 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
148154 db.query<MatchRow, [number]>(
149 `SELECT id, identity, session, name, kills, deaths, headshots, damage, rounds,
155 `SELECT id, identity, session, name, map, kills, deaths, headshots, damage, rounds,
150156 score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL}
151157 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
152158 ).all(limit);
src/pages/game.test.ts +22
@@ -6,6 +6,7 @@ import { openDatabase, saveMatch, standings } from "../db.ts";
66 import { credit } from "../player.ts";
77 import type { Result } from "../result.ts";
88 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
9 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
910 import { gamePage, statsHtml } from "./game.ts";
1011
1112 const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
@@ -55,6 +56,7 @@ test("a fullscreen control sits in the menu and in the pause overlay", () => {
5556
5657 const matchResult = (over: Partial<Result> = {}): Result => ({
5758 matchId: crypto.randomUUID(),
59 map: "de_forge",
5860 rounds: 5, scoreMine: 5, scoreTheirs: 1,
5961 kills: 12, deaths: 3, headshots: 5, damage: 1500, seconds: 500, finished: true, left: false,
6062 ...over,
@@ -118,6 +120,26 @@ test("the crosshair controls are in the menu and in the pause overlay, one set e
118120 expect(new Set(ids).size).toBe(ids.length);
119121 });
120122
123 test("a Map select sits beside Difficulty in the menu, offering every catalogued map", () => {
124 const menu = element(gamePage(db, options), "menu");
125 expect(menu).toContain(`id="map"`);
126 for (const name of MAP_NAMES) expect(menu).toContain(`<option value="${name}"`);
127 expect(menu).toContain(`<option value="${DEFAULT_MAP}" selected>`);
128 });
129
130 test("the pause overlay says which map the match is on", () => {
131 const pause = element(gamePage(db, options), "pause");
132 expect(pause).toContain(`id="pause-map"`);
133 });
134
135 test("a recent match's row names the map it was played on", () => {
136 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
137 const writer = { identity: "user:u_3", name: "reyes", guest: false, session: "d".repeat(32) };
138 saveMatch(stats, writer, matchResult({ map: "de_forge" }));
139 const html = statsHtml(stats, player);
140 expect(html).toContain("de_forge");
141 });
142
121143 test("a crosshair field can only offer the numbers the parser would keep", () => {
122144 const panel = element(gamePage(db, options), "menu");
123145 for (const [field, limit] of Object.entries(CROSSHAIR_LIMITS)) {
src/pages/game.ts +6 −2
@@ -6,6 +6,7 @@
66
77 import type { Database } from "bun:sqlite";
88 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
9 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
910 import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts";
1011 import { isGuest, type Player } from "../player.ts";
1112 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
@@ -36,7 +37,7 @@ export function statsHtml(db: Database, player: Player): string {
3637 const feed = recent
3738 .map(
3839 (m) => `<li><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` +
39 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` +
40 `<span>${escape(m.map)} · ${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` +
4041 `<time>${escape(m.updated_at)}</time></li>`,
4142 )
4243 .join("");
@@ -266,6 +267,9 @@ export function gamePage(db: Database, options: PageOptions): string {
266267 <option value="normal" selected>Normal</option>
267268 <option value="hard">Hard</option>
268269 </select></label>
270 <label>Map<select id="map">${MAP_NAMES
271 .map((name) => `<option value="${name}"${name === DEFAULT_MAP ? " selected" : ""}>${name}</option>`)
272 .join("")}</select></label>
269273 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
270274 </div>
271275 <div class="row"><button type="button" class="primary" id="play">Play</button>
@@ -305,7 +309,7 @@ export function gamePage(db: Database, options: PageOptions): string {
305309 <button type="button" id="mute" aria-pressed="false">Mute</button>
306310 <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button>
307311 </div>
308 <p class="hint">Difficulty takes effect from the next round.</p>
312 <p class="hint">Difficulty takes effect from the next round. Playing on <b id="pause-map"></b> — chosen in the menu, fixed for the match.</p>
309313 <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details>
310314 ${crosshairControls("pause")}
311315 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
src/result.test.ts +17
@@ -5,6 +5,7 @@ import { parseResult, won } from "./result.ts";
55
66 const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
77 matchId: crypto.randomUUID(),
8 map: "de_forge",
89 rounds: 5, scoreMine: 3, scoreTheirs: 2,
910 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
1011 ...over,
@@ -42,6 +43,22 @@ test("nonsense is refused rather than clamped", () => {
4243 expect(parseResult(undefined)).toBe(null);
4344 });
4445
46 test("a map name present but outside the fixed list is refused, never clamped to the default", () => {
47 expect(parseResult(result({ map: "de_dust3" }))).toBe(null);
48 expect(parseResult(result({ map: "" }))).toBe(null);
49 expect(parseResult(result({ map: 7 }))).toBe(null);
50 expect(parseResult(result({ map: null }))).toBe(null);
51 });
52
53 test("a valid map name parses and is carried through", () => {
54 expect(parseResult(result({ map: "de_forge" }))?.map).toBe("de_forge");
55 });
56
57 test("a client that says nothing about map — an old bundle held across a deploy — gets today's default, not a refusal", () => {
58 const { map: _map, ...withoutMap } = result();
59 expect(parseResult(withoutMap)?.map).toBe("de_forge");
60 });
61
4562 test("a match walked out of parses as left, not finished", () => {
4663 const parsed = parseResult(result({ finished: false, left: true }));
4764 expect(parsed?.left).toBe(true);
src/result.ts +12
@@ -5,8 +5,11 @@
55 * refuse anything else outright.
66 */
77
8 import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./client/map.ts";
9
810 export type Result = {
911 matchId: string;
12 map: MapName;
1013 rounds: number;
1114 scoreMine: number;
1215 scoreTheirs: number;
@@ -47,6 +50,14 @@ export function parseResult(value: unknown): Result | null {
4750 const body = value as Record<string, unknown>;
4851 const matchId = typeof body.matchId === "string" ? body.matchId : "";
4952 if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null;
53 // Missing entirely — an older client, or one still holding last deploy's
54 // bundle — reads as today's default, the same way a missing `left` does.
55 // Present and wrong is a different thing: a name that used to be valid, or
outdated

I anchor this here because this line is where the catalogue meets the wire.

Approved — the seam is exactly the one #131 asked for, and it is built the way the rest of this repository is built.

What I checked, at 836a6a9:

  • Validation at every boundary. settings.ts guards map precisely as it guards difficulty (junk, a number, an array, and a name that was valid last week all fall back to DEFAULT_MAP and never reach createWorld); parseResult accepts only a name from MAP_NAMES, so an unknown map is a 422 and never a row; mapFor uses Object.hasOwn, so "constructor" and friends get the default rather than a prototype member. The posted name is world.map.name from the built map, not the raw setting — the client cannot hand the server a name it did not first validate twice.
  • Escaping. escape(m.map) in the recent-matches feed, safe(m.map) in the terminal's new column, and the select's options are compile-time constants. Nothing user-typed reaches markup unescaped.
  • The migration is additive. ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge', guarded on columns.includes("map"), no UPDATE over existing rows, and a test that opens a pre-catalogue schema and reads de_forge back. That was the one irreversible part of the issue and it is done right. SQL stays parameterised.
  • Both doors. The map lands on the page feed and in the terminal's matches tab with a map header, tested on both sides. Nothing private moved, and gating is untouched.
  • Tests. I ran bun run check on the head myself: tsc --noEmit clean, 555 pass / 0 fail. Each added behaviour has a test that fails without it — the fallback, the refusal, the migration, the select, the pause label, the terminal column. The id-contract test from #127 picks up map and pause-map automatically, which is the bullet the issue wanted.
  • Preview. The edge is answering 404 for https://cs--136--forge-trevato-apps.open-platform.sh right now (three tries, ~0.3 s each), so I judged the look from the pictures at this head: the Map select sits beside Difficulty showing de_forge on desktop, the row is unbroken on phone, and the recent-matches line reads de_forge · 5k 1d · 367 damage · abandoned. Nothing in the diff explains the 404; it reads as the preview being down, not the change.

No new dependency, no dead code of consequence, and the label-not-a-second-select choice for the pause overlay is the right call given the map is fixed for a match.

Three small things for whoever touches this next, none of them blocking:

  1. A missing map is now a hard 422. A tab holding the previous bundle across a deploy posts without map and loses its in-flight result. left handles that case deliberately ("which is also what an older client sends"); map could default a missing field to DEFAULT_MAP while still refusing a wrong name, and keep both the issue's rule and that precedent.
  2. The catalogue key and MapDef.name must agree or every post 422s. They do today (DE_FORGE_LAYOUT.name is "de_forge"), but nothing asserts it, and the day a second layout lands with a key that differs from its name, results fail silently. One line in map.test.ts: for (const n of MAP_NAMES) expect(MAPS[n].name).toBe(n).
  3. isMapName(value): value is string narrows to string, so Settings.map is a bare string where difficulty is a Difficulty; value is MapName would carry the type through. And export type { MapDef, MapName } in world.ts is unused — renderer.ts and hud.ts import those from map.ts directly.
56 // one that never was, is refused rather than quietly corrected.
57 const map: MapName | null = body.map === undefined ? DEFAULT_MAP

I anchor this here: this is the line the last round turned on, and it now reads the way the rest of the parser reads.

Approved. All three notes from my first pass are answered, and answered properly rather than papered over.

The one that mattered. A missing map is now today's default and a present but wrong one is still a hard refusal — the distinction I asked for, with a comment that says why and a test named for the case it protects (an old bundle held across a deploy). {"map":null} and {"map":7} still 422, which is right: JSON cannot carry undefined, so body.map === undefined means the key was absent and nothing else. JSON.parse never sets a prototype, so a __proto__ payload lands as an own key and still reaches this ternary, not around it.

The invariant is now a test. for (const name of MAP_NAMES) expect(MAPS[name].name).toBe(name) — with the comment explaining the failure it prevents (a second layout whose key and MapDef.name disagree builds fine, shows in the menu, and only fails when a match is saved). That is the test I would have opened an issue for.

Types carry through. Settings.map and Result.map are MapName, isMapName narrows to MapName, and the unused export type { MapDef, MapName } from world.ts is gone — main.ts takes the type from map.ts where it lives and the values from world.ts where the rest of the game's surface comes from.

Re-checked at dec01ac, not assumed. bun run check on this head: tsc --noEmit clean, 557 pass / 0 fail (two more than last round, which is the two behaviours this round adds). The security floor is untouched: identity, the same-origin gate, readBody's counted cap and both limiters are not in this diff. map is validated against a closed list before it is anything, parameterised into SQL, escaped in the feed and safed in the terminal column. The migration is still additive — ADD COLUMN ... DEFAULT 'de_forge' behind columns.includes("map"), no UPDATE over existing rows — which was the only irreversible part of #131.

Preview. https://cs--136--forge-trevato-apps.open-platform.sh answers 404 from my sandbox at this head as it did at the last one, and nothing in the diff explains that, so I judged the look from the forge's pictures at dec01ac: Map sits between Difficulty and Sensitivity showing de_forge on desktop, the menu row is unbroken on phone, and the recent-matches line reads de_forge · 5k 1d · 367 damage · abandoned. The page is unchanged from the previous head apart from the commit label, which is what a types-and-parser round should look like.

Nothing left from me. One-option select and all, this is the seam #131 asked for, and the day a second layout lands it is a key in MAPS rather than a refactor.

Approved — final verdict for head dec01ac. Recording it here because the two passes I already left on this pull (at 836a6a9 and at dec01ac) went in as anchored line comments and the pull still shows no verdict against it; nothing below contradicts them, it is the same judgement stated once more so it is on the record.

Re-verified at this head, not carried over: bun run check — tsc --noEmit clean, 557 pass / 0 fail. The forge's pictures at dec01ac show Map between Difficulty and Sensitivity reading de_forge, an unbroken menu row on phone, and de_forge · 5k 1d · 367 damage · abandoned in the feed. The preview host itself still answers 404 from my sandbox, at this head as at the last; nothing in the diff explains it and the pictures at the same commit show the page rendering, so I read it as the preview being down rather than a fault in the change.

Against the bar, strictly:

  • Security. No identity, session, body-cap, limiter or header code is in this diff. map is checked against a closed list at three boundaries — parseSettings (localStorage), parseResult (the API) and mapFor (which uses Object.hasOwn, so constructor and __proto__ get the default, not a prototype member) — then parameterised into SQL and escaped on the page, safed in the terminal. The posted value is world.map.name off the built map, not the raw setting.
  • The migration is additive and guarded: ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge' behind !columns.includes("map"), no UPDATE over existing rows, with a test that opens a pre-catalogue schema and reads the old row back. That was the only irreversible part of #131 and it is done right.
  • Both doors. The map lands in the page feed and in the terminal's matches tab under a map header, tested on both sides; gating is untouched, and the terminal reads the same database the page does.
  • Tests. Every behaviour has one that fails without it: the fallback, the refusal, the missing-field default, the key-equals-MapDef.name invariant, the migration, the select, the pause label, the terminal column. The id-contract test picks up map and pause-map on its own.
  • Clean and true to direction. One concept per module, no dependency, no dead code, no TODO; README says what the catalogue is. It does #131 and stops there — a one-option select today, a key in MAPS the day a second layout lands.

All three notes from my first pass were answered in the second round: missing map defaults while a present-but-wrong one still 422s, the invariant is a test, and the types carry MapName end to end. Nothing outstanding from me — land it.

58 : typeof body.map === "string" && (MAP_NAMES as readonly string[]).includes(body.map) ? (body.map as MapName)
59 : null;
60 if (map === null) return null;
5061 const rounds = integer(body.rounds, MAX_ROUNDS);
5162 const scoreMine = integer(body.scoreMine, MAX_ROUNDS);
5263 const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS);
@@ -75,6 +86,7 @@ export function parseResult(value: unknown): Result | null {
7586 if (finished && left) return null;
7687 return {
7788 matchId,
89 map,
7890 rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds,
7991 finished, left,
8092 };
src/server.test.ts +1
@@ -24,6 +24,7 @@ const forge = (id: string, name: string, key = KEY): Record<string, string> => {
2424 const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
2525 matchId: crypto.randomUUID(),
2626 name: "tester",
27 map: "de_forge",
2728 rounds: 5, scoreMine: 3, scoreTheirs: 2,
2829 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
2930 ...over,
src/tty.test.ts +10 −1
@@ -12,7 +12,8 @@ const standing = (over: Partial<Standing> = {}): Standing => ({
1212 });
1313
1414 const match = (over: Partial<MatchRow> = {}): MatchRow => ({
15 id: "m1", identity: "user:1", session: "s1", name: "tester", kills: 9, deaths: 4, headshots: 3, damage: 1200, rounds: 5,
15 id: "m1", identity: "user:1", session: "s1", name: "tester", map: "de_forge", kills: 9, deaths: 4, headshots: 3,
16 damage: 1200, rounds: 5,
1617 score_mine: 3, score_theirs: 2, finished: 1, walked_out: 0, seconds: 420, updated_at: "2024-01-01 12:00:00",
1718 state: "finished", ...over,
1819 });
@@ -125,6 +126,13 @@ test("the matches tab renders every row, and says so when there are none", () =>
125126 expect(withRows.some((l) => l.includes("Vesper"))).toBe(true);
126127 });
127128
129 test("the matches tab names the map each match was played on", () => {
130 const lines = render({ ...initialState, tab: "matches" }, data({ matches: [match({ name: "Vesper", map: "de_forge" })] }));
131 const line = lines.find((l) => l.includes("Vesper")) ?? "";
132 expect(line).toContain("de_forge");
133 expect(lines.some((l) => l.includes("map"))).toBe(true); // the header
134 });
135
128136 test("the policy tab renders with an empty database, since it does not read one", () => {
129137 const lines = render({ ...initialState, tab: "policy" }, data({ standings: [], matches: [], total: 0 }));
130138 expect(lines.some((l) => l.includes("the policy the bots fight with"))).toBe(true);
@@ -196,6 +204,7 @@ test("standings and matches say who a row belongs to, guest or signed", () => {
196204
197205 const result = (over: Partial<Result> = {}): Result => ({
198206 matchId: crypto.randomUUID(),
207 map: "de_forge",
199208 rounds: 5, scoreMine: 3, scoreTheirs: 2,
200209 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false,
201210 ...over,
src/tty.ts +2 −2
@@ -110,11 +110,11 @@ export function render(state: State, data: Data): string[] {
110110 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
111111 });
112112 } else {
113 lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`);
113 lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("map", 10)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`);
114114 if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
115115 data.matches.forEach((m, i) => {
116116 const status = m.state === "finished" ? "" : m.state === "abandoned" ? " (abandoned)" : " (live)";
117 const line = `${pad(safe(m.name), 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
117 const line = `${pad(safe(m.name), 22)}${pad(who(m.identity), 8)}${pad(safe(m.map), 10)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
118118 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}${status}`;
119119 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
120120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs