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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/256-the-leaderboard-has-a-ceiling-it-is-allo → main 2 commits fork
11 files
changed +400
−31
src/db.test.ts +144 −2
| @@ -10,11 +10,13 @@ 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 | dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player, | |
| 14 | recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings, | |
| 15 | 15 | } from "./db.ts"; |
| 16 | import { KEEP } from "./keep.ts"; | |
| 16 | 17 | import type { RoundPlan } from "./plan/plan.ts"; |
| 17 | 18 | import { statsHtml } from "./pages/game.ts"; |
| 19 | import { boardsHtml } from "./pages/range.ts"; | |
| 18 | 20 | import { createHub } from "./rooms/hub.ts"; |
| 19 | 21 | import type { Room } from "./rooms/room.ts"; |
| 20 | 22 | 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, | ||
| 563 | 565 | writer.write({ id: "gone", result: result() }); |
| 564 | 566 | expect(recentMatches(db).length).toBe(1); |
| 565 | 567 | }); |
| 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"; | ||
| 23 | 23 | import { mkdirSync } from "node:fs"; |
| 24 | 24 | import { join } from "node:path"; |
| 25 | 25 | import { dayFiled } from "./daily.ts"; |
| 26 | import { KEEP, keepFrom, type Keep } from "./keep.ts"; | |
| 26 | 27 | import { parsePlan, type RoundPlan } from "./plan/plan.ts"; |
| 27 | 28 | import type { Result } from "./result.ts"; |
| 28 | 29 | import type { RangeRun } from "./run.ts"; |
| @@ -66,7 +67,16 @@ export type Standing = { | ||
| 66 | 67 | rounds: number; |
| 67 | 68 | }; |
| 68 | 69 | |
| 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 { | |
| 70 | 80 | mkdirSync(dir, { recursive: true }); |
| 71 | 81 | const db = new Database(join(dir, "app.db")); |
| 72 | 82 | db.exec("PRAGMA journal_mode = WAL"); |
| @@ -170,9 +180,54 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 170 | 180 | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| 171 | 181 | PRIMARY KEY (match, situation) |
| 172 | 182 | )`); |
| 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 | })(); | |
| 173 | 188 | return db; |
| 174 | 189 | } |
| 175 | 190 | |
| 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 | ||
| 176 | 231 | // One player's row of the board, summed from their matches: the same columns |
| 177 | 232 | // whether it is built for the whole table once or for one player on a write. |
| 178 | 233 | 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 | ||
| 284 | 339 | // A post may rename the row (a forge rename, a guest who retyped), which |
| 285 | 340 | // moves it from one board row to another: both are recomputed. |
| 286 | 341 | 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 | } | |
| 287 | 349 | restand(db, player.userId, player.name); |
| 288 | 350 | })(); |
| 289 | 351 | return true; |
| @@ -429,15 +491,19 @@ export function saveRun(db: Database, player: Player, run: RangeRun): boolean { | ||
| 429 | 491 | if (existing.user_id !== player.userId) return false; |
| 430 | 492 | if (player.userId === "" && existing.session !== player.session) return false; |
| 431 | 493 | } |
| 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 | })(); | |
| 441 | 507 | return true; |
| 442 | 508 | } |
| 443 | 509 |
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 @@ | ||
| 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 | dayBoard, displayName, headshotShare, keepOf, liveness, type Liveness, matchCount, ratio, recentMatches, standings, | |
| 23 | } from "../db.ts"; | |
| 24 | import { counted } from "../keep.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"; |
| @@ -112,13 +115,26 @@ export function statsHtml(db: Database, now = Date.now()): string { | ||
| 112 | 115 | return `<h2>Leaderboard</h2> |
| 113 | 116 | ${todayHtml(db, dailyOn(now))} |
| 114 | 117 | <h3>All time</h3> |
| 115 | ${table.length === 0 | |
| 118 | ${keptHtml(db)}${table.length === 0 | |
| 116 | 119 | ? `<p class="empty">No matches yet. Play one and it lands here.</p>` |
| 117 | 120 | : `<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 | 121 | <h3>Recent matches</h3> |
| 119 | 122 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| 120 | 123 | } |
| 121 | 124 | |
| 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 | ||
| 122 | 138 | /** |
| 123 | 139 | * What the link a person followed asked for, validated in `src/server.ts` and |
| 124 | 140 | * escaped here. `match` is a match id or null — never the raw parameter — and |
src/pages/range.ts +10 −2
| @@ -29,7 +29,8 @@ | ||
| 29 | 29 | */ |
| 30 | 30 | |
| 31 | 31 | 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"; | |
| 33 | 34 | // The shooter's numbers, for the two sentences that say what the range is not. |
| 34 | 35 | // A page is the chrome both games are served inside and may read both, which |
| 35 | 36 | // is exactly why the comparison belongs here and in neither game. |
| @@ -89,7 +90,14 @@ ${board.length === 0 | ||
| 89 | 90 | return `<h2>The board</h2> |
| 90 | 91 | <p class="hint">One row a shooter, their best run at that drill, ranked by ${escape(RANKED_BY)}. A run is |
| 91 | 92 | 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`; | |
| 93 | 101 | } |
| 94 | 102 | |
| 95 | 103 | const NOTE = ` |
src/terminal/keys.test.ts +2
| @@ -10,6 +10,7 @@ import type { Card } from "../client/weights.ts"; | ||
| 10 | 10 | import type { MatchRow } from "../db.ts"; |
| 11 | 11 | import type { Data, View } from "./screen.ts"; |
| 12 | 12 | import { dailyOn } from "../daily.ts"; |
| 13 | import { KEEP } from "../keep.ts"; | |
| 13 | 14 | import { press, removed } from "./keys.ts"; |
| 14 | 15 | |
| 15 | 16 | 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 = { | ||
| 58 | 59 | runs: [run("r1", "ace"), run("r2", "bo")], |
| 59 | 60 | total: 4, |
| 60 | 61 | runTotal: 2, |
| 62 | keep: KEEP, | |
| 61 | 63 | card: CARD, |
| 62 | 64 | // The keys read rows and a cursor; the clock is the screen's business. |
| 63 | 65 | 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 ". | ||
| 13 | 13 | import { WEAPONS } from "../client/weapons.ts"; |
| 14 | 14 | import { backSaid, damageRows, GROUPS } from "../sheet.ts"; |
| 15 | 15 | import { dailyOn } from "../daily.ts"; |
| 16 | import { KEEP } from "../keep.ts"; | |
| 16 | 17 | |
| 17 | 18 | const CARD: Card = { |
| 18 | 19 | trainedAt: "2026-09-14", |
| @@ -88,6 +89,7 @@ const data = (over: Partial<Data> = {}): Data => ({ | ||
| 88 | 89 | runs: [runRow("r1", "ace"), runRow("r2", "bo", { user_id: "", drill: "tracking", score: 700 })], |
| 89 | 90 | runTotal: 2, |
| 90 | 91 | total: 4, |
| 92 | keep: KEEP, | |
| 91 | 93 | card: CARD, |
| 92 | 94 | now: NOW, |
| 93 | 95 | ...over, |
| @@ -98,10 +100,13 @@ const view = (over: Partial<View> = {}): View => | ||
| 98 | 100 | |
| 99 | 101 | const text = (v: View, d: Data = data()): string[] => screen(v, d).map(plain); |
| 100 | 102 | |
| 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"); | |
| 105 | 110 | }); |
| 106 | 111 | |
| 107 | 112 | 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 | ||
| 338 | 343 | test("a runs tab with nothing on it says so, and the header counts both games", () => { |
| 339 | 344 | const empty = screen(view({ tab: "runs" }), data({ runs: [], runTotal: 0 })).map(plain); |
| 340 | 345 | 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"); | |
| 343 | 348 | }); |
| 344 | 349 | |
| 345 | 350 | 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"; | ||
| 21 | 21 | import { type Daily, RANKED_BY } from "../daily.ts"; |
| 22 | 22 | import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts"; |
| 23 | 23 | import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts"; |
| 24 | import { counted, type Keep } from "../keep.ts"; | |
| 24 | 25 | import { matchOf } from "../matchid.ts"; |
| 25 | 26 | import { failures, localPlans, minuteOf, plannedSaid, type Rate, type Readout } from "../plan/readout.ts"; |
| 26 | 27 | |
| @@ -61,6 +62,12 @@ export type Data = { | ||
| 61 | 62 | total: number; |
| 62 | 63 | /** Every range run ever recorded. */ |
| 63 | 64 | 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; | |
| 64 | 71 | card: Card; |
| 65 | 72 | /** |
| 66 | 73 | * 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[] { | ||
| 377 | 384 | const cursor = clamp(view.cursor, rowsOf(view.tab, data)); |
| 378 | 385 | const lines: string[] = []; |
| 379 | 386 | 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`, | |
| 381 | 388 | ); |
| 382 | 389 | lines.push(""); |
| 383 | 390 | lines.push(header(view.tab)); |
src/tty.test.ts +15 −7
| @@ -19,6 +19,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 19 | 19 | import { tmpdir } from "node:os"; |
| 20 | 20 | import { join } from "node:path"; |
| 21 | 21 | import { matchCount, openDatabase, saveMatch, type Player } from "./db.ts"; |
| 22 | import { KEEP, type Keep } from "./keep.ts"; | |
| 22 | 23 | import type { Result } from "./result.ts"; |
| 23 | 24 | import { emptyTally, type Readout } from "./plan/readout.ts"; |
| 24 | 25 | import { plain } from "./terminal/screen.ts"; |
| @@ -76,9 +77,10 @@ function session( | ||
| 76 | 77 | env?: Record<string, string | undefined>; |
| 77 | 78 | seed?: (db: Database) => void; |
| 78 | 79 | plans?: () => Readout | null; |
| 80 | keep?: Keep; | |
| 79 | 81 | } = {}, |
| 80 | 82 | ): Session { |
| 81 | const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-"))); | |
| 83 | const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-")), options.keep ?? KEEP); | |
| 82 | 84 | options.seed?.(db); |
| 83 | 85 | |
| 84 | 86 | const writes: string[] = []; |
| @@ -130,13 +132,19 @@ test("the first paint is the standings, under the environment and the match coun | ||
| 130 | 132 | expect(tty.writes[0]).toStartWith(CLEAR); |
| 131 | 133 | |
| 132 | 134 | 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"); | |
| 134 | 136 | expect(lines[2]).toBe(" standings matches policy today runs weapons "); |
| 135 | 137 | expect(tty.marked()).toStartWith("▸ ace"); |
| 136 | 138 | expect(lines.some((line) => line.startsWith(" bo"))).toBe(true); |
| 137 | 139 | expect(tty.exits).toEqual([]); |
| 138 | 140 | }); |
| 139 | 141 | |
| 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 | ||
| 140 | 148 | test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off when it is unset", () => { |
| 141 | 149 | const on = session({ env: { FORGE_APP_SSH: "ssh -p 2222 [email protected]" } }); |
| 142 | 150 | 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 | ||
| 150 | 158 | |
| 151 | 159 | test("a preview says which pull it is, and production says production", () => { |
| 152 | 160 | 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"); | |
| 155 | 163 | // 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"); | |
| 157 | 165 | }); |
| 158 | 166 | |
| 159 | 167 | 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", () => { | ||
| 203 | 211 | |
| 204 | 212 | test("r picks up a match written behind the session's back", () => { |
| 205 | 213 | 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"); | |
| 207 | 215 | |
| 208 | 216 | play(tty.db, "newcomer", { kills: 30 }); |
| 209 | 217 | // Not on the screen until it is asked for: the session queries when it draws. |
| 210 | 218 | expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false); |
| 211 | 219 | 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"); | |
| 213 | 221 | expect(tty.marked()).toStartWith("▸ newcomer"); |
| 214 | 222 | }); |
| 215 | 223 |
src/tty.ts +2 −1
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | |
| 24 | 24 | import type { Database } from "bun:sqlite"; |
| 25 | 25 | import { |
| 26 | dayBoard, deleteMatch, deleteRun, matchCount, openDatabase, recentMatches, recentRuns, runCount, standings, | |
| 26 | dayBoard, deleteMatch, deleteRun, keepOf, matchCount, openDatabase, recentMatches, recentRuns, runCount, standings, | |
| 27 | 27 | } from "./db.ts"; |
| 28 | 28 | import { dailyOn } from "./daily.ts"; |
| 29 | 29 | import { CARD } from "./client/weights.ts"; |
| @@ -101,6 +101,7 @@ export function run(io: Io): void { | ||
| 101 | 101 | runs: recentRuns(io.db, 20), |
| 102 | 102 | total: matchCount(io.db), |
| 103 | 103 | runTotal: runCount(io.db), |
| 104 | keep: keepOf(io.db), | |
| 104 | 105 | card: CARD, |
| 105 | 106 | plans: io.plans?.() ?? null, |
| 106 | 107 | // Read with the rows, and only ever here: the screen tells a live match |