Public: anyone can read this; the crew can change it.
forked from trevato/cs
The board lists the maps worth comparing: a bounded set of links, and the rule that chose them #425
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/416-the-board-lists-the-maps-worth-comparing → main 1 commit fork
5 files
changed +195
−55
README.md +7 −2
| @@ -332,8 +332,13 @@ down and hand to somebody who has never played the round it happened in. | ||
| 332 | 332 | results played on that map, and `/?board=de_forge` to the fixed one; no |
| 333 | 333 | `?board=` is every map added together, and the page says in a sentence which |
| 334 | 334 | one it is showing. The links above the table are every map, de_forge, and the |
| 335 | six generated maps with the most players (at least two each) — a board of one | |
| 336 | is a personal best, not a comparison. `?board=` goes through the same | |
| 335 | generated maps with at least three finished results (`LISTED_RESULTS` in | |
| 336 | `src/db.ts`), most played first, at most eight maps in all (`LISTED_MAPS`); | |
| 337 | a map a link named that is not listed takes the last place, and the line under | |
| 338 | the links says the rule. The list is kept one row a map in `board_map`, moved | |
| 339 | by each write, and read as the first few entries of `board_map_by_results` | |
| 340 | (`SEARCH board_map USING COVERING INDEX board_map_by_results (results>?)`, no sort | |
| 341 | step), however many maps have ever been generated. `?board=` goes through the same | |
| 337 | 342 | `src/mapname.ts` grammar as a stored result; anything else is every map and a |
| 338 | 343 | sentence saying so. A round played from a page on one map's board keeps it: |
| 339 | 344 | the page posts to `/api/match?board=<map>` and the leaderboard it gets back is |
src/db.test.ts +47 −10
| @@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { |
| 13 | boardMaps, dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player, | |
| 13 | boardMaps, dayBoard, deleteMatch, LISTED_MAPS, LISTED_RESULTS, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player, | |
| 14 | 14 | playersOn, |
| 15 | 15 | recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, type Standing, standings, |
| 16 | 16 | } from "./db.ts"; |
| @@ -394,18 +394,51 @@ test("the de_forge board leaves out a generated map's rows even for the same pla | ||
| 394 | 394 | expect(standings(db).map((r) => [r.name, r.kills])).toEqual([["kestrel", 33], ["falcon", 1]]); |
| 395 | 395 | }); |
| 396 | 396 | |
| 397 | test("the maps listed are generated maps with enough players on them, the most played first, and no more than asked", () => { | |
| 397 | test("a generated map is listed at LISTED_RESULTS finished results and not one below, the most played first", () => { | |
| 398 | 398 | const db = openDatabase(dir()); |
| 399 | // gen_ir1: three players. gen_zz: two. gen_aa: one, which is not a comparison. | |
| 400 | for (const [who, map] of [["u-1", "gen_ir1"], ["u-2", "gen_ir1"], ["u-3", "gen_ir1"], ["u-1", "gen_zz"], | |
| 401 | ["u-2", "gen_zz"], ["u-1", "gen_aa"], ["u-1", "de_forge"], ["u-2", "de_forge"]] as const) | |
| 402 | saveMatch(db, player(who, who), result({ map })); | |
| 399 | const play = (map: PlayedMap, times: number, finished = true): void => { | |
| 400 | for (let i = 0; i < times; i++) saveMatch(db, player(`u-${i}`, `p${i}`), result({ map, finished })); | |
| 401 | }; | |
| 402 | play("gen_ir1", LISTED_RESULTS + 1); | |
| 403 | play("gen_zz", LISTED_RESULTS); | |
| 404 | play("gen_aa", LISTED_RESULTS - 1); | |
| 405 | // Unfinished matches are not results: however many, they list nothing. | |
| 406 | play("gen_bb", LISTED_RESULTS + 5, false); | |
| 407 | play("de_forge", 10); | |
| 403 | 408 | expect(boardMaps(db)).toEqual([ |
| 404 | { map: "gen_ir1", players: 3, matches: 3 }, | |
| 405 | { map: "gen_zz", players: 2, matches: 2 }, | |
| 409 | { map: "gen_ir1", results: LISTED_RESULTS + 1 }, | |
| 410 | { map: "gen_zz", results: LISTED_RESULTS }, | |
| 406 | 411 | ]); |
| 407 | expect(boardMaps(db, 2, 1).map((m) => m.map)).toEqual(["gen_ir1"]); | |
| 408 | expect(boardMaps(db, 1).map((m) => m.map)).toEqual(["gen_ir1", "gen_zz", "gen_aa"]); | |
| 412 | // One more finished result, from the same player, is what lists gen_aa. | |
| 413 | saveMatch(db, player("u-0", "p0"), result({ map: "gen_aa" })); | |
| 414 | expect(boardMaps(db).map((m) => m.map)).toEqual(["gen_ir1", "gen_aa", "gen_zz"]); | |
| 415 | expect(boardMaps(db, LISTED_RESULTS, 1).map((m) => m.map)).toEqual(["gen_ir1"]); | |
| 416 | }); | |
| 417 | ||
| 418 | test("the maps list is never more than LISTED_MAPS maps with de_forge, however many qualify", () => { | |
| 419 | const db = openDatabase(dir()); | |
| 420 | for (let m = 0; m < LISTED_MAPS + 4; m++) { | |
| 421 | for (let i = 0; i < LISTED_RESULTS; i++) saveMatch(db, guest(`g${i}`), result({ map: `gen_${m.toString(36)}q` })); | |
| 422 | } | |
| 423 | expect(boardMaps(db).length).toBe(LISTED_MAPS - 1); | |
| 424 | }); | |
| 425 | ||
| 426 | test("the maps list follows a match that moves map, a delete and the ceiling, and is rebuilt from an older table", () => { | |
| 427 | const path = dir(); | |
| 428 | const db = openDatabase(path); | |
| 429 | const ids = Array.from({ length: LISTED_RESULTS }, () => crypto.randomUUID()); | |
| 430 | for (const id of ids) saveMatch(db, guest("kite"), result({ matchId: id, map: "gen_ir1" })); | |
| 431 | expect(boardMaps(db).map((m) => m.map)).toEqual(["gen_ir1"]); | |
| 432 | // A later post of one of them says it was another map: gen_ir1 drops below. | |
| 433 | saveMatch(db, guest("kite"), result({ matchId: ids[0], map: "gen_zz" })); | |
| 434 | expect(boardMaps(db)).toEqual([]); | |
| 435 | expect(boardMaps(db, 1)).toEqual([{ map: "gen_ir1", results: 2 }, { map: "gen_zz", results: 1 }]); | |
| 436 | deleteMatch(db, ids[0] as string); | |
| 437 | expect(boardMaps(db, 1)).toEqual([{ map: "gen_ir1", results: 2 }]); | |
| 438 | // A database from before `board_map` builds it from what it has. | |
| 439 | db.exec("DROP TABLE board_map"); | |
| 440 | db.close(); | |
| 441 | expect(boardMaps(openDatabase(path), 1)).toEqual([{ map: "gen_ir1", results: 2 }]); | |
| 409 | 442 | }); |
| 410 | 443 | |
| 411 | 444 | test("the board on one map is a range of the index: 20,000 matches on 200 maps answer in under 10 ms", () => { |
| @@ -433,6 +466,10 @@ test("the board on one map is a range of the index: 20,000 matches on 200 maps a | ||
| 433 | 466 | standings(db, 10, "de_forge"); |
| 434 | 467 | const onForge = median(() => standings(db, 10, "de_forge")); |
| 435 | 468 | const onGen = median(() => standings(db, 10, "gen_1z")); |
| 469 | // The maps list is the first entries of its own index, in order: a range, | |
| 470 | // no sort step and no GROUP BY, whatever the number of maps. | |
| 471 | expect(plan(`SELECT map, results FROM board_map WHERE results >= 3 AND map != 'de_forge' ORDER BY results DESC, map LIMIT 7`)) | |
| 472 | .toBe("SEARCH board_map USING COVERING INDEX board_map_by_results (results>?)"); | |
| 436 | 473 | const listed = median(() => boardMaps(db)); |
| 437 | 474 | console.log(` board on de_forge ${onForge.toFixed(3)} ms, on a generated map ${onGen.toFixed(3)} ms, ` + |
| 438 | 475 | `the maps list ${listed.toFixed(3)} ms — ${db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM standing").get()?.n} standing rows`); |
src/db.ts +64 −30
| @@ -157,18 +157,21 @@ export function openDatabase( | ||
| 157 | 157 | // built to retire; the kept row costs one more INSERT … SELECT on a write, |
| 158 | 158 | // bounded by that one player's matches. A table built before the map was a |
| 159 | 159 | // column is dropped and built again from `match`, which holds every number it |
| 160 | // ever had: it is a cache of that table, not a record of its own. | |
| 160 | // ever had: it is a cache of that table, not a record of its own. So is one | |
| 161 | // built before it counted finished matches (#416), for the same reason. | |
| 161 | 162 | const standingColumns = db.query<{ name: string }, []>("PRAGMA table_info(standing)").all().map((c) => c.name); |
| 162 | if (standingColumns.length > 0 && !standingColumns.includes("map")) { | |
| 163 | const restanding = !standingColumns.includes("finished"); | |
| 164 | if (standingColumns.length > 0 && restanding) { | |
| 163 | 165 | db.exec("DROP INDEX IF EXISTS standing_by_rank"); |
| 164 | 166 | db.exec("DROP TABLE standing"); |
| 165 | 167 | } |
| 166 | if (!standingColumns.includes("map")) { | |
| 168 | if (restanding) { | |
| 167 | 169 | db.exec(`CREATE TABLE standing ( |
| 168 | 170 | map TEXT NOT NULL, |
| 169 | 171 | user_id TEXT NOT NULL, |
| 170 | 172 | name TEXT NOT NULL, |
| 171 | 173 | matches INTEGER NOT NULL, |
| 174 | finished INTEGER NOT NULL, | |
| 172 | 175 | kills INTEGER NOT NULL, |
| 173 | 176 | deaths INTEGER NOT NULL, |
| 174 | 177 | headshots INTEGER NOT NULL, |
| @@ -177,10 +180,22 @@ export function openDatabase( | ||
| 177 | 180 | rounds INTEGER NOT NULL, |
| 178 | 181 | PRIMARY KEY (user_id, name, map) |
| 179 | 182 | )`); |
| 180 | db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 181 | SELECT '', ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`); | |
| 182 | db.exec(`INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 183 | SELECT map, ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY map, user_id, name`); | |
| 183 | db.exec(`INSERT INTO standing (map, ${STANDING_INTO}) SELECT '', ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`); | |
| 184 | db.exec(`INSERT INTO standing (map, ${STANDING_INTO}) SELECT map, ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY map, user_id, name`); | |
| 185 | } | |
| 186 | // The maps worth a board of their own (#416), kept one row a map with how | |
| 187 | // many finished results it has, so the list above the board is a read of the | |
| 188 | // index's first few entries and not a GROUP BY of every player on every map | |
| 189 | // ever generated. `restand` moves each count by the difference one player's | |
| 190 | // write made to it; a database from before this table builds it once from | |
| 191 | // `standing`, and a rebuilt `standing` rebuilds it too. | |
| 192 | const boardMapExisted = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'board_map'").get() !== null; | |
| 193 | db.exec("CREATE TABLE IF NOT EXISTS board_map (map TEXT PRIMARY KEY, results INTEGER NOT NULL)"); | |
| 194 | db.exec("CREATE INDEX IF NOT EXISTS board_map_by_results ON board_map (results DESC, map)"); | |
| 195 | if (restanding || !boardMapExisted) { | |
| 196 | db.exec("DELETE FROM board_map"); | |
| 197 | db.exec(`INSERT INTO board_map (map, results) SELECT map, SUM(finished) FROM standing | |
| 198 | WHERE map != '' GROUP BY map HAVING SUM(finished) > 0`); | |
| 184 | 199 | } |
| 185 | 200 | // `restand` reads one player's matches on every write; without this it reads |
| 186 | 201 | // everyone's, and the write grows with the table the board was built to spare. |
| @@ -247,7 +262,8 @@ function prune(db: Database, table: "match" | "run", keep: number): { user_id: s | ||
| 247 | 262 | |
| 248 | 263 | // One player's row of the board, summed from their matches: the same columns |
| 249 | 264 | // whether it is built for the whole table once or for one player on a write. |
| 250 | const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage), | |
| 265 | const STANDING_INTO = "user_id, name, matches, finished, kills, deaths, headshots, damage, wins, rounds"; | |
| 266 | const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(finished), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage), | |
| 251 | 267 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds)`; |
| 252 | 268 | |
| 253 | 269 | /** |
| @@ -257,18 +273,34 @@ const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM( | ||
| 257 | 273 | * (a delete, or only rounds-0 rows) has no row. |
| 258 | 274 | */ |
| 259 | 275 | function restand(db: Database, userId: string, name: string): void { |
| 276 | const finishedOn = (): Map<string, number> => new Map( | |
| 277 | db.query<{ map: string; finished: number }, [string, string]>( | |
| 278 | "SELECT map, finished FROM standing WHERE user_id = ?1 AND name = ?2 AND map != ''", | |
| 279 | ).all(userId, name).map((r) => [r.map, r.finished]), | |
| 280 | ); | |
| 281 | const before = finishedOn(); | |
| 260 | 282 | // By player and not by map: a later post of a match may carry another map |
| 261 | 283 | // than the one it first did, so every map row of this player is redone. |
| 262 | 284 | db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name); |
| 263 | 285 | db.query( |
| 264 | `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 286 | `INSERT INTO standing (map, ${STANDING_INTO}) | |
| 265 | 287 | SELECT '', ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`, |
| 266 | 288 | ).run(userId, name); |
| 267 | 289 | db.query( |
| 268 | `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 290 | `INSERT INTO standing (map, ${STANDING_INTO}) | |
| 269 | 291 | SELECT map, ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 |
| 270 | 292 | GROUP BY map, user_id, name`, |
| 271 | 293 | ).run(userId, name); |
| 294 | // Each map's count moves by what this player's rows moved it by: the maps | |
| 295 | // they had before and the maps they have now, and no other map's row. | |
| 296 | const after = finishedOn(); | |
| 297 | for (const map of new Set([...before.keys(), ...after.keys()])) { | |
| 298 | const delta = (after.get(map) ?? 0) - (before.get(map) ?? 0); | |
| 299 | if (delta === 0) continue; | |
| 300 | db.query(`INSERT INTO board_map (map, results) VALUES (?1, ?2) | |
| 301 | ON CONFLICT(map) DO UPDATE SET results = results + excluded.results`).run(map, delta); | |
| 302 | db.query("DELETE FROM board_map WHERE map = ?1 AND results <= 0").run(map); | |
| 303 | } | |
| 272 | 304 | } |
| 273 | 305 | |
| 274 | 306 | /** Who a match is written under: a verified forge identity, or a browser. */ |
| @@ -443,37 +475,39 @@ export const standings = (db: Database, limit = 10, map: PlayedMap | null = null | ||
| 443 | 475 | export const playersOn = (db: Database, map: PlayedMap | null = null): number => |
| 444 | 476 | db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM standing WHERE map = ?1").get(map ?? EVERY_MAP)?.n ?? 0; |
| 445 | 477 | |
| 446 | /** A map somebody has results on, and how many: one link of the board's map list. */ | |
| 447 | export type BoardMap = { map: PlayedMap; players: number; matches: number }; | |
| 478 | /** A map with finished results on it, and how many: one link of the board's map list. */ | |
| 479 | export type BoardMap = { map: PlayedMap; results: number }; | |
| 448 | 480 | |
| 449 | 481 | /** |
| 450 | * A map is listed once this many players have a result on it: a board of one | |
| 451 | * is a personal best, not a comparison. de_forge is listed whatever its count. | |
| 482 | * A generated map is listed once it has this many finished results: one is a | |
| 483 | * receipt, two can be one rematch, and three is the fewest that begins to be | |
| 484 | * a comparison. de_forge is listed whatever its count. | |
| 452 | 485 | */ |
| 453 | export const LISTED_PLAYERS = 2; | |
| 486 | export const LISTED_RESULTS = 3; | |
| 454 | 487 | |
| 455 | 488 | /** |
| 456 | * At most this many generated maps are listed. Every match on "a new map | |
| 457 | * every match" makes one, so the list is the most-played few and not all. | |
| 489 | * At most this many maps are listed, de_forge among them. Every match on "a | |
| 490 | * new map every match" makes one, so the list is the most-played few and not | |
| 491 | * all of them. | |
| 458 | 492 | */ |
| 459 | export const LISTED_MAPS = 6; | |
| 493 | export const LISTED_MAPS = 8; | |
| 460 | 494 | |
| 461 | 495 | /** |
| 462 | * The maps worth a board of their own: generated maps with at least | |
| 463 | * `LISTED_PLAYERS` players on them, the most players first, then the most | |
| 464 | * matches, then by name so equal maps keep one order. de_forge is left out here | |
| 465 | * because the page always offers it; the list is what is *added* to it. | |
| 496 | * The generated maps worth a board of their own: at least `min` finished | |
| 497 | * results, the most first, then by name so equal maps keep one order. de_forge | |
| 498 | * is left out because the page always offers it; the list is what is *added*, | |
| 499 | * so it is one shorter than `LISTED_MAPS`. | |
| 466 | 500 | * |
| 467 | * It groups `standing`, which is players × maps they played rather than every | |
| 468 | * match, and the `map` prefix of `standing_by_rank` hands it the rows already | |
| 469 | * in groups. | |
| 501 | * One read of `board_map_by_results`, `(results DESC, map)`: the range | |
| 502 | * `results >= min` taken in order and stopped at the limit, so it reads at | |
| 503 | * most `limit + 1` rows (the one more is de_forge) however many maps were | |
| 504 | * ever generated — the same shape as the board it sits above. | |
| 470 | 505 | */ |
| 471 | export const boardMaps = (db: Database, min = LISTED_PLAYERS, limit = LISTED_MAPS): BoardMap[] => | |
| 506 | export const boardMaps = (db: Database, min = LISTED_RESULTS, limit = LISTED_MAPS - 1): BoardMap[] => | |
| 472 | 507 | db.query<BoardMap, [number, number]>( |
| 473 | `SELECT map, COUNT(*) AS players, SUM(matches) AS matches FROM standing | |
| 474 | WHERE map NOT IN ('', 'de_forge') | |
| 475 | GROUP BY map HAVING COUNT(*) >= ?1 | |
| 476 | ORDER BY players DESC, matches DESC, map | |
| 508 | `SELECT map, results FROM board_map | |
| 509 | WHERE results >= ?1 AND map != 'de_forge' | |
| 510 | ORDER BY results DESC, map | |
| 477 | 511 | LIMIT ?2`, |
| 478 | 512 | ).all(min, limit); |
| 479 | 513 |
src/pages/game.test.ts +62 −3
| @@ -23,11 +23,11 @@ import { expect, test } from "bun:test"; | ||
| 23 | 23 | import { mkdtempSync } from "node:fs"; |
| 24 | 24 | import { tmpdir } from "node:os"; |
| 25 | 25 | import { join } from "node:path"; |
| 26 | import { IDLE_SECONDS, openDatabase, type Player, recentMatches, saveMatch, standings } from "../db.ts"; | |
| 26 | import { IDLE_SECONDS, LISTED_MAPS, LISTED_RESULTS, openDatabase, type Player, recentMatches, saveMatch, standings } from "../db.ts"; | |
| 27 | 27 | import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 28 | 28 | import { playKey } from "../matchid.ts"; |
| 29 | 29 | import { LONGEST_PASTE, pastedLink } from "../matchlink.ts"; |
| 30 | import { type PlayedMap, seedOf } from "../mapname.ts"; | |
| 30 | import { type PlayedMap, playedOn, seedOf } from "../mapname.ts"; | |
| 31 | 31 | import type { Result } from "../result.ts"; |
| 32 | 32 | import { CARD, type Card } from "../client/weights.ts"; |
| 33 | 33 | import { COMPETITIVE, SHORT } from "../client/match.ts"; |
| @@ -37,7 +37,7 @@ import { ALL, damageRows, gunsOf } from "../sheet.ts"; | ||
| 37 | 37 | import { GAMES } from "../games.ts"; |
| 38 | 38 | import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts"; |
| 39 | 39 | import { |
| 40 | gamePage, planHtml, PLANNED_ID, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions, | |
| 40 | boardsNav, gamePage, listedRule, planHtml, PLANNED_ID, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions, | |
| 41 | 41 | foldOpen, offerOf, OPTIONS, type Asked, |
| 42 | 42 | } from "./game.ts"; |
| 43 | 43 | import { A_GENERATED_MAP, offerSaid } from "../client/offer.ts"; |
| @@ -617,3 +617,62 @@ test("the leaderboard section carries the board it shows, and only a map", () => | ||
| 617 | 617 | expect(every).toContain(`<section id="stats">`); |
| 618 | 618 | expect(every).not.toContain("data-board"); |
| 619 | 619 | }); |
| 620 | ||
| 621 | // The nav of boards above the all-time table (#416), read the way a browser | |
| 622 | // reads it: each link's `?board=` taken back out of the unescaped href. | |
| 623 | const boardsOf = (html: string): { href: string; current: boolean; text: string }[] => { | |
| 624 | const nav = html.match(/<nav class="boards"[^>]*>(.*?)<\/nav>/s)?.[1] ?? ""; | |
| 625 | return [...nav.matchAll(/<a class="share" href="([^"]*)"( aria-current="page")?>([^<]*)<\/a>/g)] | |
| 626 | .map((m) => ({ href: unescapeAttr(m[1] as string), current: m[2] !== undefined, text: m[3] as string })); | |
| 627 | }; | |
| 628 | const unescapeAttr = (s: string): string => | |
| 629 | s.replace(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); | |
| 630 | const finished = (home: ReturnType<typeof db>, map: PlayedMap, times: number): void => { | |
| 631 | for (let i = 0; i < times; i++) { | |
| 632 | saveMatch(home, { userId: "", session: "s", name: `p${i}` }, { | |
| 633 | matchId: crypto.randomUUID(), name: "x", map, rounds: 1, scoreMine: 1, scoreTheirs: 0, | |
| 634 | kills: 1, deaths: 0, headshots: 0, damage: 100, seconds: 60, finished: true, | |
| 635 | }); | |
| 636 | } | |
| 637 | }; | |
| 638 | ||
| 639 | test("the boards nav lists a map at LISTED_RESULTS and not one below, and says the rule it used", () => { | |
| 640 | const home = db(); | |
| 641 | finished(home, "gen_at", LISTED_RESULTS); | |
| 642 | finished(home, "gen_below", LISTED_RESULTS - 1); | |
| 643 | const html = boardsNav(home, null); | |
| 644 | expect(boardsOf(html).map((l) => l.text)).toEqual(["every map", "de_forge", "gen_at"]); | |
| 645 | expect(html).toContain(escape(listedRule())); | |
| 646 | expect(listedRule()).toContain(`at least ${LISTED_RESULTS} finished results, most played first`); | |
| 647 | }); | |
| 648 | ||
| 649 | test("the boards nav never passes LISTED_MAPS maps, even with an unlisted map being read", () => { | |
| 650 | const home = db(); | |
| 651 | for (let m = 0; m < LISTED_MAPS + 3; m++) finished(home, `gen_${m.toString(36)}q` as PlayedMap, LISTED_RESULTS + m); | |
| 652 | for (const board of [null, "gen_lonely", "de_forge"] as const) { | |
| 653 | const links = boardsOf(boardsNav(home, board)); | |
| 654 | // Every map is not a map: the cap counts the ones after it. | |
| 655 | expect(links.length - 1).toBe(LISTED_MAPS); | |
| 656 | expect(links.filter((l) => l.current).map((l) => l.text)).toEqual([board ?? "every map"]); | |
| 657 | } | |
| 658 | // The most played first: the map with the most results leads, after de_forge. | |
| 659 | expect(boardsOf(boardsNav(home, null))[2]?.text).toBe(`gen_${(LISTED_MAPS + 2).toString(36)}q`); | |
| 660 | }); | |
| 661 | ||
| 662 | test("every board link is escaped and parses back through src/mapname.ts to the map it names", () => { | |
| 663 | const home = db(); | |
| 664 | finished(home, "gen_1z141z3", LISTED_RESULTS); | |
| 665 | const html = boardsNav(home, "gen_ab12"); | |
| 666 | const links = boardsOf(html); | |
| 667 | expect(links.length).toBe(4); | |
| 668 | for (const link of links) { | |
| 669 | const url = new URL(link.href, "http://x"); | |
| 670 | expect(url.pathname).toBe("/"); | |
| 671 | const board = url.searchParams.get("board"); | |
| 672 | if (link.text === "every map") expect(board).toBeNull(); | |
| 673 | else expect(playedOn(board)).toBe(link.text as PlayedMap); | |
| 674 | } | |
| 675 | expect(links.find((l) => l.current)?.text).toBe("gen_ab12"); | |
| 676 | // Nothing in the nav is raw markup a map name could have brought in. | |
| 677 | expect(html.match(/<nav class="boards"[^>]*>(.*?)<\/nav>/s)?.[1]).not.toMatch(/<(?!a |\/a>)/); | |
| 678 | }); |
src/pages/game.ts +15 −10
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | 20 | import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 21 | 21 | import { |
| 22 | boardMaps, dayBoard, displayName, headshotShare, keepOf, LISTED_MAPS, LISTED_PLAYERS, liveness, type Liveness, matchCount, | |
| 22 | boardMaps, dayBoard, displayName, headshotShare, keepOf, LISTED_MAPS, LISTED_RESULTS, liveness, type Liveness, matchCount, | |
| 23 | 23 | playersOn, ratio, recentMatches, standings, |
| 24 | 24 | } from "../db.ts"; |
| 25 | 25 | import { counted } from "../keep.ts"; |
| @@ -110,24 +110,29 @@ export const boardLink = (map: PlayedMap | null): string => (map === null ? "/#s | ||
| 110 | 110 | /** What the page says when `?board=` was there and was not a map. */ |
| 111 | 111 | export const BAD_BOARD = "That link's board is not a map — this is every map instead."; |
| 112 | 112 | |
| 113 | /** The rule the map list was chosen by, said under it in these words. */ | |
| 114 | export const listedRule = (): string => | |
| 115 | `Maps with at least ${figure(LISTED_RESULTS)} finished results, most played first — de_forge always, and at most ` + | |
| 116 | `${figure(LISTED_MAPS)} maps.`; | |
| 117 | ||
| 113 | 118 | /** |
| 114 | 119 | * Which boards there are to read: every map, de_forge, and the generated maps |
| 115 | * `boardMaps` lists — plus the one being read, when a link named a map too new | |
| 116 | * to be listed, so the page never shows a board it offers no way back to. The | |
| 120 | * `boardMaps` lists — at most `LISTED_MAPS` maps in all. When a link named a | |
| 121 | * map too new to be listed, it takes the last place, so the page never shows a | |
| 122 | * board it offers no way back to and the list still never passes its cap. The | |
| 117 | 123 | * one on screen is marked `aria-current` rather than only coloured. |
| 118 | 124 | */ |
| 119 | function boardsNav(db: Database, board: PlayedMap | null): string { | |
| 120 | const listed: (PlayedMap | null)[] = [null, "de_forge", ...boardMaps(db).map((m) => m.map)]; | |
| 121 | if (board !== null && !listed.includes(board)) listed.push(board); | |
| 122 | const links = listed | |
| 125 | export function boardsNav(db: Database, board: PlayedMap | null): string { | |
| 126 | const maps: PlayedMap[] = ["de_forge", ...boardMaps(db).map((m) => m.map)]; | |
| 127 | if (board !== null && !maps.includes(board)) maps.splice(LISTED_MAPS - 1, maps.length, board); | |
| 128 | const links = [null, ...maps] | |
| 123 | 129 | .map((map) => `<a class="share" href="${escape(boardLink(map))}"${map === board ? ` aria-current="page"` : ""}>${ |
| 124 | 130 | map === null ? "every map" : escape(map) |
| 125 | 131 | }</a>`) |
| 126 | 132 | .join(" "); |
| 127 | 133 | return `<nav class="boards" aria-label="The all-time board, one map at a time">${links}</nav> |
| 128 | <p class="hint">Listed: de_forge, and the generated maps at least ${num(LISTED_PLAYERS)} players have a result on — the | |
| 129 | ${num(LISTED_MAPS)} with the most players. A generated map is new every match, so most have one player and are not a | |
| 130 | comparison; any map's board is still one link away, <code>/?board=</code> and its name.</p>`; | |
| 134 | <p class="hint" id="listedrule">${escape(listedRule())} A generated map is new every match, so most are played once; | |
| 135 | any map's board is still one link away, <code>/?board=</code> and its name.</p>`; | |
| 131 | 136 | } |
| 132 | 137 | |
| 133 | 138 | /** |