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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/224-every-number-the-pages-boast-is-read-fro → main 1 commit fork
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 | ||
| 368 | 368 | and the terminal's **weapons** tab renders the same sheet as columns. The assumptions are printed under the tables, because a figure without |
| 369 | 369 | the body it was measured against is a rumour. |
| 370 | 370 | |
| 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 | ||
| 371 | 387 | ## Playing |
| 372 | 388 | |
| 373 | 389 | <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 | "&": "&", "<": "<", ">": ">", """: '"', "'": "'", | |
| 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"; | ||
| 29 | 29 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; |
| 30 | 30 | import { bytes, kB } from "../bytes.ts"; |
| 31 | 31 | 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"; | |
| 33 | 34 | |
| 34 | 35 | const ROOT = join(import.meta.dir, "..", ".."); |
| 35 | 36 | |
| @@ -111,17 +112,23 @@ test("no number the page states about itself is one somebody typed", () => { | ||
| 111 | 112 | // The four that were typed, and were wrong by the time this was written. |
| 112 | 113 | for (const stale of ["8,754", "4,229", "71,646", "seventy-nine", "about two hundred"]) |
| 113 | 114 | 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. | |
| 117 | 120 | const cost = measureExample(BUILT); |
| 118 | const section = costHtml(cost); | |
| 119 | const allowed = new Set([ | |
| 121 | const allowed = [ | |
| 120 | 122 | bytes(cost.script.raw), bytes(cost.shooter.raw), |
| 121 | 123 | 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`); | |
| 127 | 134 | }); |
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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The cs page, read the way a visitor gets it: a whole document from the shell, |
| 3 | 3 | * 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. | |
| 5 | 5 | * |
| 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`. | |
| 12 | 19 | */ |
| 13 | 20 | |
| 14 | 21 | import { expect, test } from "bun:test"; |
| @@ -21,7 +28,12 @@ import { playKey } from "../matchid.ts"; | ||
| 21 | 28 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 22 | 29 | import type { Result } from "../result.ts"; |
| 23 | 30 | 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"; | |
| 24 | 35 | import { GAMES } from "../games.ts"; |
| 36 | import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts"; | |
| 25 | 37 | import { gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts"; |
| 26 | 38 | import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts"; |
| 27 | 39 | 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 | ||
| 317 | 329 | // One style block still, and it is this page's own. |
| 318 | 330 | expect(html.match(/<style>/g)?.length).toBe(1); |
| 319 | 331 | }); |
| 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 @@ | ||
| 7 | 7 | * Server-rendered: the leaderboard and the instructions are in the first paint, |
| 8 | 8 | * and the only script is the game bundle, fetched once and cached by its |
| 9 | 9 | * 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. | |
| 10 | 17 | */ |
| 11 | 18 | |
| 12 | 19 | import type { Database } from "bun:sqlite"; |
| 13 | 20 | import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 14 | 21 | 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"; | |
| 15 | 24 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 25 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; | |
| 16 | 26 | import { CARD, type Card } from "../client/weights.ts"; |
| 17 | 27 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| 18 | 28 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 19 | 29 | import type { Who } from "../who.ts"; |
| 30 | import { figure } from "./figure.ts"; | |
| 20 | 31 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 21 | 32 | import { WEAPONS_HREF, WEAPONS_SUMMARY } from "./weapons.ts"; |
| 22 | 33 | 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 | ||
| 216 | 227 | #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; } |
| 217 | 228 | `; |
| 218 | 229 | |
| 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 | ||
| 219 | 239 | /** 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."; | |
| 221 | 241 | |
| 222 | 242 | /** And the same for `?map=`: a name that is not one is de_forge, never an error. */ |
| 223 | 243 | 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 => ` | ||
| 316 | 336 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${ |
| 317 | 337 | visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly` |
| 318 | 338 | }></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> | |
| 320 | 340 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 321 | 341 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${ |
| 322 | 342 | asked.map === "generated" ? " selected" : "" |
| @@ -374,12 +394,6 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 374 | 394 | table the fight is tuned by. It is a page of its own so it is a link you can send.</p> |
| 375 | 395 | `; |
| 376 | 396 | |
| 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 | ||
| 383 | 397 | /** |
| 384 | 398 | * What the page says about the bots' policy — the only paragraph on it made of |
| 385 | 399 | * measurements. Every number in it comes from the card `scripts/train.ts` wrote |
| @@ -549,15 +563,15 @@ export function gamePage( | ||
| 549 | 563 | <section> |
| 550 | 564 | <h2>What this is</h2> |
| 551 | 565 | <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 | |
| 553 | 567 | 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 | |
| 557 | 571 | of it, by the angle you were looking at it from and how far away it went off, and none at all |
| 558 | 572 | 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 | |
| 561 | 575 | the simulation and not only on the screen — the one function that answers "can this body see |
| 562 | 576 | that one" traces the ball, so a bot behind smoke is as blind as you are and will not shoot |
| 563 | 577 | 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 @@ | ||
| 15 | 15 | * drill in front of the score — and the drills themselves are read from |
| 16 | 16 | * `src/range/targets.ts`, so a third drill is a third table and no edit here. |
| 17 | 17 | * |
| 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 | * | |
| 18 | 25 | * This page is not a game's source: it is the chrome one game is served inside, |
| 19 | 26 | * which is why it may know what a drill is. `src/independence.test.ts` holds |
| 20 | 27 | * the rule that matters — nothing under `src/range/` imports this, or anything |
| @@ -23,9 +30,19 @@ | ||
| 23 | 30 | |
| 24 | 31 | import { type Database } from "bun:sqlite"; |
| 25 | 32 | 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"; | |
| 26 | 41 | import { DRILLS, DRILL_NAMES } from "../range/targets.ts"; |
| 42 | import { figure } from "./figure.ts"; | |
| 27 | 43 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 28 | 44 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 45 | import { crouchDrop, jumpApex } from "./tuning.ts"; | |
| 29 | 46 | |
| 30 | 47 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 31 | 48 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; |
| @@ -33,6 +50,15 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 33 | 50 | /** How many rows one drill's table shows. The leaderboard's ten, for the same reason. */ |
| 34 | 51 | export const BOARD_ROWS = 10; |
| 35 | 52 | |
| 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 | ||
| 36 | 62 | /** The one sentence that says how a drill's rows are ordered. Printed under them. */ |
| 37 | 63 | export const RANKED_BY = "score, then hits, then the fewest shots they took"; |
| 38 | 64 | |
| @@ -74,8 +100,8 @@ const NOTE = ` | ||
| 74 | 100 | <strong>pop-up</strong>, where plates stand up down the lane and drop when their time is |
| 75 | 101 | up, and <strong>tracking</strong>, where they stand up and slide across it at their own |
| 76 | 102 | 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 | |
| 79 | 105 | lane is walked and not sprinted. On a browser with no WebGL2 the range falls back to the |
| 80 | 106 | flat horizon it shipped with, plates as squares projected from the angle each subtends, |
| 81 | 107 | and the same two drills play on it.</p> |
| @@ -135,7 +161,7 @@ ${boardsHtml(db)} | ||
| 135 | 161 | than a folder: every line behind this page comes from the engine or from |
| 136 | 162 | <code>src/range/</code>, and a test reads the sources and fails if one of them reaches into |
| 137 | 163 | 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 | |
| 139 | 165 | bindings table, and a readout of what both say: ticks run, milliseconds a frame, which |
| 140 | 166 | actions are held, what movement they and the thumb stick add up to, and where you are |
| 141 | 167 | looking.</p> |
| @@ -149,10 +175,10 @@ ${boardsHtml(db)} | ||
| 149 | 175 | <p>Two drills share that lane, and <kbd>T</kbd> chooses between them before a run — mid-run |
| 150 | 176 | the key does nothing rather than splicing two games into one score. <strong>Pop-up</strong> |
| 151 | 177 | 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. | |
| 153 | 179 | <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 | |
| 156 | 182 | crossing at — so the fast one is the one to take first, and you have to lead it. The best |
| 157 | 183 | score is kept per drill, because a tracking score means nothing beside a pop-up one. A |
| 158 | 184 | drill is one entry in a table in <code>src/range/targets.ts</code> — spawn rate, lifetime, |
| @@ -163,7 +189,7 @@ ${boardsHtml(db)} | ||
| 163 | 189 | <p>You can see it now, and by the same renderer the shooter uses: the room is one mesh built |
| 164 | 190 | from this game's own numbers, every plate is one instance of one cube sized to its own box, |
| 165 | 191 | 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 | |
| 167 | 193 | materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read |
| 168 | 194 | by a test with no browser in the room. If your browser has no WebGL2, the flat |
| 169 | 195 | horizon is still there and the panel says so.</p> |
| @@ -171,10 +197,10 @@ ${boardsHtml(db)} | ||
| 171 | 197 | <code>src/engine/physics.ts</code>, which holds not one tuned number of its own, steps a body |
| 172 | 198 | through this room fifty times a second on the tuning and hull in |
| 173 | 199 | <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 | |
| 175 | 201 | 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 | |
| 178 | 204 | what every plate's distance is measured from and what the thirty seconds pinned in |
| 179 | 205 | <code>src/range/pin.ts</code> were shot from. The shot leaves the eye you actually have, so |
| 180 | 206 | walking down the lane and crouching at the line change what you can hit; the panel prints |
| @@ -185,8 +211,8 @@ ${boardsHtml(db)} | ||
| 185 | 211 | crack, the hit, the knock of a plate standing up, the tick of each of the last three seconds |
| 186 | 212 | and the clock running out. The shooter's sounds are not reused: this crack is higher, a third |
| 187 | 213 | 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 | |
| 190 | 216 | the eye you are standing behind and the way it is facing: a plate that stands up on the left |
| 191 | 217 | of the lane knocks on the left, one sliding across the lane crosses the ear with it, and |
| 192 | 218 | 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; |
new above
offline