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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/187-the-range-gets-the-board-it-deserves-a-r → main 1 commit fork
17 files
changed +842
−96
src/db.ts +138 −3
| @@ -1,7 +1,16 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 14 | * |
| 6 | 15 | * Every row also carries who it belongs to: `user_id` is the forge identity |
| 7 | 16 | * that posted it, empty for a guest, and `session` is the browser that started |
| @@ -15,6 +24,7 @@ import { mkdirSync } from "node:fs"; | ||
| 15 | 24 | import { join } from "node:path"; |
| 16 | 25 | import { dayFiled } from "./daily.ts"; |
| 17 | 26 | import type { Result } from "./result.ts"; |
| 27 | import type { RangeRun } from "./run.ts"; | |
| 18 | 28 | |
| 19 | 29 | export type MatchRow = { |
| 20 | 30 | id: string; |
| @@ -97,6 +107,28 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 97 | 107 | // The day's board is one query over one day's rows, so the day is the index: |
| 98 | 108 | // without it, today's board is a scan of every match ever played here. |
| 99 | 109 | 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)"); | |
| 100 | 132 | return db; |
| 101 | 133 | } |
| 102 | 134 | |
| @@ -272,6 +304,109 @@ export const dayBoard = (db: Database, day: string, limit = 10): DayRun[] => | ||
| 272 | 304 | LIMIT ?2`, |
| 273 | 305 | ).all(day, limit); |
| 274 | 306 | |
| 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 | ||
| 275 | 410 | export const matchCount = (db: Database): number => |
| 276 | 411 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 277 | 412 |
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 @@ | ||
| 2 | 2 | * The range page: this game's content and nothing about the document. The |
| 3 | 3 | * shell in `./shell.ts` renders the doctype, the style, the header, the stage |
| 4 | 4 | * 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. | |
| 6 | 6 | * |
| 7 | 7 | * It says what the page is — a room, plates to shoot, a clock and a score, all |
| 8 | 8 | * 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. | |
| 11 | 22 | */ |
| 12 | 23 | |
| 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"; | |
| 13 | 27 | 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"; | |
| 15 | 29 | |
| 16 | 30 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 17 | 31 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; |
| 18 | 32 | |
| 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 | ||
| 19 | 69 | const NOTE = ` |
| 20 | 70 | <h2>The range: thirty seconds on the clock, two drills</h2> |
| 21 | 71 | <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates: each one is a box in |
| @@ -29,6 +79,10 @@ const NOTE = ` | ||
| 29 | 79 | lane is walked and not sprinted. On a browser with no WebGL2 the range falls back to the |
| 30 | 80 | flat horizon it shipped with, plates as squares projected from the angle each subtends, |
| 31 | 81 | 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> | |
| 32 | 86 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| 33 | 87 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 34 | 88 | <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload · |
| @@ -44,7 +98,7 @@ const NOTE = ` | ||
| 44 | 98 | you start a run: the browser opens no audio device before that.</p> |
| 45 | 99 | `; |
| 46 | 100 | |
| 47 | export function rangePage(options: PageOptions): string { | |
| 101 | export function rangePage(db: Database, options: PageOptions): string { | |
| 48 | 102 | return page({ |
| 49 | 103 | ...options, |
| 50 | 104 | title: "range — the engine as a package", |
| @@ -53,8 +107,8 @@ export function rangePage(options: PageOptions): string { | ||
| 53 | 107 | heading: "range", |
| 54 | 108 | nav: navFor("/range"), |
| 55 | 109 | 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", | |
| 58 | 112 | stageHtml: stage({ |
| 59 | 113 | touch: [ |
| 60 | 114 | [ |
| @@ -70,7 +124,10 @@ export function rangePage(options: PageOptions): string { | ||
| 70 | 124 | ], |
| 71 | 125 | overlays: [{ id: "note", html: NOTE }], |
| 72 | 126 | }), |
| 73 | sectionsHtml: ` <section> | |
| 127 | sectionsHtml: ` <section id="board"> | |
| 128 | ${boardsHtml(db)} | |
| 129 | </section> | |
| 130 | <section> | |
| 74 | 131 | <h2>What this is</h2> |
| 75 | 132 | <p>A second game's plumbing, and the proof that <code>src/engine/</code> is a package rather |
| 76 | 133 | than a folder: every line behind this page comes from the engine or from |
| @@ -85,8 +142,8 @@ export function rangePage(options: PageOptions): string { | ||
| 85 | 142 | lifetime, and a click traces a ray from your eye along your own view angles with the |
| 86 | 143 | engine's own box trace — so a hit is a hit in three dimensions, not a hit on a picture. The |
| 87 | 144 | 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> | |
| 90 | 147 | <p>Two drills share that lane, and <kbd>T</kbd> chooses between them before a run — mid-run |
| 91 | 148 | the key does nothing rather than splicing two games into one score. <strong>Pop-up</strong> |
| 92 | 149 | 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 { | ||
| 129 | 186 | engine's <code>attenuate</code> on this room's own numbers, so a plate hit at 1,200 units is a |
| 130 | 187 | quarter the volume of one at 200. The device is opened on the gesture that starts a run and |
| 131 | 188 | 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: | |
| 134 | 201 | ${othersHtml("/range")}</p> |
| 135 | 202 | </section>`, |
| 136 | 203 | }); |
src/range/main.ts +27 −4
| @@ -11,8 +11,10 @@ | ||
| 11 | 11 | * the button beside it, asks `targets.ts` for the next one in its table. The |
| 12 | 12 | * game itself decides whether that is allowed — a live run refuses — and this |
| 13 | 13 | * 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. | |
| 16 | 18 | * |
| 17 | 19 | * Every sound is a crossing this file found in the game's own facts: a plate |
| 18 | 20 | * 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"; | ||
| 42 | 44 | import { |
| 43 | 45 | RANGE_HULL, RANGE_MOVE, createRangeBody, createRangeWish, rangeEye, rangeWish, |
| 44 | 46 | } from "./movement.ts"; |
| 47 | import { createPoster, NOTHING_POSTED, type Posts } from "./post.ts"; | |
| 45 | 48 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 46 | 49 | import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts"; |
| 47 | 50 | import { |
| @@ -149,8 +152,9 @@ export function boot(): void { | ||
| 149 | 152 | const run = createRun(freshSeed()); |
| 150 | 153 | /** |
| 151 | 154 | * 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. | |
| 154 | 158 | */ |
| 155 | 159 | const bests: Record<DrillName, number> = { popup: 0, tracking: 0 }; |
| 156 | 160 | // All three of these are edges, not holds: a held trigger is one shot, a held |
| @@ -165,6 +169,21 @@ export function boot(): void { | ||
| 165 | 169 | bests[run.drill.name] = Math.max(bests[run.drill.name], run.score); |
| 166 | 170 | }; |
| 167 | 171 | |
| 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 | ||
| 168 | 187 | // The mouse only turns the view under pointer lock; a finger on the stage |
| 169 | 188 | // turns it either way, which is why the button is the only thing in the way. |
| 170 | 189 | // A phone has no pointer to lock, and says so by throwing or by rejecting: |
| @@ -307,6 +326,9 @@ export function boot(): void { | ||
| 307 | 326 | if (wasLive && run.phase === "over") { |
| 308 | 327 | keepBest(); |
| 309 | 328 | 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()); | |
| 310 | 332 | } |
| 311 | 333 | |
| 312 | 334 | // One shot per press, down the barrel: the direction is the view's, and |
| @@ -361,6 +383,7 @@ export function boot(): void { | ||
| 361 | 383 | pitch: view.pitch, |
| 362 | 384 | locked: controls.locked, |
| 363 | 385 | drawn: renderer !== null, |
| 386 | posted, | |
| 364 | 387 | }, |
| 365 | 388 | hw, |
| 366 | 389 | 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 & { | ||
| 63 | 63 | locked: boolean; |
| 64 | 64 | /** Is the room being drawn, or is this browser on the 2D horizon instead? */ |
| 65 | 65 | 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; | |
| 66 | 73 | }; |
| 67 | 74 | |
| 68 | 75 | const SKY = "#101826"; |
| @@ -102,7 +109,7 @@ export function clockLine(run: Report, next: string): string { | ||
| 102 | 109 | |
| 103 | 110 | /** The panel's lines, in order. Separate from the drawing so a test can read them. */ |
| 104 | 111 | export function readoutLines(r: Readout): string[] { |
| 105 | return [ | |
| 112 | const lines = [ | |
| 106 | 113 | scoreLine(r.run, r.best), |
| 107 | 114 | clockLine(r.run, r.next), |
| 108 | 115 | `tick ${r.ticks} · ${r.hz} Hz fixed step`, |
| @@ -115,6 +122,10 @@ export function readoutLines(r: Readout): string[] { | ||
| 115 | 122 | r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen", |
| 116 | 123 | r.drawn ? "drawn by the engine's renderer" : "no WebGL2 here · the 2D horizon instead", |
| 117 | 124 | ]; |
| 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; | |
| 118 | 129 | } |
| 119 | 130 | |
| 120 | 131 | /** 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 @@ | ||
| 14 | 14 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; |
| 15 | 15 | |
| 16 | 16 | /** 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"]; | |
| 18 | 18 | |
| 19 | 19 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 20 | 20 | export const RANGE_ACTIONS = ["duck", "jump", "fire"]; |
| @@ -159,6 +159,21 @@ export function audioCounter(): FakeAudio { | ||
| 159 | 159 | }; |
| 160 | 160 | } |
| 161 | 161 | |
| 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 | ||
| 162 | 177 | export type RangePage = { |
| 163 | 178 | document: FakeDocument; |
| 164 | 179 | window: FakeWindow; |
| @@ -166,6 +181,8 @@ export type RangePage = { | ||
| 166 | 181 | hud: FakeCanvas; |
| 167 | 182 | /** The `AudioContext` this page would give the engine's sound device. */ |
| 168 | 183 | audio: FakeAudio; |
| 184 | /** Every run this page posted, and what the server said. */ | |
| 185 | fetch: FakeFetch; | |
| 169 | 186 | by(id: string): FakeElement; |
| 170 | 187 | buttons: FakeElement[]; |
| 171 | 188 | /** The frames asked for but not yet run, oldest first. */ |
| @@ -179,7 +196,7 @@ export type RangePage = { | ||
| 179 | 196 | /** The globals the last lines of `main.ts` and the engine's two edges name. */ |
| 180 | 197 | const GLOBALS = [ |
| 181 | 198 | "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", |
| 182 | "performance", "AudioContext", | |
| 199 | "performance", "AudioContext", "fetch", | |
| 183 | 200 | ]; |
| 184 | 201 | |
| 185 | 202 | /** |
| @@ -203,6 +220,11 @@ export function page( | ||
| 203 | 220 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; |
| 204 | 221 | if (options.webgl === true) scene.gl = glRecorder(); |
| 205 | 222 | 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 | }; | |
| 206 | 228 | |
| 207 | 229 | return { |
| 208 | 230 | document: fake.document, |
| @@ -210,6 +232,7 @@ export function page( | ||
| 210 | 232 | scene, |
| 211 | 233 | hud, |
| 212 | 234 | audio, |
| 235 | fetch: net, | |
| 213 | 236 | buttons: fake.buttons, |
| 214 | 237 | frames: fake.frames, |
| 215 | 238 | by: fake.by, |
| @@ -227,6 +250,7 @@ export function page( | ||
| 227 | 250 | globals.document = fake.document; |
| 228 | 251 | globals.window = fake.window; |
| 229 | 252 | globals.AudioContext = audio.Ctor; |
| 253 | globals.fetch = fetching; | |
| 230 | 254 | globals.requestAnimationFrame = fake.window.requestAnimationFrame; |
| 231 | 255 | globals.cancelAnimationFrame = fake.window.cancelAnimationFrame; |
| 232 | 256 | globals.matchMedia = fake.window.matchMedia; |
src/result.ts +9 −16
| @@ -3,8 +3,15 @@ | ||
| 3 | 3 | * is client-reported — the game runs entirely in the page — so the server's |
| 4 | 4 | * job is to keep the numbers inside what a real match can produce and to |
| 5 | 5 | * 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. | |
| 6 | 12 | */ |
| 7 | 13 | |
| 14 | import { cleanName, integer } from "./gate.ts"; | |
| 8 | 15 | import { isMatchId } from "./matchid.ts"; |
| 9 | 16 | import { DEFAULT_MAP, type PlayedMap, playedOn } from "./mapname.ts"; |
| 10 | 17 | |
| @@ -39,22 +46,6 @@ const KILLS_PER_ROUND = 5; // five enemies is all there are | ||
| 39 | 46 | */ |
| 40 | 47 | const SECONDS_PER_ROUND = 180; |
| 41 | 48 | |
| 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 | ||
| 58 | 49 | /** Parse a submitted result, or null if it is not one. */ |
| 59 | 50 | export function parseResult(value: unknown): Result | null { |
| 60 | 51 | if (typeof value !== "object" || value === null) return null; |
| @@ -103,5 +94,7 @@ export function parseResult(value: unknown): Result | null { | ||
| 103 | 94 | }; |
| 104 | 95 | } |
| 105 | 96 | |
| 97 | export { cleanName }; | |
| 98 | ||
| 106 | 99 | export const won = (result: Result): boolean => |
| 107 | 100 | 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 () => { | ||
| 528 | 528 | // that differs is the style hash — each page's own, never the other's. |
| 529 | 529 | expect(range.headers.get("content-security-policy")).not.toBe(game.headers.get("content-security-policy")); |
| 530 | 530 | // 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"); | |
| 532 | 536 | }); |
| 533 | 537 | |
| 534 | 538 | 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"; | ||
| 51 | 51 | import { kB } from "./bytes.ts"; |
| 52 | 52 | import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts"; |
| 53 | 53 | 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"; | |
| 55 | 56 | import { GAMES } from "./games.ts"; |
| 56 | 57 | import { createLimiter } from "./limit.ts"; |
| 57 | 58 | import { examplePage } from "./pages/example.ts"; |
| @@ -62,10 +63,11 @@ import { parseSituation } from "./plan/situation.ts"; | ||
| 62 | 63 | import { createPlanner, type Planner } from "./plan/typesafe.ts"; |
| 63 | 64 | import { matchFrom } from "./matchid.ts"; |
| 64 | 65 | 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"; | |
| 66 | 67 | import { policyFor } from "./policy.ts"; |
| 67 | 68 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| 68 | 69 | import { cleanName, parseResult } from "./result.ts"; |
| 70 | import { parseRun } from "./run.ts"; | |
| 69 | 71 | import { who, type Who } from "./who.ts"; |
| 70 | 72 | |
| 71 | 73 | const db = openDatabase(); |
| @@ -492,10 +494,17 @@ const RENDER: Record<string, Render> = { | ||
| 492 | 494 | }; |
| 493 | 495 | }, |
| 494 | 496 | }, |
| 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 | }, | |
| 499 | 508 | // The example: the guide's game with a page around it, no session either, and |
| 500 | 509 | // the measured cost of being a third game, weighed once above. |
| 501 | 510 | example: { |
| @@ -503,6 +512,41 @@ const RENDER: Record<string, Render> = { | ||
| 503 | 512 | }, |
| 504 | 513 | }; |
| 505 | 514 | |
| 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 | ||
| 506 | 550 | // The read routes, resolved once at start-up: the path each game answers on, |
| 507 | 551 | // against its renderer holding its own bundle. A game in the registry with no |
| 508 | 552 | // 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 | ||
| 643 | 687 | if (request.method === "POST" && !sameOrigin(request)) |
| 644 | 688 | return text("forbidden", 403); |
| 645 | 689 | |
| 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) { | |
| 647 | 696 | const visitor = who(request, identityKey); |
| 648 | 697 | const held = readSession(request); |
| 649 | 698 | if (!mayWrite(visitor, held, listener.requestIP(request)?.address)) |
| @@ -656,12 +705,12 @@ async function route(request: Request, listener: Bun.Server<undefined>): Promise | ||
| 656 | 705 | } catch { |
| 657 | 706 | return text("bad json", 400); |
| 658 | 707 | } |
| 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); | |
| 661 | 710 | 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)); | |
| 663 | 712 | 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) }); | |
| 665 | 714 | } |
| 666 | 715 | |
| 667 | 716 | /** |
src/terminal/keys.test.ts +32 −6
| @@ -33,6 +33,20 @@ const match = (id: string, name: string): MatchRow => ({ | ||
| 33 | 33 | updated_at: "2026-09-16 10:00:00", |
| 34 | 34 | }); |
| 35 | 35 | |
| 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 | ||
| 36 | 50 | const data: Data = { |
| 37 | 51 | daily: dailyOn(Date.parse("2026-09-16T10:00:30Z")), |
| 38 | 52 | today: [], |
| @@ -41,7 +55,9 @@ const data: Data = { | ||
| 41 | 55 | { name: "bo", user_id: "", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 }, |
| 42 | 56 | ], |
| 43 | 57 | matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")], |
| 58 | runs: [run("r1", "ace"), run("r2", "bo")], | |
| 44 | 59 | total: 4, |
| 60 | runTotal: 2, | |
| 45 | 61 | card: CARD, |
| 46 | 62 | // The keys read rows and a cursor; the clock is the screen's business. |
| 47 | 63 | now: Date.parse("2026-09-16T10:00:30Z"), |
| @@ -50,7 +66,7 @@ const data: Data = { | ||
| 50 | 66 | const view = (over: Partial<View> = {}): View => |
| 51 | 67 | ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over }); |
| 52 | 68 | |
| 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", () => { | |
| 54 | 70 | const first = press(view({ cursor: 1 }), "\t", data); |
| 55 | 71 | expect(first.view.tab).toBe("matches"); |
| 56 | 72 | expect(first.view.cursor).toBe(0); |
| @@ -61,8 +77,11 @@ test("tab cycles standings → matches → policy → today → standings, and t | ||
| 61 | 77 | expect(third.view.tab).toBe("today"); |
| 62 | 78 | expect(third.view.cursor).toBe(0); |
| 63 | 79 | const fourth = press(third.view, "\t", data); |
| 64 | expect(fourth.view.tab).toBe("standings"); | |
| 80 | expect(fourth.view.tab).toBe("runs"); | |
| 65 | 81 | 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); | |
| 66 | 85 | expect(fourth.command).toEqual({ kind: "nothing" }); |
| 67 | 86 | }); |
| 68 | 87 | |
| @@ -98,19 +117,26 @@ test("on the policy tab j and k change nothing", () => { | ||
| 98 | 117 | |
| 99 | 118 | test("x on the matches tab asks for the match under the cursor to go", () => { |
| 100 | 119 | 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" }); | |
| 102 | 121 | expect(step.view.cursor).toBe(1); |
| 103 | 122 | }); |
| 104 | 123 | |
| 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 | ||
| 105 | 129 | test("x anywhere else does nothing, and neither does x with nothing to remove", () => { |
| 106 | 130 | expect(press(view(), "x", data).command).toEqual({ kind: "nothing" }); |
| 107 | 131 | expect(press(view({ tab: "policy" }), "x", data).command).toEqual({ kind: "nothing" }); |
| 108 | 132 | 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" }); | |
| 109 | 134 | }); |
| 110 | 135 | |
| 111 | 136 | 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"); | |
| 114 | 140 | }); |
| 115 | 141 | |
| 116 | 142 | test("q and ctrl-C quit", () => { |
| @@ -131,5 +157,5 @@ test("the note from the last press is gone by the next one", () => { | ||
| 131 | 157 | |
| 132 | 158 | test("a cursor left over from a longer list is pulled back onto the list before it is used", () => { |
| 133 | 159 | 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" }); | |
| 135 | 161 | }); |
src/terminal/keys.ts +16 −8
| @@ -13,13 +13,16 @@ import { clamp, rowsOf, TABS, type Data, type View } from "./screen.ts"; | ||
| 13 | 13 | export type Command = |
| 14 | 14 | | { kind: "nothing" } |
| 15 | 15 | | { 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"; | |
| 17 | 20 | |
| 18 | 21 | export type Press = { view: View; command: Command }; |
| 19 | 22 | |
| 20 | 23 | const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } }); |
| 21 | 24 | |
| 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. */ | |
| 23 | 26 | export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!; |
| 24 | 27 | |
| 25 | 28 | export function press(view: View, key: string, data: Data): Press { |
| @@ -30,15 +33,20 @@ export function press(view: View, key: string, data: Data): Press { | ||
| 30 | 33 | if (key === "j" || key === "\x1b[B") return nothing({ ...now, cursor: clamp(now.cursor + 1, rows) }); |
| 31 | 34 | if (key === "k" || key === "\x1b[A") return nothing({ ...now, cursor: clamp(now.cursor - 1, rows) }); |
| 32 | 35 | 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 } }; | |
| 37 | 45 | } |
| 38 | 46 | // r redraws, and so does anything else: the caller draws after every press. |
| 39 | 47 | return nothing(now); |
| 40 | 48 | } |
| 41 | 49 | |
| 42 | 50 | /** 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 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import type { Card } from "../client/weights.ts"; |
| 10 | import type { MatchRow, Standing } from "../db.ts"; | |
| 10 | import type { MatchRow, RunRow, 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 | 13 | import { dailyOn } from "../daily.ts"; |
| @@ -60,6 +60,21 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow | ||
| 60 | 60 | const ROW_AT = Date.parse("2026-09-16T10:00:00Z"); |
| 61 | 61 | const NOW = ROW_AT + 30_000; |
| 62 | 62 | |
| 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 | ||
| 63 | 78 | const data = (over: Partial<Data> = {}): Data => ({ |
| 64 | 79 | daily: dailyOn(NOW), |
| 65 | 80 | today: [ |
| @@ -68,6 +83,8 @@ const data = (over: Partial<Data> = {}): Data => ({ | ||
| 68 | 83 | ], |
| 69 | 84 | standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })], |
| 70 | 85 | 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, | |
| 71 | 88 | total: 4, |
| 72 | 89 | card: CARD, |
| 73 | 90 | now: NOW, |
| @@ -80,19 +97,20 @@ const view = (over: Partial<View> = {}): View => | ||
| 80 | 97 | const text = (v: View, d: Data = data()): string[] => screen(v, d).map(plain); |
| 81 | 98 | |
| 82 | 99 | 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"); | |
| 86 | 103 | }); |
| 87 | 104 | |
| 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 "); | |
| 90 | 107 | expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m"); |
| 91 | 108 | expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m"); |
| 92 | 109 | expect(screen(view({ tab: "policy" }), data())[2]).toContain("\x1b[7m policy \x1b[0m"); |
| 93 | 110 | 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"); | |
| 94 | 112 | // 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) { | |
| 96 | 114 | expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1); |
| 97 | 115 | } |
| 98 | 116 | }); |
| @@ -224,7 +242,7 @@ test("a note is the last line when there is one, under the ssh line", () => { | ||
| 224 | 242 | }); |
| 225 | 243 | |
| 226 | 244 | 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"); | |
| 228 | 246 | }); |
| 229 | 247 | |
| 230 | 248 | 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 | ||
| 276 | 294 | // And a day nobody has played says so rather than drawing an empty table. |
| 277 | 295 | expect(text(view({ tab: "today" }), data({ today: [] })).join("\n")).toContain("nobody has played it yet"); |
| 278 | 296 | }); |
| 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 @@ | ||
| 15 | 15 | |
| 16 | 16 | import type { Card } from "../client/weights.ts"; |
| 17 | 17 | 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"; | |
| 20 | 20 | import { matchOf } from "../matchid.ts"; |
| 21 | 21 | |
| 22 | export type Tab = "standings" | "matches" | "policy" | "today"; | |
| 22 | export type Tab = "standings" | "matches" | "policy" | "today" | "runs"; | |
| 23 | 23 | |
| 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"]; | |
| 26 | 31 | |
| 27 | 32 | export type View = { |
| 28 | 33 | tab: Tab; |
| @@ -43,8 +48,12 @@ export type Data = { | ||
| 43 | 48 | today: DayRun[]; |
| 44 | 49 | standings: Standing[]; |
| 45 | 50 | matches: MatchRow[]; |
| 51 | /** The range's runs, newest first: the same rows `/range` ranks per drill. */ | |
| 52 | runs: RunRow[]; | |
| 46 | 53 | /** Every match ever recorded, including the empty ones the lists skip. */ |
| 47 | 54 | total: number; |
| 55 | /** Every range run ever recorded. */ | |
| 56 | runTotal: number; | |
| 48 | 57 | card: Card; |
| 49 | 58 | /** |
| 50 | 59 | * 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) | ||
| 64 | 73 | |
| 65 | 74 | /** Rows the cursor can stand on for a tab. The policy card has none. */ |
| 66 | 75 | 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; | |
| 68 | 83 | |
| 69 | 84 | /** The cursor a view actually draws with: never past the end of its list. */ |
| 70 | 85 | export const clamp = (cursor: number, rows: number): number => |
| @@ -193,22 +208,48 @@ function matchLines(matches: MatchRow[], cursor: number, now: number): string[] | ||
| 193 | 208 | return lines; |
| 194 | 209 | } |
| 195 | 210 | |
| 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 | ||
| 196 | 236 | /** Every line the terminal would print, top to bottom, without the clear. */ |
| 197 | 237 | export function screen(view: View, data: Data): string[] { |
| 198 | 238 | const cursor = clamp(view.cursor, rowsOf(view.tab, data)); |
| 199 | 239 | const lines: string[] = []; |
| 200 | 240 | 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`, | |
| 202 | 242 | ); |
| 203 | 243 | lines.push(""); |
| 204 | 244 | lines.push(header(view.tab)); |
| 205 | 245 | 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)); | |
| 207 | 248 | else if (view.tab === "policy") lines.push(...policyLines(data.card)); |
| 208 | 249 | else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor)); |
| 209 | 250 | else lines.push(...matchLines(data.matches, cursor, data.now)); |
| 210 | 251 | 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`); | |
| 212 | 253 | lines.push(view.ssh === undefined |
| 213 | 254 | ? `${esc}2mthe app's terminal is off${esc}0m` |
| 214 | 255 | : `${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 | ||
| 122 | 122 | expect(tty.writes[0]).toStartWith(CLEAR); |
| 123 | 123 | |
| 124 | 124 | 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 "); | |
| 127 | 127 | expect(tty.marked()).toStartWith("▸ ace"); |
| 128 | 128 | expect(lines.some((line) => line.startsWith(" bo"))).toBe(true); |
| 129 | 129 | expect(tty.exits).toEqual([]); |
| @@ -142,25 +142,27 @@ test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off wh | ||
| 142 | 142 | |
| 143 | 143 | test("a preview says which pull it is, and production says production", () => { |
| 144 | 144 | 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"); | |
| 147 | 147 | // 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"); | |
| 149 | 149 | }); |
| 150 | 150 | |
| 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", () => { | |
| 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 today "); | |
| 155 | expect(tty.frame()[2]).toBe(" standings matches policy today runs "); | |
| 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 | 160 | expect(tty.frame()[4]).toStartWith(" the match of the day"); |
| 161 | 161 | tty.press("\t"); |
| 162 | expect(tty.frame()[4]).toStartWith(" shooter drill"); | |
| 163 | tty.press("\t"); | |
| 162 | 164 | expect(tty.frame()[4]).toStartWith(" player matches"); |
| 163 | expect(tty.writes.length).toBe(5); | |
| 165 | expect(tty.writes.length).toBe(6); | |
| 164 | 166 | }); |
| 165 | 167 | |
| 166 | 168 | 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", () => { | ||
| 191 | 193 | |
| 192 | 194 | test("r picks up a match written behind the session's back", () => { |
| 193 | 195 | 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"); | |
| 195 | 197 | |
| 196 | 198 | play(tty.db, "newcomer", { kills: 30 }); |
| 197 | 199 | // Not on the screen until it is asked for: the session queries when it draws. |
| 198 | 200 | expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false); |
| 199 | 201 | 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"); | |
| 201 | 203 | expect(tty.marked()).toStartWith("▸ newcomer"); |
| 202 | 204 | }); |
| 203 | 205 |
src/tty.ts +14 −5
| @@ -2,9 +2,10 @@ | ||
| 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, 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. | |
| 8 | 9 | * |
| 9 | 10 | * This file is the wiring only — the database, raw stdin, stdout, resize and |
| 10 | 11 | * exit. What it draws is `src/terminal/screen.ts` and what it answers is |
| @@ -21,7 +22,9 @@ | ||
| 21 | 22 | */ |
| 22 | 23 | |
| 23 | 24 | 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"; | |
| 25 | 28 | import { dailyOn } from "./daily.ts"; |
| 26 | 29 | import { CARD } from "./client/weights.ts"; |
| 27 | 30 | import { printable, screen, type Data, type View } from "./terminal/screen.ts"; |
| @@ -88,7 +91,9 @@ export function run(io: Io): void { | ||
| 88 | 91 | today: dayBoard(io.db, daily.day, 20), |
| 89 | 92 | standings: standings(io.db, 20), |
| 90 | 93 | matches: recentMatches(io.db, 20), |
| 94 | runs: recentRuns(io.db, 20), | |
| 91 | 95 | total: matchCount(io.db), |
| 96 | runTotal: runCount(io.db), | |
| 92 | 97 | card: CARD, |
| 93 | 98 | // Read with the rows, and only ever here: the screen tells a live match |
| 94 | 99 | // from an abandoned one against the moment it was handed. |
| @@ -134,7 +139,11 @@ export function run(io: Io): void { | ||
| 134 | 139 | view = step.view; |
| 135 | 140 | if (step.command.kind === "quit") return finish(0); |
| 136 | 141 | 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); | |
| 138 | 147 | } |
| 139 | 148 | draw(); |
| 140 | 149 | })); |
new above
offline