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 #419

Merged opened this

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

Merged

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

  • Merged. torque/242-the-board-can-be-read-one-map-at-a-time was merged into main as 20f4f9d.

Review

Sign in to review this change.
6 files changed +355 −27
README.md +10
@@ -317,6 +317,16 @@ down and hand to somebody who has never played the round it happened in.
317317 The name of the map you are playing, under the stage, is a link to it too.
318318 - **Open it.** `/?map=gen_14u5j6g` preselects that map. `?map=` is the shooter's
319319 parameter and no other page reads it.
320 - **Compare on it.** `/?board=gen_14u5j6g` narrows the all-time leaderboard to
321 results played on that map, and `/?board=de_forge` to the fixed one; no
322 `?board=` is every map added together, and the page says in a sentence which
323 one it is showing. The links above the table are every map, de_forge, and the
324 six generated maps with the most players (at least two each) — a board of one
325 is a personal best, not a comparison. `?board=` goes through the same
326 `src/mapname.ts` grammar as a stored result; anything else is every map and a
327 sentence saying so. The board on one map is one range of the `standing_by_rank`
328 index (`SEARCH standing USING INDEX standing_by_rank (map=?)`, no sort step),
329 about 0.04 ms at 20,000 matches on 200 maps.
320330
321331 A name is a look as well as a layout. The atlas holds nine materials, painted
322332 once at boot; `src/client/looks.ts` writes down eight named sets of them —
src/db.test.ts +114 −3
@@ -10,10 +10,12 @@ 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, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player,
13 boardMaps, dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player,
14 playersOn,
1415 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
1516 } from "./db.ts";
1617 import { KEEP } from "./keep.ts";
18 import type { PlayedMap } from "./mapname.ts";
1719 import type { RoundPlan } from "./plan/plan.ts";
1820 import { statsHtml } from "./pages/game.ts";
1921 import { boardsHtml } from "./pages/range.ts";
@@ -139,8 +141,8 @@ test("a match records the map it was played on, and a generated one comes back w
139141 const row = recentMatches(db).find((m) => m.id === id);
140142 expect(row?.map).toBe("gen_1z141z3");
141143 expect(row?.kills).toBe(11);
142 // The standings are what they were: this issue adds the fact, it does not
143 // split the board by it.
144 // The board of every map still has both of them in one table; the board of
145 // one map is #242's, below.
144146 expect(standings(db).length).toBe(2);
145147 });
146148
@@ -249,6 +251,103 @@ test("standings reads the board, not the match table: 20,000 matches answer in u
249251 expect(top[0]?.kills).toBe(39);
250252 });
251253
254 test("two results on two maps are two boards of one map each, and one board of every map", () => {
255 const db = openDatabase(dir());
256 saveMatch(db, player("u-1", "kestrel"), result({ map: "de_forge", kills: 9 }));
257 saveMatch(db, player("u-2", "falcon"), result({ map: "gen_ir1", kills: 20 }));
258 expect(standings(db).map((r) => r.name)).toEqual(["falcon", "kestrel"]);
259 // de_forge's board leaves out a generated map's rows, and the other way round.
260 expect(standings(db, 10, "de_forge").map((r) => r.name)).toEqual(["kestrel"]);
261 expect(standings(db, 10, "gen_ir1").map((r) => r.name)).toEqual(["falcon"]);
262 expect(standings(db, 10, "gen_zz")).toEqual([]);
263
264 // One player on two maps is one row on each board and their sum on the whole.
265 saveMatch(db, player("u-1", "kestrel"), result({ map: "gen_ir1", kills: 4 }));
266 expect(standings(db, 10, "de_forge")).toMatchObject([{ name: "kestrel", matches: 1, kills: 9 }]);
267 expect(standings(db, 10, "gen_ir1").find((r) => r.name === "kestrel")).toMatchObject({ matches: 1, kills: 4 });
268 expect(standings(db).find((r) => r.name === "kestrel")).toMatchObject({ matches: 2, kills: 13 });
269 expect(playersOn(db, "gen_ir1")).toBe(2);
270 expect(playersOn(db)).toBe(2);
271 });
272
273 test("a later post that names another map moves the match to that map's board", () => {
274 const db = openDatabase(dir());
275 const id = crypto.randomUUID();
276 saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, map: "de_forge" }));
277 saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, map: "gen_ir1" }));
278 expect(standings(db, 10, "de_forge")).toEqual([]);
279 expect(standings(db, 10, "gen_ir1").length).toBe(1);
280 expect(deleteMatch(db, id)).toBe(true);
281 expect(standings(db, 10, "gen_ir1")).toEqual([]);
282 });
283
284 test("a standing table from before the map was a column is built again, per map, from the matches", () => {
285 const home = dir();
286 const db0 = openDatabase(home);
287 saveMatch(db0, player("u-1", "kestrel"), result({ map: "gen_ir1" }));
288 saveMatch(db0, player("u-1", "kestrel"), result({ map: "de_forge" }));
289 // The board as it was before #242: one row a player, no map.
290 db0.exec("DROP INDEX standing_by_rank");
291 db0.exec("DROP TABLE standing");
292 db0.exec(`CREATE TABLE standing (user_id TEXT NOT NULL, name TEXT NOT NULL, matches INTEGER NOT NULL,
293 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, damage INTEGER NOT NULL,
294 wins INTEGER NOT NULL, rounds INTEGER NOT NULL, PRIMARY KEY (user_id, name))`);
295 db0.exec("INSERT INTO standing VALUES ('u-1', 'kestrel', 2, 18, 8, 6, 2400, 2, 10)");
296 db0.close();
297
298 const db = openDatabase(home);
299 expect(standings(db)).toMatchObject([{ name: "kestrel", matches: 2 }]);
300 expect(standings(db, 10, "gen_ir1")).toMatchObject([{ name: "kestrel", matches: 1 }]);
301 expect(standings(db, 10, "de_forge")).toMatchObject([{ name: "kestrel", matches: 1 }]);
302 // And a second open leaves the rebuilt table as it is.
303 expect(standings(openDatabase(home), 10, "de_forge")[0]?.matches).toBe(1);
304 });
305
306 test("the maps listed are generated maps with enough players on them, the most played first, and no more than asked", () => {
307 const db = openDatabase(dir());
308 // gen_ir1: three players. gen_zz: two. gen_aa: one, which is not a comparison.
309 for (const [who, map] of [["u-1", "gen_ir1"], ["u-2", "gen_ir1"], ["u-3", "gen_ir1"], ["u-1", "gen_zz"],
310 ["u-2", "gen_zz"], ["u-1", "gen_aa"], ["u-1", "de_forge"], ["u-2", "de_forge"]] as const)
311 saveMatch(db, player(who, who), result({ map }));
312 expect(boardMaps(db)).toEqual([
313 { map: "gen_ir1", players: 3, matches: 3 },
314 { map: "gen_zz", players: 2, matches: 2 },
315 ]);
316 expect(boardMaps(db, 2, 1).map((m) => m.map)).toEqual(["gen_ir1"]);
317 expect(boardMaps(db, 1).map((m) => m.map)).toEqual(["gen_ir1", "gen_zz", "gen_aa"]);
318 });
319
320 test("the board on one map is a range of the index: 20,000 matches on 200 maps answer in under 10 ms", () => {
321 const db = openDatabase(dir());
322 db.transaction(() => {
323 for (let i = 0; i < 20_000; i++) {
324 const map: PlayedMap = i % 2 === 0 ? "de_forge" : `gen_${(i % 200).toString(36)}z`;
325 saveMatch(db, player(`u-${i % 2_000}`, `player ${i % 2_000}`), result({ map, kills: i % 40 }));
326 }
327 })();
328 const plan = (sql: string): string =>
329 db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join(" / ");
330 // No sort step: the rows come off `standing_by_rank` already in board order.
331 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`))
332 .toBe("SEARCH standing USING INDEX standing_by_rank (map=?)");
333 const median = (read: () => unknown): number =>
334 Array.from({ length: 7 }, () => {
335 const start = performance.now();
336 read();
337 return performance.now() - start;
338 }).sort((a, b) => a - b)[3] as number;
339 standings(db, 10, "de_forge");
340 const onForge = median(() => standings(db, 10, "de_forge"));
341 const onGen = median(() => standings(db, 10, "gen_1z"));
342 const listed = median(() => boardMaps(db));
343 console.log(` board on de_forge ${onForge.toFixed(3)} ms, on a generated map ${onGen.toFixed(3)} ms, ` +
344 `the maps list ${listed.toFixed(3)} ms — ${db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM standing").get()?.n} standing rows`);
345 expect(onForge).toBeLessThan(10);
346 expect(onGen).toBeLessThan(10);
347 expect(listed).toBeLessThan(10);
348 expect(standings(db, 10, "de_forge")[0]?.kills).toBeGreaterThan(0);
349 });
350
252351 // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the
253352 // second, in the format `datetime('now')` hands back.
254353 const NOW = Date.parse("2026-09-21T12:00:00Z");
@@ -656,6 +755,18 @@ test("a database already over the ceiling is brought under it when it opens, not
656755 expect(standings(after).map((s) => [s.name, s.matches, s.kills])).toEqual([["cy", 1, 5], ["ace", 1, 4]]);
657756 });
658757
758 test("a pruned match leaves the board on its map as well as the whole one", () => {
759 const db = openDatabase(dir(), { matches: 2, runs: 2 });
760 saveMatch(db, player("u-1", "ace"), result({ matchId: "a0000000", map: "gen_ir1", kills: 9 }));
761 saveMatch(db, player("u-1", "ace"), result({ matchId: "b0000000", map: "de_forge", kills: 4 }));
762 expect(standings(db, 10, "gen_ir1").map((s) => s.kills)).toEqual([9]);
763 // The third write pushes out the oldest, the only one on gen_ir1.
764 saveMatch(db, player("u-2", "cy"), result({ matchId: "c0000000", map: "de_forge", kills: 2 }));
765 expect(standings(db, 10, "gen_ir1")).toEqual([]);
766 expect(standings(db, 10, "de_forge").map((s) => [s.name, s.kills])).toEqual([["ace", 4], ["cy", 2]]);
767 expect(standings(db).map((s) => [s.name, s.kills])).toEqual([["ace", 4], ["cy", 2]]);
768 });
769
659770 test("nothing is pruned while the table is under its ceiling", () => {
660771 const db = openDatabase(dir());
661772 for (let i = 0; i < 20; i++) saveMatch(db, guest("ace"), result({ matchId: `m${String(i).padStart(7, "0")}` }));
src/db.ts +88 −15
@@ -25,6 +25,7 @@ import { join } from "node:path";
2525 import { dayFiled } from "./daily.ts";
2626 import { KEEP, keepFrom, type Keep } from "./keep.ts";
2727 import { parsePlan, type RoundPlan } from "./plan/plan.ts";
28 import type { PlayedMap } from "./mapname.ts";
2829 import type { Result } from "./result.ts";
2930 import type { RangeRun } from "./run.ts";
3031
@@ -146,11 +147,21 @@ export function openDatabase(
146147 // matches, so `standings` is a sorted read of a table the size of the player
147148 // count. A database written before this table existed builds it once, with
148149 // the GROUP BY `standings` used to run on every read, and never pays it again.
149 const hasStanding = db.query<{ name: string }, []>(
150 "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'standing'",
151 ).get() !== null;
152 if (!hasStanding) {
150 //
151 // Each player has one row for every map (`map` = `EVERY_MAP`, "") and one
152 // for each map they have a result on (#242), so the board on one map is the
153 // same sorted read as the whole one, narrowed by the first column of its
154 // index. A table built before the map was a column is dropped and built
155 // again from `match`, which holds every number it ever had: it is a cache of
156 // that table, not a record of its own.
157 const standingColumns = db.query<{ name: string }, []>("PRAGMA table_info(standing)").all().map((c) => c.name);
158 if (standingColumns.length > 0 && !standingColumns.includes("map")) {
159 db.exec("DROP INDEX IF EXISTS standing_by_rank");
160 db.exec("DROP TABLE standing");
161 }
162 if (!standingColumns.includes("map")) {
153163 db.exec(`CREATE TABLE standing (
164 map TEXT NOT NULL,
154165 user_id TEXT NOT NULL,
155166 name TEXT NOT NULL,
156167 matches INTEGER NOT NULL,
@@ -160,15 +171,17 @@ export function openDatabase(
160171 damage INTEGER NOT NULL,
161172 wins INTEGER NOT NULL,
162173 rounds INTEGER NOT NULL,
163 PRIMARY KEY (user_id, name)
174 PRIMARY KEY (user_id, name, map)
164175 )`);
165 db.exec(`INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
166 SELECT ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`);
176 db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
177 SELECT '', ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`);
178 db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
179 SELECT map, ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY map, user_id, name`);
167180 }
168181 // `restand` reads one player's matches on every write; without this it reads
169182 // everyone's, and the write grows with the table the board was built to spare.
170183 db.exec("CREATE INDEX IF NOT EXISTS match_by_player ON match (user_id, name)");
171 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC, user_id, name)");
184 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (map, kills DESC, damage DESC, user_id, name)");
172185 // The plans a planned match was played to (#238): one row per round of a
173186 // match, keyed by the match id and the situation the round was asked in
174187 // (`keyOf`), holding the plan it was given. Additive like the run table: a
@@ -240,10 +253,17 @@ const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(
240253 * (a delete, or only rounds-0 rows) has no row.
241254 */
242255 function restand(db: Database, userId: string, name: string): void {
256 // By player and not by map: a later post of a match may carry another map
257 // than the one it first did, so every map row of this player is redone.
243258 db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name);
244259 db.query(
245 `INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
246 SELECT ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`,
260 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
261 SELECT '', ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`,
262 ).run(userId, name);
263 db.query(
264 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
265 SELECT map, ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0
266 GROUP BY map, user_id, name`,
247267 ).run(userId, name);
248268 }
249269
@@ -381,24 +401,77 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
381401 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
382402 ).all(limit);
383403
404 /**
405 * The key of the rows in `standing` that count every map at once. Not a map
406 * name — `playedOn` in `src/mapname.ts` refuses the empty string — so no map
407 * can ever be asked for and get the combined rows back.
408 */
409 export const EVERY_MAP = "";
410
384411 /**
385412 * The table people actually look at: one row a player, best first. A player is
386413 * a forge identity, or — for the guests, who are nobody in particular — the
387414 * name they typed. Grouping by the name alone is how a guest calling
388415 * themselves someone else lands in that someone's row.
389416 *
417 * `map` narrows it to the results played on one map (#242), so a run of
418 * de_forge and a run of `gen_1z3f` are two comparisons and not one sum; absent
419 * is every map, the board as it has always been. It is a `PlayedMap` or
420 * nothing because the caller settled it at the boundary.
421 *
390422 * The rows are kept in `standing` by every write (`restand`), so this read
391 * costs the size of the board, not the size of the match table behind it.
423 * costs the size of the board, not the size of the match table behind it —
424 * `standing_by_rank` is `(map, kills DESC, damage DESC, …)`, so either board
425 * is one range of the index read in order and stopped at the limit.
392426 */
393 export const standings = (db: Database, limit = 10): Standing[] =>
394 db.query<Standing, [number]>(
427 export const standings = (db: Database, limit = 10, map: PlayedMap | null = null): Standing[] =>
428 db.query<Standing, [string, number]>(
395429 `SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds
396430 FROM standing
431 WHERE map = ?1
397432 -- A tie is broken the way the old GROUP BY broke it, by player, said out
398433 -- loud here so the order of equal rows is the rule and not the insert order.
399434 ORDER BY kills DESC, damage DESC, user_id, name
400 LIMIT ?1`,
401 ).all(limit);
435 LIMIT ?2`,
436 ).all(map ?? EVERY_MAP, limit);
437
438 /** How many players have ever posted a result on a map, from the board's own rows. */
439 export const playersOn = (db: Database, map: PlayedMap | null = null): number =>
440 db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM standing WHERE map = ?1").get(map ?? EVERY_MAP)?.n ?? 0;
441
442 /** A map somebody has results on, and how many: one link of the board's map list. */
443 export type BoardMap = { map: PlayedMap; players: number; matches: number };
444
445 /**
446 * A map is listed once this many players have a result on it: a board of one
447 * is a personal best, not a comparison. de_forge is listed whatever its count.
448 */
449 export const LISTED_PLAYERS = 2;
450
451 /**
452 * At most this many generated maps are listed. Every match on "a new map
453 * every match" makes one, so the list is the most-played few and not all.
454 */
455 export const LISTED_MAPS = 6;
456
457 /**
458 * The maps worth a board of their own: generated maps with at least
459 * `LISTED_PLAYERS` players on them, the most players first, then the most
460 * matches, then by name so equal maps keep one order. de_forge is left out here
461 * because the page always offers it; the list is what is *added* to it.
462 *
463 * It groups `standing`, which is players × maps they played rather than every
464 * match, and the `map` prefix of `standing_by_rank` hands it the rows already
465 * in groups.
466 */
467 export const boardMaps = (db: Database, min = LISTED_PLAYERS, limit = LISTED_MAPS): BoardMap[] =>
468 db.query<BoardMap, [number, number]>(
469 `SELECT map, COUNT(*) AS players, SUM(matches) AS matches FROM standing
470 WHERE map NOT IN ('', 'de_forge')
471 GROUP BY map HAVING COUNT(*) >= ?1
472 ORDER BY players DESC, matches DESC, map
473 LIMIT ?2`,
474 ).all(min, limit);
402475
403476 /** One player's day: their best run at the match of the day, and how many they had. */
404477 export type DayRun = {
src/pages/game.ts +81 −8
@@ -19,7 +19,8 @@
1919 import type { Database } from "bun:sqlite";
2020 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2121 import {
22 dayBoard, displayName, headshotShare, keepOf, liveness, type Liveness, matchCount, ratio, recentMatches, standings,
22 boardMaps, dayBoard, displayName, headshotShare, keepOf, LISTED_MAPS, LISTED_PLAYERS, liveness, type Liveness, matchCount,
23 playersOn, ratio, recentMatches, standings,
2324 } from "../db.ts";
2425 import { counted } from "../keep.ts";
2526 import { COMPETITIVE, SHORT } from "../client/match.ts";
@@ -29,7 +30,7 @@ import { NOTHING_POSTED } from "../client/pending.ts";
2930 import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts";
3031 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
3132 import { CARD, type Card } from "../client/weights.ts";
32 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
33 import { mapFrom, mapLink, type MapChoice, type PlayedMap } from "../mapname.ts";
3334 import { LONGEST_PASTE } from "../matchlink.ts";
3435 import { roomLink } from "../roomlink.ts";
3536 import type { Who } from "../who.ts";
@@ -83,6 +84,55 @@ ${board.length === 0
8384 <p class="hint">Ranked by ${escape(RANKED_BY)}. ${escape(NOT_A_LADDER)}</p>`;
8485 }
8586
87 /**
88 * The link that shows the all-time board on one map, or on every map for null.
89 * The map is a `PlayedMap`, which `src/mapname.ts` only hands out in this
90 * app's own spelling, so nothing a visitor typed is copied into the href.
91 */
92 export const boardLink = (map: PlayedMap | null): string => (map === null ? "/#stats" : `/?board=${map}#stats`);
93
94 /** What the page says when `?board=` was there and was not a map. */
95 export const BAD_BOARD = "That link's board is not a map — this is every map instead.";
96
97 /**
98 * Which boards there are to read: every map, de_forge, and the generated maps
99 * `boardMaps` lists — plus the one being read, when a link named a map too new
100 * to be listed, so the page never shows a board it offers no way back to. The
101 * one on screen is marked `aria-current` rather than only coloured.
102 */
103 function boardsNav(db: Database, board: PlayedMap | null): string {
104 const listed: (PlayedMap | null)[] = [null, "de_forge", ...boardMaps(db).map((m) => m.map)];
105 if (board !== null && !listed.includes(board)) listed.push(board);
106 const links = listed
107 .map((map) => `<a class="share" href="${escape(boardLink(map))}"${map === board ? ` aria-current="page"` : ""}>${
108 map === null ? "every map" : escape(map)
109 }</a>`)
110 .join(" ");
111 return `<nav class="boards" aria-label="The all-time board, one map at a time">${links}</nav>
112 <p class="hint">Listed: de_forge, and the generated maps at least ${num(LISTED_PLAYERS)} players have a result on — the
113 ${num(LISTED_MAPS)} with the most players. A generated map is new every match, so most have one player and are not a
114 comparison; any map's board is still one link away, <code>/?board=</code> and its name.</p>`;
115 }
116
117 /**
118 * The sentence above the all-time table that says which board it is, so a
119 * board on one map is never read as the whole one — and says who is missing
120 * from it and why, because a filter that hides people without saying so is
121 * worse than no filter.
122 */
123 function boardSaid(db: Database, board: PlayedMap | null, badBoard: boolean): string {
124 if (board === null) {
125 return `<p class="hint" id="boardsaid">${badBoard ? `${escape(BAD_BOARD)} ` : ""}Every result on every map, added
126 together: de_forge and every generated map in one table. The links above read it one map at a time.</p>`;
127 }
128 const players = playersOn(db, board);
129 const map = `<a href="${escape(mapLink(board))}">${escape(board)}</a>`;
130 return `<p class="hint" id="boardsaid">Only results played on ${map}: ${num(players)} ${
131 players === 1 ? "player has" : "players have"
132 } one. Anybody who has not played ${escape(board)} is not on this board for that reason alone —
133 <a href="${escape(boardLink(null))}">every map</a> has everyone.</p>`;
134 }
135
86136 /**
87137 * The leaderboard block: today's board, then the all-time one, then the feed.
88138 * Also what POST /api/match answers with, so a run that lands moves the day's
@@ -91,9 +141,15 @@ ${board.length === 0
91141 * `now` is the moment it is rendered at, and it decides two things: which day
92142 * the board is of, and whether a match in the feed still reads as live. A test
93143 * passes its own rather than waiting for midnight or for ten minutes.
144 *
145 * `board` is the map the all-time table is narrowed to (#242), settled from
146 * `?board=` in `src/server.ts`; null is every map, and `badBoard` says the
147 * link asked for something that was not a map and got every map instead. The
148 * day's board and the feed are not narrowed: the day is one map already, and
149 * the feed is what happened, whatever it was played on.
94150 */
95 export function statsHtml(db: Database, now = Date.now()): string {
96 const table = standings(db, 10);
151 export function statsHtml(db: Database, now = Date.now(), board: PlayedMap | null = null, badBoard = false): string {
152 const table = standings(db, 10, board);
97153 const recent = recentMatches(db, 8);
98154 const rows = table
99155 .map(
@@ -113,11 +169,17 @@ export function statsHtml(db: Database, now = Date.now()): string {
113169 `<time>${escape(m.updated_at)}</time></li>`,
114170 )
115171 .join("");
172 const empty = board === null
173 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
174 : `<p class="empty">Nobody has a result on ${escape(board)} yet. <a href="${escape(mapLink(board))}">Play it</a> and
175 yours is the first.</p>`;
116176 return `<h2>Leaderboard</h2>
117177 ${todayHtml(db, dailyOn(now))}
118 <h3>All time</h3>
119 ${keptHtml(db)}${table.length === 0
120 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
178 <h3>All time${board === null ? "" : ` on ${escape(board)}`}</h3>
179 ${keptHtml(db)}${boardsNav(db, board)}
180 ${boardSaid(db, board, badBoard)}
181 ${table.length === 0
182 ? empty
121183 : `<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>`}
122184 <h3>Recent matches</h3>
123185 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
@@ -157,6 +219,13 @@ export type Asked = {
157219 * text of its own to put on this page. Absent is null.
158220 */
159221 room?: string | null;
222 /**
223 * The map `?board=` narrows the all-time table to: a map `playedOn` accepted,
224 * or null for every map. Absent is null.
225 */
226 board?: PlayedMap | null;
227 /** A `?board=` was there and was not a map: every map, and a sentence. */
228 badBoard?: boolean;
160229 };
161230
162231 /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */
@@ -260,6 +329,10 @@ a.share[hidden] { display:none; }
260329 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
261330 border-radius:.3rem; background:#161b24; }
262331 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
332 /* The maps the all-time board can be read on: share-sized links that wrap on a
333 phone, and the one on screen told apart by more than its colour. */
334 nav.boards { display:flex; flex-wrap:wrap; gap:.4rem; margin:.35rem 0; }
335 nav.boards a[aria-current] { background:#27303f; border-color:var(--ct); font-weight:600; }
263336 /* The buy panel's shelf: the column src/client/buy.ts fills, sized here so the
264337 grid inside it has a width to be a fraction of. It takes the focus when the
265338 panel opens and is not a control, so the ring is drawn on it and nothing
@@ -666,7 +739,7 @@ export function gamePage(
666739 }),
667740 sectionsHtml: `${thisMatch(asked)}
668741
669 <section id="stats">${statsHtml(db, now)}</section>
742 <section id="stats">${statsHtml(db, now, asked.board ?? null, asked.badBoard ?? false)}</section>
670743 ${SAVED_LINE}
671744
672745 <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