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

forked from trevato/cs

The range gets the board it deserves: a run is posted, checked and ranked per drill — the server's second game #265

Merged opened this

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

Merged

torque/cs-engine:torque/187-the-range-gets-the-board-it-deserves-a-r → main 1 commit fork

  • Merged. torque/187-the-range-gets-the-board-it-deserves-a-r was merged into main as afba481.

Review

Sign in to review this change.
17 files changed +842 −96
src/db.ts +138 −3
@@ -1,7 +1,16 @@
11 /**
2 * The match records, in SQLite under FORGE_APP_DATA. One row per match, keyed
3 * by the id the browser generated for it, so a match that is still being
4 * played updates its own row instead of filling the table with fragments.
2 * The records both games keep, in SQLite under FORGE_APP_DATA. One row per
3 * match, keyed by the id the browser generated for it, so a match that is still
4 * being played updates its own row instead of filling the table with fragments
5 * — and, since the range started posting, one row per range run beside it.
6 *
7 * **Two tables, not one with a game column.** A match and a run share three
8 * columns (who, when, a name) and no others: a match has rounds, kills and a
9 * bomb, a run has a drill, a clock and an accuracy. One table would be two
10 * halves of nullable columns, every query filtered by a game nobody forgets at
11 * their peril, and an index that serves neither. What the two genuinely share
12 * is the door they come in through — `src/gate.ts` — and that is shared as one
13 * thing. The schema is not.
514 *
615 * Every row also carries who it belongs to: `user_id` is the forge identity
716 * that posted it, empty for a guest, and `session` is the browser that started
@@ -15,6 +24,7 @@ import { mkdirSync } from "node:fs";
1524 import { join } from "node:path";
1625 import { dayFiled } from "./daily.ts";
1726 import type { Result } from "./result.ts";
27 import type { RangeRun } from "./run.ts";
1828
1929 export type MatchRow = {
2030 id: string;
@@ -97,6 +107,28 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
97107 // The day's board is one query over one day's rows, so the day is the index:
98108 // without it, today's board is a scan of every match ever played here.
99109 db.exec("CREATE INDEX IF NOT EXISTS match_by_day ON match (day)");
110 // The range's runs: its own table, in the same shape as the one above —
111 // keyed by the id the page generated, carrying who it belongs to, additive to
112 // a database that has never seen one. A run is finished when it is posted, so
113 // there is no `finished` column and no liveness to read: the clock ran out.
114 db.exec(`CREATE TABLE IF NOT EXISTS run (
115 id TEXT PRIMARY KEY,
116 session TEXT NOT NULL,
117 user_id TEXT NOT NULL DEFAULT '',
118 name TEXT NOT NULL,
119 drill TEXT NOT NULL,
120 score INTEGER NOT NULL,
121 hits INTEGER NOT NULL,
122 shots INTEGER NOT NULL,
123 seed INTEGER NOT NULL,
124 seconds INTEGER NOT NULL,
125 created_at TEXT NOT NULL DEFAULT (datetime('now')),
126 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
127 )`);
128 // The board is one drill's rows, best score first, so that is the index: the
129 // page draws one table per drill on every paint of `/range`.
130 db.exec("CREATE INDEX IF NOT EXISTS run_by_drill ON run (drill, score DESC)");
131 db.exec("CREATE INDEX IF NOT EXISTS run_by_time ON run (updated_at DESC)");
100132 return db;
101133 }
102134
@@ -272,6 +304,109 @@ export const dayBoard = (db: Database, day: string, limit = 10): DayRun[] =>
272304 LIMIT ?2`,
273305 ).all(day, limit);
274306
307 /** One posted range run, as the table holds it. */
308 export type RunRow = {
309 id: string;
310 session: string;
311 /** The forge identity that posted it, or "" for a guest. */
312 user_id: string;
313 name: string;
314 /** Which drill was played: a key of `DRILLS` in `src/range/targets.ts`. */
315 drill: string;
316 score: number;
317 hits: number;
318 shots: number;
319 /** The seed it was spawned from, so the same thirty seconds can be played again. */
320 seed: number;
321 seconds: number;
322 updated_at: string;
323 };
324
325 /**
326 * Write a run. The ownership rule is the matches' rule, unchanged: a run
327 * belongs to whoever posted it — a signed-in player's is theirs on any browser,
328 * a guest's is that browser's — and anybody else sending the same id is refused
329 * rather than allowed to overwrite it. The page posts a run once, when its
330 * clock runs out, so in practice this only ever inserts; the update is there so
331 * that a retried post is the same row and not a second one.
332 */
333 export function saveRun(db: Database, player: Player, run: RangeRun): boolean {
334 const existing = db
335 .query<{ session: string; user_id: string }, [string]>("SELECT session, user_id FROM run WHERE id = ?1")
336 .get(run.runId);
337 if (existing !== null) {
338 if (existing.user_id !== player.userId) return false;
339 if (player.userId === "" && existing.session !== player.session) return false;
340 }
341 db.query(
342 `INSERT INTO run (id, session, user_id, name, drill, score, hits, shots, seed, seconds)
343 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
344 ON CONFLICT(id) DO UPDATE SET
345 name = excluded.name, drill = excluded.drill, score = excluded.score, hits = excluded.hits,
346 shots = excluded.shots, seed = excluded.seed, seconds = excluded.seconds,
347 updated_at = datetime('now')`,
348 ).run(
349 run.runId, player.session, player.userId, player.name, run.drill,
350 run.score, run.hits, run.shots, run.seed, run.seconds,
351 );
352 return true;
353 }
354
355 /** One player's best run at one drill, and how many they have had at it. */
356 export type RunStanding = {
357 name: string;
358 /** The forge identity it was played under, or "" for a guest. */
359 user_id: string;
360 drill: string;
361 score: number;
362 hits: number;
363 shots: number;
364 /** How many runs this player has posted at this drill. The row is their best one. */
365 runs: number;
366 };
367
368 /**
369 * One drill's board: one row a player, their **best** run at that drill, best
370 * score first and then the cleaner run — more hits, then fewer shots taken to
371 * get them. Per drill because a pop-up score and a tracking score are two
372 * different games, which is the same thing the range's own readout says by
373 * printing the drill in front of the score.
374 *
375 * Best rather than every run, and one row a player rather than one a run, for
376 * the reason the match of the day's board is: a list of every run is a list
377 * whoever plays most fills. `runs` says how many there were, so the row is not
378 * silent about it. A drill nobody has played is an empty board, never an error.
379 */
380 export const runBoard = (db: Database, drill: string, limit = 10): RunStanding[] =>
381 db.query<RunStanding, [string, number]>(
382 `SELECT name, user_id, drill, score, hits, shots, runs FROM (
383 SELECT name, user_id, drill, score, hits, shots,
384 COUNT(*) OVER (PARTITION BY user_id, name) AS runs,
385 ROW_NUMBER() OVER (
386 PARTITION BY user_id, name ORDER BY score DESC, hits DESC, shots ASC
387 ) AS best
388 FROM run WHERE drill = ?1
389 ) WHERE best = 1
390 ORDER BY score DESC, hits DESC, shots ASC
391 LIMIT ?2`,
392 ).all(drill, limit);
393
394 export const recentRuns = (db: Database, limit = 10): RunRow[] =>
395 db.query<RunRow, [number]>(
396 `SELECT id, session, user_id, name, drill, score, hits, shots, seed, seconds, updated_at
397 FROM run ORDER BY updated_at DESC LIMIT ?1`,
398 ).all(limit);
399
400 export const runCount = (db: Database): number =>
401 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM run").get()?.n ?? 0;
402
403 export const deleteRun = (db: Database, id: string): boolean =>
404 db.query("DELETE FROM run WHERE id = ?1").run(id).changes > 0;
405
406 /** Hits over shots as a percentage, or a dash: no shots is no accuracy. */
407 export const hitShare = (hits: number, shots: number): string =>
408 shots === 0 ? "—" : `${Math.round((hits / shots) * 100)}%`;
409
275410 export const matchCount = (db: Database): number =>
276411 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
277412
src/gate.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The door a posted score comes in through, and what every game's numbers are
3 * checked with on the way.
4 *
5 * Two games on this server now say what they played: the shooter posts a match
6 * (`src/result.ts`) and the range posts a run (`src/run.ts`). Neither is the
7 * other — a match has rounds and a run has a clock — but everything *around*
8 * the numbers is one thing and is written once, here: a body is read under a
9 * cap, parsed as JSON, validated by shape, attributed to a verified identity or
10 * to a guest, written under that identity and answered with that game's board.
11 * `src/server.ts` runs that sequence once and hands it one of these; a third
12 * game is one more `Gate` and no second copy of the sequence.
13 *
14 * It is called a gate rather than a match because a match is one game's word
15 * for one game's thing. What is shared is not a match: it is the door, what is
16 * accepted at it, and who the thing that came through belongs to.
17 *
18 * Nothing here knows a game. It holds the two primitives every boundary in this
19 * app needs — a counted number and a printable name — and the two shapes the
20 * server calls: `Gate`, one game's door, and `Checked`, what came through it.
21 * The games' own gates are assembled in `src/server.ts` beside `RENDER`, for
22 * the same reason the pages are: a gate holds functions, and a function is not
23 * data in a registry.
24 */
25
26 import type { Database } from "bun:sqlite";
27 import type { Player } from "./db.ts";
28
29 /**
30 * A whole number between 0 and `max`, or null. Everything either game posts is
31 * a count of something, so this is the whole of what a number is allowed to be
32 * at this boundary: finite, not negative, not larger than what the game can
33 * produce, and floored rather than rejected for having a fraction.
34 *
35 * `-0 < 0` is false, so negative zero would otherwise pass as a count and be
36 * stored, sorted and digested as a number that is not quite zero.
37 */
38 export function integer(value: unknown, max: number): number | null {
39 if (typeof value !== "number" || !Number.isFinite(value)) return null;
40 const n = Math.floor(value);
41 if (Object.is(n, -0)) return null;
42 return n < 0 || n > max ? null : n;
43 }
44
45 /** A name is one line of printable text, short, and never empty. */
46 export function cleanName(value: unknown): string {
47 if (typeof value !== "string") return "anon";
48 const stripped = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 24);
49 return stripped.length === 0 ? "anon" : stripped;
50 }
51
52 /**
53 * A posted score that passed the boundary: the name the browser claimed — which
54 * a signed-in player's identity replaces — and the one thing left to do with it.
55 *
56 * `write` is the game's own writer with its own numbers already closed over, so
57 * the server never holds one game's parsed shape as the other's. False is "this
58 * row is not yours": somebody else's identity or somebody else's browser posted
59 * that id first, and it is refused rather than overwritten.
60 */
61 export type Checked = {
62 name: string;
63 write(db: Database, player: Player): boolean;
64 };
65
66 /** The door one game's scores come in through. */
67 export type Gate = {
68 /** Where it answers. One path, one game. */
69 path: string;
70 /** What this door refuses, in the words of the 422: "bad result", "bad run". */
71 refusal: string;
72 /** The numbers, validated by shape and capped at what the game can produce. */
73 accept(value: unknown): Checked | null;
74 /** What a post that landed answers with: this game's board, rendered. */
75 board(db: Database): string;
76 };
src/pages/range.ts +79 −12
@@ -2,20 +2,70 @@
22 * The range page: this game's content and nothing about the document. The
33 * shell in `./shell.ts` renders the doctype, the style, the header, the stage
44 * and the footer; what is left here is the range's — its one overlay, its touch
5 * buttons and its prose.
5 * buttons, its board and its prose.
66 *
77 * It says what the page is — a room, plates to shoot, a clock and a score, all
88 * of it drawn by the engine's renderer — and what a browser with no WebGL2
9 * gets instead. There is no database on this page: the leaderboard and the
10 * terminal stay Counter-Strike's.
9 * gets instead.
10 *
11 * The board is server-rendered into the first paint, like the shooter's: one
12 * table per drill, ten rows each, every name escaped here and every guest said
13 * to be one. It is per drill because a pop-up score and a tracking score are
14 * different games — the same thing the range's own readout says by printing the
15 * drill in front of the score — and the drills themselves are read from
16 * `src/range/targets.ts`, so a third drill is a third table and no edit here.
17 *
18 * This page is not a game's source: it is the chrome one game is served inside,
19 * which is why it may know what a drill is. `src/independence.test.ts` holds
20 * the rule that matters — nothing under `src/range/` imports this, or anything
21 * else outside the engine.
1122 */
1223
24 import { type Database } from "bun:sqlite";
25 import { displayName, hitShare, runBoard, type RunStanding } from "../db.ts";
26 import { DRILLS, DRILL_NAMES } from "../range/targets.ts";
1327 import { navFor, noscriptFor, othersHtml } from "./games.ts";
14 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
28 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1529
1630 /** What the shell needs that this page cannot know: the environment it runs in. */
1731 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
1832
33 /** How many rows one drill's table shows. The leaderboard's ten, for the same reason. */
34 export const BOARD_ROWS = 10;
35
36 /** The one sentence that says how a drill's rows are ordered. Printed under them. */
37 export const RANKED_BY = "score, then hits, then the fewest shots they took";
38
39 const rowsHtml = (board: RunStanding[]): string =>
40 board
41 .map(
42 (r, i) => `<tr><td>${i + 1}</td><td>${escape(displayName(r))}</td><td>${r.score}</td>` +
43 `<td>${r.hits}/${r.shots}</td><td>${hitShare(r.hits, r.shots)}</td><td>${r.runs}</td></tr>`,
44 )
45 .join("");
46
47 /**
48 * The board, one table a drill: what `/range` paints and what a posted run is
49 * answered with, so a run that lands moves the board on the page it was shot
50 * from without that page being reloaded.
51 */
52 export function boardsHtml(db: Database): string {
53 const tables = DRILL_NAMES.map((name) => {
54 const drill = DRILLS[name];
55 const board = runBoard(db, name, BOARD_ROWS);
56 return `<h3>${escape(drill.label)}</h3>
57 <p class="hint">${escape(drill.about)}</p>
58 ${board.length === 0
59 ? `<p class="empty">No ${escape(drill.label)} runs yet. Shoot one and it lands here.</p>`
60 : `<table><thead><tr><th>#</th><th>shooter</th><th>score</th><th>hits</th><th>accuracy</th><th>runs</th></tr>` +
61 `</thead><tbody>${rowsHtml(board)}</tbody></table>`}`;
62 }).join("\n");
63 return `<h2>The board</h2>
64 <p class="hint">One row a shooter, their best run at that drill, ranked by ${escape(RANKED_BY)}. A run is
65 thirty seconds; the two drills are ranked apart, because a tracking score means nothing beside a pop-up one.</p>
66 ${tables}`;
67 }
68
1969 const NOTE = `
2070 <h2>The range: thirty seconds on the clock, two drills</h2>
2171 <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates: each one is a box in
@@ -29,6 +79,10 @@ const NOTE = `
2979 lane is walked and not sprinted. On a browser with no WebGL2 the range falls back to the
3080 flat horizon it shipped with, plates as squares projected from the angle each subtends,
3181 and the same two drills play on it.</p>
82 <div class="row">
83 <label for="name">the name your runs go on the board under</label>
84 <input id="name" type="text" maxlength="24" autocomplete="off" placeholder="anon">
85 </div>
3286 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
3387 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
3488 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
@@ -44,7 +98,7 @@ const NOTE = `
4498 you start a run: the browser opens no audio device before that.</p>
4599 `;
46100
47 export function rangePage(options: PageOptions): string {
101 export function rangePage(db: Database, options: PageOptions): string {
48102 return page({
49103 ...options,
50104 title: "range — the engine as a package",
@@ -53,8 +107,8 @@ export function rangePage(options: PageOptions): string {
53107 heading: "range",
54108 nav: navFor("/range"),
55109 noscript: noscriptFor("/range"),
56 terminalLead: "The shooter's leaderboard in a terminal:",
57 footer: "no database, no cookie: this page keeps nothing",
110 terminalLead: "The board, the runs and the shooter's leaderboard in a terminal:",
111 footer: "a finished run goes on the board, per drill",
58112 stageHtml: stage({
59113 touch: [
60114 [
@@ -70,7 +124,10 @@ export function rangePage(options: PageOptions): string {
70124 ],
71125 overlays: [{ id: "note", html: NOTE }],
72126 }),
73 sectionsHtml: ` <section>
127 sectionsHtml: ` <section id="board">
128 ${boardsHtml(db)}
129 </section>
130 <section>
74131 <h2>What this is</h2>
75132 <p>A second game's plumbing, and the proof that <code>src/engine/</code> is a package rather
76133 than a folder: every line behind this page comes from the engine or from
@@ -85,8 +142,8 @@ export function rangePage(options: PageOptions): string {
85142 lifetime, and a click traces a ray from your eye along your own view angles with the
86143 engine's own box trace — so a hit is a hit in three dimensions, not a hit on a picture. The
87144 clock ends the run, stops the trigger and reports the score, the hits, the shots and the
88 accuracy; <kbd>Enter</kbd> starts the next one, and the best of this visit is kept on the
89 page and nowhere else.</p>
145 accuracy; <kbd>Enter</kbd> starts the next one, the best of this visit stays on the panel,
146 and the run itself goes to the board below.</p>
90147 <p>Two drills share that lane, and <kbd>T</kbd> chooses between them before a run — mid-run
91148 the key does nothing rather than splicing two games into one score. <strong>Pop-up</strong>
92149 is the drill this range shipped with, unchanged in every number: three plates at a time,
@@ -129,8 +186,18 @@ export function rangePage(options: PageOptions): string {
129186 engine's <code>attenuate</code> on this room's own numbers, so a plate hit at 1,200 units is a
130187 quarter the volume of one at 200. The device is opened on the gesture that starts a run and
131188 never before it — a page you only look at opens no audio at all.</p>
132 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
133 the leaderboard, the database and the terminal belong to the game next door:
189 <p>A finished run is posted, and that is the second thing this game proves: the server can
190 hold a game it was not built for. The page sends one small object when the clock runs out —
191 the drill, the score, the hits, the shots, the seed and the thirty seconds — to
192 <code>/api/run</code>, which is the same door <code>/api/match</code> is: validated by shape
193 at the boundary, capped at what one run of that drill could possibly produce, same-origin
194 only, behind the same rate limiter, and recorded under the forge identity you are signed in
195 with or as a guest. Accuracy is not sent at all — it is hits over shots, worked out here, so
196 no browser gets to claim one. Your name is yours to type and a guest's is marked as a
197 guest's. The board above is drawn on the server, in the first paint, before a line of
198 JavaScript runs; the cookie this page now sets says which browser a guest's runs belong to
199 and carries nothing else.</p>
200 <p>The leaderboard of the game next door is still its own, and so is its table:
134201 ${othersHtml("/range")}</p>
135202 </section>`,
136203 });
src/range/main.ts +27 −4
@@ -11,8 +11,10 @@
1111 * the button beside it, asks `targets.ts` for the next one in its table. The
1212 * game itself decides whether that is allowed — a live run refuses — and this
1313 * file only passes the press on and prints which drill is being played and what
14 * the key would switch to. The best score is kept per drill, here and nowhere
15 * else: nothing on this page is stored.
14 * the key would switch to. The best score of this visit is kept per drill, on
15 * this page; the run itself goes to the board, through `post.ts` — this game's
16 * own plumbing, which imports nothing outside this directory, because the rule
17 * that makes the range the proof refuses the shooter's reporter.
1618 *
1719 * Every sound is a crossing this file found in the game's own facts: a plate
1820 * with an id the last frame had not reached stood up, the shot `shoot` handed
@@ -42,6 +44,7 @@ import { RANGE_ROOM } from "./level.ts";
4244 import {
4345 RANGE_HULL, RANGE_MOVE, createRangeBody, createRangeWish, rangeEye, rangeWish,
4446 } from "./movement.ts";
47 import { createPoster, NOTHING_POSTED, type Posts } from "./post.ts";
4548 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
4649 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
4750 import {
@@ -149,8 +152,9 @@ export function boot(): void {
149152 const run = createRun(freshSeed());
150153 /**
151154 * The best of this visit, per drill: a tracking score next to a pop-up best
152 * would be two games in one number. Nothing is stored anywhere — this page
153 * keeps no cookie and writes no table — so both start at zero.
155 * would be two games in one number. It is this page's memory and not the
156 * board's — the board is per drill too, and is the server's — so both start
157 * at zero on every visit.
154158 */
155159 const bests: Record<DrillName, number> = { popup: 0, tracking: 0 };
156160 // All three of these are edges, not holds: a held trigger is one shot, a held
@@ -165,6 +169,21 @@ export function boot(): void {
165169 bests[run.drill.name] = Math.max(bests[run.drill.name], run.score);
166170 };
167171
172 /**
173 * The board, and the one sentence about it. A run that ran out of clock is
174 * posted once through this game's own plumbing — `post.ts`, which imports
175 * nothing outside this directory — and whatever comes back is the last line
176 * of the panel. Nothing here waits for it: the frame goes on.
177 */
178 let posted = NOTHING_POSTED;
179 const poster = createPoster({
180 fetch: ((url, init) => fetch(url, init as RequestInit)) as Posts,
181 id: () => crypto.randomUUID(),
182 say: (line) => { posted = line; },
183 });
184 /** The name a guest's runs go on the board under. Signed in, the server uses yours. */
185 const namedBy = (): string => $<HTMLInputElement>("name").value ?? "";
186
168187 // The mouse only turns the view under pointer lock; a finger on the stage
169188 // turns it either way, which is why the button is the only thing in the way.
170189 // A phone has no pointer to lock, and says so by throwing or by rejecting:
@@ -307,6 +326,9 @@ export function boot(): void {
307326 if (wasLive && run.phase === "over") {
308327 keepBest();
309328 audio.over();
329 // The clock ran out, so the run is finished and complete: this is the one
330 // moment a run is posted, and it is posted once.
331 poster.post(run, namedBy());
310332 }
311333
312334 // One shot per press, down the barrel: the direction is the view's, and
@@ -361,6 +383,7 @@ export function boot(): void {
361383 pitch: view.pitch,
362384 locked: controls.locked,
363385 drawn: renderer !== null,
386 posted,
364387 },
365388 hw,
366389 hh,
src/range/post.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * The run on its way to the board: a finished thirty seconds turned into the
3 * bytes the server takes, sent once, and said out loud in one sentence.
4 *
5 * This is the range's own plumbing and it is deliberately not the shooter's: it
6 * imports nothing but its siblings, because `src/independence.test.ts` refuses
7 * an import from `src/client/` and that rule is the whole point of this game. A
8 * match posts a snapshot every round and therefore needs a queue, a backoff and
9 * four attempts (`src/client/report.ts`, `src/client/pending.ts`); a run is
10 * posted once, when its clock runs out, and there is nothing after it to make
11 * stale. So this is one attempt and one line of feedback — less machinery, not
12 * a copy of that one.
13 *
14 * It is not a browser: it is given a `fetch`-shaped call, a source of ids and
15 * somewhere to say what happened. `main.ts` is the only file that knows those
16 * are the window's. Nothing here is awaited by a frame — `post` starts the
17 * request and returns, and the answer lands whenever it lands.
18 */
19
20 import { report, type Run } from "./targets.ts";
21
22 /** Where a run goes. The same path `src/server.ts` gates runs at. */
23 export const RUN_ENDPOINT = "/api/run";
24
25 /** What the panel says before a run has been finished on this page. */
26 export const NOTHING_POSTED = "finish a run and it goes on the board";
27 /** …while it is in the air. */
28 export const SENDING = "posting this run…";
29 /** …when the server took it. */
30 export const SAVED = "this run is on the board";
31 /** …when the server refused it: the same bytes will not be taken next time. */
32 export const REFUSED = "the board refused this run";
33 /** …when nothing answered. Nothing is queued, so the run is gone and says so. */
34 export const LOST = "the board could not be reached: this run is not on it";
35
36 /** What this module reads off a response: whether it landed, and the status. */
37 export type Reply = { readonly ok: boolean; readonly status: number };
38
39 /** The one call this module makes. A browser's `fetch` is one. */
40 export type Posts = (
41 url: string,
42 init: { method: string; headers: Record<string, string>; body: string; credentials: string },
43 ) => Promise<Reply>;
44
45 /** What the page lends this module: the network, an id a run is filed under, and a voice. */
46 export type Wire = {
47 fetch: Posts;
48 /** A fresh id per run. `crypto.randomUUID()` in a browser. */
49 id(): string;
50 /** The sentence the panel shows. Called as the attempt starts and as it ends. */
51 say(line: string): void;
52 };
53
54 /**
55 * Exactly what is sent, and the only thing the server is told: which drill, the
56 * score, the hits, the shots, the seed the run was spawned from and how long it
57 * ran. No accuracy — the server works that out from the hits and the shots, so
58 * there is no third number here to disagree with them — and no position, no
59 * timing, nothing about the browser.
60 */
61 export type Posting = {
62 runId: string;
63 name: string;
64 drill: string;
65 score: number;
66 hits: number;
67 shots: number;
68 seed: number;
69 seconds: number;
70 };
71
72 /**
73 * A finished run as the server takes it. The seconds are the drill's clock
74 * minus whatever is left on it, which is the drill's whole clock for a run the
75 * clock ended — and less for one that has not, which the server's ceiling reads
76 * as the shorter run it is.
77 */
78 export const postingOf = (run: Run, runId: string, name: string): Posting => {
79 const said = report(run);
80 return {
81 runId,
82 name,
83 drill: said.drill,
84 score: said.score,
85 hits: said.hits,
86 shots: said.shots,
87 seed: run.seed,
88 seconds: Math.round(run.drill.runTime - Math.max(0, run.clock)),
89 };
90 };
91
92 export type Poster = {
93 /** Send this run. One attempt; the answer becomes one of the sentences above. */
94 post(run: Run, name: string): void;
95 };
96
97 /**
98 * The poster. A 2xx is on the board, any other answer is a refusal, and a fetch
99 * that throws or never answers is lost — there is no retry, so there is no
100 * state to keep between runs and nothing to go stale in it.
101 *
102 * The `try` is not decoration: a page whose network call throws on the spot
103 * (a relative URL with no document behind it, a browser with fetch turned off)
104 * must lose the run, not the frame it was posted from.
105 */
106 export function createPoster(wire: Wire): Poster {
107 return {
108 post(run, name) {
109 const body = JSON.stringify(postingOf(run, wire.id(), name));
110 wire.say(SENDING);
111 try {
112 wire
113 .fetch(RUN_ENDPOINT, {
114 method: "POST",
115 headers: { "content-type": "application/json" },
116 body,
117 // The session cookie names this browser, and a guest's runs are
118 // filed under it: the request has to carry it.
119 credentials: "same-origin",
120 })
121 .then((reply) => wire.say(reply.ok ? SAVED : REFUSED))
122 .catch(() => wire.say(LOST));
123 } catch {
124 wire.say(LOST);
125 }
126 },
127 };
128 }
src/range/readout.ts +12 −1
@@ -63,6 +63,13 @@ export type Readout = View & {
6363 locked: boolean;
6464 /** Is the room being drawn, or is this browser on the 2D horizon instead? */
6565 drawn: boolean;
66 /**
67 * Where the last finished run stands with the board: one of the sentences in
68 * `post.ts`, or absent on a page that has not finished one. It is the last
69 * line of the panel because it is the only line that is not about this
70 * instant — and it is never invented here: this file prints what it is given.
71 */
72 posted?: string;
6673 };
6774
6875 const SKY = "#101826";
@@ -102,7 +109,7 @@ export function clockLine(run: Report, next: string): string {
102109
103110 /** The panel's lines, in order. Separate from the drawing so a test can read them. */
104111 export function readoutLines(r: Readout): string[] {
105 return [
112 const lines = [
106113 scoreLine(r.run, r.best),
107114 clockLine(r.run, r.next),
108115 `tick ${r.ticks} · ${r.hz} Hz fixed step`,
@@ -115,6 +122,10 @@ export function readoutLines(r: Readout): string[] {
115122 r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen",
116123 r.drawn ? "drawn by the engine's renderer" : "no WebGL2 here · the 2D horizon instead",
117124 ];
125 // Only once there is something true to say: a page that has finished no run
126 // has no line about the board, rather than a line that says nothing.
127 if (r.posted !== undefined && r.posted !== "") lines.push(r.posted);
128 return lines;
118129 }
119130
120131 /** What is standing in the lane, and where it is looked at from. */
src/range/stage.test-helper.ts +26 −2
@@ -14,7 +14,7 @@
1414 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1515
1616 /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */
17 export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full"];
17 export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full", "name"];
1818
1919 /** The `[data-action]` buttons the shell renders for this game. */
2020 export const RANGE_ACTIONS = ["duck", "jump", "fire"];
@@ -159,6 +159,21 @@ export function audioCounter(): FakeAudio {
159159 };
160160 }
161161
162 /**
163 * The page's network, recorded. `main.ts` posts a finished run through the
164 * window's `fetch` (`post.ts`), so a stub page has one: every request it made
165 * is here in order, and `reply` is what the next one comes back with. Without
166 * it a boot test would reach a real network with a relative URL, which is a
167 * different test and a slower one.
168 */
169 export type FakeFetch = {
170 calls: { url: string; body: unknown }[];
171 /** What the next call resolves to. A test moves it to refuse a run. */
172 reply: { ok: boolean; status: number };
173 /** Set this and the call rejects instead, which is a board nobody could reach. */
174 fails: boolean;
175 };
176
162177 export type RangePage = {
163178 document: FakeDocument;
164179 window: FakeWindow;
@@ -166,6 +181,8 @@ export type RangePage = {
166181 hud: FakeCanvas;
167182 /** The `AudioContext` this page would give the engine's sound device. */
168183 audio: FakeAudio;
184 /** Every run this page posted, and what the server said. */
185 fetch: FakeFetch;
169186 by(id: string): FakeElement;
170187 buttons: FakeElement[];
171188 /** The frames asked for but not yet run, oldest first. */
@@ -179,7 +196,7 @@ export type RangePage = {
179196 /** The globals the last lines of `main.ts` and the engine's two edges name. */
180197 const GLOBALS = [
181198 "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia",
182 "performance", "AudioContext",
199 "performance", "AudioContext", "fetch",
183200 ];
184201
185202 /**
@@ -203,6 +220,11 @@ export function page(
203220 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
204221 if (options.webgl === true) scene.gl = glRecorder();
205222 const audio = audioCounter();
223 const net: FakeFetch = { calls: [], reply: { ok: true, status: 200 }, fails: false };
224 const fetching = (url: string, init: { body?: string }): Promise<{ ok: boolean; status: number }> => {
225 net.calls.push({ url, body: init.body === undefined ? null : JSON.parse(init.body) });
226 return net.fails ? Promise.reject(new Error("no network")) : Promise.resolve(net.reply);
227 };
206228
207229 return {
208230 document: fake.document,
@@ -210,6 +232,7 @@ export function page(
210232 scene,
211233 hud,
212234 audio,
235 fetch: net,
213236 buttons: fake.buttons,
214237 frames: fake.frames,
215238 by: fake.by,
@@ -227,6 +250,7 @@ export function page(
227250 globals.document = fake.document;
228251 globals.window = fake.window;
229252 globals.AudioContext = audio.Ctor;
253 globals.fetch = fetching;
230254 globals.requestAnimationFrame = fake.window.requestAnimationFrame;
231255 globals.cancelAnimationFrame = fake.window.cancelAnimationFrame;
232256 globals.matchMedia = fake.window.matchMedia;
src/result.ts +9 −16
@@ -3,8 +3,15 @@
33 * is client-reported — the game runs entirely in the page — so the server's
44 * job is to keep the numbers inside what a real match can produce and to
55 * refuse anything else outright.
6 *
7 * What is *not* particular to a match lives in `src/gate.ts` and is shared with
8 * the range's runs rather than copied beside them: a counted number, a
9 * printable name, and the door both games' scores are posted at. `cleanName` is
10 * re-exported here because this is where the rest of the app already asks for
11 * it; it is one function either way.
612 */
713
14 import { cleanName, integer } from "./gate.ts";
815 import { isMatchId } from "./matchid.ts";
916 import { DEFAULT_MAP, type PlayedMap, playedOn } from "./mapname.ts";
1017
@@ -39,22 +46,6 @@ const KILLS_PER_ROUND = 5; // five enemies is all there are
3946 */
4047 const SECONDS_PER_ROUND = 180;
4148
42 const integer = (value: unknown, max: number): number | null => {
43 if (typeof value !== "number" || !Number.isFinite(value)) return null;
44 const n = Math.floor(value);
45 // `-0 < 0` is false, so negative zero would otherwise pass as a count and be
46 // stored, sorted and digested as a number that is not quite zero.
47 if (Object.is(n, -0)) return null;
48 return n < 0 || n > max ? null : n;
49 };
50
51 /** A name is one line of printable text, short, and never empty. */
52 export function cleanName(value: unknown): string {
53 if (typeof value !== "string") return "anon";
54 const stripped = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 24);
55 return stripped.length === 0 ? "anon" : stripped;
56 }
57
5849 /** Parse a submitted result, or null if it is not one. */
5950 export function parseResult(value: unknown): Result | null {
6051 if (typeof value !== "object" || value === null) return null;
@@ -103,5 +94,7 @@ export function parseResult(value: unknown): Result | null {
10394 };
10495 }
10596
97 export { cleanName };
98
10699 export const won = (result: Result): boolean =>
107100 result.finished && result.scoreMine > result.scoreTheirs;
src/run.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * What a browser is allowed to say about a range run it played, which is the
3 * range's half of what `src/result.ts` is for the shooter. The game runs
4 * entirely in the page, so every number here is the client's word and the
5 * server's job is the same one: keep it inside what thirty seconds of that
6 * drill can produce and refuse anything else outright.
7 *
8 * A run is not a match and is not stored as one: it is a drill, a score, hits,
9 * shots, the seed it was spawned from and how long it lasted. What the two
10 * share is the door they come in through (`src/gate.ts`) and nothing else.
11 *
12 * **The ceilings are the drill's own.** `src/range/targets.ts` is where a drill
13 * is defined — how often a plate stands up, how long it stands, how fast it
14 * slides and what hitting it is worth — so the most a run can score, the most
15 * plates it can ever hit and the seconds it can last are read out of that table
16 * rather than written down again here. A third drill raises its own ceiling by
17 * existing, and a tuning that makes plates worth more moves the ceiling with it.
18 * The server importing the range's table is not the range importing the server:
19 * this file is the app's, and the rule in `src/independence.test.ts` is about a
20 * *game's* sources.
21 *
22 * **Accuracy is not taken from the browser.** It is hits over shots, computed
23 * wherever it is shown, so there is no number here that can disagree with the
24 * two it is made of.
25 */
26
27 import { cleanName, integer } from "./gate.ts";
28 import { isMatchId } from "./matchid.ts";
29 import { DRILLS, type Drill, type DrillName } from "./range/targets.ts";
30
31 export type RangeRun = {
32 /**
33 * The key this playing is stored under, generated by the page. It is checked
34 * for the one id shape this app accepts — `src/matchid.ts` owns that shape,
35 * and a run key is that shape too: hex and dashes, eight to sixty-four.
36 */
37 runId: string;
38 name: string;
39 /** Which drill was played. A score means nothing without it. */
40 drill: DrillName;
41 score: number;
42 hits: number;
43 shots: number;
44 /** The seed the run was spawned from, so the same thirty seconds can be replayed. */
45 seed: number;
46 /** How long it ran, in seconds. A run that claims longer than its drill lasts is refused. */
47 seconds: number;
48 };
49
50 /** A drill by name, or null: the whole of what a posted drill may be. */
51 export const drillNamed = (value: unknown): Drill | null =>
52 typeof value === "string" && Object.hasOwn(DRILLS, value) ? DRILLS[value as DrillName] : null;
53
54 /**
55 * Most plates a drill can ever stand up in one run: the ones up when it starts,
56 * one more every `gap` seconds of the clock, and one for the tick the clock
57 * ends on. Nobody can hit a plate that never stood up, so this is the ceiling
58 * on hits — the "hit rate above the clock's ceiling" a run is refused for.
59 */
60 export const mostPlates = (drill: Drill): number =>
61 drill.atOnce + Math.floor(drill.runTime / drill.gap) + 1;
62
63 /** The most one plate of this drill can be worth: its farthest, at its fastest. */
64 export const mostPerPlate = (drill: Drill): number =>
65 Math.max(drill.points.near, drill.points.far) + drill.points.perSpeed * drill.speed.max;
66
67 /** The most a run of this drill can score: every plate it could raise, at its best. */
68 export const mostScore = (drill: Drill): number => Math.ceil(mostPlates(drill) * mostPerPlate(drill));
69
70 /**
71 * How fast a trigger can be pulled, in shots a second. Twenty is far above what
72 * a hand does and far below what a script would want: the point is that a run
73 * cannot claim a hundred thousand shots and sit at the bottom of an accuracy
74 * table for ever.
75 */
76 export const SHOTS_A_SECOND = 20;
77
78 /** The most shots a run of this drill can take. */
79 export const mostShots = (drill: Drill): number => Math.ceil(drill.runTime * SHOTS_A_SECOND);
80
81 /** The seed a run may carry: a whole number inside 32 bits, which is what the page draws. */
82 export const MAX_SEED = 0xffff_ffff;
83
84 /** Parse a submitted run, or null if it is not one. */
85 export function parseRun(value: unknown): RangeRun | null {
86 if (typeof value !== "object" || value === null) return null;
87 const body = value as Record<string, unknown>;
88 const runId = body.runId;
89 if (!isMatchId(runId)) return null;
90 const drill = drillNamed(body.drill);
91 if (drill === null) return null;
92 const score = integer(body.score, mostScore(drill));
93 const hits = integer(body.hits, mostPlates(drill));
94 const shots = integer(body.shots, mostShots(drill));
95 const seed = integer(body.seed, MAX_SEED);
96 // A run lasts exactly its drill's clock, and a slow last frame can only round
97 // it up by a hair: a whole second of slack, and not one more.
98 const seconds = integer(body.seconds, Math.ceil(drill.runTime) + 1);
99 if (score === null || hits === null || shots === null || seed === null || seconds === null) return null;
100 // The parts have to add up: every hit was a shot, and a score is worth at
101 // most what the plates hit could pay.
102 if (hits > shots) return null;
103 if (score > Math.ceil(hits * mostPerPlate(drill))) return null;
104 return { runId, name: cleanName(body.name), drill: drill.name, score, hits, shots, seed, seconds };
105 }
src/server.test.ts +5 −1
@@ -528,7 +528,11 @@ test("the range stands on the same security floor as the game", async () => {
528528 // that differs is the style hash — each page's own, never the other's.
529529 expect(range.headers.get("content-security-policy")).not.toBe(game.headers.get("content-security-policy"));
530530 // No session is started for a page that stores nothing.
531 expect(range.headers.get("set-cookie")).toBeNull();
531 // The range keeps runs now, so it names the browser a guest's runs belong to
532 // — the shooter's cookie, with the shooter's flags and nothing else in it.
533 expect(range.headers.get("set-cookie")).toContain("cs_session=");
534 expect(range.headers.get("set-cookie")).toContain("HttpOnly");
535 expect(range.headers.get("set-cookie")).toContain("SameSite=Lax");
532536 });
533537
534538 test("HEAD /range answers like GET, and an unknown path is still the same 404", async () => {
src/server.ts +60 −11
@@ -51,7 +51,8 @@ import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts";
5151 import { kB } from "./bytes.ts";
5252 import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts";
5353 import { measureExample } from "./cost.ts";
54 import { openDatabase, type Player, saveMatch } from "./db.ts";
54 import { openDatabase, type Player, saveMatch, saveRun } from "./db.ts";
55 import { type Gate } from "./gate.ts";
5556 import { GAMES } from "./games.ts";
5657 import { createLimiter } from "./limit.ts";
5758 import { examplePage } from "./pages/example.ts";
@@ -62,10 +63,11 @@ import { parseSituation } from "./plan/situation.ts";
6263 import { createPlanner, type Planner } from "./plan/typesafe.ts";
6364 import { matchFrom } from "./matchid.ts";
6465 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
65 import { rangePage } from "./pages/range.ts";
66 import { boardsHtml, rangePage } from "./pages/range.ts";
6667 import { policyFor } from "./policy.ts";
6768 import { styleFor, type Linked } from "./pages/shell.ts";
6869 import { cleanName, parseResult } from "./result.ts";
70 import { parseRun } from "./run.ts";
6971 import { who, type Who } from "./who.ts";
7072
7173 const db = openDatabase();
@@ -492,10 +494,17 @@ const RENDER: Record<string, Render> = {
492494 };
493495 },
494496 },
495 // The range: its own page and its own bundle, and no session — it stores
496 // nothing, so it has nothing to name a browser for. No CSS of its own either:
497 // its style block is the shell's, exactly.
498 range: { draw: (_request, script) => ({ body: rangePage({ env, pull, commit, script, ssh }) }) },
497 // The range: its own page, its own bundle, its own board out of this
498 // environment's database — and a session, now that it posts runs, for the
499 // same one reason the shooter has one: a guest's runs are that browser's and
500 // nobody else's. No CSS of its own: its style block is the shell's, exactly.
501 range: {
502 draw: (request, script) => {
503 const session = readSession(request);
504 const extra: Headers = session === null ? { "set-cookie": sessionCookie(newSession()) } : {};
505 return { body: rangePage(db, { env, pull, commit, script, ssh }), extra };
506 },
507 },
499508 // The example: the guide's game with a page around it, no session either, and
500509 // the measured cost of being a third game, weighed once above.
501510 example: {
@@ -503,6 +512,41 @@ const RENDER: Record<string, Render> = {
503512 },
504513 };
505514
515 /**
516 * The write routes: one door per game that keeps a score, against the parser,
517 * the writer and the board that are that game's own. Everything *around* those
518 * three — same-origin, the limiter, the capped body, the JSON, the identity,
519 * the session cookie and every status — is one sequence in `route` below and is
520 * not written twice. `src/gate.ts` says what a gate is and why it is not called
521 * a match.
522 *
523 * It is here rather than in that module for the reason `RENDER` is here: a gate
524 * holds functions over this process's database and this server's pages, and a
525 * function is not an entry in a registry `scripts/build.ts` also reads.
526 */
527 const GATES: readonly Gate[] = [
528 {
529 path: "/api/match",
530 refusal: "bad result",
531 accept: (value) => {
532 const result = parseResult(value);
533 return result === null ? null : { name: result.name, write: (db, player) => saveMatch(db, player, result) };
534 },
535 board: (db) => statsHtml(db),
536 },
537 {
538 path: "/api/run",
539 refusal: "bad run",
540 accept: (value) => {
541 const run = parseRun(value);
542 return run === null ? null : { name: run.name, write: (db, player) => saveRun(db, player, run) };
543 },
544 board: (db) => boardsHtml(db),
545 },
546 ];
547
548 const gateAt = (path: string): Gate | undefined => GATES.find((gate) => gate.path === path);
549
506550 // The read routes, resolved once at start-up: the path each game answers on,
507551 // against its renderer holding its own bundle. A game in the registry with no
508552 // page here is a mistake that fails now, at the first boot, rather than as a
@@ -643,7 +687,12 @@ async function route(request: Request, listener: Bun.Server<undefined>): Promise
643687 if (request.method === "POST" && !sameOrigin(request))
644688 return text("forbidden", 403);
645689
646 if (request.method === "POST" && url.pathname === "/api/match") {
690 // A posted score, whichever game played it: one sequence, one gate per game.
691 // The shooter's match and the range's run differ in three things — what the
692 // numbers are, where they are written and which board comes back — and in
693 // nothing else, so nothing else here is said twice.
694 const gate = request.method === "POST" ? gateAt(url.pathname) : undefined;
695 if (gate !== undefined) {
647696 const visitor = who(request, identityKey);
648697 const held = readSession(request);
649698 if (!mayWrite(visitor, held, listener.requestIP(request)?.address))
@@ -656,12 +705,12 @@ async function route(request: Request, listener: Bun.Server<undefined>): Promise
656705 } catch {
657706 return text("bad json", 400);
658707 }
659 const result = parseResult(parsed);
660 if (result === null) return text("bad result", 422);
708 const checked = gate.accept(parsed);
709 if (checked === null) return text(gate.refusal, 422);
661710 const session = held ?? newSession();
662 const stored = saveMatch(db, playerOf(visitor, session, result.name), result);
711 const stored = checked.write(db, playerOf(visitor, session, checked.name));
663712 if (!stored) return text("not yours", 409);
664 return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) });
713 return html(gate.board(db), SECURITY, 200, { "set-cookie": sessionCookie(session) });
665714 }
666715
667716 /**
src/terminal/keys.test.ts +32 −6
@@ -33,6 +33,20 @@ const match = (id: string, name: string): MatchRow => ({
3333 updated_at: "2026-09-16 10:00:00",
3434 });
3535
36 const run = (id: string, name: string) => ({
37 id,
38 session: "s-1",
39 user_id: name === "bo" ? "" : "u-1",
40 name,
41 drill: "popup",
42 score: 900,
43 hits: 6,
44 shots: 10,
45 seed: 4242,
46 seconds: 30,
47 updated_at: "2026-09-16 10:00:00",
48 });
49
3650 const data: Data = {
3751 daily: dailyOn(Date.parse("2026-09-16T10:00:30Z")),
3852 today: [],
@@ -41,7 +55,9 @@ const data: Data = {
4155 { name: "bo", user_id: "", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 },
4256 ],
4357 matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")],
58 runs: [run("r1", "ace"), run("r2", "bo")],
4459 total: 4,
60 runTotal: 2,
4561 card: CARD,
4662 // The keys read rows and a cursor; the clock is the screen's business.
4763 now: Date.parse("2026-09-16T10:00:30Z"),
@@ -50,7 +66,7 @@ const data: Data = {
5066 const view = (over: Partial<View> = {}): View =>
5167 ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over });
5268
53 test("tab cycles standings → matches → policy → today → standings, and the cursor resets each time", () => {
69 test("tab cycles standings → matches → policy → today → runs → standings, and the cursor resets each time", () => {
5470 const first = press(view({ cursor: 1 }), "\t", data);
5571 expect(first.view.tab).toBe("matches");
5672 expect(first.view.cursor).toBe(0);
@@ -61,8 +77,11 @@ test("tab cycles standings → matches → policy → today → standings, and t
6177 expect(third.view.tab).toBe("today");
6278 expect(third.view.cursor).toBe(0);
6379 const fourth = press(third.view, "\t", data);
64 expect(fourth.view.tab).toBe("standings");
80 expect(fourth.view.tab).toBe("runs");
6581 expect(fourth.view.cursor).toBe(0);
82 const fifth = press({ ...fourth.view, cursor: 1 }, "\t", data);
83 expect(fifth.view.tab).toBe("standings");
84 expect(fifth.view.cursor).toBe(0);
6685 expect(fourth.command).toEqual({ kind: "nothing" });
6786 });
6887
@@ -98,19 +117,26 @@ test("on the policy tab j and k change nothing", () => {
98117
99118 test("x on the matches tab asks for the match under the cursor to go", () => {
100119 const step = press(view({ tab: "matches", cursor: 1 }), "x", data);
101 expect(step.command).toEqual({ kind: "delete", id: "m2", name: "bo" });
120 expect(step.command).toEqual({ kind: "delete", what: "match", id: "m2", name: "bo" });
102121 expect(step.view.cursor).toBe(1);
103122 });
104123
124 test("x on the runs tab asks for the range run under the cursor to go, and says which table", () => {
125 const step = press(view({ tab: "runs", cursor: 1 }), "x", data);
126 expect(step.command).toEqual({ kind: "delete", what: "run", id: "r2", name: "bo" });
127 });
128
105129 test("x anywhere else does nothing, and neither does x with nothing to remove", () => {
106130 expect(press(view(), "x", data).command).toEqual({ kind: "nothing" });
107131 expect(press(view({ tab: "policy" }), "x", data).command).toEqual({ kind: "nothing" });
108132 expect(press(view({ tab: "matches" }), "x", { ...data, matches: [] }).command).toEqual({ kind: "nothing" });
133 expect(press(view({ tab: "runs" }), "x", { ...data, runs: [] }).command).toEqual({ kind: "nothing" });
109134 });
110135
111136 test("what happened to the match is what the note says", () => {
112 expect(removed(view(), "bo", true).note).toBe("removed bo's match");
113 expect(removed(view(), "bo", false).note).toBe("nothing removed");
137 expect(removed(view(), "bo", "match", true).note).toBe("removed bo's match");
138 expect(removed(view(), "bo", "run", true).note).toBe("removed bo's run");
139 expect(removed(view(), "bo", "match", false).note).toBe("nothing removed");
114140 });
115141
116142 test("q and ctrl-C quit", () => {
@@ -131,5 +157,5 @@ test("the note from the last press is gone by the next one", () => {
131157
132158 test("a cursor left over from a longer list is pulled back onto the list before it is used", () => {
133159 const step = press(view({ tab: "matches", cursor: 9 }), "x", { ...data, matches: [match("m1", "ace")] });
134 expect(step.command).toEqual({ kind: "delete", id: "m1", name: "ace" });
160 expect(step.command).toEqual({ kind: "delete", what: "match", id: "m1", name: "ace" });
135161 });
src/terminal/keys.ts +16 −8
@@ -13,13 +13,16 @@ import { clamp, rowsOf, TABS, type Data, type View } from "./screen.ts";
1313 export type Command =
1414 | { kind: "nothing" }
1515 | { kind: "quit" }
16 | { kind: "delete"; id: string; name: string };
16 | { kind: "delete"; what: What; id: string; name: string };
17
18 /** Which table a delete is about. Two games keep scores, so a row says which. */
19 export type What = "match" | "run";
1720
1821 export type Press = { view: View; command: Command };
1922
2023 const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } });
2124
22 /** The next tab in the cycle: standings → matches → policy → today → standings. */
25 /** The next tab in the cycle: standings → matches → policy → today → runs → standings. */
2326 export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!;
2427
2528 export function press(view: View, key: string, data: Data): Press {
@@ -30,15 +33,20 @@ export function press(view: View, key: string, data: Data): Press {
3033 if (key === "j" || key === "\x1b[B") return nothing({ ...now, cursor: clamp(now.cursor + 1, rows) });
3134 if (key === "k" || key === "\x1b[A") return nothing({ ...now, cursor: clamp(now.cursor - 1, rows) });
3235 if (key === "\t") return nothing({ ...now, tab: nextTab(now), cursor: 0 });
33 if (key === "x" && now.tab === "matches") {
34 const match = data.matches[now.cursor];
35 if (match === undefined) return nothing(now);
36 return { view: now, command: { kind: "delete", id: match.id, name: match.name } };
36 // x removes the row the cursor is on, whichever list it is: the two games
37 // keep two tables, and the command says which one this row is in rather than
38 // the caller guessing from a tab. Whatever identity check that action grows
39 // (#40) grows for both at once, because there is one action.
40 if (key === "x" && (now.tab === "matches" || now.tab === "runs")) {
41 const row = now.tab === "matches" ? data.matches[now.cursor] : data.runs[now.cursor];
42 if (row === undefined) return nothing(now);
43 const what: What = now.tab === "matches" ? "match" : "run";
44 return { view: now, command: { kind: "delete", what, id: row.id, name: row.name } };
3745 }
3846 // r redraws, and so does anything else: the caller draws after every press.
3947 return nothing(now);
4048 }
4149
4250 /** Fold the outcome of a delete back into the view, as the note a person reads. */
43 export const removed = (view: View, name: string, ok: boolean): View =>
44 ({ ...view, note: ok ? `removed ${name}'s match` : "nothing removed" });
51 export const removed = (view: View, name: string, what: What, ok: boolean): View =>
52 ({ ...view, note: ok ? `removed ${name}'s ${what}` : "nothing removed" });
src/terminal/screen.test.ts +53 −8
@@ -7,7 +7,7 @@
77
88 import { expect, test } from "bun:test";
99 import type { Card } from "../client/weights.ts";
10 import type { MatchRow, Standing } from "../db.ts";
10 import type { MatchRow, RunRow, 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";
1313 import { dailyOn } from "../daily.ts";
@@ -60,6 +60,21 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow
6060 const ROW_AT = Date.parse("2026-09-16T10:00:00Z");
6161 const NOW = ROW_AT + 30_000;
6262
63 const runRow = (id: string, name: string, over: Partial<RunRow> = {}): RunRow => ({
64 id,
65 session: "s",
66 user_id: "u-1",
67 name,
68 drill: "popup",
69 score: 1450,
70 hits: 9,
71 shots: 12,
72 seed: 4242,
73 seconds: 30,
74 updated_at: "2026-09-16 10:00:00",
75 ...over,
76 });
77
6378 const data = (over: Partial<Data> = {}): Data => ({
6479 daily: dailyOn(NOW),
6580 today: [
@@ -68,6 +83,8 @@ const data = (over: Partial<Data> = {}): Data => ({
6883 ],
6984 standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })],
7085 matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })],
86 runs: [runRow("r1", "ace"), runRow("r2", "bo", { user_id: "", drill: "tracking", score: 700 })],
87 runTotal: 2,
7188 total: 4,
7289 card: CARD,
7390 now: NOW,
@@ -80,19 +97,20 @@ const view = (over: Partial<View> = {}): View =>
8097 const text = (v: View, d: Data = data()): string[] => screen(v, d).map(plain);
8198
8299 test("the header says where we are and how many matches there are, plural when it should be", () => {
83 expect(text(view())[0]).toBe("cs local · 4 matches");
84 expect(text(view(), data({ total: 1 }))[0]).toBe("cs local · 1 match");
85 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches");
100 expect(text(view())[0]).toBe("cs local · 4 matches · 2 runs");
101 expect(text(view(), data({ total: 1, runTotal: 1 }))[0]).toBe("cs local · 1 match · 1 run");
102 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches · 2 runs");
86103 });
87104
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 ");
105 test("the tab strip names all five, and the highlight follows the tab you are on", () => {
106 expect(text(view())[2]).toBe(" standings matches policy today runs ");
90107 expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m");
91108 expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m");
92109 expect(screen(view({ tab: "policy" }), data())[2]).toContain("\x1b[7m policy \x1b[0m");
93110 expect(screen(view({ tab: "today" }), data())[2]).toContain("\x1b[7m today \x1b[0m");
111 expect(screen(view({ tab: "runs" }), data())[2]).toContain("\x1b[7m runs \x1b[0m");
94112 // And only ever one of them.
95 for (const tab of ["standings", "matches", "policy", "today"] as const) {
113 for (const tab of ["standings", "matches", "policy", "today", "runs"] as const) {
96114 expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1);
97115 }
98116 });
@@ -224,7 +242,7 @@ test("a note is the last line when there is one, under the ssh line", () => {
224242 });
225243
226244 test("the keys are printed where a person can read them", () => {
227 expect(text(view())).toContain("j/k move · tab switch · x remove a match · r refresh · q quit");
245 expect(text(view())).toContain("j/k move · tab switch · x remove the row · r refresh · q quit");
228246 });
229247
230248 test("the cursor never draws past the end of the list, and the policy card has no rows at all", () => {
@@ -276,3 +294,30 @@ test("the today tab is the match of the day: the id, the map, the rule and the b
276294 // And a day nobody has played says so rather than drawing an empty table.
277295 expect(text(view({ tab: "today" }), data({ today: [] })).join("\n")).toContain("nobody has played it yet");
278296 });
297
298 test("the runs tab lists the range's runs, with the drill, the accuracy and the seed", () => {
299 const lines = screen(view({ tab: "runs" }), data()).map(plain);
300 const board = lines.join("\n");
301 expect(board).toContain("shooter");
302 expect(board).toContain("drill");
303 // A signed-in shooter by name, a guest said to be one, each with its drill.
304 expect(lines.some((l) => l.includes("ace") && l.includes("popup") && l.includes("1450") && l.includes("9/12"))).toBe(true);
305 expect(lines.some((l) => l.includes("bo (guest)") && l.includes("tracking"))).toBe(true);
306 // 9 of 12 is 75%, worked out here and never stored.
307 expect(board).toContain("75%");
308 // The seed of the row the cursor is on, so the same thirty seconds can be played again.
309 expect(board).toContain("seed 4242");
310 });
311
312 test("a runs tab with nothing on it says so, and the header counts both games", () => {
313 const empty = screen(view({ tab: "runs" }), data({ runs: [], runTotal: 0 })).map(plain);
314 expect(empty.join("\n")).toContain("no runs yet");
315 expect(screen(view(), data({ runTotal: 0 })).map(plain)[0]).toContain("0 runs");
316 expect(screen(view(), data({ runTotal: 1 })).map(plain)[0]).toContain("1 run");
317 });
318
319 test("a name carrying an escape cannot move the cursor out of a run's row", () => {
320 const nasty = runRow("r9", "a\u001b[2Jb");
321 const lines = screen(view({ tab: "runs" }), data({ runs: [nasty] })).map(plain);
322 expect(lines.some((l) => l.includes("a·[2Jb"))).toBe(true);
323 });
src/terminal/screen.ts +50 −9
@@ -15,14 +15,19 @@
1515
1616 import type { Card } from "../client/weights.ts";
1717 import { type Daily, RANKED_BY } from "../daily.ts";
18 import type { DayRun, Liveness, MatchRow, Standing } from "../db.ts";
19 import { displayName, headshotShare, liveness, ratio } from "../db.ts";
18 import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts";
19 import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts";
2020 import { matchOf } from "../matchid.ts";
2121
22 export type Tab = "standings" | "matches" | "policy" | "today";
22 export type Tab = "standings" | "matches" | "policy" | "today" | "runs";
2323
24 /** The four tabs in the order tab cycles them. */
25 export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today"];
24 /**
25 * The five tabs in the order tab cycles them. `runs` is the range's board — a
26 * second game keeps scores here now, so the terminal lists them the way it
27 * lists matches — and it is last so that the four a person already knows are
28 * the same four presses away they always were.
29 */
30 export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today", "runs"];
2631
2732 export type View = {
2833 tab: Tab;
@@ -43,8 +48,12 @@ export type Data = {
4348 today: DayRun[];
4449 standings: Standing[];
4550 matches: MatchRow[];
51 /** The range's runs, newest first: the same rows `/range` ranks per drill. */
52 runs: RunRow[];
4653 /** Every match ever recorded, including the empty ones the lists skip. */
4754 total: number;
55 /** Every range run ever recorded. */
56 runTotal: number;
4857 card: Card;
4958 /**
5059 * The moment those rows were read, which is the only clock this file has:
@@ -64,7 +73,13 @@ const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1)
6473
6574 /** Rows the cursor can stand on for a tab. The policy card has none. */
6675 export const rowsOf = (tab: Tab, data: Data): number =>
67 tab === "standings" ? data.standings.length : tab === "matches" ? data.matches.length : 0;
76 tab === "standings"
77 ? data.standings.length
78 : tab === "matches"
79 ? data.matches.length
80 : tab === "runs"
81 ? data.runs.length
82 : 0;
6883
6984 /** The cursor a view actually draws with: never past the end of its list. */
7085 export const clamp = (cursor: number, rows: number): number =>
@@ -193,22 +208,48 @@ function matchLines(matches: MatchRow[], cursor: number, now: number): string[]
193208 return lines;
194209 }
195210
211 /**
212 * The range's runs, newest first, in the columns that game is scored in: which
213 * drill, the score, the hits over the shots, the accuracy those two make and
214 * when it landed. The drill is a column rather than a heading because the list
215 * is a feed and not a board — `/range` ranks them per drill, this says what has
216 * been shot lately — and the seed of the row the cursor is on is printed under
217 * it, because that is what a person does with this screen: play the same thirty
218 * seconds again.
219 */
220 function runLines(runs: RunRow[], cursor: number): string[] {
221 const lines = [
222 `${esc}2m ${pad("shooter", 22)}${pad("drill", 10)}${pad("score", 8)}${pad("hits", 9)}${pad("acc", 6)}when${esc}0m`,
223 ];
224 if (runs.length === 0) lines.push(`${esc}2m no runs yet${esc}0m`);
225 runs.forEach((r, i) => {
226 const line = `${pad(printable(displayName(r)), 22)}${pad(printable(r.drill), 10)}${pad(String(r.score), 8)}` +
227 `${pad(`${r.hits}/${r.shots}`, 9)}${pad(hitShare(r.hits, r.shots), 6)}${printable(r.updated_at)}`;
228 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
229 });
230 const selected = runs[cursor];
231 if (selected !== undefined)
232 lines.push("", `${esc}2mthis run was the ${printable(selected.drill)} drill on seed ${selected.seed}${esc}0m`);
233 return lines;
234 }
235
196236 /** Every line the terminal would print, top to bottom, without the clear. */
197237 export function screen(view: View, data: Data): string[] {
198238 const cursor = clamp(view.cursor, rowsOf(view.tab, data));
199239 const lines: string[] = [];
200240 lines.push(
201 `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`,
241 `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} match${data.total === 1 ? "" : "es"} · ${data.runTotal} run${data.runTotal === 1 ? "" : "s"}${esc}0m`,
202242 );
203243 lines.push("");
204244 lines.push(header(view.tab));
205245 lines.push("");
206 if (view.tab === "today") lines.push(...todayLines(data.daily, data.today));
246 if (view.tab === "runs") lines.push(...runLines(data.runs, cursor));
247 else if (view.tab === "today") lines.push(...todayLines(data.daily, data.today));
207248 else if (view.tab === "policy") lines.push(...policyLines(data.card));
208249 else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor));
209250 else lines.push(...matchLines(data.matches, cursor, data.now));
210251 lines.push("");
211 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
252 lines.push(`${esc}2mj/k move · tab switch · x remove the row · r refresh · q quit${esc}0m`);
212253 lines.push(view.ssh === undefined
213254 ? `${esc}2mthe app's terminal is off${esc}0m`
214255 : `${esc}2mreach this terminal with: ${printable(view.ssh)}${esc}0m`);
src/tty.test.ts +12 −10
@@ -122,8 +122,8 @@ test("the first paint is the standings, under the environment and the match coun
122122 expect(tty.writes[0]).toStartWith(CLEAR);
123123
124124 const lines = tty.frame();
125 expect(lines[0]).toBe("cs local · 2 matches");
126 expect(lines[2]).toBe(" standings matches policy today ");
125 expect(lines[0]).toBe("cs local · 2 matches · 0 runs");
126 expect(lines[2]).toBe(" standings matches policy today runs ");
127127 expect(tty.marked()).toStartWith("▸ ace");
128128 expect(lines.some((line) => line.startsWith(" bo"))).toBe(true);
129129 expect(tty.exits).toEqual([]);
@@ -142,25 +142,27 @@ test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off wh
142142
143143 test("a preview says which pull it is, and production says production", () => {
144144 const preview = session({ env: { FORGE_APP_ENV: "preview", FORGE_APP_PULL: "109" } });
145 expect(preview.frame()[0]).toBe("cs preview of #109 · 0 matches");
146 expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 matches");
145 expect(preview.frame()[0]).toBe("cs preview of #109 · 0 matches · 0 runs");
146 expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 matches · 0 runs");
147147 // A preview that was not told its number still says it is one.
148 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches");
148 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches · 0 runs");
149149 });
150150
151 test("tab cycles the four tabs, and every press draws exactly one frame", () => {
151 test("tab cycles the five 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 today ");
155 expect(tty.frame()[2]).toBe(" standings matches policy today runs ");
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");
160160 expect(tty.frame()[4]).toStartWith(" the match of the day");
161161 tty.press("\t");
162 expect(tty.frame()[4]).toStartWith(" shooter drill");
163 tty.press("\t");
162164 expect(tty.frame()[4]).toStartWith(" player matches");
163 expect(tty.writes.length).toBe(5);
165 expect(tty.writes.length).toBe(6);
164166 });
165167
166168 test("j and k move the cursor and stop at both ends of the list", () => {
@@ -191,13 +193,13 @@ test("j and k move the cursor and stop at both ends of the list", () => {
191193
192194 test("r picks up a match written behind the session's back", () => {
193195 const tty = session({ seed: (db) => { play(db, "ace"); } });
194 expect(tty.frame()[0]).toBe("cs local · 1 match");
196 expect(tty.frame()[0]).toBe("cs local · 1 match · 0 runs");
195197
196198 play(tty.db, "newcomer", { kills: 30 });
197199 // Not on the screen until it is asked for: the session queries when it draws.
198200 expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false);
199201 tty.press("r");
200 expect(tty.frame()[0]).toBe("cs local · 2 matches");
202 expect(tty.frame()[0]).toBe("cs local · 2 matches · 0 runs");
201203 expect(tty.marked()).toStartWith("▸ newcomer");
202204 });
203205
src/tty.ts +14 −5
@@ -2,9 +2,10 @@
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, 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.
5 * the matches, the policy card, the match of the day — the id everybody is
6 * playing today and the board of who has played it best — and the range's runs,
7 * x removes the selected row from whichever of the two tables it is in, r
8 * refreshes, q leaves. Plain ANSI, no dependencies.
89 *
910 * This file is the wiring only — the database, raw stdin, stdout, resize and
1011 * exit. What it draws is `src/terminal/screen.ts` and what it answers is
@@ -21,7 +22,9 @@
2122 */
2223
2324 import type { Database } from "bun:sqlite";
24 import { dayBoard, matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts";
25 import {
26 dayBoard, deleteMatch, deleteRun, matchCount, openDatabase, recentMatches, recentRuns, runCount, standings,
27 } from "./db.ts";
2528 import { dailyOn } from "./daily.ts";
2629 import { CARD } from "./client/weights.ts";
2730 import { printable, screen, type Data, type View } from "./terminal/screen.ts";
@@ -88,7 +91,9 @@ export function run(io: Io): void {
8891 today: dayBoard(io.db, daily.day, 20),
8992 standings: standings(io.db, 20),
9093 matches: recentMatches(io.db, 20),
94 runs: recentRuns(io.db, 20),
9195 total: matchCount(io.db),
96 runTotal: runCount(io.db),
9297 card: CARD,
9398 // Read with the rows, and only ever here: the screen tells a live match
9499 // from an abandoned one against the moment it was handed.
@@ -134,7 +139,11 @@ export function run(io: Io): void {
134139 view = step.view;
135140 if (step.command.kind === "quit") return finish(0);
136141 if (step.command.kind === "delete") {
137 view = removed(view, step.command.name, deleteMatch(io.db, step.command.id));
142 // One action, two tables: the row said which it is in, so this file does
143 // not read the tab to find out.
144 const { what, id, name } = step.command;
145 const gone = what === "match" ? deleteMatch(io.db, id) : deleteRun(io.db, id);
146 view = removed(view, name, what, gone);
138147 }
139148 draw();
140149 }));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs