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

Merged opened this

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

Merged

torque/cs-engine:torque/416-the-board-lists-the-maps-worth-comparing → main 1 commit fork

  • Merged. torque/416-the-board-lists-the-maps-worth-comparing was merged into main as a4f1cfd.

Review

Sign in to review this change.
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.
332332 results played on that map, and `/?board=de_forge` to the fixed one; no
333333 `?board=` is every map added together, and the page says in a sentence which
334334 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
337342 `src/mapname.ts` grammar as a stored result; anything else is every map and a
338343 sentence saying so. A round played from a page on one map's board keeps it:
339344 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";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 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,
1414 playersOn,
1515 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, type Standing, standings,
1616 } from "./db.ts";
@@ -394,18 +394,51 @@ test("the de_forge board leaves out a generated map's rows even for the same pla
394394 expect(standings(db).map((r) => [r.name, r.kills])).toEqual([["kestrel", 33], ["falcon", 1]]);
395395 });
396396
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", () => {
398398 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);
403408 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 },
406411 ]);
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 }]);
409442 });
410443
411444 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
433466 standings(db, 10, "de_forge");
434467 const onForge = median(() => standings(db, 10, "de_forge"));
435468 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>?)");
436473 const listed = median(() => boardMaps(db));
437474 console.log(` board on de_forge ${onForge.toFixed(3)} ms, on a generated map ${onGen.toFixed(3)} ms, ` +
438475 `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(
157157 // built to retire; the kept row costs one more INSERT … SELECT on a write,
158158 // bounded by that one player's matches. A table built before the map was a
159159 // 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.
161162 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) {
163165 db.exec("DROP INDEX IF EXISTS standing_by_rank");
164166 db.exec("DROP TABLE standing");
165167 }
166 if (!standingColumns.includes("map")) {
168 if (restanding) {
167169 db.exec(`CREATE TABLE standing (
168170 map TEXT NOT NULL,
169171 user_id TEXT NOT NULL,
170172 name TEXT NOT NULL,
171173 matches INTEGER NOT NULL,
174 finished INTEGER NOT NULL,
172175 kills INTEGER NOT NULL,
173176 deaths INTEGER NOT NULL,
174177 headshots INTEGER NOT NULL,
@@ -177,10 +180,22 @@ export function openDatabase(
177180 rounds INTEGER NOT NULL,
178181 PRIMARY KEY (user_id, name, map)
179182 )`);
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`);
184199 }
185200 // `restand` reads one player's matches on every write; without this it reads
186201 // 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
247262
248263 // One player's row of the board, summed from their matches: the same columns
249264 // 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),
251267 SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds)`;
252268
253269 /**
@@ -257,18 +273,34 @@ const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(
257273 * (a delete, or only rounds-0 rows) has no row.
258274 */
259275 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();
260282 // By player and not by map: a later post of a match may carry another map
261283 // than the one it first did, so every map row of this player is redone.
262284 db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name);
263285 db.query(
264 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
286 `INSERT INTO standing (map, ${STANDING_INTO})
265287 SELECT '', ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`,
266288 ).run(userId, name);
267289 db.query(
268 `INSERT INTO standing (map, user_id, name, matches, kills, deaths, headshots, damage, wins, rounds)
290 `INSERT INTO standing (map, ${STANDING_INTO})
269291 SELECT map, ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0
270292 GROUP BY map, user_id, name`,
271293 ).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 }
272304 }
273305
274306 /** 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
443475 export const playersOn = (db: Database, map: PlayedMap | null = null): number =>
444476 db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM standing WHERE map = ?1").get(map ?? EVERY_MAP)?.n ?? 0;
445477
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 };
448480
449481 /**
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.
452485 */
453 export const LISTED_PLAYERS = 2;
486 export const LISTED_RESULTS = 3;
454487
455488 /**
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.
458492 */
459 export const LISTED_MAPS = 6;
493 export const LISTED_MAPS = 8;
460494
461495 /**
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`.
466500 *
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.
470505 */
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[] =>
472507 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
477511 LIMIT ?2`,
478512 ).all(min, limit);
479513
src/pages/game.test.ts +62 −3
@@ -23,11 +23,11 @@ import { expect, test } from "bun:test";
2323 import { mkdtempSync } from "node:fs";
2424 import { tmpdir } from "node:os";
2525 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";
2727 import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2828 import { playKey } from "../matchid.ts";
2929 import { LONGEST_PASTE, pastedLink } from "../matchlink.ts";
30 import { type PlayedMap, seedOf } from "../mapname.ts";
30 import { type PlayedMap, playedOn, seedOf } from "../mapname.ts";
3131 import type { Result } from "../result.ts";
3232 import { CARD, type Card } from "../client/weights.ts";
3333 import { COMPETITIVE, SHORT } from "../client/match.ts";
@@ -37,7 +37,7 @@ import { ALL, damageRows, gunsOf } from "../sheet.ts";
3737 import { GAMES } from "../games.ts";
3838 import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts";
3939 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,
4141 foldOpen, offerOf, OPTIONS, type Asked,
4242 } from "./game.ts";
4343 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", () =>
617617 expect(every).toContain(`<section id="stats">`);
618618 expect(every).not.toContain("data-board");
619619 });
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(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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 @@
1919 import type { Database } from "bun:sqlite";
2020 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2121 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,
2323 playersOn, ratio, recentMatches, standings,
2424 } from "../db.ts";
2525 import { counted } from "../keep.ts";
@@ -110,24 +110,29 @@ export const boardLink = (map: PlayedMap | null): string => (map === null ? "/#s
110110 /** What the page says when `?board=` was there and was not a map. */
111111 export const BAD_BOARD = "That link's board is not a map — this is every map instead.";
112112
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
113118 /**
114119 * 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
117123 * one on screen is marked `aria-current` rather than only coloured.
118124 */
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]
123129 .map((map) => `<a class="share" href="${escape(boardLink(map))}"${map === board ? ` aria-current="page"` : ""}>${
124130 map === null ? "every map" : escape(map)
125131 }</a>`)
126132 .join(" ");
127133 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>`;
131136 }
132137
133138 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs