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

forked from trevato/cs

Every number the pages boast is read from the thing it describes, on all three pages and not just /example #306

Merged opened this

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

Merged

torque/cs-engine:torque/224-every-number-the-pages-boast-is-read-fro → main 1 commit fork

  • Merged. torque/224-every-number-the-pages-boast-is-read-fro was merged into main as db506ac.

Review

Sign in to review this change.
10 files changed +512 −44
README.md +16
@@ -368,6 +368,22 @@ read off the same sheet. `src/pages/weapons.ts` renders it into the first paint
368368 and the terminal's **weapons** tab renders the same sheet as columns. The assumptions are printed under the tables, because a figure without
369369 the body it was measured against is a rumour.
370370
371 That rule is no longer the table's alone. Every number in the prose of `/`,
372 `/range` and `/example` is rendered from the module that owns it — the HE's
373 damage, falloff and fuse, the flashbang's blind time, the step height and the
374 smoke's ball from `src/client/`, the range's speed, gravity, friction, jump
375 apex, crouch drop, drills and atlas from `src/range/`, the two games' fixed
376 steps from each game's own pinned script, and the bundle sizes from the
377 bundles themselves. A jump apex is not stored anywhere, so `src/pages/tuning.ts`
378 derives it once (v₀²/2g) for the page and the test both, and a test jumps a
379 body through the engine's solver to check the shortcut. What holds it together
380 is one test helper, `src/pages/digits.test-helper.ts`: it reads a page, throws
381 away the shell, the styles and the rows a database filled, and reports every
382 group of digits left that no module accounts for — with the sentence it sits
383 in, so the fix is obvious. Words are still words: "about eight kilobytes",
384 "thirty seconds on the clock" and "a quarter of the air acceleration" are
385 prose, and the rule is about digits, not about claims.
386
371387 ## Playing
372388
373389 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
src/pages/digits.test-helper.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The digit rule, once, for every page that boasts: **a number a page prints
3 * is a number some module owns.**
4 *
5 * `/example` has had this rule since #207 — it could not print a byte count it
6 * had not weighed — and the other two pages had nothing of the kind, so the
7 * shooter typed "18-unit steps" two paragraphs above a table that renders the
8 * step height from `src/client/movement.ts`, and the range typed its whole
9 * tuning. #224 put all three under one rule, and this is the one
10 * implementation of it: each page's test builds the set of numbers its sources
11 * actually hold, and `unsourced` hands back the digits in the page that are
12 * not among them, with the sentence each sits in.
13 *
14 * What counts as a number, written down rather than left to the regular
15 * expression:
16 *
17 * - it is **digits in prose**, not claims. "about eight kilobytes" and "thirty
18 * seconds on the clock" are honest English and this rule never sees them; a
19 * page that would rather spell a number than print it is free to, and the
20 * pull that does it says so.
21 * - it is a number, not a name. A digit glued to a letter is part of a word —
22 * `WebGL2`, `int8`, `gen_19948bw` — and a word is not a measurement.
23 * - it is the page's own words. The head, the style block, the script, the
24 * header and the footer are the shell's and are held honest by
25 * `shell.test.ts`; a caller may drop a `<section>` by id as well, which is
26 * how the leaderboard and the range's board — rows a database fills, not
27 * prose — stay out of a rule about prose.
28 */
29
30 import { figure } from "./figure.ts";
31
32 /** The five entities `escape` writes, read back, so the prose is what is read. */
33 const UNESCAPE: Record<string, string> = {
34 "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
35 };
36
37 /** What is never prose: the shell's own chrome, and everything that is not text. */
38 const CHROME = [
39 /<head>[\s\S]*?<\/head>/g,
40 /<style>[\s\S]*?<\/style>/g,
41 /<script\b[\s\S]*?<\/script>/g,
42 /<header\b[\s\S]*?<\/header>/g,
43 /<footer\b[\s\S]*?<\/footer>/g,
44 ];
45
46 /**
47 * A number standing on its own: thousands may be grouped and a tenth may
48 * follow, it begins and ends on a digit — so the comma after "20260922," is
49 * punctuation and not part of it — and a letter or a digit on either side
50 * makes the whole thing part of a word instead. The digit either side matters:
51 * without it the engine would happily backtrack `gen_19948bw` down to "1994"
52 * and report a map's name as a measurement.
53 */
54 const NUMBER = /(?<![A-Za-z0-9.])\d(?:[\d,]*\d)?(?:\.\d+)?(?![A-Za-z0-9])/g;
55
56 /**
57 * The words a visitor reads, out of the markup they arrive in: the chrome cut,
58 * the sections named in `drop` cut, the tags gone — so an attribute is never
59 * mistaken for a sentence — the entities read back, and the wrapping collapsed
60 * to single spaces. What is checked is the words and the numbers, never where
61 * the template happened to wrap.
62 */
63 export function proseOf(html: string, drop: readonly string[] = []): string {
64 let text = html;
65 for (const cut of CHROME) text = text.replace(cut, " ");
66 for (const id of drop) {
67 if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new Error(`not a section id: ${id}`);
68 text = text.replace(new RegExp(`<section id="${id}"[\\s\\S]*?</section>`, "g"), " ");
69 }
70 text = text.replace(/<[^>]*>/g, " ");
71 return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => UNESCAPE[e] ?? e).replace(/\s+/g, " ").trim();
72 }
73
74 /** Every number in a run of prose, in the order it is read. */
75 export const digitsIn = (prose: string): string[] => prose.match(NUMBER) ?? [];
76
77 /** A number as a page would have written it, so a test may pass the raw value. */
78 const said = (value: string | number): string => (typeof value === "number" ? figure(value) : value);
79
80 /**
81 * The digits in `prose` that nothing in `allowed` accounts for, one sentence
82 * each: which page, which digit, and the words around it. A page whose numbers
83 * are all readouts hands back an empty array, which is what its test asserts —
84 * and when it does not, the message is the fix, because the digit that has no
85 * source is named beside the sentence somebody typed it into.
86 */
87 export function unsourced(
88 where: string,
89 prose: string,
90 allowed: Iterable<string | number>,
91 ): string[] {
92 const known = new Set([...allowed].map(said));
93 const found: string[] = [];
94 for (const match of prose.matchAll(NUMBER)) {
95 if (known.has(match[0])) continue;
96 const at = match.index ?? 0;
97 const around = prose.slice(Math.max(0, at - 60), at + match[0].length + 60);
98 found.push(`${where}: ${match[0]} is in no module — "…${around}…"`);
99 }
100 return found;
101 }
src/pages/example.test.ts +18 −11
@@ -29,7 +29,8 @@ import { buildBundles } from "../bundles.ts";
2929 import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts";
3030 import { bytes, kB } from "../bytes.ts";
3131 import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts";
32 import { costHtml, examplePage } from "./example.ts";
32 import { proseOf, unsourced } from "./digits.test-helper.ts";
33 import { examplePage } from "./example.ts";
3334
3435 const ROOT = join(import.meta.dir, "..", "..");
3536
@@ -111,17 +112,23 @@ test("no number the page states about itself is one somebody typed", () => {
111112 // The four that were typed, and were wrong by the time this was written.
112113 for (const stale of ["8,754", "4,229", "71,646", "seventy-nine", "about two hundred"])
113114 expect(body).not.toContain(stale);
114 // Every group of digits in the paragraphs about the cost is one of the
115 // measured numbers, the gzip level, or the 1,024 the unit is defined by. A
116 // number typed into this prose fails here.
115 // Every number this page prints is one of the measured ones, the gzip level,
116 // or the 1,024 the unit is defined by. The rule is `digits.test-helper.ts`
117 // and the other two pages are under the same one — it ran over the cost
118 // section alone when only this page had it, and it runs over the whole
119 // document now, because there was nothing else on it to be afraid of.
117120 const cost = measureExample(BUILT);
118 const section = costHtml(cost);
119 const allowed = new Set([
121 const allowed = [
120122 bytes(cost.script.raw), bytes(cost.shooter.raw),
121123 kB(cost.script.gzip).replace(" kB", ""), kB(cost.shooter.gzip).replace(" kB", ""),
122 String(cost.game), String(cost.wiring), String(GZIP_LEVEL), String(WIRE_LEVEL), "1,024",
123 "2", // "2D on one canvas", and the two files of wiring
124 "404", // the page nobody had to edit
125 ]);
126 for (const found of section.match(/\d[\d,]*(?:\.\d+)?/g) ?? []) expect([...allowed]).toContain(found);
124 cost.game, cost.wiring, GZIP_LEVEL, WIRE_LEVEL, "1,024",
125 "404", // the page nobody had to edit, which is a path and not a measurement
126 ];
127 expect(unsourced("/example", proseOf(rendered()), allowed)).toEqual([]);
128 // And the rule is a rule: hand it a bundle a byte bigger than the one that
129 // was weighed and the page's own figure is the digit it names.
130 const wrong = allowed.filter((value) => value !== bytes(cost.script.raw));
131 const complaints = unsourced("/example", proseOf(rendered()), [...wrong, bytes(cost.script.raw + 1)]);
132 expect(complaints.length).toBe(1);
133 expect(complaints[0]).toContain(`/example: ${bytes(cost.script.raw)} is in no module`);
127134 });
src/pages/figure.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * A number, as a page writes one.
3 *
4 * Every figure in these pages' prose is read off the module that owns it, so a
5 * page prints whatever that module happens to hold: 8,192,000 ticks want their
6 * thousands grouped, a 1.5-second fuse wants its tenth, and a jump apex that
7 * is really 56.99977… units wants to read 57 rather than the float the
8 * parabola has. One rule for all three — grouped by thousands, no finer than a
9 * tenth — so the page and the test that holds it honest write the same number
10 * the same way rather than each reaching for `toLocaleString`.
11 *
12 * Nothing is escaped here. A page escapes what it prints at the one boundary
13 * where everything else it prints is escaped too, and a figure is not special.
14 */
15
16 /** A number as prose: grouped by thousands, rounded to a tenth. */
17 export const figure = (n: number): string => (Math.round(n * 10) / 10).toLocaleString("en");
src/pages/game.test.ts +86 −7
@@ -1,14 +1,21 @@
11 /**
22 * The cs page, read the way a visitor gets it: a whole document from the shell,
33 * the leaderboard and the menu in the first paint, and — the reason this file
4 * exists — the paragraph about the bots.
4 * exists — the numbers the prose is made of.
55 *
6 * That paragraph is the only prose on the page made of measurements, and every
7 * number in it is the trainer's card in `src/client/weights.ts`. The check that
8 * it is *only* the card is the one below that renders the page with a card
9 * nobody trained: every number moves with it and not one of today's is left
10 * behind, which a number typed into the prose by hand could not do.
11 * `src/client/weights.test.ts` holds the card itself honest.
6 * The paragraph about the bots is the trainer's card in
7 * `src/client/weights.ts`, and the check that it is *only* the card is the one
8 * below that renders the page with a card nobody trained: every number moves
9 * with it and not one of today's is left behind, which a number typed into the
10 * prose by hand could not do. `src/client/weights.test.ts` holds the card
11 * itself honest.
12 *
13 * The rest of the page is under the same rule since #224, by the test at the
14 * bottom of this file: the movement's step, the HE, the flashbang, the smoke,
15 * the two match lengths, the match of the day and the weapons summary are all
16 * readings of the modules that hold them, and a digit this page prints that no
17 * module accounts for fails the run with the sentence it was typed into.
18 * `digits.test-helper.ts` is that rule, shared with `/range` and `/example`.
1219 */
1320
1421 import { expect, test } from "bun:test";
@@ -21,7 +28,12 @@ import { playKey } from "../matchid.ts";
2128 import { LONGEST_PASTE } from "../matchlink.ts";
2229 import type { Result } from "../result.ts";
2330 import { CARD, type Card } from "../client/weights.ts";
31 import { COMPETITIVE, SHORT } from "../client/match.ts";
32 import { CS_MOVE } from "../client/movement.ts";
33 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
34 import { ALL, damageRows, gunsOf } from "../sheet.ts";
2435 import { GAMES } from "../games.ts";
36 import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts";
2537 import { gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts";
2638 import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts";
2739 import { escape } from "./shell.ts";
@@ -317,3 +329,70 @@ test("the weapons tables are a link now, and the summary that carries it is the
317329 // One style block still, and it is this page's own.
318330 expect(html.match(/<style>/g)?.length).toBe(1);
319331 });
332
333 /**
334 * The numbers the shooter's prose is made of, read from the modules that own
335 * them — #224's rule, the one `/example` has had since #207, with this page
336 * under it too. Every entry is a value somebody could change today and every
337 * one of them would move the sentence with it.
338 */
339 const SOURCED = (now: number): (string | number)[] => [
340 // The Length control: the two rule sets in `src/client/match.ts`.
341 SHORT.winScore, COMPETITIVE.winScore,
342 // The movement, the HE, the flashbang and the smoke, in the paragraph that
343 // used to type all seven of these.
344 CS_MOVE.stepSize,
345 HE.blast.damage, HE.blast.radius, HE.blast.fuse,
346 FLASH.flash.maxBlind,
347 SMOKE.smoke.radius, SMOKE.smoke.life,
348 // The trainer's card, which this page has rendered from since it was written.
349 CARD.envSteps, CARD.minutes, CARD.duels, CARD.winRate, CARD.baselineWinRate,
350 CARD.maps, CARD.maps - 1, CARD.unseenMaps, CARD.unseenWinRate, CARD.unseenBaselineWinRate,
351 // The match of the day, which `src/daily.ts` derives from the same clock the
352 // page was handed: the day, the id and the map's name are all its sentence's.
353 ...digitsIn(saidToday(dailyOn(now))),
354 // The weapons summary, which is `src/sheet.ts` read out: how many guns, how
355 // many thrown, what the dearest costs, how many one-tap a helmet — and the
356 // digits in a weapon's name, because an AK-47 is called that by the registry.
357 gunsOf(ALL).length,
358 ALL.length - gunsOf(ALL).length,
359 Math.max(...gunsOf(ALL).map((weapon) => weapon.price)),
360 damageRows(ALL).filter((row) => row.kit === "armor" && row.cost.head.shots === 1).length,
361 ...ALL.flatMap((weapon) => digitsIn(weapon.name)),
362 // Named, as the rule allows: these three are keys on a keyboard, printed in
363 // `<kbd>` beside Tab and Esc. A key's label is not a measurement of anything.
364 "1", "2", "3",
365 ];
366
367 test("every number the shooter's prose states is read from the module that owns it", () => {
368 const now = Date.UTC(2026, 8, 21, 12);
369 // The leaderboard is dropped: those digits are rows a database filled, which
370 // is data rather than prose, and `statsHtml`'s own tests hold them.
371 const prose = proseOf(gamePage(db(), { ...options, now }), ["stats"]);
372 expect(unsourced("/", prose, SOURCED(now))).toEqual([]);
373 // And the sentences themselves, so a substitution that landed in the wrong
374 // place is a failure here rather than a page that reads like nonsense.
375 expect(prose).toContain(`air acceleration, ${CS_MOVE.stepSize}-unit steps`);
376 expect(prose).toContain(
377 `${HE.blast.damage} damage at the centre falling off over ${HE.blast.radius} units, ` +
378 `on a ${HE.blast.fuse}-second fuse`,
379 );
380 expect(prose).toContain(`up to ${FLASH.flash.maxBlind} seconds of it`);
381 expect(prose).toContain(`a ball ${SMOKE.smoke.radius} units across for ${SMOKE.smoke.life} seconds`);
382 expect(prose).toContain(`Short — first to ${SHORT.winScore}`);
383 expect(prose).toContain(`Competitive — first to ${COMPETITIVE.winScore}`);
384 });
385
386 test("a tuning that moves takes the prose with it, and the rule catches one that does not", () => {
387 const now = Date.UTC(2026, 8, 21, 12);
388 const prose = proseOf(gamePage(db(), { ...options, now }), ["stats"]);
389 // What the page said before #224, typed in by hand: an HE of 98 over 350 on
390 // a 1.5-second fuse. It is what the modules hold today, so the sentence is
391 // the same sentence — but it is now a reading of them, and the proof is that
392 // the rule refuses the page the moment the two disagree.
393 const moved = SOURCED(now).filter((value) => value !== HE.blast.damage);
394 const complaints = unsourced("/", prose, [...moved, HE.blast.damage + 1]);
395 expect(complaints.length).toBe(1);
396 expect(complaints[0]).toContain(`/: ${HE.blast.damage} is in no module`);
397 expect(complaints[0]).toContain("damage at the centre falling off over");
398 });
src/pages/game.ts +28 −14
@@ -7,16 +7,27 @@
77 * Server-rendered: the leaderboard and the instructions are in the first paint,
88 * and the only script is the game bundle, fetched once and cached by its
99 * content hash.
10 *
11 * It types no number. Every figure in the prose is read from the module that
12 * owns it — the match lengths from `src/client/match.ts`, the step from
13 * `src/client/movement.ts`, the grenades from `src/client/weapons.ts`, the
14 * bots from the card in `src/client/weights.ts`, the weapons summary from
15 * `src/sheet.ts` — so a tuning that moves moves the sentence with it.
16 * `game.test.ts` refuses a digit here that no module accounts for.
1017 */
1118
1219 import type { Database } from "bun:sqlite";
1320 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
1421 import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
22 import { COMPETITIVE, SHORT } from "../client/match.ts";
23 import { CS_MOVE } from "../client/movement.ts";
1524 import { NOTHING_POSTED } from "../client/pending.ts";
25 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
1626 import { CARD, type Card } from "../client/weights.ts";
1727 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
1828 import { LONGEST_PASTE } from "../matchlink.ts";
1929 import type { Who } from "../who.ts";
30 import { figure } from "./figure.ts";
2031 import { navFor, noscriptFor, othersHtml } from "./games.ts";
2132 import { WEAPONS_HREF, WEAPONS_SUMMARY } from "./weapons.ts";
2233 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -216,8 +227,17 @@ ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-dec
216227 #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; }
217228 `;
218229
230 /**
231 * A number this page prints, as `./figure.ts` writes one — and escaped like
232 * any other value that came from a file rather than from here. Every figure in
233 * the prose below goes through it: the trainer's card, the match rules, the
234 * movement tuning and what a grenade does, none of which this file knows a
235 * digit of.
236 */
237 const num = (n: number): string => escape(figure(n));
238
219239 /** What the menu's hint under the id field says when the link carried junk. */
220 export const BAD_ID = "That link's match id is not one — this is a fresh match instead.";
240 export const BAD_ID ="That link's match id is not one — this is a fresh match instead.";
221241
222242 /** And the same for `?map=`: a name that is not one is de_forge, never an error. */
223243 export const BAD_MAP = "That link's map name is not one — this is de_forge instead.";
@@ -316,7 +336,7 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
316336 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${
317337 visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly`
318338 }></label>
319 <label>Length<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
339 <label>Length<select id="length"><option value="short">Short — first to ${num(SHORT.winScore)}</option><option value="long">Competitive — first to ${num(COMPETITIVE.winScore)}</option></select></label>
320340 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
321341 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${
322342 asked.map === "generated" ? " selected" : ""
@@ -374,12 +394,6 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
374394 table the fight is tuned by. It is a page of its own so it is a link you can send.</p>
375395 `;
376396
377 /**
378 * A number of the card's, as this page writes one: grouped by thousands, and
379 * escaped like any other value that came from a file rather than from here.
380 */
381 const num = (n: number): string => escape(n.toLocaleString("en"));
382
383397 /**
384398 * What the page says about the bots' policy — the only paragraph on it made of
385399 * measurements. Every number in it comes from the card `scripts/train.ts` wrote
@@ -549,15 +563,15 @@ export function gamePage(
549563 <section>
550564 <h2>What this is</h2>
551565 <p>A defusal shooter written for the browser: WebGL2 drawn by hand, Source-style movement
552 (friction, air acceleration, 18-unit steps, strafe jumping), CS damage with armour and
566 (friction, air acceleration, ${num(CS_MOVE.stepSize)}-unit steps, strafe jumping), CS damage with armour and
553567 hitgroups, fixed spray patterns you can learn, a round clock, a bomb, the buy economy and an
554 HE grenade that bounces off the walls on the same solver the players walk on — 98 damage at
555 the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see —
556 and a flashbang thrown the same way, which takes sight rather than health: up to 1.6 seconds
568 HE grenade that bounces off the walls on the same solver the players walk on — ${num(HE.blast.damage)} damage at
569 the centre falling off over ${num(HE.blast.radius)} units, on a ${num(HE.blast.fuse)}-second fuse, and only to what it can see —
570 and a flashbang thrown the same way, which takes sight rather than health: up to ${num(FLASH.flash.maxBlind)} seconds
557571 of it, by the angle you were looking at it from and how far away it went off, and none at all
558572 through a wall or over your shoulder, and a smoke grenade, which is the one that changes how
559 a round is played: it opens where the canister came to rest and stands a ball 180 units
560 across for fifteen seconds, growing and thinning on the round's own clock. It blocks sight in
573 a round is played: it opens where the canister came to rest and stands a ball ${num(SMOKE.smoke.radius)} units
574 across for ${num(SMOKE.smoke.life)} seconds, growing and thinning on the round's own clock. It blocks sight in
561575 the simulation and not only on the screen — the one function that answers "can this body see
562576 that one" traces the ball, so a bot behind smoke is as blind as you are and will not shoot
563577 what it cannot see, and it walks around a cloud rather than into one.
src/pages/range.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * The range page, read the way a visitor gets it — and the reason this file
3 * exists: **every number in its prose is a reading of the module that owns
4 * it.**
5 *
6 * The page used to type the range's whole tuning into a paragraph beside a
7 * board that was rendered from the database: 180 units a second, 900 of
8 * gravity, a jump of 40.5, a crouch of 22, the drills' scores and the two
9 * games' steps, all of them memories of what was true when somebody wrote the
10 * sentence. #224 put this page under the rule `/example` has had since #207,
11 * and `digits.test-helper.ts` is the one implementation of it: the numbers the
12 * sources hold, against the digits the page prints.
13 *
14 * What is deliberately not under the rule is prose that is prose. "thirty
15 * seconds on the clock", "three plates at a time", "a tracer for eighty
16 * milliseconds" and "a quarter of the air acceleration" are English, and a
17 * rule that demanded a digit for every quantity would ruin the writing. The
18 * rule is about digits.
19 */
20
21 import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
23 import { tmpdir } from "node:os";
24 import { join } from "node:path";
25 import { openDatabase, type Player } from "../db.ts";
26 import { CS_MOVE } from "../client/movement.ts";
27 import { TICKS_PER_SECOND as SHOOTER_HZ } from "../client/replay.ts";
28 import { RANGE_HULL, RANGE_MOVE } from "../range/movement.ts";
29 import { RANGE_TEX_SIZE } from "../range/paint.ts";
30 import { TICKS_PER_SECOND as RANGE_HZ } from "../range/pin.ts";
31 import { DRILLS } from "../range/targets.ts";
32 import { figure } from "./figure.ts";
33 import { proseOf, unsourced } from "./digits.test-helper.ts";
34 import { rangePage, type PageOptions } from "./range.ts";
35 import { crouchDrop, jumpApex } from "./tuning.ts";
36
37 const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "range-page-")));
38
39 const options: PageOptions = {
40 env: "local",
41 pull: undefined,
42 commit: "abc1234",
43 script: "/static/range.0123456789.js",
44 ssh: undefined,
45 };
46
47 /** Every number the range's prose is allowed to print, and where each lives. */
48 const SOURCED: (string | number)[] = [
49 // The lane's tuning and hull: `src/range/movement.ts`, with the apex and the
50 // crouch's drop derived in `./tuning.ts` rather than stored anywhere.
51 RANGE_MOVE.maxSpeed, RANGE_MOVE.gravity, RANGE_MOVE.friction,
52 jumpApex(RANGE_MOVE), crouchDrop(RANGE_HULL), RANGE_HULL.eyeStand,
53 // The shooter's, for the sentences that say what the range is not.
54 CS_MOVE.maxSpeed, CS_MOVE.gravity, CS_MOVE.friction, jumpApex(CS_MOVE),
55 // The two fixed steps, each owned by the game that runs it.
56 RANGE_HZ, SHOOTER_HZ,
57 // The drills: `src/range/targets.ts`, including the two ends of the lane the
58 // plates stand in, which is what the sentence about loudness compares.
59 DRILLS.popup.points.near, DRILLS.popup.points.far,
60 DRILLS.popup.depth.min, DRILLS.popup.depth.max,
61 DRILLS.tracking.points.near, DRILLS.tracking.points.far,
62 DRILLS.tracking.speed.min, DRILLS.tracking.speed.max,
63 // The atlas the plates and the room are painted from: `src/range/paint.ts`.
64 RANGE_TEX_SIZE,
65 ];
66
67 test("the page is the document the shell renders, with the board in the first paint", () => {
68 const home = db();
69 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
70 home.query(
71 `INSERT INTO run (user_id, session, name, drill, seed, score, hits, shots, seconds)
72 VALUES (?1, ?2, ?3, 'popup', 1, 900, 9, 12, 30)`,
73 ).run(player.userId, player.session, player.name);
74 const html = rangePage(home, options);
75 expect(html.startsWith(`<!doctype html>\n<html lang="en">\n`)).toBe(true);
76 expect(html).toContain("<title>range — the engine as a package</title>");
77 expect(html).toContain(`<script type="module" src="/static/range.0123456789.js"></script>`);
78 expect(html).toContain("<h2>The board</h2>");
79 expect(html).toContain("<td>kestrel</td>");
80 });
81
82 test("every number the range's prose states is read from the module that owns it", () => {
83 // The board is dropped: its digits are rows a database filled, which is data
84 // rather than prose, and `boardsHtml` is held honest by what it was handed.
85 const prose = proseOf(rangePage(db(), options), ["board"]);
86 expect(unsourced("/range", prose, SOURCED)).toEqual([]);
87 // And the sentences themselves, so a substitution in the wrong place fails
88 // here rather than reading as nonsense on the page.
89 expect(prose).toContain(
90 `${figure(RANGE_MOVE.maxSpeed)} units a second, a heavier ${figure(RANGE_MOVE.gravity)} of gravity, ` +
91 `a jump that clears ${figure(jumpApex(RANGE_MOVE))} units and a crouch that drops your eye ` +
92 `${figure(crouchDrop(RANGE_HULL))}`,
93 );
94 expect(prose).toContain(`fixed step at ${figure(RANGE_HZ)} Hz — its own rate, not the shooter's ${figure(SHOOTER_HZ)}`);
95 expect(prose).toContain(
96 `${figure(RANGE_MOVE.maxSpeed)} units a second against ${figure(CS_MOVE.maxSpeed)}, ` +
97 `${figure(RANGE_MOVE.gravity)} of gravity against ${figure(CS_MOVE.gravity)}, ` +
98 `${figure(RANGE_MOVE.friction)} of friction against ${figure(CS_MOVE.friction)}`,
99 );
100 expect(prose).toContain(
101 `a jump that clears ${figure(jumpApex(RANGE_MOVE))} units where the shooter's clears ${figure(jumpApex(CS_MOVE))}`,
102 );
103 expect(prose).toContain(`a standing eye ${figure(RANGE_HULL.eyeStand)} units off the floor`);
104 expect(prose).toContain(
105 `worth ${figure(DRILLS.popup.points.near)} to ${figure(DRILLS.popup.points.far)} points`,
106 );
107 expect(prose).toContain(
108 `at ${figure(DRILLS.tracking.speed.min)} to ${figure(DRILLS.tracking.speed.max)} units a second`,
109 );
110 expect(prose).toContain(`three ${figure(RANGE_TEX_SIZE)}-pixel layers`);
111 });
112
113 test("a tuning that moves takes the prose with it, and the rule catches one that does not", () => {
114 const prose = proseOf(rangePage(db(), options), ["board"]);
115 // The tuning the page typed before #224 is the tuning the module holds now,
116 // so the sentence is the same sentence — the proof that it is a reading of
117 // the module is that the rule refuses the page the moment the two disagree.
118 const moved = SOURCED.filter((value) => value !== RANGE_MOVE.gravity);
119 const complaints = unsourced("/range", prose, [...moved, RANGE_MOVE.gravity + 100]);
120 // Twice: the paragraph in the overlay, and the one that compares the games.
121 expect(complaints.length).toBe(2);
122 for (const said of complaints) expect(said).toContain(`/range: ${RANGE_MOVE.gravity} is in no module`);
123 expect(complaints[0]).toContain("of gravity, a jump that clears");
124 });
src/pages/range.ts +38 −12
@@ -15,6 +15,13 @@
1515 * drill in front of the score — and the drills themselves are read from
1616 * `src/range/targets.ts`, so a third drill is a third table and no edit here.
1717 *
18 * It types no number either. The lane's tuning, the drills' scores and speeds,
19 * the atlas, the two games' fixed steps and both jump apexes are read from the
20 * modules that hold them — `src/range/movement.ts`, `targets.ts`, `paint.ts`,
21 * `pin.ts`, and the shooter's own `src/client/` for the sentences that say what
22 * the range is not — so a tuning that moves moves the paragraph with it.
23 * `range.test.ts` refuses a digit here that no module accounts for.
24 *
1825 * This page is not a game's source: it is the chrome one game is served inside,
1926 * which is why it may know what a drill is. `src/independence.test.ts` holds
2027 * the rule that matters — nothing under `src/range/` imports this, or anything
@@ -23,9 +30,19 @@
2330
2431 import { type Database } from "bun:sqlite";
2532 import { displayName, hitShare, runBoard, type RunStanding } from "../db.ts";
33 // The shooter's numbers, for the two sentences that say what the range is not.
34 // A page is the chrome both games are served inside and may read both, which
35 // is exactly why the comparison belongs here and in neither game.
36 import { CS_MOVE } from "../client/movement.ts";
37 import { TICKS_PER_SECOND as SHOOTER_HZ } from "../client/replay.ts";
38 import { RANGE_HULL, RANGE_MOVE } from "../range/movement.ts";
39 import { RANGE_TEX_SIZE } from "../range/paint.ts";
40 import { TICKS_PER_SECOND as RANGE_HZ } from "../range/pin.ts";
2641 import { DRILLS, DRILL_NAMES } from "../range/targets.ts";
42 import { figure } from "./figure.ts";
2743 import { navFor, noscriptFor, othersHtml } from "./games.ts";
2844 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
45 import { crouchDrop, jumpApex } from "./tuning.ts";
2946
3047 /** What the shell needs that this page cannot know: the environment it runs in. */
3148 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
@@ -33,6 +50,15 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
3350 /** How many rows one drill's table shows. The leaderboard's ten, for the same reason. */
3451 export const BOARD_ROWS = 10;
3552
53 /**
54 * A number this page prints, as `./figure.ts` writes one, escaped like every
55 * other value that came from a file. Nothing in the prose below is typed: the
56 * lane's tuning is `src/range/movement.ts`, the drills are
57 * `src/range/targets.ts`, the atlas is `src/range/paint.ts`, the two steps are
58 * the two games' own, and what a jump clears is derived in `./tuning.ts`.
59 */
60 const num = (n: number): string => escape(figure(n));
61
3662 /** The one sentence that says how a drill's rows are ordered. Printed under them. */
3763 export const RANKED_BY = "score, then hits, then the fewest shots they took";
3864
@@ -74,8 +100,8 @@ const NOTE = `
74100 <strong>pop-up</strong>, where plates stand up down the lane and drop when their time is
75101 up, and <strong>tracking</strong>, where they stand up and slide across it at their own
76102 speed, worth more the faster they are going. You walk the lane on this game's own numbers:
77 180 units a second, a heavier 900 of gravity, a jump that clears 40.5 units and a crouch
78 that drops your eye 22 — slower and stickier than the shooter next door, because a firing
103 ${num(RANGE_MOVE.maxSpeed)} units a second, a heavier ${num(RANGE_MOVE.gravity)} of gravity, a jump that clears ${num(jumpApex(RANGE_MOVE))} units and a crouch
104 that drops your eye ${num(crouchDrop(RANGE_HULL))} — slower and stickier than the shooter next door, because a firing
79105 lane is walked and not sprinted. On a browser with no WebGL2 the range falls back to the
80106 flat horizon it shipped with, plates as squares projected from the angle each subtends,
81107 and the same two drills play on it.</p>
@@ -135,7 +161,7 @@ ${boardsHtml(db)}
135161 than a folder: every line behind this page comes from the engine or from
136162 <code>src/range/</code>, and a test reads the sources and fails if one of them reaches into
137163 the shooter in <code>src/client/</code>. What runs right now is the engine's fixed step at
138 50 Hz — its own rate, not the shooter's 64 — the engine's controls with the range's own
164 ${num(RANGE_HZ)} Hz — its own rate, not the shooter's ${num(SHOOTER_HZ)} — the engine's controls with the range's own
139165 bindings table, and a readout of what both say: ticks run, milliseconds a frame, which
140166 actions are held, what movement they and the thumb stick add up to, and where you are
141167 looking.</p>
@@ -149,10 +175,10 @@ ${boardsHtml(db)}
149175 <p>Two drills share that lane, and <kbd>T</kbd> chooses between them before a run — mid-run
150176 the key does nothing rather than splicing two games into one score. <strong>Pop-up</strong>
151177 is the drill this range shipped with, unchanged in every number: three plates at a time,
152 standing still, worth 100 to 200 points by how far down the lane they stood.
178 standing still, worth ${num(DRILLS.popup.points.near)} to ${num(DRILLS.popup.points.far)} points by how far down the lane they stood.
153179 <strong>Tracking</strong> is the second: two at a time, nearer and longer-lived, each
154 sliding across the lane at 90 to 260 units a second until its time or its lane runs out,
155 and worth 100 to 160 for the distance plus a third of a point for every unit a second it is
180 sliding across the lane at ${num(DRILLS.tracking.speed.min)} to ${num(DRILLS.tracking.speed.max)} units a second until its time or its lane runs out,
181 and worth ${num(DRILLS.tracking.points.near)} to ${num(DRILLS.tracking.points.far)} for the distance plus a third of a point for every unit a second it is
156182 crossing at — so the fast one is the one to take first, and you have to lead it. The best
157183 score is kept per drill, because a tracking score means nothing beside a pop-up one. A
158184 drill is one entry in a table in <code>src/range/targets.ts</code> — spawn rate, lifetime,
@@ -163,7 +189,7 @@ ${boardsHtml(db)}
163189 <p>You can see it now, and by the same renderer the shooter uses: the room is one mesh built
164190 from this game's own numbers, every plate is one instance of one cube sized to its own box,
165191 the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is
166 three 64-pixel layers of this game's own — not a line of the shooter's map, models or
192 three ${num(RANGE_TEX_SIZE)}-pixel layers of this game's own — not a line of the shooter's map, models or
167193 materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read
168194 by a test with no browser in the room. If your browser has no WebGL2, the flat
169195 horizon is still there and the panel says so.</p>
@@ -171,10 +197,10 @@ ${boardsHtml(db)}
171197 <code>src/engine/physics.ts</code>, which holds not one tuned number of its own, steps a body
172198 through this room fifty times a second on the tuning and hull in
173199 <code>src/range/movement.ts</code>. They are the range's and they are not Counter-Strike's:
174 180 units a second against 250, 900 of gravity against 800, 7 of friction against 5.2, a
200 ${num(RANGE_MOVE.maxSpeed)} units a second against ${num(CS_MOVE.maxSpeed)}, ${num(RANGE_MOVE.gravity)} of gravity against ${num(CS_MOVE.gravity)}, ${num(RANGE_MOVE.friction)} of friction against ${num(CS_MOVE.friction)}, a
175201 quarter of the air acceleration — there is no strafe-jumping at this range — a wider hull, a
176 deeper crouch and a jump that clears 40.5 units where the shooter's clears 57. One number is
177 the same on purpose and not by inheritance: a standing eye 64 units off the floor, which is
202 deeper crouch and a jump that clears ${num(jumpApex(RANGE_MOVE))} units where the shooter's clears ${num(jumpApex(CS_MOVE))}. One number is
203 the same on purpose and not by inheritance: a standing eye ${num(RANGE_HULL.eyeStand)} units off the floor, which is
178204 what every plate's distance is measured from and what the thirty seconds pinned in
179205 <code>src/range/pin.ts</code> were shot from. The shot leaves the eye you actually have, so
180206 walking down the lane and crouching at the line change what you can hit; the panel prints
@@ -185,8 +211,8 @@ ${boardsHtml(db)}
185211 crack, the hit, the knock of a plate standing up, the tick of each of the last three seconds
186212 and the clock running out. The shooter's sounds are not reused: this crack is higher, a third
187213 as long and has no thump under it, because a range bay is not a firefight. Distance is the
188 engine's <code>attenuate</code> on this room's own numbers, so a plate hit at 1,200 units is a
189 quarter the volume of one at 200, and direction is the engine's <code>panOf</code> against
214 engine's <code>attenuate</code> on this room's own numbers, so a plate hit at ${num(DRILLS.popup.depth.max)} units is a
215 quarter the volume of one at ${num(DRILLS.popup.depth.min)}, and direction is the engine's <code>panOf</code> against
190216 the eye you are standing behind and the way it is facing: a plate that stands up on the left
191217 of the lane knocks on the left, one sliding across the lane crosses the ear with it, and
192218 looking at either centres it. Only the plates are out there — your own crack, the ticks and
src/pages/tuning.test.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * The derived numbers the pages print, against the solver they are a shortcut
3 * for. `jumpApex` is the closed form of the parabola `move` integrates a tick
4 * at a time, so the way to check it is to jump a body with the engine and see
5 * where it got to — a shortcut nobody re-checks is exactly the kind of number
6 * #224 is about, derived instead of typed but still a claim.
7 *
8 * Both games' real tunings are jumped here, because both are printed on
9 * `/range`: the range's 270 against 900 and the shooter's 301.993 against 800.
10 */
11
12 import { expect, test } from "bun:test";
13 import { move, type Body, type Box, type Hull, type MoveTuning, type Wish } from "../engine/index.ts";
14 import { CS_HULL, CS_MOVE } from "../client/movement.ts";
15 import { RANGE_HULL, RANGE_MOVE } from "../range/movement.ts";
16 import { crouchDrop, jumpApex } from "./tuning.ts";
17
18 /** A floor at z = 0, wide enough that nothing here jumps off it. */
19 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
20
21 /** A fine step, so the peak the solver reaches is not a step's worth short. */
22 const TICK = 1 / 1000;
23
24 const wish = (tuning: MoveTuning, jump: boolean): Wish => ({
25 forward: 0, side: 0, yaw: 0, jump, duck: false, walk: false, maxSpeed: tuning.maxSpeed,
26 });
27
28 /** How high one jump from the floor actually gets, through the engine's mover. */
29 function jumped(tuning: MoveTuning, hull: Hull): number {
30 const body: Body = { pos: [0, 0, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0 };
31 move(body, wish(tuning, true), TICK, [FLOOR], tuning, hull);
32 let peak = body.pos[2];
33 while (!body.onGround) {
34 move(body, wish(tuning, false), TICK, [FLOOR], tuning, hull);
35 peak = Math.max(peak, body.pos[2]);
36 }
37 return peak;
38 }
39
40 test("the apex a page prints is the height the solver actually reaches", () => {
41 // v₀²/2g, by hand: 270²/1800 is exactly 40.5, and 301.993²/1600 is 56.9997…
42 expect(jumpApex(RANGE_MOVE)).toBeCloseTo(40.5, 10);
43 expect(jumpApex(CS_MOVE)).toBeCloseTo(57, 2);
44 // And the same number arrived at the long way, one thousandth of a second at
45 // a time. The tolerance is the step's own error — g·dt² — and nothing looser.
46 for (const [tuning, hull] of [[RANGE_MOVE, RANGE_HULL], [CS_MOVE, CS_HULL]] as const)
47 expect(Math.abs(jumped(tuning, hull) - jumpApex(tuning))).toBeLessThan(tuning.gravity * TICK * TICK);
48 });
49
50 test("the crouch's drop is the hull's two eye heights, and the range's is the deeper", () => {
51 expect(crouchDrop(RANGE_HULL)).toBe(RANGE_HULL.eyeStand - RANGE_HULL.eyeDuck);
52 expect(crouchDrop(RANGE_HULL)).toBe(22);
53 expect(crouchDrop(CS_HULL)).toBe(18);
54 expect(crouchDrop(RANGE_HULL)).toBeGreaterThan(crouchDrop(CS_HULL));
55 });
src/pages/tuning.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * What a movement tuning implies, for the pages that print it.
3 *
4 * `/range` says how high each game's jump clears and how far its crouch drops
5 * the eye. Neither number is stored anywhere: the apex is v₀²/2g of the numbers
6 * `src/range/movement.ts` and `src/client/movement.ts` hand the solver, and the
7 * drop is one subtraction of a hull's two eye heights. They are derived here,
8 * once, so the page that prints them and the test that holds it honest cannot
9 * do the arithmetic differently — a readout of a derived number still has to be
10 * derived somewhere, and twice is how the pages got into this state.
11 *
12 * It holds no number of its own, and it is not the engine's: `move` in
13 * `src/engine/physics.ts` integrates that parabola a tick at a time, and this
14 * is its closed form. `tuning.test.ts` jumps a body through the solver and
15 * checks the two agree, so the shortcut cannot quietly stop being the truth.
16 */
17
18 import type { Hull, MoveTuning } from "../engine/index.ts";
19
20 /**
21 * How high a jump on this tuning reaches above the floor it left, in units:
22 * v₀²/2g. The range's 270 against 900 is exactly 40.5; the shooter's 301.993
23 * against 800 is 56.99977…, which is 57 to anybody reading a sentence.
24 */
25 export const jumpApex = (tuning: MoveTuning): number =>
26 (tuning.jumpVelocity * tuning.jumpVelocity) / (2 * tuning.gravity);
27
28 /** How far a crouch drops the eye on this hull, in units: standing less ducked. */
29 export const crouchDrop = (hull: Hull): number => hull.eyeStand - hull.eyeDuck;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs