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

forked from trevato/cs

The leaderboard has a ceiling it is allowed to be: a stated retention rule, pruned as matches are written #412

Merged opened this

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

Merged

torque/cs-engine:torque/256-the-leaderboard-has-a-ceiling-it-is-allo → main 2 commits fork

  • Merged. torque/256-the-leaderboard-has-a-ceiling-it-is-allo was merged into main as ac46148.

Review

Sign in to review this change.
11 files changed +400 −31
src/db.test.ts +144 −2
@@ -10,11 +10,13 @@ import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, type MatchRow, openDatabase, type Player, recentMatches,
14 recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player,
14 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
1515 } from "./db.ts";
16 import { KEEP } from "./keep.ts";
1617 import type { RoundPlan } from "./plan/plan.ts";
1718 import { statsHtml } from "./pages/game.ts";
19 import { boardsHtml } from "./pages/range.ts";
1820 import { createHub } from "./rooms/hub.ts";
1921 import type { Room } from "./rooms/room.ts";
2022 import { ENDING_TICKS, lastRound, opened, spy } from "./rooms/stage.test-helper.ts";
@@ -563,3 +565,143 @@ test("a room's writer refuses what a post would be refused: another owner's row,
563565 writer.write({ id: "gone", result: result() });
564566 expect(recentMatches(db).length).toBe(1);
565567 });
568
569 // --- the ceiling (#256): how much of the disk the records are allowed to be ---
570
571 /** The ids a table still holds, oldest insert first. */
572 const ids = (db: Database, table: "match" | "run"): string[] =>
573 db.query<{ id: string }, []>(`SELECT id FROM ${table} ORDER BY rowid`).all().map((r) => r.id);
574
575 /** Move a row's clock, as a later post would: the prune reads `updated_at`, not the insert order. */
576 const touchedAt = (db: Database, table: "match" | "run", id: string, at: string): void => {
577 db.query(`UPDATE ${table} SET updated_at = ?1 WHERE id = ?2`).run(at, id);
578 };
579
580 test("writing past the ceiling leaves exactly the ceiling's worth of matches, and the newest are the ones that stay", () => {
581 const db = openDatabase(dir(), { matches: 3, runs: 3 });
582 const written = ["a", "b", "c", "d", "e"].map((id) => {
583 saveMatch(db, guest("ace"), result({ matchId: `${id}0000000` }));
584 return `${id}0000000`;
585 });
586 // Five written, three kept: the last three, inserted in the same second, so
587 // the earlier insert is the older one.
588 expect(ids(db, "match")).toEqual(written.slice(2));
589 expect(matchCount(db)).toBe(3);
590 });
591
592 test("oldest is least recently written, so a match still being posted to outlives newer ones that stopped", () => {
593 const db = openDatabase(dir(), { matches: 3, runs: 3 });
594 for (const id of ["a", "b", "c"]) saveMatch(db, guest("ace"), result({ matchId: `${id}0000000` }));
595 touchedAt(db, "match", "a0000000", "2020-01-01 12:00:03");
596 touchedAt(db, "match", "b0000000", "2020-01-01 12:00:01");
597 touchedAt(db, "match", "c0000000", "2020-01-01 12:00:02");
598 saveMatch(db, guest("ace"), result({ matchId: "d0000000" }));
599 // b was written longest ago, though a was inserted first.
600 expect(ids(db, "match")).toEqual(["a0000000", "c0000000", "d0000000"]);
601 });
602
603 test("a post to a match already on the table never prunes, even at the ceiling", () => {
604 const db = openDatabase(dir(), { matches: 2, runs: 2 });
605 const first = result({ matchId: "a0000000", rounds: 1, finished: false });
606 saveMatch(db, guest("ace"), first);
607 saveMatch(db, guest("ace"), result({ matchId: "b0000000" }));
608 saveMatch(db, guest("ace"), { ...first, rounds: 2 });
609 expect(ids(db, "match")).toEqual(["a0000000", "b0000000"]);
610 });
611
612 test("the standings after a prune are the standings of what is left, not what was there", () => {
613 const db = openDatabase(dir(), { matches: 2, runs: 2 });
614 saveMatch(db, guest("bo"), result({ matchId: "a0000000", kills: 50 }));
615 saveMatch(db, player("u-1", "ace"), result({ matchId: "b0000000", kills: 7 }));
616 saveMatch(db, player("u-1", "ace"), result({ matchId: "c0000000", kills: 5 }));
617 // bo's only match went, so bo is off the board rather than kept at fifty.
618 expect(standings(db).map((s) => [s.name, s.matches, s.kills])).toEqual([["ace", 2, 12]]);
619 // And ace's older one goes next: ace is summed again from the one that is left.
620 saveMatch(db, guest("cy"), result({ matchId: "d0000000", kills: 1 }));
621 expect(standings(db).map((s) => [s.name, s.matches, s.kills])).toEqual([["ace", 1, 5], ["cy", 1, 1]]);
622 // Which is exactly what summing the table from scratch says.
623 const summed = db.query<{ name: string; n: number; k: number }, []>(
624 "SELECT name, COUNT(*) AS n, SUM(kills) AS k FROM match GROUP BY user_id, name ORDER BY k DESC",
625 ).all();
626 expect(summed.map((s) => [s.name, s.n, s.k])).toEqual([["ace", 1, 5], ["cy", 1, 1]]);
627 });
628
629 test("the range's runs are held to their own ceiling, and a drill's board is ranked from what is left", () => {
630 const db = openDatabase(dir(), { matches: 1_000, runs: 2 });
631 saveRun(db, guest("ace"), range({ runId: "a0000000", score: 9_000 }));
632 saveRun(db, guest("bo"), range({ runId: "b0000000", score: 100 }));
633 saveRun(db, guest("cy"), range({ runId: "c0000000", score: 200 }));
634 expect(ids(db, "run")).toEqual(["b0000000", "c0000000"]);
635 // The best run anyone had is gone with its row: the board is the two that remain.
636 expect(runBoard(db, "popup").map((r) => [r.name, r.score])).toEqual([["cy", 200], ["bo", 100]]);
637 // A retried post of a run already kept is the same row, and prunes nothing.
638 saveRun(db, guest("bo"), range({ runId: "b0000000", score: 100 }));
639 expect(ids(db, "run")).toEqual(["b0000000", "c0000000"]);
640 });
641
642 test("a database already over the ceiling is brought under it when it opens, not on the next write", () => {
643 const where = dir();
644 const before = openDatabase(where, { matches: 1_000, runs: 1_000 });
645 for (const [i, name] of ["bo", "bo", "ace", "ace", "cy"].entries()) {
646 saveMatch(before, guest(name), result({ matchId: `m${i}000000`, kills: i + 1 }));
647 saveRun(before, guest(name), range({ runId: `r${i}000000`, score: i }));
648 }
649 before.close();
650
651 // The operator lowers the ceiling: the next start holds the table to it at once.
652 const after = openDatabase(where, { matches: 2, runs: 3 });
653 expect(ids(after, "match")).toEqual(["m3000000", "m4000000"]);
654 expect(ids(after, "run")).toEqual(["r2000000", "r3000000", "r4000000"]);
655 // bo lost both matches and is gone; ace kept one of two and is summed from it.
656 expect(standings(after).map((s) => [s.name, s.matches, s.kills])).toEqual([["cy", 1, 5], ["ace", 1, 4]]);
657 });
658
659 test("nothing is pruned while the table is under its ceiling", () => {
660 const db = openDatabase(dir());
661 for (let i = 0; i < 20; i++) saveMatch(db, guest("ace"), result({ matchId: `m${String(i).padStart(7, "0")}` }));
662 expect(matchCount(db)).toBe(20);
663 expect(keepOf(db)).toEqual(KEEP);
664 });
665
666 test("a worst-case row costs what src/keep.ts says it costs on disk, indexes included", () => {
667 // Every field at its cap: a 64-character id, a 32-hex session, a 26-character
668 // forge id, 24 three-byte characters of name, a generated map, a day and the
669 // largest counters a result can carry. 5,000 of each, measured by the pages
670 // the file grew by.
671 const n = 5_000;
672 const bytesPerRow = (table: "match" | "run"): number => {
673 const db = openDatabase(dir(), KEEP);
674 const pages = (): number => db.query<{ page_count: number }, []>("PRAGMA page_count").get()?.page_count ?? 0;
675 const size = db.query<{ page_size: number }, []>("PRAGMA page_size").get()?.page_size ?? 0;
676 const empty = pages();
677 const name = "語".repeat(24);
678 const insert = table === "match"
679 ? db.query(`INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds,
680 score_mine, score_theirs, finished, seconds) VALUES (?1, ?2, ?3, ?4, 'gen_zzzzzzz', '2026-09-23',
681 150, 30, 150, 30000, 30, 16, 14, 1, 5400)`)
682 : db.query(`INSERT INTO run (id, session, user_id, name, drill, score, hits, shots, seed, seconds)
683 VALUES (?1, ?2, ?3, ?4, 'tracking', 99999, 999, 999, 4294967295, 30)`);
684 db.transaction(() => {
685 for (let i = 0; i < n; i++) insert.run(String(i).padStart(64, "-"), "f".repeat(32), "u".repeat(26), name);
686 })();
687 return ((pages() - empty) * size) / n;
688 };
689 const match = bytesPerRow("match");
690 const run = bytesPerRow("run");
691 expect(match).toBeGreaterThan(450);
692 expect(match).toBeLessThanOrEqual(560);
693 expect(run).toBeGreaterThan(340);
694 expect(run).toBeLessThanOrEqual(440);
695 });
696
697 test("the page says the board forgets once it is full, and says nothing before", () => {
698 const db = openDatabase(dir(), { matches: 2, runs: 2 });
699 saveMatch(db, guest("ace"), result({ matchId: "a0000000" }));
700 expect(statsHtml(db)).not.toContain("keeps the most recent");
701 saveMatch(db, guest("ace"), result({ matchId: "b0000000" }));
702 expect(statsHtml(db)).toContain("This board keeps the most recent 2 matches, and it is full");
703 saveRun(db, guest("ace"), range({ runId: "a0000000" }));
704 expect(boardsHtml(db)).not.toContain("keeps the most recent");
705 saveRun(db, guest("ace"), range({ runId: "b0000000" }));
706 expect(boardsHtml(db)).toContain("The board keeps the most recent 2 runs, and it is full");
707 });
src/db.ts +76 −10
@@ -23,6 +23,7 @@ import { Database } from "bun:sqlite";
2323 import { mkdirSync } from "node:fs";
2424 import { join } from "node:path";
2525 import { dayFiled } from "./daily.ts";
26 import { KEEP, keepFrom, type Keep } from "./keep.ts";
2627 import { parsePlan, type RoundPlan } from "./plan/plan.ts";
2728 import type { Result } from "./result.ts";
2829 import type { RangeRun } from "./run.ts";
@@ -66,7 +67,16 @@ export type Standing = {
6667 rounds: number;
6768 };
6869
69 export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Database {
70 /**
71 * Open the database, and hold it to `keep` from here on (`src/keep.ts`). The
72 * ceilings are read from the environment once, here, at start-up; a test
73 * passes its own. A database already over them is brought back under before
74 * anything reads it, not on whichever write happens to come next.
75 */
76 export function openDatabase(
77 dir = process.env.FORGE_APP_DATA ?? "./data",
78 keep: Keep = keepFrom(process.env),
79 ): Database {
7080 mkdirSync(dir, { recursive: true });
7181 const db = new Database(join(dir, "app.db"));
7282 db.exec("PRAGMA journal_mode = WAL");
@@ -170,9 +180,54 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
170180 created_at TEXT NOT NULL DEFAULT (datetime('now')),
171181 PRIMARY KEY (match, situation)
172182 )`);
183 kept.set(db, keep);
184 db.transaction(() => {
185 for (const p of prune(db, "match", keep.matches)) restand(db, p.user_id, p.name);
186 prune(db, "run", keep.runs);
187 })();
173188 return db;
174189 }
175190
191 /** The ceilings each open database was opened with. One not opened here keeps the defaults. */
192 const kept = new WeakMap<Database, Keep>();
193
194 export const keepOf = (db: Database): Keep => kept.get(db) ?? KEEP;
195
196 /**
197 * Delete the oldest rows of `table` until it holds `keep`, and say whose they
198 * were so the caller can recompute those players' standing from what is left.
199 *
200 * Oldest is least recently written — a match still being played is written on
201 * every round and is never the oldest — with the earlier insert going first
202 * between two rows written in the same second. Both steps are reads of
203 * `*_by_time`: `MIN(updated_at)` is the index's last entry, and the rows at
204 * that instant are one equality range in it, already in rowid order, so no
205 * step sorts the table. On a write the table is at most one over, so this is
206 * one COUNT (22 µs at 50,000 rows), one MIN, one read and one delete of a
207 * single row; at start-up it loops once per second of history it removes.
208 */
209 function prune(db: Database, table: "match" | "run", keep: number): { user_id: string; name: string }[] {
210 const gone: { user_id: string; name: string }[] = [];
211 let over = (db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0) - keep;
212 while (over > 0) {
213 const at = db.query<{ at: string }, []>(`SELECT MIN(updated_at) AS at FROM ${table}`).get()?.at;
214 if (at === undefined) break;
215 const rows = db.query<{ r: number; user_id: string; name: string }, [string, number]>(
216 `SELECT rowid AS r, user_id, name FROM ${table} WHERE updated_at = ?1 ORDER BY rowid LIMIT ?2`,
217 ).all(at, over);
218 const last = rows.at(-1);
219 if (last === undefined) break;
220 db.query(`DELETE FROM ${table} WHERE updated_at = ?1 AND rowid <= ?2`).run(at, last.r);
221 over -= rows.length;
222 for (const row of rows) {
223 if (!gone.some((g) => g.user_id === row.user_id && g.name === row.name)) {
224 gone.push({ user_id: row.user_id, name: row.name });
225 }
226 }
227 }
228 return gone;
229 }
230
176231 // One player's row of the board, summed from their matches: the same columns
177232 // whether it is built for the whole table once or for one player on a write.
178233 const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage),
@@ -284,6 +339,13 @@ export function saveMatch(db: Database, player: Player, result: Result, now = Da
284339 // A post may rename the row (a forge rename, a guest who retyped), which
285340 // moves it from one board row to another: both are recomputed.
286341 if (existing !== null && existing.name !== player.name) restand(db, existing.user_id, existing.name);
342 // A new row may take the table past its ceiling; an update never does.
343 // Whoever lost a match to that is recomputed from the matches they have
344 // left, in this transaction, so the board never shows a row it no longer has.
345 const gone = existing === null ? prune(db, "match", keepOf(db).matches) : [];
346 for (const p of gone) {
347 if (p.user_id !== player.userId || p.name !== player.name) restand(db, p.user_id, p.name);
348 }
287349 restand(db, player.userId, player.name);
288350 })();
289351 return true;
@@ -429,15 +491,19 @@ export function saveRun(db: Database, player: Player, run: RangeRun): boolean {
429491 if (existing.user_id !== player.userId) return false;
430492 if (player.userId === "" && existing.session !== player.session) return false;
431493 }
432 db.query(
433 `INSERT INTO run (id, session, user_id, name, drill, score, hits, shots, seed, seconds)
434 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
435 ON CONFLICT(id) DO UPDATE SET
436 name = excluded.name, updated_at = datetime('now')`,
437 ).run(
438 run.runId, player.session, player.userId, player.name, run.drill,
439 run.score, run.hits, run.shots, run.seed, run.seconds,
440 );
494 db.transaction(() => {
495 db.query(
496 `INSERT INTO run (id, session, user_id, name, drill, score, hits, shots, seed, seconds)
497 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
498 ON CONFLICT(id) DO UPDATE SET
499 name = excluded.name, updated_at = datetime('now')`,
500 ).run(
501 run.runId, player.session, player.userId, player.name, run.drill,
502 run.score, run.hits, run.shots, run.seed, run.seconds,
503 );
504 // The boards are read from the rows, so nothing is kept to recompute.
505 if (existing === null) prune(db, "run", keepOf(db).runs);
506 })();
441507 return true;
442508 }
443509
src/keep.test.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * The ceilings and the two environment variables that move them: the default
3 * is the stated number, a good value is taken, and anything else stops the
4 * start with a sentence naming the variable instead of running with a number
5 * nobody chose.
6 */
7
8 import { expect, test } from "bun:test";
9 import { counted, KEEP, KEEP_MAX, KEEP_MIN, keepFrom } from "./keep.ts";
10
11 test("unset or empty is the stated ceiling, for both tables", () => {
12 expect(KEEP).toEqual({ matches: 50_000, runs: 50_000 });
13 expect(keepFrom({})).toEqual(KEEP);
14 expect(keepFrom({ KEEP_MATCHES: "", KEEP_RUNS: " " })).toEqual(KEEP);
15 });
16
17 test("an operator raises either one without the other", () => {
18 expect(keepFrom({ KEEP_MATCHES: "200000" })).toEqual({ matches: 200_000, runs: 50_000 });
19 expect(keepFrom({ KEEP_RUNS: " 1000 " })).toEqual({ matches: 50_000, runs: 1_000 });
20 expect(keepFrom({ KEEP_MATCHES: String(KEEP_MAX), KEEP_RUNS: String(KEEP_MIN) }))
21 .toEqual({ matches: KEEP_MAX, runs: KEEP_MIN });
22 });
23
24 test("a value that is not a whole number in range refuses to start, and says which variable", () => {
25 for (const bad of ["999", "1000001", "-5000", "5e4", "50,000", "50000.5", "0x10000", "lots", "12345678"]) {
26 expect(() => keepFrom({ KEEP_MATCHES: bad })).toThrow(/^KEEP_MATCHES must be a whole number from 1000 to 1000000/);
27 expect(() => keepFrom({ KEEP_RUNS: bad })).toThrow(/^KEEP_RUNS must be/);
28 }
29 });
30
31 test("the arithmetic in the comment: both tables at their ceilings, at the measured worst-case row sizes", () => {
32 // 560 B a match and 440 B a run at most, indexes included (measured in `db.test.ts`).
33 expect(KEEP.matches * 560).toBe(28_000_000);
34 expect(KEEP.runs * 440).toBe(22_000_000);
35 expect(KEEP.matches * 560 + KEEP.runs * 440).toBe(50_000_000);
36 // And the worst write: one name owning every match, re-summed at ≈ 0.9 µs a row.
37 expect(KEEP.matches * 0.9e-3).toBeLessThan(50);
38 });
39
40 test("a ceiling is printed with thousands separators", () => {
41 expect(counted(50_000)).toBe("50,000");
42 expect(counted(1_000_000)).toBe("1,000,000");
43 expect(counted(3)).toBe("3");
44 });
src/keep.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * How much of this app's disk the records are allowed to be: a ceiling on the
3 * match table and one on the range's run table, and the one place both are
4 * said. `src/db.ts` holds each table to its ceiling by pruning the oldest rows
5 * (by `updated_at`) inside the write that went past it, and once at start-up
6 * for a database that is already over.
7 *
8 * **50,000 matches.** Measured, not guessed: a worst-case match row — a 64-byte
9 * id, a 32-byte session, a 26-byte forge id, a 24-character name at three bytes
10 * a character, a generated map, a day, both timestamps and every counter at its
11 * cap — costs at most 560 bytes on disk with its four indexes (the primary
12 * key, `match_by_time`, `match_by_day`, `match_by_player`; 550 at 100,000 rows,
13 * 552 at 5,000), so the table at its ceiling is 50,000 × 560 B = 28 MB. The
14 * number is the one where the worst write stays inside the app's 50 ms: a write
15 * re-sums the writer's own matches (`restand`, #100) at about 0.9 µs a row, and
16 * a name that owned every row would make each of its writes ≈ 45 ms. Played for
17 * real, at twenty minutes a match, it is over 16,000 hours of play before the
18 * first result goes.
19 *
20 * **50,000 runs.** A worst-case run row is at most 440 bytes with its three
21 * indexes (414 at 100,000 rows, 430 at 5,000), so 50,000 × 440 B = 22 MB. The
22 * same number as the matches, so there is one figure to remember: both tables
23 * together are 50 MB at most. `db.test.ts` measures both row sizes on a real
24 * file and fails if they grow past these.
25 *
26 * What the ceiling does not bound is who fills it. The limiter lets a guest
27 * address post four results a second, so a caller who keeps at it reaches
28 * 50,000 in under four hours and pushes real results out ahead of theirs. It
29 * bounds the disk, which is what it is for; it is not an abuse control.
30 */
31
32 /** Rows each table keeps, newest by `updated_at` first. */
33 export type Keep = { matches: number; runs: number };
34
35 export const KEEP: Keep = { matches: 50_000, runs: 50_000 };
36
37 /**
38 * What an operator may set through `KEEP_MATCHES` and `KEEP_RUNS`. Below a
39 * thousand is a typo that would prune a real board to nothing on the next
40 * start; above a million is 560 MB of matches and a worst write of ≈ 0.9 s.
41 */
42 export const KEEP_MIN = 1_000;
43 export const KEEP_MAX = 1_000_000;
44
45 const DIGITS = /^[0-9]{1,7}$/;
46
47 function one(env: Record<string, string | undefined>, name: string, fallback: number): number {
48 const raw = env[name];
49 if (raw === undefined || raw.trim() === "") return fallback;
50 const text = raw.trim();
51 const n = DIGITS.test(text) ? Number(text) : Number.NaN;
52 if (!Number.isInteger(n) || n < KEEP_MIN || n > KEEP_MAX) {
53 throw new Error(`${name} must be a whole number from ${KEEP_MIN} to ${KEEP_MAX}; it is ${JSON.stringify(raw.slice(0, 40))}`);
54 }
55 return n;
56 }
57
58 /**
59 * The ceilings this process runs with, read once at start-up. Unset or empty
60 * is the default; anything else that is not a whole number in range stops the
61 * start with a sentence naming the variable, rather than quietly running with
62 * a number nobody chose.
63 */
64 export const keepFrom = (env: Record<string, string | undefined>): Keep => ({
65 matches: one(env, "KEEP_MATCHES", KEEP.matches),
66 runs: one(env, "KEEP_RUNS", KEEP.runs),
67 });
68
69 /** `50,000`: how a ceiling is printed, on the page and in the terminal alike. */
70 export const counted = (n: number): string => n.toLocaleString("en-US");
src/pages/game.ts +18 −2
@@ -18,7 +18,10 @@
1818
1919 import type { Database } from "bun:sqlite";
2020 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
21 import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
21 import {
22 dayBoard, displayName, headshotShare, keepOf, liveness, type Liveness, matchCount, ratio, recentMatches, standings,
23 } from "../db.ts";
24 import { counted } from "../keep.ts";
2225 import { COMPETITIVE, SHORT } from "../client/match.ts";
2326 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
2427 import { CS_MOVE } from "../client/movement.ts";
@@ -112,13 +115,26 @@ export function statsHtml(db: Database, now = Date.now()): string {
112115 return `<h2>Leaderboard</h2>
113116 ${todayHtml(db, dailyOn(now))}
114117 <h3>All time</h3>
115 ${table.length === 0
118 ${keptHtml(db)}${table.length === 0
116119 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
117120 : `<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>`}
118121 <h3>Recent matches</h3>
119122 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
120123 }
121124
125 /**
126 * The board forgets, and says so once it is about to: at its ceiling
127 * (`src/keep.ts`) every new match drops the oldest, so the counts below are no
128 * longer every match ever played. Below it the sentence would be a promise
129 * about a future most boards never reach, and the page says nothing. A COUNT
130 * is 22 µs at the ceiling.
131 */
132 function keptHtml(db: Database): string {
133 const keep = keepOf(db).matches;
134 return matchCount(db) < keep ? "" : `<p class="hint">This board keeps the most recent ${counted(keep)} matches, and it is full: ` +
135 `each new one drops the oldest, and every row is summed from the matches that are kept.</p>\n`;
136 }
137
122138 /**
123139 * What the link a person followed asked for, validated in `src/server.ts` and
124140 * escaped here. `match` is a match id or null — never the raw parameter — and
src/pages/range.ts +10 −2
@@ -29,7 +29,8 @@
2929 */
3030
3131 import { type Database } from "bun:sqlite";
32 import { displayName, hitShare, runBoard, type RunStanding } from "../db.ts";
32 import { displayName, hitShare, keepOf, runBoard, runCount, type RunStanding } from "../db.ts";
33 import { counted } from "../keep.ts";
3334 // The shooter's numbers, for the two sentences that say what the range is not.
3435 // A page is the chrome both games are served inside and may read both, which
3536 // is exactly why the comparison belongs here and in neither game.
@@ -89,7 +90,14 @@ ${board.length === 0
8990 return `<h2>The board</h2>
9091 <p class="hint">One row a shooter, their best run at that drill, ranked by ${escape(RANKED_BY)}. A run is
9192 thirty seconds; the two drills are ranked apart, because a tracking score means nothing beside a pop-up one.</p>
92 ${tables}`;
93 ${keptHtml(db)}${tables}`;
94 }
95
96 /** `keptHtml` in `./game.ts`, for the runs: said once the table is full, and not before. */
97 function keptHtml(db: Database): string {
98 const keep = keepOf(db).runs;
99 return runCount(db) < keep ? "" : `<p class="hint">The board keeps the most recent ${counted(keep)} runs, and it is full: ` +
100 `each new one drops the oldest, and every row is the best of the runs that are kept.</p>\n`;
93101 }
94102
95103 const NOTE = `
src/terminal/keys.test.ts +2
@@ -10,6 +10,7 @@ import type { Card } from "../client/weights.ts";
1010 import type { MatchRow } from "../db.ts";
1111 import type { Data, View } from "./screen.ts";
1212 import { dailyOn } from "../daily.ts";
13 import { KEEP } from "../keep.ts";
1314 import { press, removed } from "./keys.ts";
1415
1516 const CARD = { trainedAt: "2026-09-14", decisions: 1, envSteps: 1, updates: 1, stepsPerSecond: 1, minutes: 1, duels: 1, winRate: 1, baselineWinRate: 1, maps: 1, unseenMaps: 1, unseenWinRate: 1, unseenBaselineWinRate: 1, unseenDrawRate: 1 } satisfies Card;
@@ -58,6 +59,7 @@ const data: Data = {
5859 runs: [run("r1", "ace"), run("r2", "bo")],
5960 total: 4,
6061 runTotal: 2,
62 keep: KEEP,
6163 card: CARD,
6264 // The keys read rows and a cursor; the clock is the screen's business.
6365 now: Date.parse("2026-09-16T10:00:30Z"),
src/terminal/screen.test.ts +11 −6
@@ -13,6 +13,7 @@ import { clamp, plain, printable, rowsOf, screen, type Data, type View } from ".
1313 import { WEAPONS } from "../client/weapons.ts";
1414 import { backSaid, damageRows, GROUPS } from "../sheet.ts";
1515 import { dailyOn } from "../daily.ts";
16 import { KEEP } from "../keep.ts";
1617
1718 const CARD: Card = {
1819 trainedAt: "2026-09-14",
@@ -88,6 +89,7 @@ const data = (over: Partial<Data> = {}): Data => ({
8889 runs: [runRow("r1", "ace"), runRow("r2", "bo", { user_id: "", drill: "tracking", score: 700 })],
8990 runTotal: 2,
9091 total: 4,
92 keep: KEEP,
9193 card: CARD,
9294 now: NOW,
9395 ...over,
@@ -98,10 +100,13 @@ const view = (over: Partial<View> = {}): View =>
98100
99101 const text = (v: View, d: Data = data()): string[] => screen(v, d).map(plain);
100102
101 test("the header says where we are and how many matches there are, plural when it should be", () => {
102 expect(text(view())[0]).toBe("cs local · 4 matches · 2 runs");
103 expect(text(view(), data({ total: 1, runTotal: 1 }))[0]).toBe("cs local · 1 match · 1 run");
104 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches · 2 runs");
103 test("the header says where we are and how many matches and runs there are, against the ceilings they are kept to", () => {
104 expect(text(view())[0]).toBe("cs local · 4 of 50,000 matches · 2 of 50,000 runs");
105 expect(text(view(), data({ total: 1, runTotal: 1 }))[0]).toBe("cs local · 1 of 50,000 matches · 1 of 50,000 runs");
106 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 of 50,000 matches · 2 of 50,000 runs");
107 // An operator's own ceilings are the ones printed, each beside its own table.
108 expect(text(view(), data({ total: 120_000, keep: { matches: 120_000, runs: 2_000 } }))[0])
109 .toBe("cs local · 120000 of 120,000 matches · 2 of 2,000 runs");
105110 });
106111
107112 test("the tab strip names all six, and the highlight follows the tab you are on", () => {
@@ -338,8 +343,8 @@ test("the runs tab lists the range's runs, with the drill, the accuracy and the
338343 test("a runs tab with nothing on it says so, and the header counts both games", () => {
339344 const empty = screen(view({ tab: "runs" }), data({ runs: [], runTotal: 0 })).map(plain);
340345 expect(empty.join("\n")).toContain("no runs yet");
341 expect(screen(view(), data({ runTotal: 0 })).map(plain)[0]).toContain("0 runs");
342 expect(screen(view(), data({ runTotal: 1 })).map(plain)[0]).toContain("1 run");
346 expect(screen(view(), data({ runTotal: 0 })).map(plain)[0]).toContain("0 of 50,000 runs");
347 expect(screen(view(), data({ runTotal: 1 })).map(plain)[0]).toContain("1 of 50,000 runs");
343348 });
344349
345350 test("a name carrying an escape cannot move the cursor out of a run's row", () => {
src/terminal/screen.ts +8 −1
@@ -21,6 +21,7 @@ import { isThrown, type Weapon } from "../client/weapons.ts";
2121 import { type Daily, RANKED_BY } from "../daily.ts";
2222 import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts";
2323 import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts";
24 import { counted, type Keep } from "../keep.ts";
2425 import { matchOf } from "../matchid.ts";
2526 import { failures, localPlans, minuteOf, plannedSaid, type Rate, type Readout } from "../plan/readout.ts";
2627
@@ -61,6 +62,12 @@ export type Data = {
6162 total: number;
6263 /** Every range run ever recorded. */
6364 runTotal: number;
65 /**
66 * The ceilings the two counts above are held to (`src/keep.ts`): printed
67 * beside them, so whoever is looking can see how close the oldest row is to
68 * going.
69 */
70 keep: Keep;
6471 card: Card;
6572 /**
6673 * What the bots' round planner has done since the server started, from the
@@ -377,7 +384,7 @@ export function screen(view: View, data: Data): string[] {
377384 const cursor = clamp(view.cursor, rowsOf(view.tab, data));
378385 const lines: string[] = [];
379386 lines.push(
380 `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} match${data.total === 1 ? "" : "es"} · ${data.runTotal} run${data.runTotal === 1 ? "" : "s"}${esc}0m`,
387 `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} of ${counted(data.keep.matches)} matches · ${data.runTotal} of ${counted(data.keep.runs)} runs${esc}0m`,
381388 );
382389 lines.push("");
383390 lines.push(header(view.tab));
src/tty.test.ts +15 −7
@@ -19,6 +19,7 @@ import { mkdtempSync } from "node:fs";
1919 import { tmpdir } from "node:os";
2020 import { join } from "node:path";
2121 import { matchCount, openDatabase, saveMatch, type Player } from "./db.ts";
22 import { KEEP, type Keep } from "./keep.ts";
2223 import type { Result } from "./result.ts";
2324 import { emptyTally, type Readout } from "./plan/readout.ts";
2425 import { plain } from "./terminal/screen.ts";
@@ -76,9 +77,10 @@ function session(
7677 env?: Record<string, string | undefined>;
7778 seed?: (db: Database) => void;
7879 plans?: () => Readout | null;
80 keep?: Keep;
7981 } = {},
8082 ): Session {
81 const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-")));
83 const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-")), options.keep ?? KEEP);
8284 options.seed?.(db);
8385
8486 const writes: string[] = [];
@@ -130,13 +132,19 @@ test("the first paint is the standings, under the environment and the match coun
130132 expect(tty.writes[0]).toStartWith(CLEAR);
131133
132134 const lines = tty.frame();
133 expect(lines[0]).toBe("cs local · 2 matches · 0 runs");
135 expect(lines[0]).toBe("cs local · 2 of 50,000 matches · 0 of 50,000 runs");
134136 expect(lines[2]).toBe(" standings matches policy today runs weapons ");
135137 expect(tty.marked()).toStartWith("▸ ace");
136138 expect(lines.some((line) => line.startsWith(" bo"))).toBe(true);
137139 expect(tty.exits).toEqual([]);
138140 });
139141
142 test("the header counts each table against the ceiling the database was opened with", () => {
143 const tty = session({ keep: { matches: 3, runs: 2_000 }, seed: (db) => { for (const n of ["a", "b", "c", "d"]) play(db, n); } });
144 // Four matches were written to a table that keeps three: it says three, of three.
145 expect(tty.frame()[0]).toBe("cs local · 3 of 3 matches · 0 of 2,000 runs");
146 });
147
140148 test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off when it is unset", () => {
141149 const on = session({ env: { FORGE_APP_SSH: "ssh -p 2222 [email protected]" } });
142150 expect(on.frame().at(-1)).toBe("reach this terminal with: ssh -p 2222 [email protected]");
@@ -150,10 +158,10 @@ test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off wh
150158
151159 test("a preview says which pull it is, and production says production", () => {
152160 const preview = session({ env: { FORGE_APP_ENV: "preview", FORGE_APP_PULL: "109" } });
153 expect(preview.frame()[0]).toBe("cs preview of #109 · 0 matches · 0 runs");
154 expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 matches · 0 runs");
161 expect(preview.frame()[0]).toBe("cs preview of #109 · 0 of 50,000 matches · 0 of 50,000 runs");
162 expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 of 50,000 matches · 0 of 50,000 runs");
155163 // A preview that was not told its number still says it is one.
156 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches · 0 runs");
164 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 of 50,000 matches · 0 of 50,000 runs");
157165 });
158166
159167 test("tab cycles the six tabs, and every press draws exactly one frame", () => {
@@ -203,13 +211,13 @@ test("j and k move the cursor and stop at both ends of the list", () => {
203211
204212 test("r picks up a match written behind the session's back", () => {
205213 const tty = session({ seed: (db) => { play(db, "ace"); } });
206 expect(tty.frame()[0]).toBe("cs local · 1 match · 0 runs");
214 expect(tty.frame()[0]).toBe("cs local · 1 of 50,000 matches · 0 of 50,000 runs");
207215
208216 play(tty.db, "newcomer", { kills: 30 });
209217 // Not on the screen until it is asked for: the session queries when it draws.
210218 expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false);
211219 tty.press("r");
212 expect(tty.frame()[0]).toBe("cs local · 2 matches · 0 runs");
220 expect(tty.frame()[0]).toBe("cs local · 2 of 50,000 matches · 0 of 50,000 runs");
213221 expect(tty.marked()).toStartWith("▸ newcomer");
214222 });
215223
src/tty.ts +2 −1
@@ -23,7 +23,7 @@
2323
2424 import type { Database } from "bun:sqlite";
2525 import {
26 dayBoard, deleteMatch, deleteRun, matchCount, openDatabase, recentMatches, recentRuns, runCount, standings,
26 dayBoard, deleteMatch, deleteRun, keepOf, matchCount, openDatabase, recentMatches, recentRuns, runCount, standings,
2727 } from "./db.ts";
2828 import { dailyOn } from "./daily.ts";
2929 import { CARD } from "./client/weights.ts";
@@ -101,6 +101,7 @@ export function run(io: Io): void {
101101 runs: recentRuns(io.db, 20),
102102 total: matchCount(io.db),
103103 runTotal: runCount(io.db),
104 keep: keepOf(io.db),
104105 card: CARD,
105106 plans: io.plans?.() ?? null,
106107 // Read with the rows, and only ever here: the screen tells a live match

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs