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

forked from trevato/cs

A match of the day: everyone plays the same map, the same bots and the same seed, and the board means something #261

Merged opened this

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

Merged

torque/cs-engine:torque/174-a-match-of-the-day-everyone-plays-the-sa → main 1 commit fork

  • Merged. torque/174-a-match-of-the-day-everyone-plays-the-sa was merged into main as f4f836f.

Review

Sign in to review this change.
18 files changed +802 −63
README.md +28
@@ -235,6 +235,34 @@ digest through the engine's `digest` door and holds it against a committed
235235 constant, so a change to the generator that would move somebody's map fails the
236236 test rather than surprising them.
237237
238 ## The match of the day
239
240 Everyone who plays today plays the same match. The rule is the whole of it:
241 **take the calendar day in UTC and write it without its dashes** — the match of
242 2026-09-21 is the match `20260921`. That is a match id like any other, so it
243 carves the map and seeds the bots, the spawns, the bomb carrier and the spray,
244 and every visitor derives it from their own clock; the server never tells
245 anyone what it is. UTC, said on the page, so the day turns at the same moment
246 for everybody.
247
248 **Pick today's match** in the menu fills the controls with it — the id, the Map
249 control on `generated`, and first to 5, because the board ranks by rounds won
250 and rounds won compares nothing across two lengths. The map is named under the
251 controls before you press Play, like any other match.
252
253 Today's board stands beside the all-time table rather than replacing it: one
254 row a player, their **best** run at today's match, ranked by rounds won, then
255 kills, then damage — the order printed beside it. Play it as often as you like;
256 only the better run counts, and the row says how many there were. Which day a
257 result counts for is read out of its id by the server and never taken from the
258 browser: a result is filed under the day its id names and never under a day
259 that has not begun here, so deriving next week's id early buys nothing. It is a
260 score against bots on a fixed seed, reported by the browser that played it —
261 not a competitive ladder.
262
263 `src/daily.ts` is the rule, `dayBoard` in `src/db.ts` is the one extra query,
264 and the terminal's fourth tab is the same board.
265
238266 ## Who a score belongs to
239267
240268 The numbers in a result are the browser's word; the name on it is not. Whoever
src/client/generate.ts +8 −19
@@ -28,20 +28,21 @@
2828 */
2929
3030 import { region, rng, type Grid, type Vec3 } from "../engine/index.ts";
31 import { nameOf, seedOf } from "../mapname.ts";
31 import { nameOf, seedFrom, seedOf } from "../mapname.ts";
3232 import { CELL, levelOf, type Cell } from "./cells.ts";
3333 import { colOf, findPath, rowOf, type Level } from "./level.ts";
3434 import { lookFor } from "./looks.ts";
3535
3636 /**
3737 * What a map is called and what a name means, both from `src/mapname.ts` and
38 * re-exported here so the two live beside each other for a reader: `nameOf` is
39 * the name of a seed's map and `seedOf` is the seed of a name, which is how a
40 * map you played is a map you can ask for again. The grammar is there rather
41 * than here because the server validates `?map=` with it and may not import a
42 * generator to do it.
38 * re-exported here so the three live beside each other for a reader: `nameOf`
39 * is the name of a seed's map, `seedOf` is the seed of a name — which is how a
40 * map you played is a map you can ask for again — and `seedFrom` is the seed a
41 * match id means, which is the number every carving below starts from. All
42 * three are there rather than here because the server validates `?map=` and
43 * names today's map with them, and may not import a generator to do it.
4344 */
44 export { nameOf, seedOf };
45 export { nameOf, seedFrom, seedOf };
4546
4647 /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */
4748 export const SIZE = 40;
@@ -59,18 +60,6 @@ const MATE: Record<Cell, Cell> = {
5960 "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t",
6061 };
6162
62 /**
63 * A seed out of whatever text a page can hand over — the match id, usually,
64 * which is a uuid the browser made. FNV-1a, thirty-two bits: a hash rather than
65 * a clock, so a page that wants a map every match needs no source of numbers of
66 * its own and a test can hand in a word and get the same map every time.
67 */
68 export function seedFrom(text: string): number {
69 let hash = 0x811c9dc5;
70 for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
71 return hash >>> 0;
72 }
73
7463 /** The page of letters a grid is, one string a row: what a test or a terminal reads. */
7564 export const page = (grid: Grid<Cell>): string[] =>
7665 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
src/client/main.test.ts +52
@@ -39,6 +39,7 @@ import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts";
3939 import { digest, snapshot, withoutEntropy } from "./replay.ts";
4040 import { WEAPONS } from "./weapons.ts";
4141 import { matchOf } from "../matchid.ts";
42 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
4243 import { parseResult, type Result } from "../result.ts";
4344 import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
4445 import { parseSituation } from "../plan/situation.ts";
@@ -1435,3 +1436,54 @@ test("a page that names a planner asks it, with the situation and nothing else",
14351436 expect(world.plan?.source).toBe("local");
14361437 expect(stepsRun(stage)).toBeGreaterThan(0);
14371438 });
1439
1440 // --- the match of the day -------------------------------------------------
1441
1442 test("today's match fills the menu's own controls, and is the match that is played", () => {
1443 const stage = painted(start(fakeStage()));
1444 const daily = dailyOn(stage.wall);
1445 // The line is the browser's own day from the first frame: the server rendered
1446 // the same sentence, and this page derived it again rather than believing it.
1447 expect(stage.by("ontoday").textContent).toBe(saidToday(daily));
1448
1449 // One press fills the three controls that say what a match is — the id, the
1450 // Map control, the length — so what is about to be played is on the screen.
1451 stage.by("today").fire("click");
1452 expect(stage.by("matchid").value).toBe(daily.match);
1453 expect(stage.by("map").value).toBe("generated");
1454 expect(stage.by("mapseed").value).toBe("");
1455 expect(stage.by("length").value).toBe(DAILY_LENGTH);
1456 // And the map is named before Play, out of the id, like every other match on
1457 // `generated` — without drawing an id of its own, because the day is the id.
1458 expect(stage.by("willplay").textContent).toBe(daily.map);
1459 expect(stage.by("copymap").attributes.href).toBe(`/?map=${daily.map}`);
1460 expect(stage.ids.length).toBe(0);
1461
1462 // Play is that match: that id, that map, that length, and the link under the
1463 // stage plays it again.
1464 stage.by("play").fire("click");
1465 expect(worldOf(stage)?.level.name).toBe(daily.map);
1466 expect(worldOf(stage)?.match.rules).toEqual(SHORT);
1467 expect(stage.by("playing").textContent).toBe(daily.match);
1468 expect(stage.by("share").attributes.href).toBe(`/?match=${daily.match}&map=generated`);
1469 // What a round will be posted under is a *playing* of today's match, so a
1470 // second run is a second row and the match inside the key is still today's.
1471 expect(matchOf(String(stage.by("pauseid").textContent).split(" ")[1] ?? "")).toBe(daily.match);
1472 });
1473
1474 test("a browser that crosses midnight picks up the new day's match, not yesterday's", () => {
1475 const stage = painted(start(fakeStage()));
1476 const before = dailyOn(stage.wall);
1477 stage.by("today").fire("click");
1478 expect(stage.by("matchid").value).toBe(before.match);
1479
1480 // The tab was left open. The day is derived from the clock on every press,
1481 // so the next one is the match of the day it is now.
1482 stage.wall += 86_400_000;
1483 const after = dailyOn(stage.wall);
1484 expect(after.match).not.toBe(before.match);
1485 stage.by("today").fire("click");
1486 expect(stage.by("matchid").value).toBe(after.match);
1487 expect(stage.by("ontoday").textContent).toBe(saidToday(after));
1488 expect(stage.by("willplay").textContent).toBe(after.map);
1489 });
src/client/main.ts +48
@@ -69,6 +69,7 @@ import {
6969 } from "../engine/index.ts";
7070 import { generateLevel, seedFrom } from "./generate.ts";
7171 import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts";
72 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
7273 import { matchFrom, playKey } from "../matchid.ts";
7374 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
7475 import type { Situation } from "../plan/situation.ts";
@@ -257,6 +258,14 @@ export type Host = {
257258 * what shape it has to be.
258259 */
259260 randomUUID(): string;
261 /**
262 * The wall clock, in milliseconds since the epoch: `Date.now()`. It is asked
263 * for exactly one thing — which calendar day it is in UTC, and so which match
264 * is the match of the day (`src/daily.ts`) — and never for how long anything
265 * took, which is `window.performance.now()` and monotonic. A test hands over
266 * a day rather than waiting for one.
267 */
268 now(): number;
260269 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
261270 createRenderer(canvas: Stage): BuildRenderer | null;
262271 createAudio(): Audio;
@@ -329,6 +338,12 @@ export function boot(host: Host): Frame {
329338 // The Map control's other half: one exact map, by name. It wins over the
330339 // select, because a person who typed a name meant that map.
331340 const seedField = by<Field>("mapseed");
341 // The match of the day: the button that fills the controls with it, and the
342 // line that says what it is. Both are about the *browser's* day — the server
343 // rendered the same sentence from its own clock, and this page derives the
344 // day again rather than believing it.
345 const todayButton = by("today");
346 const onToday = by("ontoday");
332347 // The name of the map Play is about to give you, the plan of it, the line
333348 // that says in words what the plan says in pixels, and the link that keeps it.
334349 const willPlay = by("willplay");
@@ -809,6 +824,38 @@ export function boot(host: Host): Frame {
809824 for (const field of [mapField, seedField, idField])
810825 for (const event of ["change", "input"]) field.addEventListener(event, nameNextMap);
811826
827 /**
828 * The match of the day, in the menu's own controls: today's id in the Match
829 * id field, the Map control on `generated` so that id carves the map, no map
830 * name overriding it, and the length every run on the day's board is played
831 * at (`DAILY_LENGTH` in `src/daily.ts` — the board ranks by rounds won, and
832 * rounds won compares nothing across two different lengths).
833 *
834 * It is a button rather than a mode: afterwards the fields say what they say,
835 * a person can still change any of them, and `start` below reads them exactly
836 * as it does for every other match. Nothing here starts a match — the map is
837 * named under it first, which is the whole point of naming it before Play.
838 *
839 * The day is derived from this browser's clock on every press, so a tab left
840 * open across midnight picks up the new day's match rather than yesterday's.
841 */
842 function pickToday(): void {
843 const daily = dailyOn(host.now());
844 idField.value = daily.match;
845 mapField.value = "generated";
846 seedField.value = "";
847 lengthField.value = DAILY_LENGTH;
848 onToday.textContent = saidToday(daily);
849 // The id changed without a keystroke, so the line before Play and the plan
850 // beside it are named again here: no event fires for a value set in code.
851 nameNextMap();
852 }
853 todayButton.addEventListener("click", pickToday);
854 // And the sentence itself, from the browser's own day rather than the
855 // server's: the same string the first paint carries, unless this browser is
856 // on another day, in which case the page it draws is the one it would play.
857 onToday.textContent = saidToday(dailyOn(host.now()));
858
812859 /**
813860 * The copy control, which is an anchor first and a clipboard second: a browser
814861 * with no clipboard — or one that refuses the write — follows the link, which
@@ -1213,6 +1260,7 @@ const browser = (): Host => ({
12131260 .then(then, () => then(null));
12141261 },
12151262 randomUUID: () => crypto.randomUUID(),
1263 now: () => Date.now(),
12161264 origin: location.origin,
12171265 // The clipboard, if this browser has one. Read off `navigator` rather than
12181266 // asked for: a browser without it has no such member, and the copy control
src/client/stage.test-helper.ts +18 −1
@@ -99,6 +99,13 @@ export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null
9999 /** Where this stub page is, for the whole URL a copied link has to be. */
100100 export const ORIGIN = "https://cs.example";
101101
102 /**
103 * What this page's wall clock says until a test moves it: noon UTC on an
104 * ordinary day, so `src/daily.ts` has a day to derive the match of the day
105 * from and no test depends on the machine it runs on.
106 */
107 export const WALL = Date.UTC(2026, 4, 17, 12, 0, 0);
108
102109 /**
103110 * Where this page says its weights are. A content-hashed name like the real
104111 * one, so nothing here can pass by matching a path the entry guessed: the
@@ -178,6 +185,12 @@ export type FakeStage = {
178185 copied: string[];
179186 /** Set before a click to have the clipboard refuse the write, as a browser may. */
180187 refuseCopy: boolean;
188 /**
189 * What this browser's `Date.now()` says, which decides one thing: the day the
190 * match of the day is the match of (`src/daily.ts`). It starts on a day with
191 * nothing special about it and a test moves it to cross midnight.
192 */
193 wall: number;
181194 /**
182195 * Install this page as the globals the shipped entry reads for itself —
183196 * `document`, `window`, `localStorage` and `fetch` — and hand back the undo
@@ -194,7 +207,7 @@ export type FakeStage = {
194207 * contract between the page and this file, so `src/server.test.ts` checks the
195208 * served page against this same list rather than a second copy of it.
196209 */
197 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
210 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
198211
199212 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
200213 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
@@ -338,6 +351,9 @@ export function fakeStage(
338351 ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`);
339352 return ids[ids.length - 1] as string;
340353 },
354 // The wall clock, which is only ever asked which day it is: a test sets
355 // `stage.wall` and the page is on that day, without waiting for one.
356 now: () => stub.wall,
341357 createRenderer: (_canvas: Stage): BuildRenderer | null =>
342358 options.webgl === false
343359 ? null
@@ -369,6 +385,7 @@ export function fakeStage(
369385 for (const fire of held.splice(0)) fire();
370386 },
371387 refuseCopy: false,
388 wall: WALL,
372389 by: page.by,
373390 step: page.step,
374391 lock(on) {
src/daily.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The match of the day. The promise is that every visitor derives the same one
3 * from their own clock and nobody is told it, so what is checked here is the
4 * derivation: one day is one match, on any machine and at any moment inside
5 * that day; the next day is a different one; and a match that is not a day's —
6 * every uuid this game has ever handed out — belongs to no day at all.
7 */
8
9 import { expect, test } from "bun:test";
10 import { dailyOn, dayFiled, dayOf, dayOfMatch, isDay, saidToday } from "./daily.ts";
11 import { isMatchId, matchOf, playKey } from "./matchid.ts";
12 import { nameOf, seedFrom } from "./mapname.ts";
13
14 /** A moment inside a day, in UTC, which is the only zone this file knows. */
15 const at = (day: string, hour = 12, minute = 0): number => Date.parse(`${day}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00.000Z`);
16
17 test("one day is one match, from any moment inside it and on any machine", () => {
18 // The first millisecond of the day, noon, and the last millisecond: one match.
19 const first = dailyOn(Date.parse("2026-09-21T00:00:00.000Z"));
20 const noon = dailyOn(at("2026-09-21"));
21 const last = dailyOn(Date.parse("2026-09-21T23:59:59.999Z"));
22 expect(first).toEqual(noon);
23 expect(last).toEqual(noon);
24 // And the match is the day with its dashes taken out: the rule, written out
25 // here so a reader can check it by eye rather than by running the module.
26 expect(noon.day).toBe("2026-09-21");
27 expect(noon.match).toBe("20260921");
28 // It is a match id like any other, so everything a pasted id does it does.
29 expect(isMatchId(noon.match)).toBe(true);
30 // The map is the one that id carves, which is how every other match on
31 // `generated` names its map: no second rule for today's.
32 expect(noon.map).toBe(nameOf(seedFrom("20260921")));
33 });
34
35 test("a different day is a different match, and a different map", () => {
36 const today = dailyOn(at("2026-09-21"));
37 const tomorrow = dailyOn(at("2026-09-22"));
38 const yesterday = dailyOn(at("2026-09-20"));
39 expect(tomorrow.match).not.toBe(today.match);
40 expect(yesterday.match).not.toBe(today.match);
41 // Two ids a character apart still carve two maps: the seed is a hash of the
42 // id, not the id, so consecutive days are not neighbouring layouts.
43 expect(tomorrow.map).not.toBe(today.map);
44 expect(yesterday.map).not.toBe(today.map);
45 // A year of them, all different: the day is the id and no two days are one.
46 const year = new Set(
47 Array.from({ length: 365 }, (_, i) => dailyOn(at("2026-01-01") + i * 86_400_000).match),
48 );
49 expect(year.size).toBe(365);
50 });
51
52 test("midnight in UTC is where the day turns, whatever a clock is beside", () => {
53 expect(dayOf(Date.parse("2026-09-21T23:59:59.999Z"))).toBe("2026-09-21");
54 expect(dayOf(Date.parse("2026-09-22T00:00:00.000Z"))).toBe("2026-09-22");
55 // A clock that is not a moment is not a day: nothing here throws at a render.
56 expect(dayOf(Number.NaN)).toBe("1970-01-01");
57 expect(dayOf(Number.POSITIVE_INFINITY)).toBe("1970-01-01");
58 // And one that is a moment no `Date` can hold is the furthest one that can:
59 // a clamp rather than a throw, because this is called from a page render.
60 expect(dayOf(9e15)).toBe("+275760-09");
61 });
62
63 test("the day a match id names is the round trip, and eight digits are not enough", () => {
64 expect(dayOfMatch("20260921")).toBe("2026-09-21");
65 // A day this calendar does not have is eight digits and nothing more.
66 expect(dayOfMatch("20260231")).toBeNull();
67 expect(dayOfMatch("20261301")).toBeNull();
68 expect(dayOfMatch("20260900")).toBeNull();
69 // And nor is anything else a browser or a person hands over.
70 expect(dayOfMatch("0123abcd")).toBeNull();
71 expect(dayOfMatch(crypto.randomUUID())).toBeNull();
72 expect(isDay("2026-02-31")).toBe(false);
73 expect(isDay("2026-02-28")).toBe(true);
74 });
75
76 test("a result is filed under the day its id names, and never under a day that has not begun", () => {
77 const now = at("2026-09-21");
78 // Today's match, and a second run at it — the row key with a nonce on it is
79 // still a playing of today's match.
80 expect(dayFiled("20260921", now)).toBe("2026-09-21");
81 expect(dayFiled(playKey("20260921", crypto.randomUUID()), now)).toBe("2026-09-21");
82 // A match started before midnight and posted after it keeps the day it was
83 // played on, rather than falling off the board it was played for.
84 expect(dayFiled("20260921", at("2026-09-22", 0, 1))).toBe("2026-09-21");
85 // Tomorrow's id, computed today and played early, counts for nothing: the
86 // rule is public, so being able to derive it early must buy nothing.
87 expect(dayFiled("20260922", now)).toBe("");
88 // Every other match is no day's.
89 expect(dayFiled(crypto.randomUUID(), now)).toBe("");
90 expect(dayFiled("20260231", now)).toBe("");
91 });
92
93 test("what the page says about today names the match, the map and the day", () => {
94 const daily = dailyOn(at("2026-09-21"));
95 const said = saidToday(daily);
96 expect(said).toContain(daily.match);
97 expect(said).toContain(daily.map);
98 expect(said).toContain("2026-09-21");
99 expect(said).toContain("UTC");
100 // The two things a player has to know before they press anything: what the
101 // board ranks by, and that playing twice is allowed and only the best stands.
102 expect(said).toContain("rounds won, then kills, then damage");
103 expect(said).toContain("best run only");
104 // The match inside a row key reads back as the match, which is what makes a
105 // second run at today's match today's match.
106 expect(matchOf(playKey(daily.match, crypto.randomUUID()))).toBe(daily.match);
107 });
src/daily.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * The match of the day: one match everybody who plays today plays, so that a
3 * board of today's runs compares runs of the same thing.
4 *
5 * **The rule, whole.** Take the calendar day in **UTC** and write it without
6 * its dashes: the match of 2026-09-21 is the match `20260921`. That is all of
7 * it. It is a match id of exactly the shape `src/matchid.ts` accepts, so
8 * everything a pasted id already does it does too — it carves the map, it seeds
9 * the bots, the spawns, the bomb carrier and the spray, and a link carries it —
10 * and every visitor derives it from their own clock without this server telling
11 * them anything. The page and the browser both compute it here, out of one
12 * module, because two copies of a rule like this is how two boards appear.
13 *
14 * **Why UTC.** A day that begins at a different moment for different people is
15 * a board that means something different to each of them. So it is UTC, said on
16 * the page, and a player in Auckland and a player in Lima roll over together.
17 *
18 * **What is filed under a day.** `dayFiled` is the whole of it: a result is
19 * filed under the day its own id names, and never under a day that has not
20 * begun on this server yet. So a match started before midnight and finished
21 * after it stays on the day it was played, and nobody can compute next week's
22 * id, play it in peace and be waiting at the top of the board when the day
23 * arrives. An id that is not a day's — the uuid every other match is — is filed
24 * under no day at all, which is the empty string.
25 *
26 * It imports only `src/matchid.ts` and `src/mapname.ts`, which import nothing,
27 * so it ships in the browser bundle and is cheap for the server, the database
28 * and the terminal to hold.
29 */
30
31 import { matchOf } from "./matchid.ts";
32 import { nameOf, seedFrom, type PlayedMap } from "./mapname.ts";
33
34 /** How far either side of the epoch a `Date` goes, so nothing here can throw. */
35 const RANGE = 8.64e15;
36
37 /** A calendar day as it is written everywhere in this app: `YYYY-MM-DD`. */
38 const DAY = /^\d{4}-\d{2}-\d{2}$/;
39
40 /** The same day as a match id: eight digits, which is also eight hex. */
41 const MATCH = /^(\d{4})(\d{2})(\d{2})$/;
42
43 /**
44 * The day a moment falls in, in UTC. Total by construction: a clock that says
45 * something a `Date` cannot hold is clamped rather than thrown on, because this
46 * is called from a page render and a browser's own `Date.now()`, and neither
47 * has anywhere to put an exception.
48 */
49 export const dayOf = (now: number): string =>
50 new Date(Number.isFinite(now) ? Math.min(Math.max(now, -RANGE), RANGE) : 0).toISOString().slice(0, 10);
51
52 /**
53 * Whether a string is a day this calendar has. The shape is not the test — the
54 * round trip is: `2026-02-31` and `2026-13-01` both look like days and neither
55 * is one, and a `Date` says so by refusing to parse them at all.
56 */
57 export function isDay(value: unknown): value is string {
58 if (typeof value !== "string" || !DAY.test(value)) return false;
59 const at = Date.parse(`${value}T00:00:00.000Z`);
60 return !Number.isNaN(at) && dayOf(at) === value;
61 }
62
63 /** Today's match, its day and the map it is on: everything the page shows. */
64 export type Daily = {
65 /** `YYYY-MM-DD`, in UTC. */
66 day: string;
67 /** The match id, which is that day with the dashes taken out. */
68 match: string;
69 /** The map that id carves, named before anyone presses Play. */
70 map: PlayedMap;
71 };
72
73 /** The match of the day for a day. Only `dailyOn` below calls it with one. */
74 const matchOfDay = (day: string): string => day.replace(/-/g, "");
75
76 /** The match of the day at a moment, derived and never looked up. */
77 export function dailyOn(now: number): Daily {
78 const day = dayOf(now);
79 const match = matchOfDay(day);
80 return { day, match, map: nameOf(seedFrom(match)) };
81 }
82
83 /**
84 * The day a match id is the match of, or null when it is not a day's match at
85 * all. This is the boundary — a stored id and a pasted one both come through
86 * here — and the check is the round trip, like `seedOf` in `src/mapname.ts`:
87 * eight digits that are not a day this calendar has are eight digits.
88 */
89 export function dayOfMatch(match: string): string | null {
90 const parts = MATCH.exec(match);
91 if (parts === null) return null;
92 const day = `${parts[1]}-${parts[2]}-${parts[3]}`;
93 return isDay(day) ? day : null;
94 }
95
96 /**
97 * The day a result is filed under: the day its own id names, "" for every match
98 * that is not a day's, and "" for a day that has not begun here yet. A row key
99 * is `<match>--<nonce>` (`src/matchid.ts`), so the match inside it is what is
100 * asked about — a second run at today's match is still today's match.
101 */
102 export function dayFiled(id: string, now: number): string {
103 const day = dayOfMatch(matchOf(id));
104 return day !== null && day <= dayOf(now) ? day : "";
105 }
106
107 /**
108 * The length today's match is played at, in words and in the menu's own value.
109 * It is fixed because the board ranks by rounds won, and rounds won compares
110 * nothing when one run was first to 5 and the next was first to 13.
111 */
112 export const DAILY_LENGTH = "short";
113 export const DAILY_LENGTH_SAID = "first to 5";
114
115 /** What the day's board is ranked by, said where the board is, in one place. */
116 export const RANKED_BY = "rounds won, then kills, then damage";
117
118 /**
119 * What the page says today's match is — one sentence, written once and rendered
120 * twice: `src/pages/game.ts` puts it in the first paint out of the server's
121 * clock, and `src/client/main.ts` writes it again out of the browser's, so a
122 * tab left open across midnight says what it would say if it were reloaded.
123 */
124 export const saidToday = (daily: Daily): string =>
125 `The match of the day is ${daily.match}, on ${daily.map}: the same map, the same bots and the same ` +
126 `seed for everyone who plays on ${daily.day} (UTC), ${DAILY_LENGTH_SAID}. ` +
127 `Pick it, press Play, and your run lands on today's board — ranked by ${RANKED_BY}, ` +
128 `your best run only, however many times you play it.`;
129
130 /**
131 * What the board is not, in one sentence, printed under it. The leaderboard
132 * already carries this caveat and the day's board makes no stronger claim: the
133 * numbers are a browser's word about a fight against bots on a fixed seed.
134 */
135 export const NOT_A_LADDER =
136 "It is a score against bots on a fixed seed, reported by the browser that played it — not a competitive ladder.";
src/db.test.ts +87 −1
@@ -10,8 +10,9 @@ import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
13 displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, saveMatch, standings,
13 dayBoard, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, saveMatch, standings,
1414 } from "./db.ts";
15 import { playKey } from "./matchid.ts";
1516 import type { Result } from "./result.ts";
1617
1718 const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
@@ -180,3 +181,88 @@ test("opening a database twice migrates once and breaks nothing", () => {
180181 const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name);
181182 expect(columns.filter((c) => c === "map").length).toBe(1);
182183 });
184
185 test("the day's board is one row a player, their best run, in the order the page states", () => {
186 const db = openDatabase(dir());
187 const now = Date.UTC(2026, 8, 21, 12);
188 const today = "20260921";
189 const run = (over: Partial<Result>): Result =>
190 result({ matchId: playKey(today, crypto.randomUUID()), map: "gen_1a331et", ...over });
191
192 // One player, two runs at today's match: the better one is the row, and the
193 // row says there were two.
194 const kestrel = player("u-1", "kestrel");
195 expect(saveMatch(db, kestrel, run({ scoreMine: 2, scoreTheirs: 3, kills: 30, damage: 9000 }), now)).toBe(true);
196 expect(saveMatch(db, kestrel, run({ scoreMine: 5, scoreTheirs: 1, kills: 8, damage: 1000 }), now)).toBe(true);
197 // Two more players, to order against: rounds won first, then kills, then
198 // damage — every tie broken by the next key and by nothing else.
199 expect(saveMatch(db, player("u-2", "wren"), run({ scoreMine: 5, scoreTheirs: 2, kills: 8, damage: 2000 }), now)).toBe(true);
200 expect(saveMatch(db, guest("finch", "s9"), run({ scoreMine: 5, scoreTheirs: 0, kills: 20, damage: 500 }), now)).toBe(true);
201 // A match that is not today's, played the same afternoon, and a run at
202 // *yesterday's* match: neither is on today's board.
203 expect(saveMatch(db, player("u-3", "stray"), result({ kills: 99, scoreMine: 5, scoreTheirs: 0 }), now)).toBe(true);
204 expect(saveMatch(db, player("u-4", "early"), result({ matchId: "20260920", scoreMine: 5, scoreTheirs: 0, kills: 99 }), now)).toBe(true);
205
206 const board = dayBoard(db, "2026-09-21");
207 expect(board.map((r) => r.name)).toEqual(["finch", "wren", "kestrel"]);
208 // finch and wren and kestrel all won 5; finch's 20 kills put it first, and
209 // wren's 2,000 damage breaks its tie with kestrel's 8 kills and 1,000.
210 expect(board.map((r) => [r.won, r.kills, r.damage])).toEqual([[5, 20, 500], [5, 8, 2000], [5, 8, 1000]]);
211 // The best run stands and the worse one is counted, not shown: 30 kills in a
212 // match kestrel lost does not outrank the match kestrel won.
213 expect(board[2]?.runs).toBe(2);
214 expect(board[0]?.runs).toBe(1);
215 // And the day's board is the day's: nobody else's match is on it, and a day
216 // nobody played is empty rather than everything.
217 expect(board.map((r) => r.name)).not.toContain("stray");
218 expect(board.map((r) => r.name)).not.toContain("early");
219 expect(dayBoard(db, "2026-09-20").map((r) => r.name)).toEqual(["early"]);
220 expect(dayBoard(db, "2026-09-22")).toEqual([]);
221 // "" is every match that was not a day's, and it is never a board.
222 expect(dayBoard(db, "")).toEqual([]);
223 // The all-time table is untouched by all of it: it still counts everything.
224 expect(standings(db).length).toBe(5);
225 });
226
227 test("which day a result counts for is read out of its id, not taken from the browser", () => {
228 const db = openDatabase(dir());
229 const now = Date.UTC(2026, 8, 21, 12);
230 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260921" }), now)).toBe(true);
231 // Tomorrow's id, derived today and played early: it counts for no day until
232 // that day has begun here, and then it counts for the day it names.
233 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922" }), now)).toBe(true);
234 expect(dayBoard(db, "2026-09-22")).toEqual([]);
235 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922", kills: 3 }), now + 86_400_000)).toBe(true);
236 expect(dayBoard(db, "2026-09-22").map((r) => r.kills)).toEqual([3]);
237 // And the row it went on is the same row: a re-post, not a second match.
238 expect(recentMatches(db).filter((m) => m.id === "20260922").length).toBe(1);
239 expect(recentMatches(db).filter((m) => m.id === "20260921")[0]?.day).toBe("2026-09-21");
240 });
241
242 test("a database written before the match of the day existed opens, and files the next result", () => {
243 const home = dir();
244 // A table with every column but the day, which is what a deployment that has
245 // been running since before this feature has on disk.
246 const old = new Database(join(home, "app.db"));
247 old.exec(`CREATE TABLE match (
248 id TEXT PRIMARY KEY, session TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
249 map TEXT NOT NULL DEFAULT 'de_forge', kills INTEGER NOT NULL, deaths INTEGER NOT NULL,
250 headshots INTEGER NOT NULL, damage INTEGER NOT NULL, rounds INTEGER NOT NULL,
251 score_mine INTEGER NOT NULL, score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL,
252 seconds INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')),
253 updated_at TEXT NOT NULL DEFAULT (datetime('now')))`);
254 old.query(
255 `INSERT INTO match (id, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
256 VALUES ('old-match-1', 's1', 'veteran', 5, 5, 1, 400, 3, 2, 1, 1, 200)`,
257 ).run();
258 old.close();
259
260 const db = openDatabase(home);
261 // The old row is still there and belongs to no day, which is what it was.
262 expect(recentMatches(db)[0]?.day).toBe("");
263 expect(standings(db)[0]?.name).toBe("veteran");
264 // And the day's board works from the first result written after the upgrade.
265 const now = Date.UTC(2026, 8, 21, 12);
266 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260921" }), now)).toBe(true);
267 expect(dayBoard(db, "2026-09-21").map((r) => r.name)).toEqual(["kestrel"]);
268 });
src/db.ts +75 −6
@@ -13,6 +13,7 @@
1313 import { Database } from "bun:sqlite";
1414 import { mkdirSync } from "node:fs";
1515 import { join } from "node:path";
16 import { dayFiled } from "./daily.ts";
1617 import type { Result } from "./result.ts";
1718
1819 export type MatchRow = {
@@ -23,6 +24,12 @@ export type MatchRow = {
2324 name: string;
2425 /** The map it was played on: `de_forge` or `gen_<base36>`, never empty. */
2526 map: string;
27 /**
28 * The day this was a playing of the match of the day, `YYYY-MM-DD` in UTC, or
29 * "" for every other match. Derived from the id by `dayFiled` when the row is
30 * written and never taken from the browser's word.
31 */
32 day: string;
2633 kills: number;
2734 deaths: number;
2835 headshots: number;
@@ -58,6 +65,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
5865 user_id TEXT NOT NULL DEFAULT '',
5966 name TEXT NOT NULL,
6067 map TEXT NOT NULL DEFAULT 'de_forge',
68 day TEXT NOT NULL DEFAULT '',
6169 kills INTEGER NOT NULL,
6270 deaths INTEGER NOT NULL,
6371 headshots INTEGER NOT NULL,
@@ -80,7 +88,15 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
8088 // the only map there was. Additive and defaulted, so yesterday's database
8189 // opens today and keeps every row it had.
8290 if (!columns.includes("map")) db.exec("ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge'");
91 // And once more for the day a match of the day was played on. Every row
92 // written before there was such a match was not one, which is what the
93 // default says; a row whose id happens to be a day's gets its day back on the
94 // next post, because `saveMatch` derives it rather than remembering it.
95 if (!columns.includes("day")) db.exec("ALTER TABLE match ADD COLUMN day TEXT NOT NULL DEFAULT ''");
8396 db.exec("CREATE INDEX IF NOT EXISTS match_by_time ON match (updated_at DESC)");
97 // The day's board is one query over one day's rows, so the day is the index:
98 // without it, today's board is a scan of every match ever played here.
99 db.exec("CREATE INDEX IF NOT EXISTS match_by_day ON match (day)");
84100 return db;
85101 }
86102
@@ -148,8 +164,12 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da
148164 * browser's. Anyone else sending the same id is refused rather than allowed to
149165 * overwrite it — including a guest who found the id of a signed-in player's
150166 * match, and a signed-in player who found a guest's.
167 *
168 * Whether it counts for a day is not asked of the browser: it is read out of
169 * the id by `dayFiled` in `src/daily.ts` against this server's clock, which is
170 * why `now` is here at all — a test moves it rather than the calendar.
151171 */
152 export function saveMatch(db: Database, player: Player, result: Result): boolean {
172 export function saveMatch(db: Database, player: Player, result: Result, now = Date.now()): boolean {
153173 const existing = db
154174 .query<{ session: string; user_id: string }, [string]>("SELECT session, user_id FROM match WHERE id = ?1")
155175 .get(result.matchId);
@@ -158,10 +178,10 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean
158178 if (player.userId === "" && existing.session !== player.session) return false;
159179 }
160180 db.query(
161 `INSERT INTO match (id, session, user_id, name, map, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
162 VALUES (?1, ?2, ?13, ?3, ?14, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
181 `INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
182 VALUES (?1, ?2, ?13, ?3, ?14, ?15, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
163183 ON CONFLICT(id) DO UPDATE SET
164 name = excluded.name, map = excluded.map, kills = excluded.kills, deaths = excluded.deaths,
184 name = excluded.name, map = excluded.map, day = excluded.day, kills = excluded.kills, deaths = excluded.deaths,
165185 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
166186 score_mine = excluded.score_mine, score_theirs = excluded.score_theirs,
167187 finished = excluded.finished, seconds = excluded.seconds,
@@ -169,14 +189,14 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean
169189 ).run(
170190 result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots,
171191 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds,
172 player.userId, result.map,
192 player.userId, result.map, dayFiled(result.matchId, now),
173193 );
174194 return true;
175195 }
176196
177197 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
178198 db.query<MatchRow, [number]>(
179 `SELECT id, session, user_id, name, map, kills, deaths, headshots, damage, rounds,
199 `SELECT id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds,
180200 score_mine, score_theirs, finished, seconds, updated_at
181201 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
182202 ).all(limit);
@@ -203,6 +223,55 @@ export const standings = (db: Database, limit = 10): Standing[] =>
203223 LIMIT ?1`,
204224 ).all(limit);
205225
226 /** One player's day: their best run at the match of the day, and how many they had. */
227 export type DayRun = {
228 name: string;
229 /** The forge identity it was played under, or "" for a guest. */
230 user_id: string;
231 /** Rounds won, which is the first thing the day is ranked by. */
232 won: number;
233 /** Rounds lost, so a reader can see the score the ranking came from. */
234 lost: number;
235 kills: number;
236 deaths: number;
237 headshots: number;
238 damage: number;
239 /** How many times this player played today's match. The row is their best one. */
240 runs: number;
241 };
242
243 /**
244 * The day's board: one row a player, their **best** run at that day's match,
245 * ranked by rounds won, then kills, then damage — the order the page prints
246 * beside it, out of `RANKED_BY` in `src/daily.ts`, so nobody has to guess.
247 *
248 * Best rather than every run, and one row a player rather than one a run, for
249 * the reason the board exists: a list of every run is a list whoever replays
250 * most fills, which is the leaderboard's own problem moved into the day. Two
251 * runs are allowed and only the better one stands, and `runs` says how many
252 * there were so the row is not silent about it.
253 *
254 * The same three keys pick the row and order the board, so the run at the top
255 * is the best run anybody had that day by exactly the rule the page states. A
256 * day of "" is every match that was not a day's, so it is refused here rather
257 * than quietly returning them.
258 */
259 export const dayBoard = (db: Database, day: string, limit = 10): DayRun[] =>
260 day === ""
261 ? []
262 : db.query<DayRun, [string, number]>(
263 `SELECT name, user_id, won, lost, kills, deaths, headshots, damage, runs FROM (
264 SELECT name, user_id, score_mine AS won, score_theirs AS lost, kills, deaths, headshots, damage,
265 COUNT(*) OVER (PARTITION BY user_id, name) AS runs,
266 ROW_NUMBER() OVER (
267 PARTITION BY user_id, name ORDER BY score_mine DESC, kills DESC, damage DESC
268 ) AS best
269 FROM match WHERE day = ?1 AND rounds > 0
270 ) WHERE best = 1
271 ORDER BY won DESC, kills DESC, damage DESC
272 LIMIT ?2`,
273 ).all(day, limit);
274
206275 export const matchCount = (db: Database): number =>
207276 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
208277
src/mapname.ts +18
@@ -23,6 +23,24 @@
2323 * two drift apart.
2424 */
2525
26 /**
27 * A seed out of whatever text a page can hand over — the match id, usually,
28 * which is a uuid the browser made. FNV-1a, thirty-two bits: a hash rather than
29 * a clock, so a page that wants a map every match needs no source of numbers of
30 * its own and a test can hand in a word and get the same map every time.
31 *
32 * It lives here, beside the names, because the seed a *string* means and the
33 * name a *seed* means are the two halves of one round trip: `src/daily.ts`
34 * needs both to say which map today's match is on, and the server may not
35 * import a map generator to find that out. `src/client/generate.ts` re-exports
36 * it, so every caller that had it there still has it.
37 */
38 export function seedFrom(text: string): number {
39 let hash = 0x811c9dc5;
40 for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
41 return hash >>> 0;
42 }
43
2644 /**
2745 * The name a seed's map answers to: `gen_1z3f`, short enough for a menu. The
2846 * return type is the name's own shape, so `MapChoice` below needs no cast.
src/pages/game.test.ts +53
@@ -16,6 +16,8 @@ import { mkdtempSync } from "node:fs";
1616 import { tmpdir } from "node:os";
1717 import { join } from "node:path";
1818 import { IDLE_SECONDS, openDatabase, type Player, saveMatch, standings } from "../db.ts";
19 import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
20 import { playKey } from "../matchid.ts";
1921 import type { Result } from "../result.ts";
2022 import { CARD, type Card } from "../client/weights.ts";
2123 import { GAMES } from "../games.ts";
@@ -221,3 +223,54 @@ test("handed a card nobody trained, every number in the boast moves with it", ()
221223 // The prose around the numbers is the same prose: only the card moved.
222224 expect(said.replace(/[\d,.]+/g, "#")).toBe(boast(gamePage(db(), options)).replace(/[\d,.]+/g, "#"));
223225 });
226
227 test("today's board is beside the all-time one, ranked by the rule it prints", () => {
228 const home = db();
229 const now = Date.UTC(2026, 8, 21, 12);
230 const daily = dailyOn(now);
231 const run = (name: string, over: Partial<Result>): boolean =>
232 saveMatch(home, { userId: `u-${name}`, session: "s", name }, {
233 matchId: playKey(daily.match, crypto.randomUUID()), name: "ignored", map: daily.map,
234 rounds: 5, scoreMine: 5, scoreTheirs: 0, kills: 9, deaths: 4, headshots: 3, damage: 1200,
235 seconds: 420, finished: true, ...over,
236 }, now);
237 expect(run("kestrel", { kills: 20 })).toBe(true);
238 expect(run("wren", { kills: 4 })).toBe(true);
239 // A match that is not today's is on the all-time table and on the feed, and
240 // not on today's board: the board is one match, which is the whole point.
241 expect(saveMatch(home, { userId: "u-stray", session: "s", name: "stray" }, {
242 matchId: crypto.randomUUID(), name: "ignored", map: "de_forge", rounds: 5, scoreMine: 5,
243 scoreTheirs: 0, kills: 99, deaths: 0, headshots: 9, damage: 9000, seconds: 420, finished: true,
244 }, now)).toBe(true);
245
246 const html = gamePage(home, { ...options, now });
247 // Both boards, in the first paint, one under the other.
248 expect(html).toContain("<h3>Today's match</h3>");
249 expect(html).toContain("<h3>All time</h3>");
250 expect(html.indexOf("<h3>Today's match</h3>")).toBeLessThan(html.indexOf("<h3>All time</h3>"));
251 // What it is, said where it is: the day in UTC, the map, and the ranking.
252 expect(html).toContain(escape(saidToday(daily)));
253 expect(html).toContain(escape(RANKED_BY));
254 expect(html).toContain(escape(NOT_A_LADDER));
255 expect(html).toContain(`href="/?match=${daily.match}&amp;map=generated"`);
256 // The rows, in the order the sentence promises: same rounds won, more kills
257 // first. And the match nobody played today is not among them.
258 const board = html.slice(html.indexOf("<h3>Today's match</h3>"), html.indexOf("<h3>All time</h3>"));
259 expect(board.indexOf("kestrel")).toBeLessThan(board.indexOf("wren"));
260 expect(board).not.toContain("stray");
261 expect(html).toContain("<td>stray</td>");
262
263 // A day nobody has played is a sentence rather than an empty table.
264 expect(gamePage(home, { ...options, now: now + 86_400_000 })).toContain("Nobody has played today's match yet.");
265 });
266
267 test("the menu offers today's match beside the other controls, with its map named", () => {
268 const now = Date.UTC(2026, 8, 21, 12);
269 const html = gamePage(db(), { ...options, now });
270 expect(html).toContain(`<button type="button" id="today">Pick today's match</button>`);
271 // The same sentence the page's own script rewrites from the browser's clock,
272 // rendered here from the server's: one string, two clocks, no second rule.
273 expect(html).toContain(`<p class="hint" id="ontoday">${escape(saidToday(dailyOn(now)))}</p>`);
274 // The map is named before Play, because the sentence names it.
275 expect(html).toContain(escape(dailyOn(now).map));
276 });
src/pages/game.ts +63 −8
@@ -10,7 +10,8 @@
1010 */
1111
1212 import type { Database } from "bun:sqlite";
13 import { displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
13 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
14 import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
1415 import { NOTHING_POSTED } from "../client/pending.ts";
1516 import { CARD, type Card } from "../client/weights.ts";
1617 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
@@ -32,10 +33,44 @@ const STATE_SAID: Record<Liveness, string> = {
3233 abandoned: " · abandoned",
3334 };
3435
36 /** The link that plays the match of the day: its id, on the map that id carves. */
37 export const dailyLink = (daily: Daily): string => `/?match=${daily.match}&map=generated`;
38
3539 /**
36 * The leaderboard block. Also what POST /api/match answers with. `now` is the
37 * moment it is rendered at, which only the feed's in-progress line reads; a
38 * test passes its own rather than waiting ten minutes.
40 * Today's board: the best run each player had at the match of the day, in the
41 * order `RANKED_BY` says — rounds won, then kills, then damage — which is also
42 * the order it is printed in, so the rule and the rows cannot disagree.
43 *
44 * It stands beside the all-time table rather than in place of it: that one
45 * counts everything anybody ever played and therefore rewards playing a lot,
46 * and this one is the one comparison on the page where every row played the
47 * same map, the same bots and the same seed.
48 */
49 function todayHtml(db: Database, daily: Daily): string {
50 const board = dayBoard(db, daily.day, 10);
51 const rows = board
52 .map(
53 (r, i) => `<tr><td>${i + 1}</td><td>${escape(displayName(r))}</td><td>${r.won}–${r.lost}</td>` +
54 `<td>${r.kills}</td><td>${r.damage}</td><td>${ratio(r.kills, r.deaths)}</td><td>${r.runs}</td></tr>`,
55 )
56 .join("");
57 return `<h3>Today's match</h3>
58 <p class="hint">${escape(saidToday(daily))}</p>
59 <p><a class="share" href="${escape(dailyLink(daily))}">Open today's match</a></p>
60 ${board.length === 0
61 ? `<p class="empty">Nobody has played today's match yet. Be the first.</p>`
62 : `<table><thead><tr><th>#</th><th>player</th><th>rounds</th><th>kills</th><th>damage</th><th>K/D</th><th>runs</th></tr></thead><tbody>${rows}</tbody></table>`}
63 <p class="hint">Ranked by ${escape(RANKED_BY)}. ${escape(NOT_A_LADDER)}</p>`;
64 }
65
66 /**
67 * The leaderboard block: today's board, then the all-time one, then the feed.
68 * Also what POST /api/match answers with, so a run that lands moves the day's
69 * board on the page it was played from.
70 *
71 * `now` is the moment it is rendered at, and it decides two things: which day
72 * the board is of, and whether a match in the feed still reads as live. A test
73 * passes its own rather than waiting for midnight or for ten minutes.
3974 */
4075 export function statsHtml(db: Database, now = Date.now()): string {
4176 const table = standings(db, 10);
@@ -59,6 +94,8 @@ export function statsHtml(db: Database, now = Date.now()): string {
5994 )
6095 .join("");
6196 return `<h2>Leaderboard</h2>
97 ${todayHtml(db, dailyOn(now))}
98 <h3>All time</h3>
6299 ${table.length === 0
63100 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
64101 : `<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>`}
@@ -104,6 +141,12 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
104141 * every round in the page, which is what the paragraph then says.
105142 */
106143 planning?: boolean;
144 /**
145 * The moment this page is being rendered at: which day the match of the day
146 * is, and which matches in the feed still read as live. Absent is now, and a
147 * test hands its own rather than waiting for midnight.
148 */
149 now?: number;
107150 };
108151
109152 /**
@@ -243,8 +286,15 @@ const summary = (visitor: Who): string => `
243286 * hands it to anyone — rendered from `?map=` here, and rewritten by
244287 * `src/client/main.ts` whenever either field changes, because the name of "a new
245288 * one every match" is the name of an id the browser has yet to draw.
289 *
290 * Today's match is a button rather than a mode: it fills the controls above —
291 * the id, the Map control and the length — with what the match of the day is,
292 * so what you are about to play is in the controls you can see and the map's
293 * name is under them before Play. The sentence beside it is `saidToday` from
294 * `src/daily.ts`, rendered here from the server's clock and written again by the
295 * page from the browser's, because both derive the day and neither is told it.
246296 */
247 const menu = (visitor: Who, asked: Asked): string => `
297 const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
248298 <h2>Defuse or detonate</h2>
249299 <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated
250300 map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar
@@ -279,6 +329,10 @@ const menu = (visitor: Who, asked: Asked): string => `
279329 cover, and the same map for both sides — it is the layout turned half a circle, so the walk
280330 to your site is the walk to theirs. A name in this field is that exact map, on any machine;
281331 empty, the Map control above decides.`}</p>
332 <div class="row">
333 <button type="button" id="today">Pick today's match</button>
334 </div>
335 <p class="hint" id="ontoday">${escape(saidToday(daily))}</p>
282336 <div id="willmap">
283337 <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas>
284338 <div id="willtext">
@@ -412,8 +466,9 @@ const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite">
412466
413467 export function gamePage(
414468 db: Database,
415 { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, ...options }: PageOptions,
469 { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, now = Date.now(), ...options }: PageOptions,
416470 ): string {
471 const daily = dailyOn(now);
417472 return page({
418473 ...options,
419474 extraStyle: STYLE,
@@ -442,7 +497,7 @@ export function gamePage(
442497 ],
443498 ],
444499 overlays: [
445 { id: "menu", html: menu(visitor, asked) },
500 { id: "menu", html: menu(visitor, asked, daily) },
446501 { id: "pause", html: PAUSE, hidden: true },
447502 // After the pause, so a match that ends while it is up is read over it
448503 // rather than under it. `src/client/main.ts` hides the pause anyway.
@@ -452,7 +507,7 @@ export function gamePage(
452507 }),
453508 sectionsHtml: `${thisMatch(asked)}
454509
455 <section id="stats">${statsHtml(db)}</section>
510 <section id="stats">${statsHtml(db, now)}</section>
456511 ${SAVED_LINE}
457512
458513 <section>
src/terminal/keys.test.ts +10 −3
@@ -9,6 +9,7 @@ import { expect, test } from "bun:test";
99 import type { Card } from "../client/weights.ts";
1010 import type { MatchRow } from "../db.ts";
1111 import type { Data, View } from "./screen.ts";
12 import { dailyOn } from "../daily.ts";
1213 import { press, removed } from "./keys.ts";
1314
1415 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;
@@ -19,6 +20,7 @@ const match = (id: string, name: string): MatchRow => ({
1920 user_id: "u-1",
2021 name,
2122 map: "de_forge",
23 day: "",
2224 kills: 7,
2325 deaths: 4,
2426 headshots: 2,
@@ -32,6 +34,8 @@ const match = (id: string, name: string): MatchRow => ({
3234 });
3335
3436 const data: Data = {
37 daily: dailyOn(Date.parse("2026-09-16T10:00:30Z")),
38 today: [],
3539 standings: [
3640 { name: "ace", user_id: "u-1", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9 },
3741 { name: "bo", user_id: "", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 },
@@ -46,7 +50,7 @@ const data: Data = {
4650 const view = (over: Partial<View> = {}): View =>
4751 ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over });
4852
49 test("tab cycles standings → matches → policy → standings, and the cursor resets each time", () => {
53 test("tab cycles standings → matches → policy → today → standings, and the cursor resets each time", () => {
5054 const first = press(view({ cursor: 1 }), "\t", data);
5155 expect(first.view.tab).toBe("matches");
5256 expect(first.view.cursor).toBe(0);
@@ -54,9 +58,12 @@ test("tab cycles standings → matches → policy → standings, and the cursor
5458 expect(second.view.tab).toBe("policy");
5559 expect(second.view.cursor).toBe(0);
5660 const third = press(second.view, "\t", data);
57 expect(third.view.tab).toBe("standings");
61 expect(third.view.tab).toBe("today");
5862 expect(third.view.cursor).toBe(0);
59 expect(third.command).toEqual({ kind: "nothing" });
63 const fourth = press(third.view, "\t", data);
64 expect(fourth.view.tab).toBe("standings");
65 expect(fourth.view.cursor).toBe(0);
66 expect(fourth.command).toEqual({ kind: "nothing" });
6067 });
6168
6269 test("j and the down arrow move down and stop at the last row", () => {
src/terminal/keys.ts +1 −1
@@ -19,7 +19,7 @@ export type Press = { view: View; command: Command };
1919
2020 const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } });
2121
22 /** The next tab in the cycle: standings → matches → policy → standings. */
22 /** The next tab in the cycle: standings → matches → policy → today → standings. */
2323 export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!;
2424
2525 export function press(view: View, key: string, data: Data): Press {
src/terminal/screen.test.ts +29 −3
@@ -10,6 +10,7 @@ import type { Card } from "../client/weights.ts";
1010 import type { MatchRow, Standing } from "../db.ts";
1111 import { IDLE_SECONDS } from "../db.ts";
1212 import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts";
13 import { dailyOn } from "../daily.ts";
1314
1415 const CARD: Card = {
1516 trainedAt: "2026-09-14",
@@ -39,6 +40,7 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow
3940 user_id: "u-1",
4041 name,
4142 map: "de_forge",
43 day: "",
4244 kills: 7,
4345 deaths: 4,
4446 headshots: 2,
@@ -59,6 +61,11 @@ const ROW_AT = Date.parse("2026-09-16T10:00:00Z");
5961 const NOW = ROW_AT + 30_000;
6062
6163 const data = (over: Partial<Data> = {}): Data => ({
64 daily: dailyOn(NOW),
65 today: [
66 { name: "ace", user_id: "u-1", won: 5, lost: 2, kills: 14, deaths: 6, headshots: 4, damage: 2100, runs: 2 },
67 { name: "bo", user_id: "", won: 3, lost: 5, kills: 9, deaths: 8, headshots: 1, damage: 1300, runs: 1 },
68 ],
6269 standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })],
6370 matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })],
6471 total: 4,
@@ -78,13 +85,14 @@ test("the header says where we are and how many matches there are, plural when i
7885 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches");
7986 });
8087
81 test("the tab strip names all three, and the highlight follows the tab you are on", () => {
82 expect(text(view())[2]).toBe(" standings matches policy ");
88 test("the tab strip names all four, and the highlight follows the tab you are on", () => {
89 expect(text(view())[2]).toBe(" standings matches policy today ");
8390 expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m");
8491 expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m");
8592 expect(screen(view({ tab: "policy" }), data())[2]).toContain("\x1b[7m policy \x1b[0m");
93 expect(screen(view({ tab: "today" }), data())[2]).toContain("\x1b[7m today \x1b[0m");
8694 // And only ever one of them.
87 for (const tab of ["standings", "matches", "policy"] as const) {
95 for (const tab of ["standings", "matches", "policy", "today"] as const) {
8896 expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1);
8997 }
9098 });
@@ -250,3 +258,21 @@ test("plain() takes off exactly the escapes and leaves the words", () => {
250258 expect(plain("\x1b[33mnothing removed\x1b[0m")).toBe("nothing removed");
251259 expect(plain("no escapes here")).toBe("no escapes here");
252260 });
261
262 test("the today tab is the match of the day: the id, the map, the rule and the board", () => {
263 const lines = text(view({ tab: "today" }), data());
264 const daily = dailyOn(NOW);
265 // What a person came here for: the id to paste, and the map it carves.
266 expect(lines.join("\n")).toContain(daily.match);
267 expect(lines.join("\n")).toContain(daily.map);
268 expect(lines.join("\n")).toContain(daily.day);
269 // The rule the rows are in, printed beside them, as on the page.
270 expect(lines.join("\n")).toContain("rounds won, then kills, then damage");
271 // One row a player, best run, with the score, the kills, the damage and how
272 // many runs it took — and the better player first, as the rule says.
273 expect(lines.some((line) => line.startsWith(" ace") && line.includes("5-2") && line.includes("2"))).toBe(true);
274 expect(lines.some((line) => line.startsWith(" bo (guest)"))).toBe(true);
275 expect(lines.findIndex((l) => l.startsWith(" ace"))).toBeLessThan(lines.findIndex((l) => l.startsWith(" bo")));
276 // And a day nobody has played says so rather than drawing an empty table.
277 expect(text(view({ tab: "today" }), data({ today: [] })).join("\n")).toContain("nobody has played it yet");
278 });
src/terminal/screen.ts +41 −5
@@ -14,14 +14,15 @@
1414 */
1515
1616 import type { Card } from "../client/weights.ts";
17 import type { Liveness, MatchRow, Standing } from "../db.ts";
17 import { type Daily, RANKED_BY } from "../daily.ts";
18 import type { DayRun, Liveness, MatchRow, Standing } from "../db.ts";
1819 import { displayName, headshotShare, liveness, ratio } from "../db.ts";
1920 import { matchOf } from "../matchid.ts";
2021
21 export type Tab = "standings" | "matches" | "policy";
22 export type Tab = "standings" | "matches" | "policy" | "today";
2223
23 /** The three tabs in the order tab cycles them. */
24 export const TABS: readonly Tab[] = ["standings", "matches", "policy"];
24 /** The four tabs in the order tab cycles them. */
25 export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today"];
2526
2627 export type View = {
2728 tab: Tab;
@@ -36,6 +37,10 @@ export type View = {
3637 };
3738
3839 export type Data = {
40 /** The match of the day, derived from `now` below by whoever read the rows. */
41 daily: Daily;
42 /** The day's board: one row a player, their best run at today's match. */
43 today: DayRun[];
3944 standings: Standing[];
4045 matches: MatchRow[];
4146 /** Every match ever recorded, including the empty ones the lists skip. */
@@ -110,6 +115,36 @@ function policyLines(card: Card): string[] {
110115 ];
111116 }
112117
118 /**
119 * The day's board, the same one the page shows and in the same order: rounds
120 * won, then kills, then damage, one row a player, their best run. The match id
121 * is printed above it because that is what a person does with this screen —
122 * paste it into the menu's Match id field and play the same match everybody
123 * else is playing today.
124 */
125 function todayLines(daily: Daily, board: DayRun[]): string[] {
126 const lines = [
127 ` ${esc}1mthe match of the day${esc}0m ${esc}2m${printable(daily.day)} (UTC)${esc}0m`,
128 "",
129 ` ${esc}2m${pad("match", 22)}${esc}0m${printable(daily.match)}`,
130 ` ${esc}2m${pad("map", 22)}${esc}0m${printable(daily.map)}`,
131 ` ${esc}2m${pad("ranked by", 22)}${esc}0m${RANKED_BY}`,
132 "",
133 `${esc}2m ${pad("player", 26)}${pad("rounds", 9)}${pad("kills", 7)}${pad("damage", 9)}runs${esc}0m`,
134 ];
135 if (board.length === 0) lines.push(`${esc}2m nobody has played it yet${esc}0m`);
136 for (const r of board)
137 lines.push(
138 ` ${pad(printable(displayName(r)), 26)}${pad(`${r.won}-${r.lost}`, 9)}${pad(String(r.kills), 7)}` +
139 `${pad(String(r.damage), 9)}${r.runs}`,
140 );
141 lines.push(
142 "",
143 `${esc}2mplay it: paste ${printable(daily.match)} into Match id, with Map on generated${esc}0m`,
144 );
145 return lines;
146 }
147
113148 function standingsLines(table: Standing[], cursor: number): string[] {
114149 const lines = [
115150 `${esc}2m ${pad("player", 26)}${pad("matches", 9)}${pad("kills", 7)}${pad("K/D", 7)}${pad("HS", 6)}wins${esc}0m`,
@@ -168,7 +203,8 @@ export function screen(view: View, data: Data): string[] {
168203 lines.push("");
169204 lines.push(header(view.tab));
170205 lines.push("");
171 if (view.tab === "policy") lines.push(...policyLines(data.card));
206 if (view.tab === "today") lines.push(...todayLines(data.daily, data.today));
207 else if (view.tab === "policy") lines.push(...policyLines(data.card));
172208 else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor));
173209 else lines.push(...matchLines(data.matches, cursor, data.now));
174210 lines.push("");
src/tty.test.ts +6 −4
@@ -123,7 +123,7 @@ test("the first paint is the standings, under the environment and the match coun
123123
124124 const lines = tty.frame();
125125 expect(lines[0]).toBe("cs local · 2 matches");
126 expect(lines[2]).toBe(" standings matches policy ");
126 expect(lines[2]).toBe(" standings matches policy today ");
127127 expect(tty.marked()).toStartWith("▸ ace");
128128 expect(lines.some((line) => line.startsWith(" bo"))).toBe(true);
129129 expect(tty.exits).toEqual([]);
@@ -148,17 +148,19 @@ test("a preview says which pull it is, and production says production", () => {
148148 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches");
149149 });
150150
151 test("tab cycles the three tabs, and every press draws exactly one frame", () => {
151 test("tab cycles the four tabs, and every press draws exactly one frame", () => {
152152 const tty = session({ seed: (db) => { play(db, "ace"); } });
153153 expect(tty.frame()[4]).toStartWith(" player matches");
154154 tty.press("\t");
155 expect(tty.frame()[2]).toBe(" standings matches policy ");
155 expect(tty.frame()[2]).toBe(" standings matches policy today ");
156156 expect(tty.frame()[4]).toStartWith(" player score");
157157 tty.press("\t");
158158 expect(tty.frame()[4]).toBe(" the policy the bots fight with");
159159 tty.press("\t");
160 expect(tty.frame()[4]).toStartWith(" the match of the day");
161 tty.press("\t");
160162 expect(tty.frame()[4]).toStartWith(" player matches");
161 expect(tty.writes.length).toBe(4);
163 expect(tty.writes.length).toBe(5);
162164 });
163165
164166 test("j and k move the cursor and stop at both ends of the list", () => {
src/tty.ts +22 −12
@@ -2,8 +2,9 @@
22 * cs, in a terminal. The forge runs this on a pty when someone `ssh`es to the
33 * app, with FORGE_APP_DATA pointing at the same database the web page uses:
44 * the same leaderboard, the same matches. j/k move, tab cycles the standings,
5 * the matches and the policy card, x removes the selected match, r refreshes,
6 * q leaves. Plain ANSI, no dependencies.
5 * the matches, the policy card and the match of the day — the id everybody is
6 * playing today and the board of who has played it best — x removes the
7 * selected match, r refreshes, q leaves. Plain ANSI, no dependencies.
78 *
89 * This file is the wiring only — the database, raw stdin, stdout, resize and
910 * exit. What it draws is `src/terminal/screen.ts` and what it answers is
@@ -20,7 +21,8 @@
2021 */
2122
2223 import type { Database } from "bun:sqlite";
23 import { matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts";
24 import { dayBoard, matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts";
25 import { dailyOn } from "./daily.ts";
2426 import { CARD } from "./client/weights.ts";
2527 import { printable, screen, type Data, type View } from "./terminal/screen.ts";
2628 import { press, removed } from "./terminal/keys.ts";
@@ -76,15 +78,23 @@ export function run(io: Io): void {
7678 ssh: io.env.FORGE_APP_SSH,
7779 };
7880
79 const read = (): Data => ({
80 standings: standings(io.db, 20),
81 matches: recentMatches(io.db, 20),
82 total: matchCount(io.db),
83 card: CARD,
84 // Read with the rows, and only ever here: the screen tells a live match
85 // from an abandoned one against the moment it was handed.
86 now: Date.now(),
87 });
81 const read = (): Data => {
82 // One clock for the whole read: which day it is, and which matches are
83 // still live, are the same moment asked twice.
84 const now = Date.now();
85 const daily = dailyOn(now);
86 return {
87 daily,
88 today: dayBoard(io.db, daily.day, 20),
89 standings: standings(io.db, 20),
90 matches: recentMatches(io.db, 20),
91 total: matchCount(io.db),
92 card: CARD,
93 // Read with the rows, and only ever here: the screen tells a live match
94 // from an abandoned one against the moment it was handed.
95 now,
96 };
97 };
8898
8999 const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); };
90100

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs