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

forked from trevato/cs

The board can be read one map at a time: the map a result carries becomes the comparison it was recorded for #411

Closed opened this
Closed without merging

torque/cs-engine:torque/242-the-board-can-be-read-one-map-at-a-time → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
5 files changed +333 −27
src/db.test.ts +101 −4
@@ -10,8 +10,8 @@ import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, type MatchRow, openDatabase, type Player, recentMatches,
14 recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
13 boardMaps, dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, type MatchRow, openDatabase, type Player, playersOn,
14 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
1515 } from "./db.ts";
1616 import type { RoundPlan } from "./plan/plan.ts";
1717 import { statsHtml } from "./pages/game.ts";
@@ -137,8 +137,8 @@ test("a match records the map it was played on, and a generated one comes back w
137137 const row = recentMatches(db).find((m) => m.id === id);
138138 expect(row?.map).toBe("gen_1z141z3");
139139 expect(row?.kills).toBe(11);
140 // The standings are what they were: this issue adds the fact, it does not
141 // split the board by it.
140 // The board of every map still has both of them in one table; the board of
141 // one map is #242's, below.
142142 expect(standings(db).length).toBe(2);
143143 });
144144
@@ -247,6 +247,103 @@ test("standings reads the board, not the match table: 20,000 matches answer in u
247247 expect(top[0]?.kills).toBe(39);
248248 });
249249
250 test("two results on two maps are two boards of one map each, and one board of every map", () => {
251 const db = openDatabase(dir());
252 saveMatch(db, player("u-1", "kestrel"), result({ map: "de_forge", kills: 9 }));
253 saveMatch(db, player("u-2", "falcon"), result({ map: "gen_ir1", kills: 20 }));
254 expect(standings(db).map((r) => r.name)).toEqual(["falcon", "kestrel"]);
255 // de_forge's board leaves out a generated map's rows, and the other way round.
256 expect(standings(db, 10, "de_forge").map((r) => r.name)).toEqual(["kestrel"]);
257 expect(standings(db, 10, "gen_ir1").map((r) => r.name)).toEqual(["falcon"]);
258 expect(standings(db, 10, "gen_zz")).toEqual([]);
259
260 // One player on two maps is one row on each board and their sum on the whole.
261 saveMatch(db, player("u-1", "kestrel"), result({ map: "gen_ir1", kills: 4 }));
262 expect(standings(db, 10, "de_forge")).toMatchObject([{ name: "kestrel", matches: 1, kills: 9 }]);
263 expect(standings(db, 10, "gen_ir1").find((r) => r.name === "kestrel")).toMatchObject({ matches: 1, kills: 4 });
264 expect(standings(db).find((r) => r.name === "kestrel")).toMatchObject({ matches: 2, kills: 13 });
265 expect(playersOn(db, "gen_ir1")).toBe(2);
266 expect(playersOn(db)).toBe(2);
267 });
268
269 test("a later post that names another map moves the match to that map's board", () => {
270 const db = openDatabase(dir());
271 const id = crypto.randomUUID();
272 saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, map: "de_forge" }));
273 saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, map: "gen_ir1" }));
274 expect(standings(db, 10, "de_forge")).toEqual([]);
275 expect(standings(db, 10, "gen_ir1").length).toBe(1);
276 expect(deleteMatch(db, id)).toBe(true);
277 expect(standings(db, 10, "gen_ir1")).toEqual([]);
278 });
279
280 test("a standing table from before the map was a column is built again, per map, from the matches", () => {
281 const home = dir();
282 const db0 = openDatabase(home);
283 saveMatch(db0, player("u-1", "kestrel"), result({ map: "gen_ir1" }));
284 saveMatch(db0, player("u-1", "kestrel"), result({ map: "de_forge" }));
285 // The board as it was before #242: one row a player, no map.
286 db0.exec("DROP INDEX standing_by_rank");
287 db0.exec("DROP TABLE standing");
288 db0.exec(`CREATE TABLE standing (user_id TEXT NOT NULL, name TEXT NOT NULL, matches INTEGER NOT NULL,
289 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, damage INTEGER NOT NULL,
290 wins INTEGER NOT NULL, rounds INTEGER NOT NULL, PRIMARY KEY (user_id, name))`);
291 db0.exec("INSERT INTO standing VALUES ('u-1', 'kestrel', 2, 18, 8, 6, 2400, 2, 10)");
292 db0.close();
293
294 const db = openDatabase(home);
295 expect(standings(db)).toMatchObject([{ name: "kestrel", matches: 2 }]);
296 expect(standings(db, 10, "gen_ir1")).toMatchObject([{ name: "kestrel", matches: 1 }]);
297 expect(standings(db, 10, "de_forge")).toMatchObject([{ name: "kestrel", matches: 1 }]);
298 // And a second open leaves the rebuilt table as it is.
299 expect(standings(openDatabase(home), 10, "de_forge")[0]?.matches).toBe(1);
300 });
301
302 test("the maps listed are generated maps with enough players on them, the most played first, and no more than asked", () => {
303 const db = openDatabase(dir());
304 // gen_ir1: three players. gen_zz: two. gen_aa: one, which is not a comparison.
305 for (const [who, map] of [["u-1", "gen_ir1"], ["u-2", "gen_ir1"], ["u-3", "gen_ir1"], ["u-1", "gen_zz"],
306 ["u-2", "gen_zz"], ["u-1", "gen_aa"], ["u-1", "de_forge"], ["u-2", "de_forge"]] as const)
307 saveMatch(db, player(who, who), result({ map }));
308 expect(boardMaps(db)).toEqual([
309 { map: "gen_ir1", players: 3, matches: 3 },
310 { map: "gen_zz", players: 2, matches: 2 },
311 ]);
312 expect(boardMaps(db, 2, 1).map((m) => m.map)).toEqual(["gen_ir1"]);
313 expect(boardMaps(db, 1).map((m) => m.map)).toEqual(["gen_ir1", "gen_zz", "gen_aa"]);
314 });
315
316 test("the board on one map is a range of the index: 20,000 matches on 200 maps answer in under 10 ms", () => {
317 const db = openDatabase(dir());
318 db.transaction(() => {
319 for (let i = 0; i < 20_000; i++) {
320 const map = i % 2 === 0 ? "de_forge" : `gen_${(i % 200).toString(36)}z`;
321 saveMatch(db, player(`u-${i % 2_000}`, `player ${i % 2_000}`), result({ map, kills: i % 40 }));
322 }
323 })();
324 const plan = (sql: string): string =>
325 db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join(" / ");
326 // No sort step: the rows come off `standing_by_rank` already in board order.
327 expect(plan(`SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds FROM standing WHERE map = 'de_forge' ORDER BY kills DESC, damage DESC, user_id, name LIMIT 10`))
328 .toBe("SEARCH standing USING INDEX standing_by_rank (map=?)");
329 const median = (read: () => unknown): number =>
330 Array.from({ length: 7 }, () => {
331 const start = performance.now();
332 read();
333 return performance.now() - start;
334 }).sort((a, b) => a - b)[3] as number;
335 standings(db, 10, "de_forge");
336 const onForge = median(() => standings(db, 10, "de_forge"));
337 const onGen = median(() => standings(db, 10, "gen_1z"));
338 const listed = median(() => boardMaps(db));
339 console.log(` board on de_forge ${onForge.toFixed(3)} ms, on a generated map ${onGen.toFixed(3)} ms, ` +
340 `the maps list ${listed.toFixed(3)} ms — ${db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM standing").get()?.n} standing rows`);
341 expect(onForge).toBeLessThan(10);
342 expect(onGen).toBeLessThan(10);
343 expect(listed).toBeLessThan(10);
344 expect(standings(db, 10, "de_forge")[0]?.kills).toBeGreaterThan(0);
345 });
346
250347 // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the
251348 // second, in the format `datetime('now')` hands back.
252349 const NOW = Date.parse("2026-09-21T12:00:00Z");
src/db.ts +88 −15
@@ -24,6 +24,7 @@ import { mkdirSync } from "node:fs";
2424 import { join } from "node:path";
2525 import { dayFiled } from "./daily.ts";
2626 import { parsePlan, type RoundPlan } from "./plan/plan.ts";
27 import type { PlayedMap } from "./mapname.ts";
2728 import type { Result } from "./result.ts";
2829 import type { RangeRun } from "./run.ts";
2930
@@ -136,11 +137,21 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
136137 // matches, so `standings` is a sorted read of a table the size of the player
137138 // count. A database written before this table existed builds it once, with
138139 // the GROUP BY `standings` used to run on every read, and never pays it again.
139 const hasStanding = db.query<{ name: string }, []>(
140 "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'standing'",
141 ).get() !== null;
142 if (!hasStanding) {
140 //
141 // Each player has one row for every map (`map` = `EVERY_MAP`, "") and one
142 // for each map they have a result on (#242), so the board on one map is the
143 // same sorted read as the whole one, narrowed by the first column of its
144 // index. A table built before the map was a column is dropped and built
145 // again from `match`, which holds every number it ever had: it is a cache of
146 // that table, not a record of its own.
147 const standingColumns = db.query<{ name: string }, []>("PRAGMA table_info(standing)").all().map((c) => c.name);
148 if (standingColumns.length > 0 && !standingColumns.includes("map")) {
149 db.exec("DROP INDEX IF EXISTS standing_by_rank");
150 db.exec("DROP TABLE standing");
151 }
152 if (!standingColumns.includes("map")) {
143153 db.exec(`CREATE TABLE standing (
154 map TEXT NOT NULL,
144155 user_id TEXT NOT NULL,
145156 name TEXT NOT NULL,
146157 matches INTEGER NOT NULL,
@@ -150,15 +161,17 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
150161 damage INTEGER NOT NULL,
151162 wins INTEGER NOT NULL,
152163 rounds INTEGER NOT NULL,
153 PRIMARY KEY (user_id, name)
164 PRIMARY KEY (user_id, name, map)
154165 )`);
155 db.exec(`INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
156 SELECT ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`);
166 db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
167 SELECT '', ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`);
168 db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
169 SELECT map, ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY map, user_id, name`);
157170 }
158171 // `restand` reads one player's matches on every write; without this it reads
159172 // everyone's, and the write grows with the table the board was built to spare.
160173 db.exec("CREATE INDEX IF NOT EXISTS match_by_player ON match (user_id, name)");
161 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC, user_id, name)");
174 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (map, kills DESC, damage DESC, user_id, name)");
162175 // The plans a planned match was played to (#238): one row per round of a
163176 // match, keyed by the match id and the situation the round was asked in
164177 // (`keyOf`), holding the plan it was given. Additive like the run table: a
@@ -185,10 +198,17 @@ const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(
185198 * (a delete, or only rounds-0 rows) has no row.
186199 */
187200 function restand(db: Database, userId: string, name: string): void {
201 // By player and not by map: a later post of a match may carry another map
202 // than the one it first did, so every map row of this player is redone.
188203 db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name);
189204 db.query(
190 `INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
191 SELECT ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`,
205 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
206 SELECT '', ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`,
207 ).run(userId, name);
208 db.query(
209 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
210 SELECT map, ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0
211 GROUP BY map, user_id, name`,
192212 ).run(userId, name);
193213 }
194214
@@ -319,24 +339,77 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
319339 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
320340 ).all(limit);
321341
342 /**
343 * The key of the rows in `standing` that count every map at once. Not a map
344 * name — `playedOn` in `src/mapname.ts` refuses the empty string — so no map
345 * can ever be asked for and get the combined rows back.
346 */
347 export const EVERY_MAP = "";
348
322349 /**
323350 * The table people actually look at: one row a player, best first. A player is
324351 * a forge identity, or — for the guests, who are nobody in particular — the
325352 * name they typed. Grouping by the name alone is how a guest calling
326353 * themselves someone else lands in that someone's row.
327354 *
355 * `map` narrows it to the results played on one map (#242), so a run of
356 * de_forge and a run of `gen_1z3f` are two comparisons and not one sum; absent
357 * is every map, the board as it has always been. It is a `PlayedMap` or
358 * nothing because the caller settled it at the boundary.
359 *
328360 * The rows are kept in `standing` by every write (`restand`), so this read
329 * costs the size of the board, not the size of the match table behind it.
361 * costs the size of the board, not the size of the match table behind it —
362 * `standing_by_rank` is `(map, kills DESC, damage DESC, …)`, so either board
363 * is one range of the index read in order and stopped at the limit.
330364 */
331 export const standings = (db: Database, limit = 10): Standing[] =>
332 db.query<Standing, [number]>(
365 export const standings = (db: Database, limit = 10, map: PlayedMap | null = null): Standing[] =>
366 db.query<Standing, [string, number]>(
333367 `SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds
334368 FROM standing
369 WHERE map = ?1
335370 -- A tie is broken the way the old GROUP BY broke it, by player, said out
336371 -- loud here so the order of equal rows is the rule and not the insert order.
337372 ORDER BY kills DESC, damage DESC, user_id, name
338 LIMIT ?1`,
339 ).all(limit);
373 LIMIT ?2`,
374 ).all(map ?? EVERY_MAP, limit);
375
376 /** How many players have ever posted a result on a map, from the board's own rows. */
377 export const playersOn = (db: Database, map: PlayedMap | null = null): number =>
378 db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM standing WHERE map = ?1").get(map ?? EVERY_MAP)?.n ?? 0;
379
380 /** A map somebody has results on, and how many: one link of the board's map list. */
381 export type BoardMap = { map: PlayedMap; players: number; matches: number };
382
383 /**
384 * A map is listed once this many players have a result on it: a board of one
385 * is a personal best, not a comparison. de_forge is listed whatever its count.
386 */
387 export const LISTED_PLAYERS = 2;
388
389 /**
390 * At most this many generated maps are listed. Every match on "a new map
391 * every match" makes one, so the list is the most-played few and not all.
392 */
393 export const LISTED_MAPS = 6;
394
395 /**
396 * The maps worth a board of their own: generated maps with at least
397 * `LISTED_PLAYERS` players on them, the most players first, then the most
398 * matches, then by name so equal maps keep one order. de_forge is left out here
399 * because the page always offers it; the list is what is *added* to it.
400 *
401 * It groups `standing`, which is players × maps they played rather than every
402 * match, and the `map` prefix of `standing_by_rank` hands it the rows already
403 * in groups.
404 */
405 export const boardMaps = (db: Database, min = LISTED_PLAYERS, limit = LISTED_MAPS): BoardMap[] =>
406 db.query<BoardMap, [number, number]>(
407 `SELECT map, COUNT(*) AS players, SUM(matches) AS matches FROM standing
408 WHERE map NOT IN ('', 'de_forge')
409 GROUP BY map HAVING COUNT(*) >= ?1
410 ORDER BY players DESC, matches DESC, map
411 LIMIT ?2`,
412 ).all(min, limit);
340413
341414 /** One player's day: their best run at the match of the day, and how many they had. */
342415 export type DayRun = {
src/pages/game.ts +82 −7
@@ -18,14 +18,17 @@
1818
1919 import type { Database } from "bun:sqlite";
2020 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
21 import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
21 import {
22 boardMaps, dayBoard, displayName, headshotShare, LISTED_MAPS, LISTED_PLAYERS, liveness, type Liveness, playersOn, ratio,
23 recentMatches, standings,
24 } from "../db.ts";
2225 import { COMPETITIVE, SHORT } from "../client/match.ts";
2326 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
2427 import { CS_MOVE } from "../client/movement.ts";
2528 import { NOTHING_POSTED } from "../client/pending.ts";
2629 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
2730 import { CARD, type Card } from "../client/weights.ts";
28 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
31 import { mapFrom, mapLink, type MapChoice, type PlayedMap } from "../mapname.ts";
2932 import { LONGEST_PASTE } from "../matchlink.ts";
3033 import { roomLink } from "../roomlink.ts";
3134 import type { Who } from "../who.ts";
@@ -79,6 +82,55 @@ ${board.length === 0
7982 <p class="hint">Ranked by ${escape(RANKED_BY)}. ${escape(NOT_A_LADDER)}</p>`;
8083 }
8184
85 /**
86 * The link that shows the all-time board on one map, or on every map for null.
87 * The map is a `PlayedMap`, which `src/mapname.ts` only hands out in this
88 * app's own spelling, so nothing a visitor typed is copied into the href.
89 */
90 export const boardLink = (map: PlayedMap | null): string => (map === null ? "/#stats" : `/?board=${map}#stats`);
91
92 /** What the page says when `?board=` was there and was not a map. */
93 export const BAD_BOARD = "That link's board is not a map — this is every map instead.";
94
95 /**
96 * Which boards there are to read: every map, de_forge, and the generated maps
97 * `boardMaps` lists — plus the one being read, when a link named a map too new
98 * to be listed, so the page never shows a board it offers no way back to. The
99 * one on screen is marked `aria-current` rather than only coloured.
100 */
101 function boardsNav(db: Database, board: PlayedMap | null): string {
102 const listed: (PlayedMap | null)[] = [null, "de_forge", ...boardMaps(db).map((m) => m.map)];
103 if (board !== null && !listed.includes(board)) listed.push(board);
104 const links = listed
105 .map((map) => `<a class="share" href="${escape(boardLink(map))}"${map === board ? ` aria-current="page"` : ""}>${
106 map === null ? "every map" : escape(map)
107 }</a>`)
108 .join(" ");
109 return `<nav class="boards" aria-label="The all-time board, one map at a time">${links}</nav>
110 <p class="hint">Listed: de_forge, and the generated maps at least ${num(LISTED_PLAYERS)} players have a result on — the
111 ${num(LISTED_MAPS)} with the most players. A generated map is new every match, so most have one player and are not a
112 comparison; any map's board is still one link away, <code>/?board=</code> and its name.</p>`;
113 }
114
115 /**
116 * The sentence above the all-time table that says which board it is, so a
117 * board on one map is never read as the whole one — and says who is missing
118 * from it and why, because a filter that hides people without saying so is
119 * worse than no filter.
120 */
121 function boardSaid(db: Database, board: PlayedMap | null, badBoard: boolean): string {
122 if (board === null) {
123 return `<p class="hint" id="boardsaid">${badBoard ? `${escape(BAD_BOARD)} ` : ""}Every result on every map, added
124 together: de_forge and every generated map in one table. The links above read it one map at a time.</p>`;
125 }
126 const players = playersOn(db, board);
127 const map = `<a href="${escape(mapLink(board))}">${escape(board)}</a>`;
128 return `<p class="hint" id="boardsaid">Only results played on ${map}: ${num(players)} ${
129 players === 1 ? "player has" : "players have"
130 } one. Anybody who has not played ${escape(board)} is not on this board for that reason alone —
131 <a href="${escape(boardLink(null))}">every map</a> has everyone.</p>`;
132 }
133
82134 /**
83135 * The leaderboard block: today's board, then the all-time one, then the feed.
84136 * Also what POST /api/match answers with, so a run that lands moves the day's
@@ -87,9 +139,15 @@ ${board.length === 0
87139 * `now` is the moment it is rendered at, and it decides two things: which day
88140 * the board is of, and whether a match in the feed still reads as live. A test
89141 * passes its own rather than waiting for midnight or for ten minutes.
142 *
143 * `board` is the map the all-time table is narrowed to (#242), settled from
144 * `?board=` in `src/server.ts`; null is every map, and `badBoard` says the
145 * link asked for something that was not a map and got every map instead. The
146 * day's board and the feed are not narrowed: the day is one map already, and
147 * the feed is what happened, whatever it was played on.
90148 */
91 export function statsHtml(db: Database, now = Date.now()): string {
92 const table = standings(db, 10);
149 export function statsHtml(db: Database, now = Date.now(), board: PlayedMap | null = null, badBoard = false): string {
150 const table = standings(db, 10, board);
93151 const recent = recentMatches(db, 8);
94152 const rows = table
95153 .map(
@@ -109,11 +167,17 @@ export function statsHtml(db: Database, now = Date.now()): string {
109167 `<time>${escape(m.updated_at)}</time></li>`,
110168 )
111169 .join("");
170 const empty = board === null
171 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
172 : `<p class="empty">Nobody has a result on ${escape(board)} yet. <a href="${escape(mapLink(board))}">Play it</a> and
173 yours is the first.</p>`;
112174 return `<h2>Leaderboard</h2>
113175 ${todayHtml(db, dailyOn(now))}
114 <h3>All time</h3>
176 <h3>All time${board === null ? "" : ` on ${escape(board)}`}</h3>
177 ${boardsNav(db, board)}
178 ${boardSaid(db, board, badBoard)}
115179 ${table.length === 0
116 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
180 ? empty
117181 : `<table><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody></table>`}
118182 <h3>Recent matches</h3>
119183 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
@@ -140,6 +204,13 @@ export type Asked = {
140204 * text of its own to put on this page. Absent is null.
141205 */
142206 room?: string | null;
207 /**
208 * The map `?board=` narrows the all-time table to: a map `playedOn` accepted,
209 * or null for every map. Absent is null.
210 */
211 board?: PlayedMap | null;
212 /** A `?board=` was there and was not a map: every map, and a sentence. */
213 badBoard?: boolean;
143214 };
144215
145216 /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */
@@ -243,6 +314,10 @@ a.share[hidden] { display:none; }
243314 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
244315 border-radius:.3rem; background:#161b24; }
245316 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
317 /* The maps the all-time board can be read on: share-sized links that wrap on a
318 phone, and the one on screen told apart by more than its colour. */
319 nav.boards { display:flex; flex-wrap:wrap; gap:.4rem; margin:.35rem 0; }
320 nav.boards a[aria-current] { background:#27303f; border-color:var(--ct); font-weight:600; }
246321 /* The buy panel's shelf: the column src/client/buy.ts fills, sized here so the
247322 grid inside it has a width to be a fraction of. It takes the focus when the
248323 panel opens and is not a control, so the ring is drawn on it and nothing
@@ -630,7 +705,7 @@ export function gamePage(
630705 }),
631706 sectionsHtml: `${thisMatch(asked)}
632707
633 <section id="stats">${statsHtml(db, now)}</section>
708 <section id="stats">${statsHtml(db, now, asked.board ?? null, asked.badBoard ?? false)}</section>
634709 ${SAVED_LINE}
635710
636711 <section>
src/server.test.ts +53 −1
@@ -11,7 +11,7 @@ import { GAMES } from "./games.ts";
1111 import { matchOf, playKey } from "./matchid.ts";
1212 import { LONGEST_PASTE } from "./matchlink.ts";
1313 import { NOTHING_POSTED } from "./client/pending.ts";
14 import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts";
14 import { BAD_BOARD, BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts";
1515 import { WEAPONS_HREF, WEAPONS_SECTION } from "./pages/weapons.ts";
1616 import { ENGINE_HREF } from "./pages/engine.ts";
1717 import { archiveDir } from "./archive.ts";
@@ -1978,3 +1978,55 @@ test("the menu says who a room's match counts for before anyone joins one", asyn
19781978 const home = await (await fetch(base + "/")).text();
19791979 expect(home).toContain("still seated — leave before the end and it is not counted for you");
19801980 });
1981
1982 /** The all-time table and what is said around it, without the feed, which names every map. */
1983 const allTime = (body: string): string => body.slice(body.indexOf("<h3>All time"), body.indexOf("<h3>Recent matches"));
1984
1985 // Last in the file on purpose: the tests above read one shared database's top
1986 // ten and last eight, and these add rows to it.
1987 test("`?board=` reads the all-time board on one map, and the link that does it is on the page", async () => {
1988 // Two maps nobody else in this file plays, and no kills, so the combined
1989 // board the other tests here read keeps the rows they expect in its ten.
1990 const cookie = "cs_session=" + "d".repeat(32);
1991 const quiet = { kills: 0, headshots: 0, damage: 0 };
1992 expect((await post(result({ name: "onab", map: "gen_ab12", ...quiet }), cookie)).status).toBe(200);
1993 expect((await post(result({ name: "oncd", map: "gen_cd34", ...quiet }), cookie)).status).toBe(200);
1994
1995 const onAb = allTime(await (await fetch(`${base}/?board=gen_ab12`)).text());
1996 expect(onAb).toContain("<h3>All time on gen_ab12</h3>");
1997 expect(onAb).toContain("onab (guest)");
1998 expect(onAb).not.toContain("oncd");
1999 // Its own link is on the page and marked as the one being read, and the map
2000 // it names is the link that plays it.
2001 expect(onAb).toContain(`href="/?board=gen_ab12#stats" aria-current="page">gen_ab12</a>`);
2002 expect(onAb).toContain(`Only results played on <a href="/?map=gen_ab12">gen_ab12</a>`);
2003
2004 const onCd = allTime(await (await fetch(`${base}/?board=gen_cd34`)).text());
2005 expect(onCd).toContain("oncd (guest)");
2006 expect(onCd).not.toContain("onab");
2007 // And de_forge's board has neither: a generated map's rows are not its.
2008 const onForge = allTime(await (await fetch(`${base}/?board=de_forge`)).text());
2009 expect(onForge).not.toContain("onab");
2010 expect(onForge).not.toContain("oncd");
2011 });
2012
2013 test("a `?board=` that is not a map is the whole board and a sentence, never an error page", async () => {
2014 for (const junk of ["<script>alert(1)</script>", "gen_0ya", "generated", "de_moon", "GEN_YA", ""]) {
2015 const response = await fetch(`${base}/?board=${encodeURIComponent(junk)}`);
2016 expect(response.status).toBe(200);
2017 const body = await response.text();
2018 expect(body).toContain(escape(BAD_BOARD));
2019 expect(allTime(body)).toContain("<h3>All time</h3>");
2020 expect(allTime(body)).toContain(`href="/#stats" aria-current="page">every map</a>`);
2021 // Nothing of what was asked for came back: the words a page says anyway
2022 // ("generated") are not evidence either way, so the ones checked are ones
2023 // no page of this app says.
2024 if (["<script>alert(1)</script>", "gen_0ya", "de_moon", "GEN_YA"].includes(junk)) {
2025 expect(body).not.toContain(junk);
2026 expect(body).not.toContain(escape(junk));
2027 }
2028 expect(body).not.toContain("alert(1)");
2029 }
2030 // And no `?board=` at all is the whole board with no complaint.
2031 expect(await (await fetch(`${base}/`)).text()).not.toContain(escape(BAD_BOARD));
2032 });
src/server.ts +9
@@ -549,6 +549,11 @@ type Render = { extraStyle?: string; draw: (request: Request, script: string, li
549549 * `?room=` is the third, and the quietest: a code `roomCodeFrom` accepts fills
550550 * the menu's room field, and anything else is as if it were never there.
551551 *
552 * `?board=` is the fourth, and it is not `?map=`: one says what to play and the
553 * other what to read, and a link can ask for both. It is a map somebody played
554 * on or nothing — `playedOn`, the same grammar a stored result is held to —
555 * and anything else is the whole board and a sentence saying so.
556 *
552557 * All belong to this game and to no other: `?map=` is read here, in the
553558 * shooter's own renderer, and the range and the example never see it.
554559 */
@@ -558,6 +563,8 @@ const asked = (request: Request): Asked => {
558563 const match = matchFrom(wanted);
559564 const wantedMap = query.get("map");
560565 const map = mapFrom(wantedMap);
566 const wantedBoard = query.get("board");
567 const board = playedOn(wantedBoard);
561568 return {
562569 match,
563570 badMatch: wanted !== null && match === null,
@@ -565,6 +572,8 @@ const asked = (request: Request): Asked => {
565572 badMap: wantedMap !== null && wantedMap !== map,
566573 // A room is a code or nothing, and never a sentence: see `Asked.room`.
567574 room: roomCodeFrom(query.get("room")),
575 board,
576 badBoard: wantedBoard !== null && board === null,
568577 };
569578 };
570579

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs