Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A seeded range run is pinned: one digest, so a renderer and a movement tuning cannot change the game #61
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/53-a-seeded-range-run-is-pinned-one-digest → main 1 commit fork
4 files
changed +474
−2
README.md +5 −1
| @@ -216,7 +216,11 @@ from the angle it subtends, and you turn on the spot. | ||
| 216 | 216 | `static/range.<hash>.js`: the committed bundle is a build artifact kept in git, |
| 217 | 217 | and this is what checks that the file a browser downloads boots, sizes its |
| 218 | 218 | canvases, draws its ticks and answers a key — so a bundling mistake fails |
| 219 | `bun test` rather than the preview. `src/pages/range.ts` is its page: its own title, prose and overlay | |
| 219 | `bun test` rather than the preview. The range's rules are pinned the way the | |
| 220 | shooter's round is: `pin.ts` plays one seeded thirty-second run through one | |
| 221 | fixed script of shots and hashes every tick, against the digest committed in | |
| 222 | `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the | |
| 223 | game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay | |
| 220 | 224 | through the same shell, with no database, no endpoint and no cookie behind it. |
| 221 | 225 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 222 | 226 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
src/engine/GUIDE.md +1 −1
| @@ -52,7 +52,7 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where | ||
| 52 | 52 | ts |
| 53 | 53 | // src/client/main.ts |
| 54 | 54 | const controls = createControls( |
| 55 | { surface: scene, window: win, document: doc }, | |
| 55 | { surface: sceneCanvas, window: win, document: doc }, | |
| 56 | 56 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, |
| 57 | 57 | ); |
| 58 | 58 |
src/range/pin.test.ts added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | /** | |
| 2 | * The range's golden digest: what this game does, pinned. | |
| 3 | * | |
| 4 | * One run — thirty seconds at 50 Hz from seed 20260917, played through the | |
| 5 | * fixed script in `pin.ts` — hashed tick by tick. If it fails, read the | |
| 6 | * message: it names the first second of the run at which the play and the pin | |
| 7 | * part company and prints the constants to paste in. Paste them only when the | |
| 8 | * change to the game was deliberate, and say in the pull what moved and why. | |
| 9 | * A renderer, an atlas or a movement tuning that moves this digest has changed | |
| 10 | * the game and is not any of those three things. | |
| 11 | * | |
| 12 | * Measured: the pinned thirty seconds fold in 8 ms (14 ms cold), and the whole | |
| 13 | * file is 60 ms on its own, Bun's start and all — `bun test` is 4.2 s before | |
| 14 | * this file and 4.3 s with it. No canvas, no document, no clock: the run is | |
| 15 | * 1,500 steps of arithmetic and seventeen rays. | |
| 16 | */ | |
| 17 | ||
| 18 | import { expect, test } from "bun:test"; | |
| 19 | import { | |
| 20 | CHECKPOINT_CHARS, compare, fold, play, PLATE_FIELDS, QUANTUM, RUN_FIELDS, SCRIPT, snapshot, | |
| 21 | TICK, TICKS_PER_SECOND, trigger, type Pin, type Pinned, | |
| 22 | } from "./pin.ts"; | |
| 23 | import { accuracy, createRun, EYE, shoot, start, step, type Run } from "./targets.ts"; | |
| 24 | import { angleVectors } from "../engine/index.ts"; | |
| 25 | ||
| 26 | /** The run: a named seed, thirty seconds of it, at the range's own 50 Hz. */ | |
| 27 | const SEED = 20260917; | |
| 28 | const SECONDS = 30; | |
| 29 | const TICKS = SECONDS * TICKS_PER_SECOND; | |
| 30 | ||
| 31 | /** | |
| 32 | * Thirty seconds of the standard run on seed 20260917. Computed on a024b3f | |
| 33 | * ("The scene becomes data"), the commit this branch left main at, before | |
| 34 | * anything in this pull touched the tree. | |
| 35 | */ | |
| 36 | const RANGE = "852637bcfcc0106c9b3e569e52a19ac9d91f1adffab8afc7d0a0e96e1817f4ed"; | |
| 37 | const RANGE_SECONDS = [ | |
| 38 | "e134b4ced2ea", "89113c07998a", "ba65ccef9684", "79a8c9cedfea", | |
| 39 | "6a9df963121d", "31320a91df95", "ea5b34e3ce90", "980f4c18477a", | |
| 40 | "4c4c062d498b", "645e74c2ab80", "98e930792c6e", "70798e05f8f4", | |
| 41 | "b7f1391d085f", "dade8b0d7d4c", "4f4a5c5635ff", "cae42486c107", | |
| 42 | "25132411c755", "0bb6fe7b4ff6", "220999383a4b", "51af58fbb62e", | |
| 43 | "411f729a0d65", "5a822ce4341f", "6e4cd57120b3", "3ed90a153739", | |
| 44 | "098edf8ce37a", "a6a3377f9a58", "27eeba7c431a", "fceb0ce6f204", | |
| 45 | "fbec5c9cf050", "6e5588cba9c4", | |
| 46 | ]; | |
| 47 | ||
| 48 | const RANGE_PIN: Pin = { | |
| 49 | label: "thirty-second range run", constant: "RANGE", digest: RANGE, checkpoints: RANGE_SECONDS, | |
| 50 | }; | |
| 51 | ||
| 52 | /** A run that has not been played yet, seeded and live. */ | |
| 53 | function ready(seed: number = SEED): Run { | |
| 54 | const run = createRun(seed); | |
| 55 | start(run, seed); | |
| 56 | return run; | |
| 57 | } | |
| 58 | ||
| 59 | const must = (run: Pinned, pin: Pin): void => { | |
| 60 | const complaint = compare(run, pin); | |
| 61 | if (complaint !== null) throw new Error(complaint); | |
| 62 | }; | |
| 63 | ||
| 64 | test("a seeded thirty-second run plays exactly as it played", () => { | |
| 65 | const run = ready(); | |
| 66 | const pinned = play(run, TICKS); | |
| 67 | must(pinned, RANGE_PIN); | |
| 68 | expect(pinned.digest).toBe(RANGE); | |
| 69 | expect(pinned.ticks).toBe(TICKS); | |
| 70 | expect(pinned.checkpoints).toEqual(RANGE_SECONDS); | |
| 71 | }); | |
| 72 | ||
| 73 | /** | |
| 74 | * What that run was, in numbers a reader can check without decoding a hash: the | |
| 75 | * script lands fourteen of its seventeen shots, scores 2,225 points across | |
| 76 | * plates worth 100 to 200 each, and finishes with the clock at zero and the | |
| 77 | * lane cleared. If the digest above moves, one of these usually moves with it | |
| 78 | * and says what kind of change it was. | |
| 79 | */ | |
| 80 | test("the pinned run is a real one: a score, fourteen hits and three misses", () => { | |
| 81 | const run = ready(); | |
| 82 | play(run, TICKS); | |
| 83 | expect(run.phase).toBe("over"); | |
| 84 | expect(run.clock).toBe(0); | |
| 85 | expect(run.targets).toEqual([]); | |
| 86 | expect(run.shots).toBe(17); | |
| 87 | expect(run.hits).toBe(14); | |
| 88 | expect(run.shots - run.hits).toBe(3); | |
| 89 | expect(run.score).toBe(2225); | |
| 90 | expect(accuracy(run.hits, run.shots)).toBeCloseTo(14 / 17, 10); | |
| 91 | // The script is the whole of what was fired: every aim in it was a shot. | |
| 92 | expect(run.shots).toBe(SCRIPT.length); | |
| 93 | expect(SCRIPT.every((aim) => aim.tick >= 0 && aim.tick < TICKS)).toBe(true); | |
| 94 | }); | |
| 95 | ||
| 96 | test("the same seed is the same run, and another seed is another run", () => { | |
| 97 | const four = 4 * TICKS_PER_SECOND; | |
| 98 | const a = play(ready(), four); | |
| 99 | const b = play(ready(), four); | |
| 100 | const other = play(ready(SEED + 1), four); | |
| 101 | expect(a.digest).toBe(b.digest); | |
| 102 | expect(a.digest).not.toBe(other.digest); | |
| 103 | expect(a.checkpoints).not.toEqual(other.checkpoints); | |
| 104 | // The first four seconds of the long run are the first four seconds, too. | |
| 105 | expect(a.checkpoints).toEqual(RANGE_SECONDS.slice(0, 4)); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a move the game could not feel leaves the digest alone; one it could moves it", () => { | |
| 109 | const run = ready(); | |
| 110 | step(run, TICK); | |
| 111 | const before = snapshot(run); | |
| 112 | const plate = run.targets[0] as NonNullable<(typeof run.targets)[0]>; | |
| 113 | plate.box.min[0] += QUANTUM / 50; // a fiftieth of the quantum: libm noise, not behaviour | |
| 114 | expect(snapshot(run)).toBe(before); | |
| 115 | plate.box.min[0] += QUANTUM * 10; // ten quanta: still invisible to a person, caught anyway | |
| 116 | expect(snapshot(run)).not.toBe(before); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the digest is about which plates stand, not the order the lane holds them in", () => { | |
| 120 | const run = ready(); | |
| 121 | for (let i = 0; i < 100; i++) step(run, TICK); | |
| 122 | expect(run.targets.length).toBeGreaterThan(1); | |
| 123 | const before = snapshot(run); | |
| 124 | run.targets.reverse(); | |
| 125 | expect(snapshot(run)).toBe(before); | |
| 126 | // An id is the implementation's bookkeeping, and no rule promises one. | |
| 127 | (run.targets[0] as NonNullable<(typeof run.targets)[0]>).id += 100; | |
| 128 | expect(snapshot(run)).toBe(before); | |
| 129 | // A plate that is worth something else is a rule, and is caught. | |
| 130 | (run.targets[0] as NonNullable<(typeof run.targets)[0]>).points += 1; | |
| 131 | expect(snapshot(run)).not.toBe(before); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a nudged game is a different run from the first second on", () => { | |
| 135 | const nudged = (() => { | |
| 136 | const run = ready(); | |
| 137 | const fire = trigger(run); | |
| 138 | return fold(run, 4 * TICKS_PER_SECOND, (tick) => { | |
| 139 | step(run, TICK); | |
| 140 | // A tenth of a second of extra life, on one plate, on the first tick. | |
| 141 | if (tick === 0) (run.targets[0] as NonNullable<(typeof run.targets)[0]>).life += 0.1; | |
| 142 | fire(tick); | |
| 143 | }); | |
| 144 | })(); | |
| 145 | expect(nudged.checkpoints[0]).not.toBe(RANGE_SECONDS[0]); | |
| 146 | const complaint = compare(nudged, { ...RANGE_PIN, checkpoints: RANGE_SECONDS.slice(0, 4) }) as string; | |
| 147 | expect(complaint).toContain("second 1 of 4 differs — the game changed there"); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a mismatch says which second went another way, and what to paste", () => { | |
| 151 | const pinned: Pin = { | |
| 152 | label: "toy", constant: "TOY", digest: "aaaa", | |
| 153 | checkpoints: ["one0000000000", "two0000000000", "three000000000"], | |
| 154 | }; | |
| 155 | const run: Pinned = { | |
| 156 | digest: "bbbb", ticks: 3 * TICKS_PER_SECOND, | |
| 157 | checkpoints: ["one0000000000", "changed000000", "three000000000"], | |
| 158 | }; | |
| 159 | const complaint = compare(run, pinned) as string; | |
| 160 | expect(complaint).toContain("second 2 of 3 differs — the game changed there"); | |
| 161 | expect(complaint).toContain("ticks 50..99"); | |
| 162 | expect(complaint).toContain("1 of 3 seconds differ"); | |
| 163 | expect(complaint).toContain("pinned two0000000000, ran changed000000"); | |
| 164 | expect(complaint).toContain('const TOY = "bbbb";'); | |
| 165 | expect(complaint).toContain('"changed000000"'); | |
| 166 | expect(compare({ ...run, digest: pinned.digest, checkpoints: [...pinned.checkpoints] }, pinned)).toBe(null); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a checkpoint is a short digest, one a second, and the whole run is a full one", () => { | |
| 170 | expect(RANGE_SECONDS.length).toBe(SECONDS); | |
| 171 | for (const second of RANGE_SECONDS) { | |
| 172 | expect(second.length).toBe(CHECKPOINT_CHARS); | |
| 173 | expect(second).toMatch(/^[0-9a-f]+$/); | |
| 174 | } | |
| 175 | expect(RANGE.length).toBe(64); | |
| 176 | expect(new Set(RANGE_SECONDS).size).toBe(SECONDS); | |
| 177 | expect(RUN_FIELDS.map((f) => f.name)).toEqual(["phase", "clock", "score", "hits", "shots"]); | |
| 178 | expect(PLATE_FIELDS.length).toBe(8); | |
| 179 | }); | |
| 180 | ||
| 181 | test("the script is fixed: it never reads the run, and it fires in order", () => { | |
| 182 | const ticks = SCRIPT.map((aim) => aim.tick); | |
| 183 | expect([...ticks].sort((a, b) => a - b)).toEqual(ticks); | |
| 184 | expect(new Set(ticks).size).toBe(ticks.length); | |
| 185 | // Every aim is a direction: a script beat that aimed nowhere would be refused | |
| 186 | // by `shoot` and would count as no shot at all. | |
| 187 | for (const aim of SCRIPT) { | |
| 188 | const forward = angleVectors(aim.yaw, aim.pitch).forward; | |
| 189 | expect(Math.hypot(forward[0], forward[1], forward[2])).toBeCloseTo(1, 10); | |
| 190 | } | |
| 191 | // Fired past the last tick it holds, the trigger has nothing left to do. | |
| 192 | const run = ready(); | |
| 193 | step(run, TICK); | |
| 194 | const fire = trigger(run); | |
| 195 | fire(TICKS); | |
| 196 | expect(run.shots).toBe(SCRIPT.length); | |
| 197 | fire(TICKS * 2); | |
| 198 | expect(run.shots).toBe(SCRIPT.length); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a shot the script takes is a shot the game takes, on the same tick", () => { | |
| 202 | // The first aim, played by hand: step to its tick, fire down the barrel, and | |
| 203 | // the run has one hit on it — the pin's shots are the page's shots. | |
| 204 | const first = SCRIPT[0] as (typeof SCRIPT)[0]; | |
| 205 | const run = ready(); | |
| 206 | for (let tick = 0; tick <= first.tick; tick++) step(run, TICK); | |
| 207 | const shot = shoot(run, EYE, angleVectors(first.yaw, first.pitch).forward); | |
| 208 | expect(shot?.target).not.toBe(null); | |
| 209 | expect(shot?.points).toBe(191); | |
| 210 | }); |
src/range/pin.ts added +258
| @@ -0,0 +1,258 @@ | ||
| 1 | /** | |
| 2 | * The range's pin: one seeded run, one fixed script of shots, one sha256. | |
| 3 | * | |
| 4 | * A renderer, an atlas and a movement tuning are all about to land under this | |
| 5 | * game, and every one of them will end with the claim "the range plays exactly | |
| 6 | * as before". Nobody can check that by eye. This module makes it a number: play | |
| 7 | * a seeded run through a script nobody changes, fold the game's state into a | |
| 8 | * hash after every tick, and compare the result with the digest committed in | |
| 9 | * `pin.test.ts`. It is the shooter's instrument in `src/client/replay.ts` built | |
| 10 | * a second time for the second game — built, not imported: `src/range/` reaches | |
| 11 | * for the engine, the shell and its own siblings and nothing else, and a | |
| 12 | * shortcut to the shooter would make that untrue. | |
| 13 | * | |
| 14 | * **When the digest moves**, read the failure: it names the first whole second | |
| 15 | * of the run whose checkpoint differs. Play that second — the ticks are printed | |
| 16 | * with it — and decide which it is. A change that was meant to leave the game | |
| 17 | * alone (an extraction, a renderer, a readout) has a bug in it: fix the code. A | |
| 18 | * change to the game's rules on purpose (a tuning, a new way to score) moves the | |
| 19 | * digest: paste the printed constants into `pin.test.ts` in the same pull, with | |
| 20 | * a sentence saying what behaviour changed and why. A digest updated without | |
| 21 | * that sentence is a pin that has stopped pinning anything. | |
| 22 | * | |
| 23 | * What is hashed is the run's rules playing out — the phase, the clock, the | |
| 24 | * score, the hits, the shots and every standing plate's place, size, lifetime | |
| 25 | * and worth. Not a plate's id and not the order of the lane: those are how | |
| 26 | * `targets.ts` happens to keep its list today, not something the game promises | |
| 27 | * a player, so the plates are hashed in a canonical order and a reshuffle of the | |
| 28 | * array is not a failure. Anything a plate is drawn with is the renderer's, and | |
| 29 | * the renderer is allowed to change. | |
| 30 | */ | |
| 31 | ||
| 32 | import { | |
| 33 | EYE, shoot, step as advance, type Run, type Target, | |
| 34 | } from "./targets.ts"; | |
| 35 | import { angleVectors } from "../engine/index.ts"; | |
| 36 | ||
| 37 | /** The range's step, and the game's own: 50 Hz, as `main.ts` runs it. */ | |
| 38 | export const TICK = 1 / 50; | |
| 39 | export const TICKS_PER_SECOND = 50; | |
| 40 | ||
| 41 | // --- what is hashed ------------------------------------------------------- | |
| 42 | ||
| 43 | /** | |
| 44 | * Floats are quantised before they are hashed: multiplied by 1e4, rounded to an | |
| 45 | * integer, written in decimal. | |
| 46 | * | |
| 47 | * Why: a plate's place comes out of `lerp` on a seeded rng and its distance out | |
| 48 | * of a square root, and a shot is traced along `Math.sin` and `Math.cos` of the | |
| 49 | * view angles. Those trig calls are library functions, not IEEE-754 operations | |
| 50 | * — two builds of libm may disagree in the last bit — so hashing raw doubles | |
| 51 | * would fail this test on somebody else's machine for a reason that is not a | |
| 52 | * bug. A ten-thousandth of a Source unit is about 2.5 microns of lane and a | |
| 53 | * ten-thousandth of a second of lifetime: far below anything a player can feel, | |
| 54 | * far above the noise. A real change moves a plate by whole units or a clock by | |
| 55 | * whole ticks, and still shows. | |
| 56 | */ | |
| 57 | export const QUANTUM = 1e-4; | |
| 58 | const SCALE = 1 / QUANTUM; | |
| 59 | ||
| 60 | /** One number, ready to hash. Non-finite values are written as themselves. */ | |
| 61 | const q = (x: number): string => (Number.isFinite(x) ? String(Math.round(x * SCALE)) : `!${x}`); | |
| 62 | ||
| 63 | /** The phases, in the order their index is hashed. */ | |
| 64 | const PHASES: Run["phase"][] = ["ready", "live", "over"]; | |
| 65 | ||
| 66 | /** Everything hashed about the run itself, in the order it is written. */ | |
| 67 | export const RUN_FIELDS: { name: string; of: (run: Run) => number }[] = [ | |
| 68 | { name: "phase", of: (run) => PHASES.indexOf(run.phase) }, | |
| 69 | { name: "clock", of: (run) => run.clock }, | |
| 70 | { name: "score", of: (run) => run.score }, | |
| 71 | { name: "hits", of: (run) => run.hits }, | |
| 72 | { name: "shots", of: (run) => run.shots }, | |
| 73 | ]; | |
| 74 | ||
| 75 | /** | |
| 76 | * Everything hashed about one standing plate: where its centre is, how big it | |
| 77 | * is, how much of its lifetime is left and what hitting it is worth. Its `span` | |
| 78 | * and its `distance` are left out on purpose — both are arithmetic on what is | |
| 79 | * already here, and a digest that hashes a number twice is no stricter. | |
| 80 | */ | |
| 81 | export const PLATE_FIELDS: { name: string; of: (plate: Target) => number }[] = [ | |
| 82 | { name: "centre.x", of: (p) => (p.box.min[0] + p.box.max[0]) / 2 }, | |
| 83 | { name: "centre.y", of: (p) => (p.box.min[1] + p.box.max[1]) / 2 }, | |
| 84 | { name: "centre.z", of: (p) => (p.box.min[2] + p.box.max[2]) / 2 }, | |
| 85 | { name: "half.x", of: (p) => (p.box.max[0] - p.box.min[0]) / 2 }, | |
| 86 | { name: "half.y", of: (p) => (p.box.max[1] - p.box.min[1]) / 2 }, | |
| 87 | { name: "half.z", of: (p) => (p.box.max[2] - p.box.min[2]) / 2 }, | |
| 88 | { name: "life", of: (p) => p.life }, | |
| 89 | { name: "points", of: (p) => p.points }, | |
| 90 | ]; | |
| 91 | ||
| 92 | /** | |
| 93 | * The state of one tick as the bytes that go into the hash: the run's fields, | |
| 94 | * how many plates stand, and each of them — sorted by the very string that is | |
| 95 | * hashed, so the digest says which plates are up and not which order a list | |
| 96 | * happens to hold them in. | |
| 97 | */ | |
| 98 | export function snapshot(run: Run): string { | |
| 99 | const head = RUN_FIELDS.map((field) => q(field.of(run))); | |
| 100 | const plates = run.targets | |
| 101 | .map((plate) => PLATE_FIELDS.map((field) => q(field.of(plate))).join(",")) | |
| 102 | .sort(); | |
| 103 | return `${head.join(",")}|${plates.length}|${plates.join(";")}\n`; | |
| 104 | } | |
| 105 | ||
| 106 | // --- the script ----------------------------------------------------------- | |
| 107 | ||
| 108 | /** | |
| 109 | * One trigger pull: on `tick`, looking `yaw` degrees around and `pitch` degrees | |
| 110 | * down. It is the whole of what a person does at the range — there is nothing | |
| 111 | * to walk and nothing to buy — and the shot goes down the barrel the same way | |
| 112 | * `main.ts` sends it, through the engine's `angleVectors`. | |
| 113 | */ | |
| 114 | export type Aim = { tick: number; yaw: number; pitch: number }; | |
| 115 | ||
| 116 | /** | |
| 117 | * Thirty seconds of somebody shooting the seeded lane: fourteen shots taken on | |
| 118 | * the plate that had the longest left to stand at that tick, and three that | |
| 119 | * miss on purpose — two aimed six degrees wide of a plate that was standing, | |
| 120 | * and one at sixty degrees of sky, where the range has nothing at all. The | |
| 121 | * misses are the half of the pin the hits cannot hold: they keep the shot | |
| 122 | * counter, the accuracy and the trace's own "nothing here" answer in the digest. | |
| 123 | * | |
| 124 | * The angles are the ones an aim at those plates came to, rounded to a | |
| 125 | * hundredth of a degree — a plate at the end of the lane is 1.7° wide, so the | |
| 126 | * rounding is nowhere near the edge of one. They are constants now: this script | |
| 127 | * never looks at the run, which is what makes it a script and not a bot. | |
| 128 | */ | |
| 129 | export const SCRIPT: Aim[] = [ | |
| 130 | { tick: 40, yaw: -11.37, pitch: -3.88 }, // hit, 191 points | |
| 131 | { tick: 130, yaw: -14.55, pitch: -2.13 }, // hit, 147 | |
| 132 | { tick: 220, yaw: 37.8, pitch: -3.13 }, // hit, 106 | |
| 133 | { tick: 300, yaw: -2.04, pitch: 1.33 }, // 6° wide of the plate at tick 300: a miss | |
| 134 | { tick: 320, yaw: -8.04, pitch: 1.33 }, // hit, 179 — the same plate, aimed at | |
| 135 | { tick: 430, yaw: 33.2, pitch: -6.14 }, // hit, 115 | |
| 136 | { tick: 540, yaw: -19.21, pitch: -0.29 }, // hit, 184 | |
| 137 | { tick: 650, yaw: -39.23, pitch: -3.93 }, // hit, 118 | |
| 138 | { tick: 700, yaw: 0, pitch: -60 }, // sky: a miss with nothing anywhere near it | |
| 139 | { tick: 760, yaw: -6.86, pitch: 2 }, // hit, 136 | |
| 140 | { tick: 870, yaw: -8.31, pitch: 1.82 }, // hit, 180 | |
| 141 | { tick: 980, yaw: 12.85, pitch: -0.08 }, // hit, 177 | |
| 142 | { tick: 1000, yaw: 5.8, pitch: -2.72 }, // 6° wide again: a miss | |
| 143 | { tick: 1090, yaw: -21.41, pitch: 0.26 }, // hit, 166 | |
| 144 | { tick: 1200, yaw: 15.74, pitch: 0.85 }, // hit, 188 | |
| 145 | { tick: 1310, yaw: -0.73, pitch: -0.7 }, // hit, 166 | |
| 146 | { tick: 1420, yaw: 11.77, pitch: 0.84 }, // hit, 172 | |
| 147 | ]; | |
| 148 | ||
| 149 | /** | |
| 150 | * A cursor over the script: hand it a tick and it fires whatever that tick | |
| 151 | * holds, in order. A tick with no aim on it is a tick with the trigger off, | |
| 152 | * which is most of them. | |
| 153 | */ | |
| 154 | export function trigger(run: Run, script: readonly Aim[] = SCRIPT): (tick: number) => void { | |
| 155 | let at = 0; | |
| 156 | return (tick: number): void => { | |
| 157 | while (at < script.length && (script[at] as Aim).tick <= tick) { | |
| 158 | const aim = script[at++] as Aim; | |
| 159 | shoot(run, EYE, angleVectors(aim.yaw, aim.pitch).forward); | |
| 160 | } | |
| 161 | }; | |
| 162 | } | |
| 163 | ||
| 164 | // --- the run -------------------------------------------------------------- | |
| 165 | ||
| 166 | export type Pinned = { | |
| 167 | /** sha256 of every tick of the run, hex. */ | |
| 168 | digest: string; | |
| 169 | /** One short digest per whole second, for finding where two runs part. */ | |
| 170 | checkpoints: string[]; | |
| 171 | ticks: number; | |
| 172 | }; | |
| 173 | ||
| 174 | /** How much of each second's sha256 is kept. 48 bits is plenty to point a finger. */ | |
| 175 | export const CHECKPOINT_CHARS = 12; | |
| 176 | ||
| 177 | /** | |
| 178 | * Fold `ticks` ticks of `run` into a digest, checkpointing every whole second. | |
| 179 | * `step` advances the game however its caller advances it — this only reads the | |
| 180 | * state afterwards — so a run stepped some other way (a bench, a bot, a second | |
| 181 | * script) is pinned by handing its stepper in. | |
| 182 | */ | |
| 183 | export function fold(run: Run, ticks: number, step: (tick: number) => void): Pinned { | |
| 184 | const whole = new Bun.CryptoHasher("sha256"); | |
| 185 | let second = new Bun.CryptoHasher("sha256"); | |
| 186 | const checkpoints: string[] = []; | |
| 187 | for (let tick = 0; tick < ticks; tick++) { | |
| 188 | step(tick); | |
| 189 | const line = snapshot(run); | |
| 190 | whole.update(line); | |
| 191 | second.update(line); | |
| 192 | if ((tick + 1) % TICKS_PER_SECOND === 0) { | |
| 193 | checkpoints.push(second.digest("hex").slice(0, CHECKPOINT_CHARS)); | |
| 194 | second = new Bun.CryptoHasher("sha256"); | |
| 195 | } | |
| 196 | } | |
| 197 | return { digest: whole.digest("hex"), checkpoints, ticks }; | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * The whole game: the run's clock and plates stepped a tick, then the script's | |
| 202 | * trigger, in that order — which is the order `main.ts` does it in, the plates | |
| 203 | * having moved before the bullet leaves. The run's own end condition ends it: | |
| 204 | * step past the clock and the phase is `over`, the lane is empty and the digest | |
| 205 | * keeps hashing a game that is finished, exactly as the page would hold it. | |
| 206 | */ | |
| 207 | export function play(run: Run, ticks: number, script: readonly Aim[] = SCRIPT): Pinned { | |
| 208 | const fire = trigger(run, script); | |
| 209 | return fold(run, ticks, (tick) => { | |
| 210 | advance(run, TICK); | |
| 211 | fire(tick); | |
| 212 | }); | |
| 213 | } | |
| 214 | ||
| 215 | // --- comparing ------------------------------------------------------------ | |
| 216 | ||
| 217 | /** A digest committed in the repository, and what to call it when it moves. */ | |
| 218 | export type Pin = { label: string; constant: string; digest: string; checkpoints: readonly string[] }; | |
| 219 | ||
| 220 | const list = (checkpoints: readonly string[]): string => { | |
| 221 | const lines: string[] = []; | |
| 222 | for (let i = 0; i < checkpoints.length; i += 4) | |
| 223 | lines.push(` ${checkpoints.slice(i, i + 4).map((s) => `"${s}"`).join(", ")},`); | |
| 224 | return `[\n${lines.join("\n")}\n]`; | |
| 225 | }; | |
| 226 | ||
| 227 | /** | |
| 228 | * Why a run and its pin differ, or null if they do not. A digest that only says | |
| 229 | * "no" is a wall: this says which second of the run first went another way, | |
| 230 | * which ticks that was, how much of the run went with it, and prints the | |
| 231 | * constants to paste once the change is the intended one. | |
| 232 | */ | |
| 233 | export function compare(run: Pinned, pin: Pin): string | null { | |
| 234 | if (run.digest === pin.digest && run.checkpoints.length === pin.checkpoints.length) return null; | |
| 235 | const count = Math.max(run.checkpoints.length, pin.checkpoints.length); | |
| 236 | let first = -1; | |
| 237 | let differ = 0; | |
| 238 | for (let i = 0; i < count; i++) { | |
| 239 | if (run.checkpoints[i] === pin.checkpoints[i]) continue; | |
| 240 | if (first < 0) first = i; | |
| 241 | differ += 1; | |
| 242 | } | |
| 243 | const where = | |
| 244 | first < 0 | |
| 245 | ? "every second matches, so the divergence is in the length of the run" | |
| 246 | : `second ${first + 1} of ${count} differs — the game changed there ` + | |
| 247 | `(ticks ${first * TICKS_PER_SECOND}..${(first + 1) * TICKS_PER_SECOND - 1}): ` + | |
| 248 | `pinned ${pin.checkpoints[first] ?? "(nothing)"}, ran ${run.checkpoints[first] ?? "(nothing)"}. ` + | |
| 249 | `${differ} of ${count} seconds differ.`; | |
| 250 | return ( | |
| 251 | `The ${pin.label} no longer matches the digest pinned in src/range/pin.test.ts: ${where}\n` + | |
| 252 | `The run hashes ${RUN_FIELDS.map((f) => f.name).join(", ")} and, per standing plate, ` + | |
| 253 | `${PLATE_FIELDS.map((f) => f.name).join(", ")}.\n` + | |
| 254 | `If this change to the game is deliberate, say so in the pull and paste these in:\n\n` + | |
| 255 | `const ${pin.constant} = "${run.digest}";\n` + | |
| 256 | `const ${pin.constant}_SECONDS = ${list(run.checkpoints)};\n` | |
| 257 | ); | |
| 258 | } |
new above
offline