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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/131-the-map-catalogue-a-validated-map-settin → main 2 commits fork 2 threads · 2 unresolved
README.md +4 −1
| @@ -362,7 +362,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 362 | 362 | - `src/client/` — the game, in modules named for one thing each: `math`, `map` |
| 363 | 363 | (a `MapDef` is a value — grid, boxes, spawns, sites — that `buildMap` makes |
| 364 | 364 | 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), | |
| 366 | 369 | `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity, |
| 367 | 370 | bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation), |
| 368 | 371 | `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 } | ||
| 19 | 19 | import { advance, TICK, waiting } from "./loop.ts"; |
| 20 | 20 | import { clamp } from "./math.ts"; |
| 21 | 21 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 22 | import type { MapName } from "./map.ts"; | |
| 22 | 23 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 23 | 24 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 24 | 25 | import type { CrosshairStyle } from "./crosshair.ts"; |
| @@ -29,8 +30,8 @@ import { CALL_LIFE, isCall, type Call } from "./plan.ts"; | ||
| 29 | 30 | import { nextWatched, watched } from "./spectate.ts"; |
| 30 | 31 | import { summarize, type MatchSummary } from "./summary.ts"; |
| 31 | 32 | 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, | |
| 34 | 35 | } from "./world.ts"; |
| 35 | 36 | |
| 36 | 37 | /** |
| @@ -60,6 +61,8 @@ function boot(): void { | ||
| 60 | 61 | const sideField = $<HTMLSelectElement>("side"); |
| 61 | 62 | const difficultyField = $<HTMLSelectElement>("difficulty"); |
| 62 | 63 | const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty"); |
| 64 | const mapField = $<HTMLSelectElement>("map"); | |
| 65 | const pauseMapLabel = $("pause-map"); | |
| 63 | 66 | const sensField = $<HTMLInputElement>("sens"); |
| 64 | 67 | const pauseSensField = $<HTMLInputElement>("pause-sens"); |
| 65 | 68 | const volumeField = $<HTMLInputElement>("volume"); |
| @@ -124,6 +127,17 @@ function boot(): void { | ||
| 124 | 127 | saveSettings(settings); |
| 125 | 128 | if (world !== null) setDifficulty(world, next); |
| 126 | 129 | }; |
| 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 | }; | |
| 127 | 141 | const updateMuteButton = (): void => { |
| 128 | 142 | muteButton.textContent = settings.muted ? "Unmute" : "Mute"; |
| 129 | 143 | muteButton.setAttribute("aria-pressed", String(settings.muted)); |
| @@ -133,6 +147,7 @@ function boot(): void { | ||
| 133 | 147 | volumeField.value = String(Math.round(settings.volume * 100)); |
| 134 | 148 | difficultyField.value = settings.difficulty; |
| 135 | 149 | pauseDifficultyField.value = settings.difficulty; |
| 150 | mapField.value = settings.map; | |
| 136 | 151 | updateMuteButton(); |
| 137 | 152 | |
| 138 | 153 | // "change" rather than "input": a number field mid-edit (cleared, or "0.") |
| @@ -141,6 +156,7 @@ function boot(): void { | ||
| 141 | 156 | pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value))); |
| 142 | 157 | difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value)); |
| 143 | 158 | pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value)); |
| 159 | mapField.addEventListener("change", () => syncMap(mapField.value)); | |
| 144 | 160 | volumeField.addEventListener("input", () => { |
| 145 | 161 | const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100)); |
| 146 | 162 | settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume }; |
| @@ -546,6 +562,7 @@ function boot(): void { | ||
| 546 | 562 | const me = world.me; |
| 547 | 563 | const body = { |
| 548 | 564 | matchId, |
| 565 | map: world.map.name, | |
| 549 | 566 | rounds: world.match.score.t + world.match.score.ct, |
| 550 | 567 | scoreMine: world.match.score[me.team], |
| 551 | 568 | scoreTheirs: world.match.score[me.team === "t" ? "ct" : "t"], |
| @@ -592,7 +609,8 @@ function boot(): void { | ||
| 592 | 609 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 593 | 610 | const team = sideField.value === "t" ? "t" : "ct"; |
| 594 | 611 | 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; | |
| 596 | 614 | matchId = crypto.randomUUID(); |
| 597 | 615 | posted = -1; |
| 598 | 616 | watching = world.me.id; |
src/client/map.test.ts +26 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | 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, | |
| 4 | 5 | } from "./map.ts"; |
| 5 | 6 | |
| 6 | 7 | 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", () | ||
| 104 | 105 | expect(siteAt(toy, toy.sites.B)).toBe("B"); |
| 105 | 106 | }); |
| 106 | 107 | |
| 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 | ||
| 107 | 132 | test("two maps built from two layouts hold no state in common", () => { |
| 108 | 133 | const a = buildMap({ ...TOY, name: "a" }); |
| 109 | 134 | 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 { | ||
| 210 | 210 | /** The map de_forge plays on, built once from today's tables. */ |
| 211 | 211 | export const DE_FORGE: MapDef = buildMap(DE_FORGE_LAYOUT); |
| 212 | 212 | |
| 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 | ||
| 213 | 230 | // --- places --------------------------------------------------------------- |
| 214 | 231 | |
| 215 | 232 | /** 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", () => { | ||
| 26 | 26 | |
| 27 | 27 | test("out-of-range numbers are clamped, not rejected", () => { |
| 28 | 28 | 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", | |
| 30 | 30 | crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 31 | 31 | }); |
| 32 | 32 | 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", | |
| 34 | 34 | crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 35 | 35 | }); |
| 36 | 36 | }); |
| @@ -50,7 +50,7 @@ test("a whole, valid settings object round-trips", () => { | ||
| 50 | 50 | const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" }; |
| 51 | 51 | const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const; |
| 52 | 52 | 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, | |
| 54 | 54 | }; |
| 55 | 55 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 56 | 56 | }); |
| @@ -126,6 +126,24 @@ test("a valid difficulty is kept exactly", () => { | ||
| 126 | 126 | expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard"); |
| 127 | 127 | }); |
| 128 | 128 | |
| 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 | ||
| 129 | 147 | test("no stored bindings at all is precisely today's map", () => { |
| 130 | 148 | expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS); |
| 131 | 149 | expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS); |
src/client/settings.ts +7 −1
| @@ -15,18 +15,20 @@ import { | ||
| 15 | 15 | type CrosshairColour, type CrosshairStyle, |
| 16 | 16 | } from "./crosshair.ts"; |
| 17 | 17 | import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts"; |
| 18 | import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts"; | |
| 18 | 19 | |
| 19 | 20 | export type Settings = { |
| 20 | 21 | muted: boolean; |
| 21 | 22 | volume: number; |
| 22 | 23 | sensitivity: number; |
| 23 | 24 | difficulty: Difficulty; |
| 25 | map: MapName; | |
| 24 | 26 | crosshair: CrosshairStyle; |
| 25 | 27 | bindings: Record<Action, string>; |
| 26 | 28 | }; |
| 27 | 29 | |
| 28 | 30 | 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, | |
| 30 | 32 | crosshair: DEFAULT_CROSSHAIR, |
| 31 | 33 | bindings: DEFAULT_BINDINGS, |
| 32 | 34 | }; |
| @@ -36,6 +38,9 @@ export const SENSITIVITY_MAX = 12; | ||
| 36 | 38 | const isDifficulty = (value: unknown): value is Difficulty => |
| 37 | 39 | typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value); |
| 38 | 40 | |
| 41 | const isMapName = (value: unknown): value is MapName => | |
| 42 | typeof value === "string" && (MAP_NAMES as readonly string[]).includes(value); | |
| 43 | ||
| 39 | 44 | /** |
| 40 | 45 | * Every action ends up with a code, and no two actions ever end up with the |
| 41 | 46 | * 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 { | ||
| 119 | 124 | volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume), |
| 120 | 125 | sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity), |
| 121 | 126 | difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty, |
| 127 | map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map, | |
| 122 | 128 | crosshair: parseCrosshair(obj.crosshair), |
| 123 | 129 | bindings: parseBindings(obj.bindings), |
| 124 | 130 | }; |
src/client/world.ts +1
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | } from "./grenades.ts"; |
| 16 | 16 | import { pose, remember, rememberPos, type Pose } from "./interpolate.ts"; |
| 17 | 17 | import { DE_FORGE, siteAt, type MapDef } from "./map.ts"; |
| 18 | export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts"; | |
| 18 | 19 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 19 | 20 | import * as rules from "./match.ts"; |
| 20 | 21 | 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-")); | ||
| 15 | 15 | |
| 16 | 16 | const result = (over: Partial<Result> = {}): Result => ({ |
| 17 | 17 | matchId: crypto.randomUUID(), |
| 18 | map: "de_forge", | |
| 18 | 19 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 19 | 20 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false, |
| 20 | 21 | ...over, |
| @@ -98,6 +99,33 @@ test("a row written under the old column name `left` keeps reading as abandoned" | ||
| 98 | 99 | expect(recentMatches(db).find((m) => m.id === "m1")?.state).toBe("abandoned"); |
| 99 | 100 | }); |
| 100 | 101 | |
| 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 | ||
| 101 | 129 | test("a match round-trips its state: finished, left, or still live", () => { |
| 102 | 130 | const db = openDatabase(dir()); |
| 103 | 131 | const finished = result({ finished: true, left: false }); |
src/db.ts +12 −6
| @@ -24,6 +24,7 @@ export type MatchRow = { | ||
| 24 | 24 | identity: string; |
| 25 | 25 | session: string; |
| 26 | 26 | name: string; |
| 27 | map: string; | |
| 27 | 28 | kills: number; |
| 28 | 29 | deaths: number; |
| 29 | 30 | headshots: number; |
| @@ -81,6 +82,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 81 | 82 | identity TEXT NOT NULL DEFAULT '', |
| 82 | 83 | session TEXT NOT NULL, |
| 83 | 84 | name TEXT NOT NULL, |
| 85 | map TEXT NOT NULL DEFAULT 'de_forge', | |
| 84 | 86 | kills INTEGER NOT NULL, |
| 85 | 87 | deaths INTEGER NOT NULL, |
| 86 | 88 | headshots INTEGER NOT NULL, |
| @@ -110,6 +112,10 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 110 | 112 | db.exec("ALTER TABLE match ADD COLUMN walked_out INTEGER NOT NULL DEFAULT 0"); |
| 111 | 113 | if (columns.includes("left")) db.exec("UPDATE match SET walked_out = left"); |
| 112 | 114 | } |
| 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'"); | |
| 113 | 119 | db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)"); |
| 114 | 120 | return db; |
| 115 | 121 | } |
| @@ -124,10 +130,10 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 124 | 130 | const existing = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(result.matchId); |
| 125 | 131 | if (existing !== null && existing.identity !== writer.identity) return false; |
| 126 | 132 | 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) | |
| 129 | 135 | 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, | |
| 131 | 137 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, |
| 132 | 138 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, |
| 133 | 139 | -- 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 | ||
| 137 | 143 | finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out), |
| 138 | 144 | seconds = excluded.seconds, updated_at = datetime('now')`, |
| 139 | 145 | ).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, | |
| 142 | 148 | result.left ? 1 : 0, result.seconds, |
| 143 | 149 | ); |
| 144 | 150 | return true; |
| @@ -146,7 +152,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 146 | 152 | |
| 147 | 153 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| 148 | 154 | 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, | |
| 150 | 156 | score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL} |
| 151 | 157 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 152 | 158 | ).all(limit); |
src/pages/game.test.ts +22
| @@ -6,6 +6,7 @@ import { openDatabase, saveMatch, standings } from "../db.ts"; | ||
| 6 | 6 | import { credit } from "../player.ts"; |
| 7 | 7 | import type { Result } from "../result.ts"; |
| 8 | 8 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; | |
| 9 | 10 | import { gamePage, statsHtml } from "./game.ts"; |
| 10 | 11 | |
| 11 | 12 | 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", () => { | ||
| 55 | 56 | |
| 56 | 57 | const matchResult = (over: Partial<Result> = {}): Result => ({ |
| 57 | 58 | matchId: crypto.randomUUID(), |
| 59 | map: "de_forge", | |
| 58 | 60 | rounds: 5, scoreMine: 5, scoreTheirs: 1, |
| 59 | 61 | kills: 12, deaths: 3, headshots: 5, damage: 1500, seconds: 500, finished: true, left: false, |
| 60 | 62 | ...over, |
| @@ -118,6 +120,26 @@ test("the crosshair controls are in the menu and in the pause overlay, one set e | ||
| 118 | 120 | expect(new Set(ids).size).toBe(ids.length); |
| 119 | 121 | }); |
| 120 | 122 | |
| 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 | ||
| 121 | 143 | test("a crosshair field can only offer the numbers the parser would keep", () => { |
| 122 | 144 | const panel = element(gamePage(db, options), "menu"); |
| 123 | 145 | for (const [field, limit] of Object.entries(CROSSHAIR_LIMITS)) { |
src/pages/game.ts +6 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; | |
| 9 | 10 | import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts"; |
| 10 | 11 | import { isGuest, type Player } from "../player.ts"; |
| 11 | 12 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; |
| @@ -36,7 +37,7 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 36 | 37 | const feed = recent |
| 37 | 38 | .map( |
| 38 | 39 | (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> ` + | |
| 40 | 41 | `<time>${escape(m.updated_at)}</time></li>`, |
| 41 | 42 | ) |
| 42 | 43 | .join(""); |
| @@ -266,6 +267,9 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 266 | 267 | <option value="normal" selected>Normal</option> |
| 267 | 268 | <option value="hard">Hard</option> |
| 268 | 269 | </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> | |
| 269 | 273 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 270 | 274 | </div> |
| 271 | 275 | <div class="row"><button type="button" class="primary" id="play">Play</button> |
| @@ -305,7 +309,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 305 | 309 | <button type="button" id="mute" aria-pressed="false">Mute</button> |
| 306 | 310 | <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button> |
| 307 | 311 | </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> | |
| 309 | 313 | <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details> |
| 310 | 314 | ${crosshairControls("pause")} |
| 311 | 315 | <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"; | ||
| 5 | 5 | |
| 6 | 6 | const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ |
| 7 | 7 | matchId: crypto.randomUUID(), |
| 8 | map: "de_forge", | |
| 8 | 9 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 9 | 10 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 10 | 11 | ...over, |
| @@ -42,6 +43,22 @@ test("nonsense is refused rather than clamped", () => { | ||
| 42 | 43 | expect(parseResult(undefined)).toBe(null); |
| 43 | 44 | }); |
| 44 | 45 | |
| 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 | ||
| 45 | 62 | test("a match walked out of parses as left, not finished", () => { |
| 46 | 63 | const parsed = parseResult(result({ finished: false, left: true })); |
| 47 | 64 | expect(parsed?.left).toBe(true); |
src/result.ts +12
| @@ -5,8 +5,11 @@ | ||
| 5 | 5 | * refuse anything else outright. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./client/map.ts"; | |
| 9 | ||
| 8 | 10 | export type Result = { |
| 9 | 11 | matchId: string; |
| 12 | map: MapName; | |
| 10 | 13 | rounds: number; |
| 11 | 14 | scoreMine: number; |
| 12 | 15 | scoreTheirs: number; |
| @@ -47,6 +50,14 @@ export function parseResult(value: unknown): Result | null { | ||
| 47 | 50 | const body = value as Record<string, unknown>; |
| 48 | 51 | const matchId = typeof body.matchId === "string" ? body.matchId : ""; |
| 49 | 52 | 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 | |
| 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 The invariant is now a test. Types carry through. Re-checked at dec01ac, not assumed. 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 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 Approved — final verdict for head Re-verified at this head, not carried over: Against the bar, strictly:
All three notes from my first pass were answered in the second round: missing |
||
| 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; | |
| 50 | 61 | const rounds = integer(body.rounds, MAX_ROUNDS); |
| 51 | 62 | const scoreMine = integer(body.scoreMine, MAX_ROUNDS); |
| 52 | 63 | const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS); |
| @@ -75,6 +86,7 @@ export function parseResult(value: unknown): Result | null { | ||
| 75 | 86 | if (finished && left) return null; |
| 76 | 87 | return { |
| 77 | 88 | matchId, |
| 89 | map, | |
| 78 | 90 | rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds, |
| 79 | 91 | finished, left, |
| 80 | 92 | }; |
src/server.test.ts +1
| @@ -24,6 +24,7 @@ const forge = (id: string, name: string, key = KEY): Record<string, string> => { | ||
| 24 | 24 | const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ |
| 25 | 25 | matchId: crypto.randomUUID(), |
| 26 | 26 | name: "tester", |
| 27 | map: "de_forge", | |
| 27 | 28 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 28 | 29 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, |
| 29 | 30 | ...over, |
src/tty.test.ts +10 −1
| @@ -12,7 +12,8 @@ const standing = (over: Partial<Standing> = {}): Standing => ({ | ||
| 12 | 12 | }); |
| 13 | 13 | |
| 14 | 14 | 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, | |
| 16 | 17 | score_mine: 3, score_theirs: 2, finished: 1, walked_out: 0, seconds: 420, updated_at: "2024-01-01 12:00:00", |
| 17 | 18 | state: "finished", ...over, |
| 18 | 19 | }); |
| @@ -125,6 +126,13 @@ test("the matches tab renders every row, and says so when there are none", () => | ||
| 125 | 126 | expect(withRows.some((l) => l.includes("Vesper"))).toBe(true); |
| 126 | 127 | }); |
| 127 | 128 | |
| 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 | ||
| 128 | 136 | test("the policy tab renders with an empty database, since it does not read one", () => { |
| 129 | 137 | const lines = render({ ...initialState, tab: "policy" }, data({ standings: [], matches: [], total: 0 })); |
| 130 | 138 | 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", () => { | ||
| 196 | 204 | |
| 197 | 205 | const result = (over: Partial<Result> = {}): Result => ({ |
| 198 | 206 | matchId: crypto.randomUUID(), |
| 207 | map: "de_forge", | |
| 199 | 208 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 200 | 209 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false, |
| 201 | 210 | ...over, |
src/tty.ts +2 −2
| @@ -110,11 +110,11 @@ export function render(state: State, data: Data): string[] { | ||
| 110 | 110 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 111 | 111 | }); |
| 112 | 112 | } 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`); | |
| 114 | 114 | if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); |
| 115 | 115 | data.matches.forEach((m, i) => { |
| 116 | 116 | 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)}` + | |
| 118 | 118 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}${status}`; |
| 119 | 119 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 120 | 120 | }); |
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:
settings.tsguardsmapprecisely as it guardsdifficulty(junk, a number, an array, and a name that was valid last week all fall back toDEFAULT_MAPand never reachcreateWorld);parseResultaccepts only a name fromMAP_NAMES, so an unknown map is a 422 and never a row;mapForusesObject.hasOwn, so"constructor"and friends get the default rather than a prototype member. The posted name isworld.map.namefrom the built map, not the raw setting — the client cannot hand the server a name it did not first validate twice.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.ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge', guarded oncolumns.includes("map"), noUPDATEover existing rows, and a test that opens a pre-catalogue schema and readsde_forgeback. That was the one irreversible part of the issue and it is done right. SQL stays parameterised.mapheader, tested on both sides. Nothing private moved, and gating is untouched.bun run checkon the head myself:tsc --noEmitclean, 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 upmapandpause-mapautomatically, which is the bullet the issue wanted.de_forgeon desktop, the row is unbroken on phone, and the recent-matches line readsde_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:
mapis now a hard 422. A tab holding the previous bundle across a deploy posts withoutmapand loses its in-flight result.lefthandles that case deliberately ("which is also what an older client sends");mapcould default a missing field toDEFAULT_MAPwhile still refusing a wrong name, and keep both the issue's rule and that precedent.MapDef.namemust agree or every post 422s. They do today (DE_FORGE_LAYOUT.nameis"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 inmap.test.ts:for (const n of MAP_NAMES) expect(MAPS[n].name).toBe(n).isMapName(value): value is stringnarrows tostring, soSettings.mapis a barestringwheredifficultyis aDifficulty;value is MapNamewould carry the type through. Andexport type { MapDef, MapName }inworld.tsis unused —renderer.tsandhud.tsimport those frommap.tsdirectly.