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
torque/cs-engine:torque/242-the-board-can-be-read-one-map-at-a-time → main 1 commit fork
5 files
changed +333
−27
src/db.test.ts +101 −4
| @@ -10,8 +10,8 @@ import { mkdtempSync } from "node:fs"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | 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, | |
| 15 | 15 | } from "./db.ts"; |
| 16 | 16 | import type { RoundPlan } from "./plan/plan.ts"; |
| 17 | 17 | 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 | ||
| 137 | 137 | const row = recentMatches(db).find((m) => m.id === id); |
| 138 | 138 | expect(row?.map).toBe("gen_1z141z3"); |
| 139 | 139 | 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. | |
| 142 | 142 | expect(standings(db).length).toBe(2); |
| 143 | 143 | }); |
| 144 | 144 | |
| @@ -247,6 +247,103 @@ test("standings reads the board, not the match table: 20,000 matches answer in u | ||
| 247 | 247 | expect(top[0]?.kills).toBe(39); |
| 248 | 248 | }); |
| 249 | 249 | |
| 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 | ||
| 250 | 347 | // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the |
| 251 | 348 | // second, in the format `datetime('now')` hands back. |
| 252 | 349 | const NOW = Date.parse("2026-09-21T12:00:00Z"); |
src/db.ts +88 −15
| @@ -24,6 +24,7 @@ import { mkdirSync } from "node:fs"; | ||
| 24 | 24 | import { join } from "node:path"; |
| 25 | 25 | import { dayFiled } from "./daily.ts"; |
| 26 | 26 | import { parsePlan, type RoundPlan } from "./plan/plan.ts"; |
| 27 | import type { PlayedMap } from "./mapname.ts"; | |
| 27 | 28 | import type { Result } from "./result.ts"; |
| 28 | 29 | import type { RangeRun } from "./run.ts"; |
| 29 | 30 | |
| @@ -136,11 +137,21 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 136 | 137 | // matches, so `standings` is a sorted read of a table the size of the player |
| 137 | 138 | // count. A database written before this table existed builds it once, with |
| 138 | 139 | // 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")) { | |
| 143 | 153 | db.exec(`CREATE TABLE standing ( |
| 154 | map TEXT NOT NULL, | |
| 144 | 155 | user_id TEXT NOT NULL, |
| 145 | 156 | name TEXT NOT NULL, |
| 146 | 157 | matches INTEGER NOT NULL, |
| @@ -150,15 +161,17 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 150 | 161 | damage INTEGER NOT NULL, |
| 151 | 162 | wins INTEGER NOT NULL, |
| 152 | 163 | rounds INTEGER NOT NULL, |
| 153 | PRIMARY KEY (user_id, name) | |
| 164 | PRIMARY KEY (user_id, name, map) | |
| 154 | 165 | )`); |
| 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`); | |
| 157 | 170 | } |
| 158 | 171 | // `restand` reads one player's matches on every write; without this it reads |
| 159 | 172 | // everyone's, and the write grows with the table the board was built to spare. |
| 160 | 173 | 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)"); | |
| 162 | 175 | // The plans a planned match was played to (#238): one row per round of a |
| 163 | 176 | // match, keyed by the match id and the situation the round was asked in |
| 164 | 177 | // (`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( | ||
| 185 | 198 | * (a delete, or only rounds-0 rows) has no row. |
| 186 | 199 | */ |
| 187 | 200 | 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. | |
| 188 | 203 | db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name); |
| 189 | 204 | 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`, | |
| 192 | 212 | ).run(userId, name); |
| 193 | 213 | } |
| 194 | 214 | |
| @@ -319,24 +339,77 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] => | ||
| 319 | 339 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 320 | 340 | ).all(limit); |
| 321 | 341 | |
| 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 | ||
| 322 | 349 | /** |
| 323 | 350 | * The table people actually look at: one row a player, best first. A player is |
| 324 | 351 | * a forge identity, or — for the guests, who are nobody in particular — the |
| 325 | 352 | * name they typed. Grouping by the name alone is how a guest calling |
| 326 | 353 | * themselves someone else lands in that someone's row. |
| 327 | 354 | * |
| 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 | * | |
| 328 | 360 | * 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. | |
| 330 | 364 | */ |
| 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]>( | |
| 333 | 367 | `SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds |
| 334 | 368 | FROM standing |
| 369 | WHERE map = ?1 | |
| 335 | 370 | -- A tie is broken the way the old GROUP BY broke it, by player, said out |
| 336 | 371 | -- loud here so the order of equal rows is the rule and not the insert order. |
| 337 | 372 | 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); | |
| 340 | 413 | |
| 341 | 414 | /** One player's day: their best run at the match of the day, and how many they had. */ |
| 342 | 415 | export type DayRun = { |
src/pages/game.ts +82 −7
| @@ -18,14 +18,17 @@ | ||
| 18 | 18 | |
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | 20 | 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"; | |
| 22 | 25 | import { COMPETITIVE, SHORT } from "../client/match.ts"; |
| 23 | 26 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; |
| 24 | 27 | import { CS_MOVE } from "../client/movement.ts"; |
| 25 | 28 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 26 | 29 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; |
| 27 | 30 | 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"; | |
| 29 | 32 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 30 | 33 | import { roomLink } from "../roomlink.ts"; |
| 31 | 34 | import type { Who } from "../who.ts"; |
| @@ -79,6 +82,55 @@ ${board.length === 0 | ||
| 79 | 82 | <p class="hint">Ranked by ${escape(RANKED_BY)}. ${escape(NOT_A_LADDER)}</p>`; |
| 80 | 83 | } |
| 81 | 84 | |
| 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 | ||
| 82 | 134 | /** |
| 83 | 135 | * The leaderboard block: today's board, then the all-time one, then the feed. |
| 84 | 136 | * Also what POST /api/match answers with, so a run that lands moves the day's |
| @@ -87,9 +139,15 @@ ${board.length === 0 | ||
| 87 | 139 | * `now` is the moment it is rendered at, and it decides two things: which day |
| 88 | 140 | * the board is of, and whether a match in the feed still reads as live. A test |
| 89 | 141 | * 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. | |
| 90 | 148 | */ |
| 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); | |
| 93 | 151 | const recent = recentMatches(db, 8); |
| 94 | 152 | const rows = table |
| 95 | 153 | .map( |
| @@ -109,11 +167,17 @@ export function statsHtml(db: Database, now = Date.now()): string { | ||
| 109 | 167 | `<time>${escape(m.updated_at)}</time></li>`, |
| 110 | 168 | ) |
| 111 | 169 | .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>`; | |
| 112 | 174 | return `<h2>Leaderboard</h2> |
| 113 | 175 | ${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)} | |
| 115 | 179 | ${table.length === 0 |
| 116 | ? `<p class="empty">No matches yet. Play one and it lands here.</p>` | |
| 180 | ? empty | |
| 117 | 181 | : `<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>`} |
| 118 | 182 | <h3>Recent matches</h3> |
| 119 | 183 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| @@ -140,6 +204,13 @@ export type Asked = { | ||
| 140 | 204 | * text of its own to put on this page. Absent is null. |
| 141 | 205 | */ |
| 142 | 206 | 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; | |
| 143 | 214 | }; |
| 144 | 215 | |
| 145 | 216 | /** 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; } | ||
| 243 | 314 | ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0; |
| 244 | 315 | border-radius:.3rem; background:#161b24; } |
| 245 | 316 | 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; } | |
| 246 | 321 | /* The buy panel's shelf: the column src/client/buy.ts fills, sized here so the |
| 247 | 322 | grid inside it has a width to be a fraction of. It takes the focus when the |
| 248 | 323 | panel opens and is not a control, so the ring is drawn on it and nothing |
| @@ -630,7 +705,7 @@ export function gamePage( | ||
| 630 | 705 | }), |
| 631 | 706 | sectionsHtml: `${thisMatch(asked)} |
| 632 | 707 | |
| 633 | <section id="stats">${statsHtml(db, now)}</section> | |
| 708 | <section id="stats">${statsHtml(db, now, asked.board ?? null, asked.badBoard ?? false)}</section> | |
| 634 | 709 | ${SAVED_LINE} |
| 635 | 710 | |
| 636 | 711 | <section> |
src/server.test.ts +53 −1
| @@ -11,7 +11,7 @@ import { GAMES } from "./games.ts"; | ||
| 11 | 11 | import { matchOf, playKey } from "./matchid.ts"; |
| 12 | 12 | import { LONGEST_PASTE } from "./matchlink.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { WEAPONS_HREF, WEAPONS_SECTION } from "./pages/weapons.ts"; |
| 16 | 16 | import { ENGINE_HREF } from "./pages/engine.ts"; |
| 17 | 17 | 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 | ||
| 1978 | 1978 | const home = await (await fetch(base + "/")).text(); |
| 1979 | 1979 | expect(home).toContain("still seated — leave before the end and it is not counted for you"); |
| 1980 | 1980 | }); |
| 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 | ||
| 549 | 549 | * `?room=` is the third, and the quietest: a code `roomCodeFrom` accepts fills |
| 550 | 550 | * the menu's room field, and anything else is as if it were never there. |
| 551 | 551 | * |
| 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 | * | |
| 552 | 557 | * All belong to this game and to no other: `?map=` is read here, in the |
| 553 | 558 | * shooter's own renderer, and the range and the example never see it. |
| 554 | 559 | */ |
| @@ -558,6 +563,8 @@ const asked = (request: Request): Asked => { | ||
| 558 | 563 | const match = matchFrom(wanted); |
| 559 | 564 | const wantedMap = query.get("map"); |
| 560 | 565 | const map = mapFrom(wantedMap); |
| 566 | const wantedBoard = query.get("board"); | |
| 567 | const board = playedOn(wantedBoard); | |
| 561 | 568 | return { |
| 562 | 569 | match, |
| 563 | 570 | badMatch: wanted !== null && match === null, |
| @@ -565,6 +572,8 @@ const asked = (request: Request): Asked => { | ||
| 565 | 572 | badMap: wantedMap !== null && wantedMap !== map, |
| 566 | 573 | // A room is a code or nothing, and never a sentence: see `Asked.room`. |
| 567 | 574 | room: roomCodeFrom(query.get("room")), |
| 575 | board, | |
| 576 | badBoard: wantedBoard !== null && board === null, | |
| 568 | 577 | }; |
| 569 | 578 | }; |
| 570 | 579 |