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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/174-a-match-of-the-day-everyone-plays-the-sa → main 1 commit fork
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 | ||
| 235 | 235 | constant, so a change to the generator that would move somebody's map fails the |
| 236 | 236 | test rather than surprising them. |
| 237 | 237 | |
| 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 | ||
| 238 | 266 | ## Who a score belongs to |
| 239 | 267 | |
| 240 | 268 | 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 @@ | ||
| 28 | 28 | */ |
| 29 | 29 | |
| 30 | 30 | 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"; | |
| 32 | 32 | import { CELL, levelOf, type Cell } from "./cells.ts"; |
| 33 | 33 | import { colOf, findPath, rowOf, type Level } from "./level.ts"; |
| 34 | 34 | import { lookFor } from "./looks.ts"; |
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | 37 | * 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. | |
| 43 | 44 | */ |
| 44 | export { nameOf, seedOf }; | |
| 45 | export { nameOf, seedFrom, seedOf }; | |
| 45 | 46 | |
| 46 | 47 | /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */ |
| 47 | 48 | export const SIZE = 40; |
| @@ -59,18 +60,6 @@ const MATE: Record<Cell, Cell> = { | ||
| 59 | 60 | "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t", |
| 60 | 61 | }; |
| 61 | 62 | |
| 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 | ||
| 74 | 63 | /** The page of letters a grid is, one string a row: what a test or a terminal reads. */ |
| 75 | 64 | export const page = (grid: Grid<Cell>): string[] => |
| 76 | 65 | 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"; | ||
| 39 | 39 | import { digest, snapshot, withoutEntropy } from "./replay.ts"; |
| 40 | 40 | import { WEAPONS } from "./weapons.ts"; |
| 41 | 41 | import { matchOf } from "../matchid.ts"; |
| 42 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; | |
| 42 | 43 | import { parseResult, type Result } from "../result.ts"; |
| 43 | 44 | import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; |
| 44 | 45 | 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", | ||
| 1435 | 1436 | expect(world.plan?.source).toBe("local"); |
| 1436 | 1437 | expect(stepsRun(stage)).toBeGreaterThan(0); |
| 1437 | 1438 | }); |
| 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 { | ||
| 69 | 69 | } from "../engine/index.ts"; |
| 70 | 70 | import { generateLevel, seedFrom } from "./generate.ts"; |
| 71 | 71 | import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts"; |
| 72 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; | |
| 72 | 73 | import { matchFrom, playKey } from "../matchid.ts"; |
| 73 | 74 | import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts"; |
| 74 | 75 | import type { Situation } from "../plan/situation.ts"; |
| @@ -257,6 +258,14 @@ export type Host = { | ||
| 257 | 258 | * what shape it has to be. |
| 258 | 259 | */ |
| 259 | 260 | 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; | |
| 260 | 269 | /** Null when this browser has no WebGL2: the menu says so and nothing starts. */ |
| 261 | 270 | createRenderer(canvas: Stage): BuildRenderer | null; |
| 262 | 271 | createAudio(): Audio; |
| @@ -329,6 +338,12 @@ export function boot(host: Host): Frame { | ||
| 329 | 338 | // The Map control's other half: one exact map, by name. It wins over the |
| 330 | 339 | // select, because a person who typed a name meant that map. |
| 331 | 340 | 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"); | |
| 332 | 347 | // The name of the map Play is about to give you, the plan of it, the line |
| 333 | 348 | // that says in words what the plan says in pixels, and the link that keeps it. |
| 334 | 349 | const willPlay = by("willplay"); |
| @@ -809,6 +824,38 @@ export function boot(host: Host): Frame { | ||
| 809 | 824 | for (const field of [mapField, seedField, idField]) |
| 810 | 825 | for (const event of ["change", "input"]) field.addEventListener(event, nameNextMap); |
| 811 | 826 | |
| 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 | ||
| 812 | 859 | /** |
| 813 | 860 | * The copy control, which is an anchor first and a clipboard second: a browser |
| 814 | 861 | * with no clipboard — or one that refuses the write — follows the link, which |
| @@ -1213,6 +1260,7 @@ const browser = (): Host => ({ | ||
| 1213 | 1260 | .then(then, () => then(null)); |
| 1214 | 1261 | }, |
| 1215 | 1262 | randomUUID: () => crypto.randomUUID(), |
| 1263 | now: () => Date.now(), | |
| 1216 | 1264 | origin: location.origin, |
| 1217 | 1265 | // The clipboard, if this browser has one. Read off `navigator` rather than |
| 1218 | 1266 | // 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 | ||
| 99 | 99 | /** Where this stub page is, for the whole URL a copied link has to be. */ |
| 100 | 100 | export const ORIGIN = "https://cs.example"; |
| 101 | 101 | |
| 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 | ||
| 102 | 109 | /** |
| 103 | 110 | * Where this page says its weights are. A content-hashed name like the real |
| 104 | 111 | * one, so nothing here can pass by matching a path the entry guessed: the |
| @@ -178,6 +185,12 @@ export type FakeStage = { | ||
| 178 | 185 | copied: string[]; |
| 179 | 186 | /** Set before a click to have the clipboard refuse the write, as a browser may. */ |
| 180 | 187 | 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; | |
| 181 | 194 | /** |
| 182 | 195 | * Install this page as the globals the shipped entry reads for itself — |
| 183 | 196 | * `document`, `window`, `localStorage` and `fetch` — and hand back the undo |
| @@ -194,7 +207,7 @@ export type FakeStage = { | ||
| 194 | 207 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 195 | 208 | * served page against this same list rather than a second copy of it. |
| 196 | 209 | */ |
| 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"]; | |
| 198 | 211 | |
| 199 | 212 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 200 | 213 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
| @@ -338,6 +351,9 @@ export function fakeStage( | ||
| 338 | 351 | ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`); |
| 339 | 352 | return ids[ids.length - 1] as string; |
| 340 | 353 | }, |
| 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, | |
| 341 | 357 | createRenderer: (_canvas: Stage): BuildRenderer | null => |
| 342 | 358 | options.webgl === false |
| 343 | 359 | ? null |
| @@ -369,6 +385,7 @@ export function fakeStage( | ||
| 369 | 385 | for (const fire of held.splice(0)) fire(); |
| 370 | 386 | }, |
| 371 | 387 | refuseCopy: false, |
| 388 | wall: WALL, | |
| 372 | 389 | by: page.by, |
| 373 | 390 | step: page.step, |
| 374 | 391 | 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"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { |
| 13 | displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, saveMatch, standings, | |
| 13 | dayBoard, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, saveMatch, standings, | |
| 14 | 14 | } from "./db.ts"; |
| 15 | import { playKey } from "./matchid.ts"; | |
| 15 | 16 | import type { Result } from "./result.ts"; |
| 16 | 17 | |
| 17 | 18 | const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); |
| @@ -180,3 +181,88 @@ test("opening a database twice migrates once and breaks nothing", () => { | ||
| 180 | 181 | const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name); |
| 181 | 182 | expect(columns.filter((c) => c === "map").length).toBe(1); |
| 182 | 183 | }); |
| 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 @@ | ||
| 13 | 13 | import { Database } from "bun:sqlite"; |
| 14 | 14 | import { mkdirSync } from "node:fs"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | import { dayFiled } from "./daily.ts"; | |
| 16 | 17 | import type { Result } from "./result.ts"; |
| 17 | 18 | |
| 18 | 19 | export type MatchRow = { |
| @@ -23,6 +24,12 @@ export type MatchRow = { | ||
| 23 | 24 | name: string; |
| 24 | 25 | /** The map it was played on: `de_forge` or `gen_<base36>`, never empty. */ |
| 25 | 26 | 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; | |
| 26 | 33 | kills: number; |
| 27 | 34 | deaths: number; |
| 28 | 35 | headshots: number; |
| @@ -58,6 +65,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 58 | 65 | user_id TEXT NOT NULL DEFAULT '', |
| 59 | 66 | name TEXT NOT NULL, |
| 60 | 67 | map TEXT NOT NULL DEFAULT 'de_forge', |
| 68 | day TEXT NOT NULL DEFAULT '', | |
| 61 | 69 | kills INTEGER NOT NULL, |
| 62 | 70 | deaths INTEGER NOT NULL, |
| 63 | 71 | headshots INTEGER NOT NULL, |
| @@ -80,7 +88,15 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 80 | 88 | // the only map there was. Additive and defaulted, so yesterday's database |
| 81 | 89 | // opens today and keeps every row it had. |
| 82 | 90 | 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 ''"); | |
| 83 | 96 | 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)"); | |
| 84 | 100 | return db; |
| 85 | 101 | } |
| 86 | 102 | |
| @@ -148,8 +164,12 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da | ||
| 148 | 164 | * browser's. Anyone else sending the same id is refused rather than allowed to |
| 149 | 165 | * overwrite it — including a guest who found the id of a signed-in player's |
| 150 | 166 | * 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. | |
| 151 | 171 | */ |
| 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 { | |
| 153 | 173 | const existing = db |
| 154 | 174 | .query<{ session: string; user_id: string }, [string]>("SELECT session, user_id FROM match WHERE id = ?1") |
| 155 | 175 | .get(result.matchId); |
| @@ -158,10 +178,10 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean | ||
| 158 | 178 | if (player.userId === "" && existing.session !== player.session) return false; |
| 159 | 179 | } |
| 160 | 180 | 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) | |
| 163 | 183 | 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, | |
| 165 | 185 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, |
| 166 | 186 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, |
| 167 | 187 | finished = excluded.finished, seconds = excluded.seconds, |
| @@ -169,14 +189,14 @@ export function saveMatch(db: Database, player: Player, result: Result): boolean | ||
| 169 | 189 | ).run( |
| 170 | 190 | result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots, |
| 171 | 191 | 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), | |
| 173 | 193 | ); |
| 174 | 194 | return true; |
| 175 | 195 | } |
| 176 | 196 | |
| 177 | 197 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| 178 | 198 | 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, | |
| 180 | 200 | score_mine, score_theirs, finished, seconds, updated_at |
| 181 | 201 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 182 | 202 | ).all(limit); |
| @@ -203,6 +223,55 @@ export const standings = (db: Database, limit = 10): Standing[] => | ||
| 203 | 223 | LIMIT ?1`, |
| 204 | 224 | ).all(limit); |
| 205 | 225 | |
| 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 | ||
| 206 | 275 | export const matchCount = (db: Database): number => |
| 207 | 276 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 208 | 277 |
src/mapname.ts +18
| @@ -23,6 +23,24 @@ | ||
| 23 | 23 | * two drift apart. |
| 24 | 24 | */ |
| 25 | 25 | |
| 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 | ||
| 26 | 44 | /** |
| 27 | 45 | * The name a seed's map answers to: `gen_1z3f`, short enough for a menu. The |
| 28 | 46 | * 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"; | ||
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | 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"; | |
| 19 | 21 | import type { Result } from "../result.ts"; |
| 20 | 22 | import { CARD, type Card } from "../client/weights.ts"; |
| 21 | 23 | import { GAMES } from "../games.ts"; |
| @@ -221,3 +223,54 @@ test("handed a card nobody trained, every number in the boast moves with it", () | ||
| 221 | 223 | // The prose around the numbers is the same prose: only the card moved. |
| 222 | 224 | expect(said.replace(/[\d,.]+/g, "#")).toBe(boast(gamePage(db(), options)).replace(/[\d,.]+/g, "#")); |
| 223 | 225 | }); |
| 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}&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 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | 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"; | |
| 14 | 15 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 15 | 16 | import { CARD, type Card } from "../client/weights.ts"; |
| 16 | 17 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| @@ -32,10 +33,44 @@ const STATE_SAID: Record<Liveness, string> = { | ||
| 32 | 33 | abandoned: " · abandoned", |
| 33 | 34 | }; |
| 34 | 35 | |
| 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 | ||
| 35 | 39 | /** |
| 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. | |
| 39 | 74 | */ |
| 40 | 75 | export function statsHtml(db: Database, now = Date.now()): string { |
| 41 | 76 | const table = standings(db, 10); |
| @@ -59,6 +94,8 @@ export function statsHtml(db: Database, now = Date.now()): string { | ||
| 59 | 94 | ) |
| 60 | 95 | .join(""); |
| 61 | 96 | return `<h2>Leaderboard</h2> |
| 97 | ${todayHtml(db, dailyOn(now))} | |
| 98 | <h3>All time</h3> | |
| 62 | 99 | ${table.length === 0 |
| 63 | 100 | ? `<p class="empty">No matches yet. Play one and it lands here.</p>` |
| 64 | 101 | : `<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 | ||
| 104 | 141 | * every round in the page, which is what the paragraph then says. |
| 105 | 142 | */ |
| 106 | 143 | 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; | |
| 107 | 150 | }; |
| 108 | 151 | |
| 109 | 152 | /** |
| @@ -243,8 +286,15 @@ const summary = (visitor: Who): string => ` | ||
| 243 | 286 | * hands it to anyone — rendered from `?map=` here, and rewritten by |
| 244 | 287 | * `src/client/main.ts` whenever either field changes, because the name of "a new |
| 245 | 288 | * 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. | |
| 246 | 296 | */ |
| 247 | const menu = (visitor: Who, asked: Asked): string => ` | |
| 297 | const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | |
| 248 | 298 | <h2>Defuse or detonate</h2> |
| 249 | 299 | <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated |
| 250 | 300 | 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 => ` | ||
| 279 | 329 | cover, and the same map for both sides — it is the layout turned half a circle, so the walk |
| 280 | 330 | to your site is the walk to theirs. A name in this field is that exact map, on any machine; |
| 281 | 331 | 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> | |
| 282 | 336 | <div id="willmap"> |
| 283 | 337 | <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas> |
| 284 | 338 | <div id="willtext"> |
| @@ -412,8 +466,9 @@ const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite"> | ||
| 412 | 466 | |
| 413 | 467 | export function gamePage( |
| 414 | 468 | 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, | |
| 416 | 470 | ): string { |
| 471 | const daily = dailyOn(now); | |
| 417 | 472 | return page({ |
| 418 | 473 | ...options, |
| 419 | 474 | extraStyle: STYLE, |
| @@ -442,7 +497,7 @@ export function gamePage( | ||
| 442 | 497 | ], |
| 443 | 498 | ], |
| 444 | 499 | overlays: [ |
| 445 | { id: "menu", html: menu(visitor, asked) }, | |
| 500 | { id: "menu", html: menu(visitor, asked, daily) }, | |
| 446 | 501 | { id: "pause", html: PAUSE, hidden: true }, |
| 447 | 502 | // After the pause, so a match that ends while it is up is read over it |
| 448 | 503 | // rather than under it. `src/client/main.ts` hides the pause anyway. |
| @@ -452,7 +507,7 @@ export function gamePage( | ||
| 452 | 507 | }), |
| 453 | 508 | sectionsHtml: `${thisMatch(asked)} |
| 454 | 509 | |
| 455 | <section id="stats">${statsHtml(db)}</section> | |
| 510 | <section id="stats">${statsHtml(db, now)}</section> | |
| 456 | 511 | ${SAVED_LINE} |
| 457 | 512 | |
| 458 | 513 | <section> |
src/terminal/keys.test.ts +10 −3
| @@ -9,6 +9,7 @@ import { expect, test } from "bun:test"; | ||
| 9 | 9 | 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 | import { dailyOn } from "../daily.ts"; | |
| 12 | 13 | import { press, removed } from "./keys.ts"; |
| 13 | 14 | |
| 14 | 15 | 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 => ({ | ||
| 19 | 20 | user_id: "u-1", |
| 20 | 21 | name, |
| 21 | 22 | map: "de_forge", |
| 23 | day: "", | |
| 22 | 24 | kills: 7, |
| 23 | 25 | deaths: 4, |
| 24 | 26 | headshots: 2, |
| @@ -32,6 +34,8 @@ const match = (id: string, name: string): MatchRow => ({ | ||
| 32 | 34 | }); |
| 33 | 35 | |
| 34 | 36 | const data: Data = { |
| 37 | daily: dailyOn(Date.parse("2026-09-16T10:00:30Z")), | |
| 38 | today: [], | |
| 35 | 39 | standings: [ |
| 36 | 40 | { name: "ace", user_id: "u-1", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9 }, |
| 37 | 41 | { 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 = { | ||
| 46 | 50 | const view = (over: Partial<View> = {}): View => |
| 47 | 51 | ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over }); |
| 48 | 52 | |
| 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", () => { | |
| 50 | 54 | const first = press(view({ cursor: 1 }), "\t", data); |
| 51 | 55 | expect(first.view.tab).toBe("matches"); |
| 52 | 56 | expect(first.view.cursor).toBe(0); |
| @@ -54,9 +58,12 @@ test("tab cycles standings → matches → policy → standings, and the cursor | ||
| 54 | 58 | expect(second.view.tab).toBe("policy"); |
| 55 | 59 | expect(second.view.cursor).toBe(0); |
| 56 | 60 | const third = press(second.view, "\t", data); |
| 57 | expect(third.view.tab).toBe("standings"); | |
| 61 | expect(third.view.tab).toBe("today"); | |
| 58 | 62 | 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" }); | |
| 60 | 67 | }); |
| 61 | 68 | |
| 62 | 69 | 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 }; | ||
| 19 | 19 | |
| 20 | 20 | const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } }); |
| 21 | 21 | |
| 22 | /** The next tab in the cycle: standings → matches → policy → standings. */ | |
| 22 | /** The next tab in the cycle: standings → matches → policy → today → standings. */ | |
| 23 | 23 | export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!; |
| 24 | 24 | |
| 25 | 25 | 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"; | ||
| 10 | 10 | import type { MatchRow, Standing } from "../db.ts"; |
| 11 | 11 | import { IDLE_SECONDS } from "../db.ts"; |
| 12 | 12 | import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts"; |
| 13 | import { dailyOn } from "../daily.ts"; | |
| 13 | 14 | |
| 14 | 15 | const CARD: Card = { |
| 15 | 16 | trainedAt: "2026-09-14", |
| @@ -39,6 +40,7 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow | ||
| 39 | 40 | user_id: "u-1", |
| 40 | 41 | name, |
| 41 | 42 | map: "de_forge", |
| 43 | day: "", | |
| 42 | 44 | kills: 7, |
| 43 | 45 | deaths: 4, |
| 44 | 46 | headshots: 2, |
| @@ -59,6 +61,11 @@ const ROW_AT = Date.parse("2026-09-16T10:00:00Z"); | ||
| 59 | 61 | const NOW = ROW_AT + 30_000; |
| 60 | 62 | |
| 61 | 63 | 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 | ], | |
| 62 | 69 | standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })], |
| 63 | 70 | matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })], |
| 64 | 71 | total: 4, |
| @@ -78,13 +85,14 @@ test("the header says where we are and how many matches there are, plural when i | ||
| 78 | 85 | expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches"); |
| 79 | 86 | }); |
| 80 | 87 | |
| 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 "); | |
| 83 | 90 | expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m"); |
| 84 | 91 | expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m"); |
| 85 | 92 | 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"); | |
| 86 | 94 | // 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) { | |
| 88 | 96 | expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1); |
| 89 | 97 | } |
| 90 | 98 | }); |
| @@ -250,3 +258,21 @@ test("plain() takes off exactly the escapes and leaves the words", () => { | ||
| 250 | 258 | expect(plain("\x1b[33mnothing removed\x1b[0m")).toBe("nothing removed"); |
| 251 | 259 | expect(plain("no escapes here")).toBe("no escapes here"); |
| 252 | 260 | }); |
| 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 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | 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"; | |
| 18 | 19 | import { displayName, headshotShare, liveness, ratio } from "../db.ts"; |
| 19 | 20 | import { matchOf } from "../matchid.ts"; |
| 20 | 21 | |
| 21 | export type Tab = "standings" | "matches" | "policy"; | |
| 22 | export type Tab = "standings" | "matches" | "policy" | "today"; | |
| 22 | 23 | |
| 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"]; | |
| 25 | 26 | |
| 26 | 27 | export type View = { |
| 27 | 28 | tab: Tab; |
| @@ -36,6 +37,10 @@ export type View = { | ||
| 36 | 37 | }; |
| 37 | 38 | |
| 38 | 39 | 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[]; | |
| 39 | 44 | standings: Standing[]; |
| 40 | 45 | matches: MatchRow[]; |
| 41 | 46 | /** Every match ever recorded, including the empty ones the lists skip. */ |
| @@ -110,6 +115,36 @@ function policyLines(card: Card): string[] { | ||
| 110 | 115 | ]; |
| 111 | 116 | } |
| 112 | 117 | |
| 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 | ||
| 113 | 148 | function standingsLines(table: Standing[], cursor: number): string[] { |
| 114 | 149 | const lines = [ |
| 115 | 150 | `${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[] { | ||
| 168 | 203 | lines.push(""); |
| 169 | 204 | lines.push(header(view.tab)); |
| 170 | 205 | 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)); | |
| 172 | 208 | else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor)); |
| 173 | 209 | else lines.push(...matchLines(data.matches, cursor, data.now)); |
| 174 | 210 | 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 | ||
| 123 | 123 | |
| 124 | 124 | const lines = tty.frame(); |
| 125 | 125 | 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 "); | |
| 127 | 127 | expect(tty.marked()).toStartWith("▸ ace"); |
| 128 | 128 | expect(lines.some((line) => line.startsWith(" bo"))).toBe(true); |
| 129 | 129 | expect(tty.exits).toEqual([]); |
| @@ -148,17 +148,19 @@ test("a preview says which pull it is, and production says production", () => { | ||
| 148 | 148 | expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches"); |
| 149 | 149 | }); |
| 150 | 150 | |
| 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", () => { | |
| 152 | 152 | const tty = session({ seed: (db) => { play(db, "ace"); } }); |
| 153 | 153 | expect(tty.frame()[4]).toStartWith(" player matches"); |
| 154 | 154 | tty.press("\t"); |
| 155 | expect(tty.frame()[2]).toBe(" standings matches policy "); | |
| 155 | expect(tty.frame()[2]).toBe(" standings matches policy today "); | |
| 156 | 156 | expect(tty.frame()[4]).toStartWith(" player score"); |
| 157 | 157 | tty.press("\t"); |
| 158 | 158 | expect(tty.frame()[4]).toBe(" the policy the bots fight with"); |
| 159 | 159 | tty.press("\t"); |
| 160 | expect(tty.frame()[4]).toStartWith(" the match of the day"); | |
| 161 | tty.press("\t"); | |
| 160 | 162 | expect(tty.frame()[4]).toStartWith(" player matches"); |
| 161 | expect(tty.writes.length).toBe(4); | |
| 163 | expect(tty.writes.length).toBe(5); | |
| 162 | 164 | }); |
| 163 | 165 | |
| 164 | 166 | test("j and k move the cursor and stop at both ends of the list", () => { |
src/tty.ts +22 −12
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | * cs, in a terminal. The forge runs this on a pty when someone `ssh`es to the |
| 3 | 3 | * app, with FORGE_APP_DATA pointing at the same database the web page uses: |
| 4 | 4 | * 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. | |
| 7 | 8 | * |
| 8 | 9 | * This file is the wiring only — the database, raw stdin, stdout, resize and |
| 9 | 10 | * exit. What it draws is `src/terminal/screen.ts` and what it answers is |
| @@ -20,7 +21,8 @@ | ||
| 20 | 21 | */ |
| 21 | 22 | |
| 22 | 23 | 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"; | |
| 24 | 26 | import { CARD } from "./client/weights.ts"; |
| 25 | 27 | import { printable, screen, type Data, type View } from "./terminal/screen.ts"; |
| 26 | 28 | import { press, removed } from "./terminal/keys.ts"; |
| @@ -76,15 +78,23 @@ export function run(io: Io): void { | ||
| 76 | 78 | ssh: io.env.FORGE_APP_SSH, |
| 77 | 79 | }; |
| 78 | 80 | |
| 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 | }; | |
| 88 | 98 | |
| 89 | 99 | const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); }; |
| 90 | 100 |
new above
offline