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

forked from trevato/cs · proposes · 4 behind

The determinism harness becomes engine: one folded digest behind the door, and both games' pins hold to the byte #139

Merged opened this

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

Merged

torque/cs-engine:torque/110-the-determinism-harness-becomes-engine-o → main 1 commit fork

  • Merged. torque/110-the-determinism-harness-becomes-engine-o was merged into main as 496e963.

Review

Sign in to review this change.
14 files changed +621 −183
README.md +14 −6
@@ -185,7 +185,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
185185 categorical head per choice it may make, and int8 packing for the weights a
186186 page downloads — the arithmetic only, with nothing about aiming, weapons or
187187 sides in it; what an observation holds is `observe` and what a head means is
188 `action`, and both stay with the game).
188 `action`, and both stay with the game) and `digest` (a run folded into a
189 hash: the quantiser, the fold with a checkpoint every so many ticks, the pin
190 and the complaint that says which span two runs parted at — the tool both
191 games pin themselves with, and the one no browser loads).
189192 Three rules keep it a package, and a test holds each: the engine imports
190193 nothing outside itself, it needs nothing from a browser to load, and
191194 everything outside it comes in through one door.
@@ -221,8 +224,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
221224 they are driven with; from `overlay` `overlay`, `Overlay` and the `Paint`
222225 a 2D game draws onto; from `scene` the frame and its pool — `createScene`,
223226 `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera`
224 and `Instance`; and from `renderer` `createRenderer` with `Renderer` and
225 `RendererAssets`, which draws that frame.
227 and `Instance`; from `renderer` `createRenderer` with `Renderer` and
228 `RendererAssets`, which draws that frame; and from `digest` `QUANTUM`,
229 `quantise`, `CHECKPOINT_CHARS`, `fold`, `compare`, `Fold`, `Folded`, `Pin`
230 and `Complaint`, which both games' pinned runs are folded by.
226231 `boundary.test.ts` fails a deep import from outside, a
227232 module that is not behind the door, and any change to that list of names that
228233 is not also a change to the list written out in the test. One exception, and
@@ -384,8 +389,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
384389 atlas and draws the room — so a bundling mistake fails
385390 `bun test` rather than the preview. The range's rules are pinned the way the
386391 shooter's round is: `pin.ts` plays one seeded thirty-second run per drill
387 through one fixed script of shots each and hashes every tick, against the two
388 digests committed in `pin.test.ts` — so a renderer, an atlas or a movement
392 through one fixed script of shots each and folds every tick through the
393 engine's `digest`, against the two digests committed in `pin.test.ts` — so a renderer, an atlas or a movement
389394 tuning that changes the game says so, naming the drill and the second of the
390395 run where it changed. A drill is data: one table in `targets.ts` holds the
391396 spawn rate, the lifetime, the speed range and the scoring of each, `createRun`
@@ -440,7 +445,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
440445 digest has changed behaviour and is not an extraction, while a deliberate
441446 change to the simulation updates the digest in the same pull and says what
442447 moved; a failure names the first second at which the two runs part and prints
443 the constants to paste.
448 the constants to paste. The folding itself is `src/engine/digest.ts` — the
449 quantiser, the fold, the checkpoints and the complaint, which this file and
450 `src/range/pin.ts` had each written out in full until #110 put one copy behind
451 the door; what stays here is the script, the fields and the 64 Hz.
444452 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
445453 schema and its queries, `src/result.ts` validates what a browser may claim,
446454 `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
src/client/replay.test.ts +2 −1
@@ -22,8 +22,9 @@
2222 import { expect, test } from "bun:test";
2323 import { DE_FORGE } from "./map.ts";
2424 import { SHORT } from "./match.ts";
25 import { CHECKPOINT_CHARS } from "../engine/index.ts";
2526 import {
26 CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
27 compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
2728 withoutEntropy, type Pin, type Run,
2829 } from "./replay.ts";
2930 import { createWorld, type Player } from "./world.ts";
src/client/replay.ts +35 −71
@@ -13,8 +13,14 @@
1313 * MATCH_FIELDS below are the whole list. Effects, the kill feed and the
1414 * messages are presentation: they would make the digest fragile and would
1515 * catch nothing the state does not already catch.
16 *
17 * The folding itself is `src/engine/digest.ts`, because the range needed the
18 * same instrument and may not import this file. What stays here is what is the
19 * shooter's: the script, the fields above, the 64 Hz tick and the claim that a
20 * digest which moves is behaviour that moved.
1621 */
1722
23 import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts";
1824 import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts";
1925 import type { MatchState, Phase } from "./match.ts";
2026
@@ -24,25 +30,11 @@ export const TICKS_PER_SECOND = 64;
2430 // --- what is hashed -------------------------------------------------------
2531
2632 /**
27 * Floats are quantised before they are hashed: every value is multiplied by
28 * 1e4, rounded to an integer and written in decimal.
29 *
30 * Why, and not just what: the simulation runs on Math.hypot, Math.atan2,
31 * Math.sin and Math.exp, and those are library functions, not IEEE-754
32 * operations — the standard does not pin their last bit, so two CPUs (or two
33 * builds of the same libm) may disagree by an ulp on a trig call. Hashing the
34 * raw doubles would make this test fail on somebody else's laptop for a reason
35 * that is not a bug. A ten-thousandth of a Source unit is about 2.5 microns of
36 * map and a ten-thousandth of a degree of aim: far below anything the game can
37 * feel, and far above the noise. A real behaviour change moves a position by
38 * whole units within a tick or two, so it still shows up.
33 * What is hashed is the simulation's numbers, quantised by the engine's
34 * `quantise` before they go in — a ten-thousandth of a Source unit, so a libm
35 * that disagrees in its last bit on a trig call is not a failed run and a
36 * behaviour change still is. `src/engine/digest.ts` says why at length.
3937 */
40 export const QUANTUM = 1e-4;
41 const SCALE = 1 / QUANTUM;
42
43 /** One number, ready to hash. Non-finite values are written as themselves. */
44 const q = (x: number): string => (Number.isFinite(x) ? String(Math.round(x * SCALE)) : `!${x}`);
45
4638 const PHASES: Phase[] = ["freeze", "live", "planted", "over", "match_over"];
4739
4840 /** Everything hashed about a player, in the order it is written. */
@@ -73,8 +65,8 @@ export const MATCH_FIELDS: { name: string; of: (m: MatchState) => number }[] = [
7365 /** The state of one tick as the bytes that go into the hash. */
7466 export function snapshot(world: World): string {
7567 const parts: string[] = [];
76 for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(q(field.of(p)));
77 for (const field of MATCH_FIELDS) parts.push(q(field.of(world.match)));
68 for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(quantise(field.of(p)));
69 for (const field of MATCH_FIELDS) parts.push(quantise(field.of(world.match)));
7870 return `${parts.join(",")}\n`;
7971 }
8072
@@ -158,31 +150,20 @@ export type Run = {
158150 ticks: number;
159151 };
160152
161 /** How much of each second's sha256 is kept. 48 bits is plenty to point a finger. */
162 export const CHECKPOINT_CHARS = 12;
163
164153 /**
165154 * Fold `ticks` ticks of `world` into a digest, checkpointing every second.
166155 * `step` advances the simulation however its environment advances it; this
167156 * only reads the state afterwards. The training environment in `src/rl` steps
168157 * its own way, and gets pinned by handing its stepper in — `src/client` never
169158 * imports `src/rl`, and this module is not going to be the file that starts.
159 *
160 * The arithmetic under it is the engine's `fold`: this hands it the rate, the
161 * stepper and a line of bytes per tick, and renames its checkpoints to the
162 * seconds this game counts in.
170163 */
171164 export function fold(world: World, ticks: number, step: (tick: number) => void): Run {
172 const whole = new Bun.CryptoHasher("sha256");
173 let second = new Bun.CryptoHasher("sha256");
174 const seconds: string[] = [];
175 for (let tick = 0; tick < ticks; tick++) {
176 step(tick);
177 const line = snapshot(world);
178 whole.update(line);
179 second.update(line);
180 if ((tick + 1) % TICKS_PER_SECOND === 0) {
181 seconds.push(second.digest("hex").slice(0, CHECKPOINT_CHARS));
182 second = new Bun.CryptoHasher("sha256");
183 }
184 }
185 return { digest: whole.digest("hex"), seconds, ticks };
165 const run = foldTicks({ ticks, step, line: () => snapshot(world), every: TICKS_PER_SECOND });
166 return { digest: run.digest, seconds: run.checkpoints, ticks: run.ticks };
186167 }
187168
188169 /** The whole game: ten players, the match rules, the bomb, the bots. */
@@ -196,43 +177,26 @@ export function digest(world: World, ticks: number, script: readonly Beat[] = SC
196177 /** A digest committed in the repository, and what to call it when it moves. */
197178 export type Pin = { label: string; constant: string; digest: string; seconds: readonly string[] };
198179
199 const list = (seconds: readonly string[]): string => {
200 const lines: string[] = [];
201 for (let i = 0; i < seconds.length; i += 4)
202 lines.push(` ${seconds.slice(i, i + 4).map((s) => `"${s}"`).join(", ")},`);
203 return `[\n${lines.join("\n")}\n]`;
204 };
205
206180 /**
207 * Why a run and its pin differ, or null if they do not. A digest that only
208 * says "no" is a wall: this says which second of play first went another way,
209 * how much of the run went with it, and prints the constants to paste once the
210 * change is the intended one.
181 * Why a run and its pin differ, or null if they do not. The engine's `compare`
182 * finds the second that first went another way and prints the constants to
183 * paste; what this file adds is what the engine cannot know — the file the
184 * digests are committed in, the names of those constants, that a checkpoint
185 * here is a second of sixty-four ticks, and the fields that went into the hash.
211186 */
212187 export function compare(run: Run, pin: Pin): string | null {
213 if (run.digest === pin.digest && run.seconds.length === pin.seconds.length) return null;
214 const count = Math.max(run.seconds.length, pin.seconds.length);
215 let first = -1;
216 let differ = 0;
217 for (let i = 0; i < count; i++) {
218 if (run.seconds[i] === pin.seconds[i]) continue;
219 if (first < 0) first = i;
220 differ += 1;
221 }
222 const where =
223 first < 0
224 ? "every second matches, so the divergence is in the length of the run"
225 : `the first second that differs is second ${first + 1} of ${count} ` +
226 `(ticks ${first * TICKS_PER_SECOND}..${(first + 1) * TICKS_PER_SECOND - 1}): ` +
227 `pinned ${pin.seconds[first] ?? "(nothing)"}, ran ${run.seconds[first] ?? "(nothing)"}. ` +
228 `${differ} of ${count} seconds differ.`;
229 return (
230 `The ${pin.label} replay no longer matches the digest pinned in src/client/replay.test.ts: ${where}\n` +
231 `Look at what changed between there and here — the run hashes ${PLAYER_FIELDS.map((f) => f.name).join(", ")} ` +
232 `per player and ${MATCH_FIELDS.map((f) => f.name).join(", ")}.\n` +
233 `If this change to the simulation is deliberate, say so in the pull and paste these in:\n\n` +
234 `const ${pin.constant} = "${run.digest}";\n` +
235 `const ${pin.constant}_SECONDS = ${list(run.seconds)};\n`
188 return complain(
189 { digest: run.digest, checkpoints: run.seconds, ticks: run.ticks },
190 { label: `${pin.label} replay`, constant: pin.constant, digest: pin.digest, checkpoints: pin.seconds },
191 {
192 file: "src/client/replay.test.ts",
193 list: `${pin.constant}_SECONDS`,
194 hashes:
195 `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player ` +
196 `and ${MATCH_FIELDS.map((f) => f.name).join(", ")}`,
197 every: TICKS_PER_SECOND,
198 unit: "second",
199 },
236200 );
237201 }
238202
src/engine/GUIDE.md +38
@@ -413,6 +413,43 @@ half lives beside your entry: `src/client/stage.test-helper.ts` and
413413 It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts`
414414 reads both committed bundles to say so.
415415
416 ### digest
417
418 How you pin your game, so that "it plays exactly as before" is a number rather
419 than a claim. Like `fakePage`, this is a tool for a test or a bench, never for a
420 frame: no committed bundle carries a byte of it, and `digest.test.ts` reads the
421 files to say so. Both games here are pinned by it — `src/client/replay.ts` at
422 64 Hz and `src/range/pin.ts` at 50 Hz — and a third is ten lines away.
423
424 **What you bring is a line of bytes per tick.** Write a snapshot of your own —
425 `replay.snapshot` and `pin.snapshot` are the two here: the fields your game
426 promises a player, each through `quantise` and joined however you like. `quantise` is ten-thousandths, rounded, in decimal — `QUANTUM` is that
427 grid — because a simulation runs on trig calls whose last bit no standard pins,
428 and a digest that fails on somebody else's libm is a digest nobody trusts. A
429 value that is not finite is written `!NaN` or `!Infinity` rather than rounded to
430 zero, so a state that has gone wrong is caught rather than hidden. What you leave
431 out matters as much: effects, feeds and anything a renderer decides are
432 presentation, and hashing them makes a pin that moves when nothing did.
433
434 **Then one call folds the run.** `fold` takes a `Fold` — how many ticks, a
435 stepper that advances your game however your game is advanced, that snapshot
436 call as a line, and how many ticks one checkpoint covers — and hands back a
437 `Folded`: the sha256 of every tick, one short digest per whole span
438 (`CHECKPOINT_CHARS` of it, which is 48 bits: enough to point a finger), and the
439 tick count. The engine never sees your state, your rate or your rules; a run
440 stepped some other way — a bench, a bot, a training arena — is pinned by handing
441 its stepper in.
442
443 **And your test holds the numbers.** Commit the digest and the checkpoints as
444 two constants, wrap them in a `Pin` with a label, and call `compare` with a
445 `Complaint`: the file they live in, the name of the list constant, a sentence
446 naming the fields you hashed, how many ticks a span is and what you call one —
447 both games say "second". What comes back is null or the whole story: which span
448 first went another way, which ticks that was, how many differ, what was hashed,
449 and the two constants to paste when the change was deliberate. Paste them only
450 then, and say in the pull what moved; a digest updated without that sentence is
451 a pin that has stopped pinning anything.
452
416453 ### net
417454
418455 A small policy network: an observation in, one choice per categorical head out.
@@ -525,6 +562,7 @@ own tuning, and nothing else in this table at all.
525562 | sound recipes | `src/client/audio.ts` | none yet |
526563 | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` |
527564 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
565 | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill |
528566 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
529567 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
530568 | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net |
src/engine/boundary.test.ts +17 −12
@@ -278,25 +278,30 @@ test("the example imports the door and nothing else, which is what it is for", (
278278 * `backward`, `Adam`, `createAdam`, `applyGrads`, `Rollout`, `createRollout`,
279279 * `computeAdvantages`, `update`, `UpdateStats`.
280280 *
281 * digest — `QUANTUM`, `quantise`, `CHECKPOINT_CHARS`, `Fold`, `fold`,
282 * `Folded`, `Pin`, `Complaint`, `compare`.
283 *
281284 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
282285 * copy of the surface, read twice, rather than two that drift.
283286 */
284287 export const SURFACE = [
285 "Activations", "Adam", "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera",
286 "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas", "FakeDocument", "FakeElement", "FakePage",
287 "FakePaint", "FakeTarget", "FakeWindow", "Frames", "Grads", "Grid", "Hit", "Host", "Hyper",
288 "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh",
289 "MouseEventish", "Options", "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Policy",
288 "Activations", "Adam", "Box", "Builder", "Burst", "Button", "ButtonOptions",
289 "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas",
290 "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
291 "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hyper", "Instance", "KeyEventish",
292 "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "Options",
293 "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM",
290294 "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape",
291295 "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3",
292296 "add", "addBox", "angleDelta", "angleVectors", "applyGrads", "attachButtons", "attenuate",
293 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "computeAdvantages",
294 "copyPolicy", "createAdam", "createControls", "createLoop", "createPolicy", "createRenderer",
295 "createRollout", "createScene", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot",
296 "fakePage", "fbm", "findPath", "finish", "forward", "greedy", "hash", "headOffset", "inside",
297 "length", "length2d", "lerp", "logitCount", "mat4", "mergeSolid", "modelMatrix", "mul",
298 "multiply", "newActivations", "nextInstance", "noise", "normalize", "overlay", "pack",
299 "paintAtlas", "painter", "paramCount", "perspective", "pushLine", "quadMesh", "rad", "rayBox",
297 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare",
298 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createLoop",
299 "createPolicy", "createRenderer", "createRollout", "createScene", "createSoundDevice",
300 "cross", "deg", "dist", "dist2d", "dot", "fakePage", "fbm", "findPath", "finish", "fold",
301 "forward", "greedy", "hash", "headOffset", "inside", "length", "length2d", "lerp",
302 "logitCount", "mat4", "mergeSolid", "modelMatrix", "mul", "multiply", "newActivations",
303 "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter",
304 "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox",
300305 "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "sub",
301306 "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf",
302307 "zeroGrads", "zeroPolicy",
src/engine/digest.test.ts added +250
@@ -0,0 +1,250 @@
1 /**
2 * The tool, pinned on a state that is not a game.
3 *
4 * Everything in here is a counter with a drifting float and a label on it, a
5 * stepper written three lines above the test that uses it, and a checkpoint
6 * that covers four ticks because four is a number and not a second. That is
7 * deliberate: `src/client/replay.ts` and `src/range/pin.ts` already hold this
8 * module against ten bots and a lane of plates, and what those two cannot say
9 * is that the fold knows nothing about either of them. If a test in here needs
10 * a player, a plate, a round or a tick rate, something has leaked.
11 *
12 * The one thing that is about this repository rather than about the tool is the
13 * last test: no committed bundle carries a byte of this file. A digest is for a
14 * test or a bench, never for a frame, which is why reaching for
15 * `Bun.CryptoHasher` from a module behind a browser-facing door is fair.
16 */
17
18 import { expect, test } from "bun:test";
19 import { readFileSync, readdirSync } from "node:fs";
20 import { join, resolve } from "node:path";
21 import {
22 CHECKPOINT_CHARS, compare, fold, quantise, QUANTUM, UNBUNDLED, type Complaint, type Folded,
23 type Pin,
24 } from "./digest.ts";
25
26 const ROOT = resolve(import.meta.dir, "..", "..");
27
28 // --- a state that is not a game -------------------------------------------
29
30 /** A counter, a float that drifts and a word: no game anywhere in it. */
31 type Counter = { at: number; drift: number; label: string };
32
33 const counter = (): Counter => ({ at: 0, drift: 0, label: "toy" });
34
35 /** The state as bytes, the way a caller writes one: its own fields, its own order. */
36 const line = (state: Counter): string =>
37 `${quantise(state.at)},${quantise(state.drift)},${state.label}\n`;
38
39 /**
40 * A stepper the test writes: one tick on the counter and a sixteenth on the
41 * drift, plus an optional nudge on one named tick — which is how a divergence
42 * gets injected where the test can name it.
43 */
44 const stepper = (state: Counter, nudge?: { at: number; by: number }) => (tick: number): void => {
45 state.at = tick + 1;
46 state.drift += 0.0625;
47 if (nudge !== undefined && tick === nudge.at) state.drift += nudge.by;
48 };
49
50 /** Four ticks to a checkpoint: a span the caller chose, and not a second. */
51 const EVERY = 4;
52
53 const run = (ticks: number, nudge?: { at: number; by: number }): Folded => {
54 const state = counter();
55 return fold({ ticks, every: EVERY, step: stepper(state, nudge), line: () => line(state) });
56 };
57
58 /** What a complaint from this file says that the fold cannot know. */
59 const ABOUT: Complaint = {
60 file: "src/engine/digest.test.ts",
61 list: "TOY_BEATS",
62 hashes: "at, drift and label",
63 every: EVERY,
64 unit: "beat",
65 };
66
67 // --- quantising -----------------------------------------------------------
68
69 test("a number is hashed as ten-thousandths of itself, rounded", () => {
70 expect(QUANTUM).toBe(1e-4);
71 expect(quantise(0)).toBe("0");
72 expect(quantise(1)).toBe("10000");
73 expect(quantise(-2.5)).toBe("-25000");
74 // Half a quantum rounds the way Math.round rounds: up, towards +∞.
75 expect(quantise(0.00005)).toBe("1");
76 expect(quantise(0.00004)).toBe("0");
77 expect(quantise(-0.00005)).toBe("0"); // Math.round(-0.5) is -0, and -0 is written "0"
78 expect(quantise(-0.00006)).toBe("-1");
79 // A thousandth of a quantum is noise and is not written down; a quantum is.
80 expect(quantise(123.4567)).toBe(quantise(123.4567 + QUANTUM / 1000));
81 expect(quantise(123.4567)).not.toBe(quantise(123.4567 + QUANTUM));
82 });
83
84 test("a number that is not a number is written as itself, with a bang", () => {
85 expect(quantise(NaN)).toBe("!NaN");
86 expect(quantise(Infinity)).toBe("!Infinity");
87 expect(quantise(-Infinity)).toBe("!-Infinity");
88 // And is not quietly hashed as zero, which rounding it would have done.
89 expect(quantise(NaN)).not.toBe(quantise(0));
90 });
91
92 // --- folding --------------------------------------------------------------
93
94 test("a fold hashes every tick and keeps a short digest every whole span", () => {
95 const folded = run(10);
96 expect(folded.ticks).toBe(10);
97 expect(folded.digest).toMatch(/^[0-9a-f]{64}$/);
98 // Ten ticks at four to a checkpoint is two checkpoints: the trailing two
99 // ticks are in the whole digest and in no checkpoint of their own.
100 expect(folded.checkpoints.length).toBe(2);
101 for (const checkpoint of folded.checkpoints) {
102 expect(checkpoint.length).toBe(CHECKPOINT_CHARS);
103 expect(checkpoint).toMatch(/^[0-9a-f]+$/);
104 }
105 expect(folded.checkpoints[0]).not.toBe(folded.checkpoints[1]);
106 });
107
108 test("the same run is the same digest, and a longer run keeps the shorter one's checkpoints", () => {
109 expect(run(12).digest).toBe(run(12).digest);
110 expect(run(12).checkpoints.slice(0, 2)).toEqual(run(8).checkpoints);
111 expect(run(12).digest).not.toBe(run(8).digest);
112 });
113
114 test("the step comes before the read, once each, in tick order", () => {
115 const seen: string[] = [];
116 const state = counter();
117 fold({
118 ticks: 3,
119 every: EVERY,
120 step: (tick) => {
121 state.at = tick + 1;
122 seen.push(`step ${tick}`);
123 },
124 line: () => {
125 seen.push(`read ${state.at}`);
126 return line(state);
127 },
128 });
129 expect(seen).toEqual(["step 0", "read 1", "step 1", "read 2", "step 2", "read 3"]);
130 });
131
132 test("a fold of no ticks is a run: nothing stepped, nothing checkpointed", () => {
133 let stepped = 0;
134 const folded = fold({ ticks: 0, every: EVERY, step: () => (stepped += 1), line: () => "x" });
135 expect(stepped).toBe(0);
136 expect(folded.ticks).toBe(0);
137 expect(folded.checkpoints).toEqual([]);
138 // sha256 of nothing at all, which is what a run of nothing hashes to.
139 expect(folded.digest).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
140 });
141
142 test("a checkpoint covers a whole number of ticks, or the fold says so", () => {
143 const bad = (every: number): (() => Folded) => () =>
144 fold({ ticks: 4, every, step: () => {}, line: () => "x" });
145 expect(bad(0)).toThrow(/whole number of ticks/);
146 expect(bad(-4)).toThrow(/whole number of ticks/);
147 expect(bad(2.5)).toThrow(/whole number of ticks/);
148 expect(bad(1)).not.toThrow();
149 });
150
151 // --- comparing ------------------------------------------------------------
152
153 test("a run that matches its pin is no complaint at all", () => {
154 const folded = run(12);
155 const pin: Pin = {
156 label: "toy run", constant: "TOY", digest: folded.digest, checkpoints: folded.checkpoints,
157 };
158 expect(compare(folded, pin, ABOUT)).toBeNull();
159 });
160
161 test("a divergence at a known tick is found at the span that holds it", () => {
162 const pinned = run(20);
163 const pin: Pin = {
164 label: "toy run", constant: "TOY", digest: pinned.digest, checkpoints: pinned.checkpoints,
165 };
166 // A nudge on tick 9, which is in the third span of four: ticks 8..11.
167 const nudged = run(20, { at: 9, by: 1 });
168 const complaint = compare(nudged, pin, ABOUT) as string;
169 expect(complaint).toContain("beat 3 of 5 differs");
170 expect(complaint).toContain("ticks 8..11");
171 // The drift carries, so every span after the third differs and none before it.
172 expect(complaint).toContain("3 of 5 beats differ");
173 expect(nudged.checkpoints.slice(0, 2)).toEqual(pinned.checkpoints.slice(0, 2));
174 });
175
176 test("the complaint names the run, the file, the fields and the constants to paste", () => {
177 const pin: Pin = {
178 label: "toy run", constant: "TOY", digest: "aaaa",
179 checkpoints: ["one0000000000", "two0000000000", "three000000000"],
180 };
181 const folded: Folded = {
182 digest: "bbbb", ticks: 12,
183 checkpoints: ["one0000000000", "changed000000", "three000000000"],
184 };
185 const complaint = compare(folded, pin, ABOUT) as string;
186 expect(complaint).toContain("The toy run no longer matches the digest pinned in src/engine/digest.test.ts");
187 expect(complaint).toContain("beat 2 of 3 differs");
188 expect(complaint).toContain("ticks 4..7");
189 expect(complaint).toContain("pinned two0000000000, ran changed000000");
190 expect(complaint).toContain("1 of 3 beats differ");
191 expect(complaint).toContain("The run hashes at, drift and label.");
192 expect(complaint).toContain('const TOY = "bbbb";');
193 expect(complaint).toContain('const TOY_BEATS = [\n "one0000000000", "changed000000", "three000000000",\n];');
194 });
195
196 test("a paste-able list is four to a line, however long the run was", () => {
197 const checkpoints = Array.from({ length: 9 }, (_, i) => `${i}`.repeat(CHECKPOINT_CHARS));
198 const pin: Pin = { label: "toy run", constant: "TOY", digest: "aaaa", checkpoints: [] };
199 const complaint = compare({ digest: "bbbb", ticks: 36, checkpoints }, pin, ABOUT) as string;
200 const printed = complaint.slice(complaint.indexOf("const TOY_BEATS"));
201 expect(printed.split("\n").filter((l) => l.includes('"')).length).toBe(3);
202 expect(printed).toContain(` "${checkpoints[8]}",\n]`);
203 });
204
205 test("a run that differs only past its last checkpoint says so rather than pointing nowhere", () => {
206 const pin: Pin = {
207 label: "toy run", constant: "TOY", digest: "aaaa", checkpoints: ["one0000000000"],
208 };
209 const complaint = compare({ digest: "bbbb", ticks: 6, checkpoints: ["one0000000000"] }, pin, ABOUT) as string;
210 expect(complaint).toContain("every beat matches, so the divergence is in the length of the run");
211 });
212
213 test("a pin with more checkpoints than the run is a divergence, and names what is missing", () => {
214 const pin: Pin = {
215 label: "toy run", constant: "TOY", digest: "aaaa",
216 checkpoints: ["one0000000000", "two0000000000"],
217 };
218 const complaint = compare({ digest: "bbbb", ticks: 4, checkpoints: ["one0000000000"] }, pin, ABOUT) as string;
219 expect(complaint).toContain("beat 2 of 2 differs");
220 expect(complaint).toContain("pinned two0000000000, ran (nothing)");
221 });
222
223 // --- what ships -----------------------------------------------------------
224
225 /**
226 * The door re-exports a module no browser will ever load, and this is what says
227 * so: the committed bundles, read the way `page.test.ts` and `learn.test.ts`
228 * read them. A game plays; a digest is how a test says the game still plays the
229 * same way. If a byte of this lands in a file a browser downloads, the design
230 * is wrong and not this test.
231 */
232 test("no committed bundle carries the fold", () => {
233 const dir = join(ROOT, "static");
234 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
235 // Every game's entry, so this is not a test that passes on an empty directory.
236 expect(bundles.length).toBeGreaterThanOrEqual(3);
237 expect(bundles.some((f) => f.startsWith("app."))).toBe(true);
238 expect(bundles.some((f) => f.startsWith("range."))).toBe(true);
239 for (const file of bundles) {
240 const code = readFileSync(join(dir, file), "utf8");
241 // The reader is alive: a bundle is a bundle and not an empty file.
242 expect(code.length).toBeGreaterThan(1000);
243 // A minifier renames every identifier it can reach and keeps the bytes of a
244 // string, so these three are this module's fingerprint: the sentence it
245 // carries, the complaint it writes and the hasher only it asks for.
246 expect(code).not.toContain(UNBUNDLED);
247 expect(code).not.toContain("no longer matches the digest pinned in");
248 expect(code).not.toContain("CryptoHasher");
249 }
250 });
src/engine/digest.ts added +190
@@ -0,0 +1,190 @@
1 /**
2 * A run folded into a hash: quantise, fold, checkpoint, compare.
3 *
4 * Every refactor in this repository ends with the claim "it plays exactly as
5 * before", and nobody can check that by eye. This module makes it a number.
6 * Step a seeded state through a script nobody changes, fold a sha256 over what
7 * the state says after every tick, keep a short digest of each span of ticks on
8 * the way past, and compare the result with a digest committed in a file. A
9 * change that moved behaviour moves the digest; a change that meant to moves it
10 * on purpose, pastes the printed constants and says so in the pull.
11 *
12 * It was written twice before it was written here — once for the shooter in
13 * `src/client/replay.ts` and once for the range in `src/range/pin.ts`, because
14 * `src/range/` may not import `src/client/`, which is this repository's whole
15 * definition of what the engine is for. So the arithmetic is here and the
16 * *claim* is the game's: what a tick is worth, what a checkpoint covers, what
17 * fields are hashed, what the state even is, and which file the digest is
18 * committed in are all the caller's. This module never sees a player, a plate,
19 * a round or a second — it sees a number of ticks, a stepper, and a function
20 * that hands it a line of bytes.
21 *
22 * It ships in no bundle. Nothing a game bundles imports it — a digest is a tool
23 * for a test or a bench, not for a frame — and `digest.test.ts` reads every
24 * committed bundle to say so, which is also why reaching for `Bun.CryptoHasher`
25 * in here is fair: no browser ever loads this file.
26 */
27
28 /**
29 * A sentence that appears in this file and in no other. A minifier renames
30 * every identifier it can reach but keeps the bytes of a string, so
31 * `digest.test.ts` reads the committed bundles for this one: a fold that ever
32 * ends up in a file a browser downloads fails there.
33 */
34 export const UNBUNDLED = "src/engine/digest.ts: a run folded into a hash, in no bundle";
35
36 // --- quantising -----------------------------------------------------------
37
38 /**
39 * Floats are quantised before they are hashed: every value is multiplied by
40 * 1e4, rounded to an integer and written in decimal.
41 *
42 * Why, and not just what: a simulation runs on Math.hypot, Math.atan2, Math.sin
43 * and Math.exp, and those are library functions, not IEEE-754 operations — the
44 * standard does not pin their last bit, so two CPUs (or two builds of the same
45 * libm) may disagree by an ulp on a trig call. Hashing the raw doubles would
46 * make a pinned run fail on somebody else's laptop for a reason that is not a
47 * bug. A ten-thousandth of a Source unit is about 2.5 microns of map and a
48 * ten-thousandth of a degree of aim: far below anything a game can feel, and
49 * far above the noise. A real behaviour change moves a position by whole units
50 * within a tick or two, so it still shows up.
51 */
52 export const QUANTUM = 1e-4;
53 const SCALE = 1 / QUANTUM;
54
55 /**
56 * One number, ready to hash. Non-finite values are written as themselves —
57 * `!NaN`, `!Infinity`, `!-Infinity` — because a state that has gone to NaN is
58 * exactly the sort of thing a digest exists to catch, and because rounding one
59 * would quietly hash it as 0.
60 */
61 export const quantise = (x: number): string =>
62 Number.isFinite(x) ? String(Math.round(x * SCALE)) : `!${x}`;
63
64 // --- folding --------------------------------------------------------------
65
66 /** How much of each checkpoint's sha256 is kept. 48 bits is plenty to point a finger. */
67 export const CHECKPOINT_CHARS = 12;
68
69 /** A run, hashed. */
70 export type Folded = {
71 /** sha256 of every tick of the run, hex. */
72 digest: string;
73 /** One short digest per whole span of `Fold.every` ticks, for finding where two runs part. */
74 checkpoints: string[];
75 ticks: number;
76 };
77
78 /** What a caller hands over to have a run folded. */
79 export type Fold = {
80 /** How many ticks to step. */
81 ticks: number;
82 /** Advance the state one tick, however this state is advanced. */
83 step: (tick: number) => void;
84 /** What the state says after that step, as the bytes that go into the hash. */
85 line: () => string;
86 /** How many ticks one checkpoint covers. Both games here make it a second. */
87 every: number;
88 };
89
90 /**
91 * Fold `run.ticks` ticks into a digest, checkpointing every `run.every` ticks.
92 * `step` advances the state however its environment advances it and `line`
93 * reads it afterwards; this only hashes. A run stepped some other way — a
94 * bench, a bot, a second script, a training arena — is pinned by handing its
95 * stepper in, which is why neither of those is a parameter of a type here.
96 *
97 * A trailing part-checkpoint is not kept: what a run pins is whole spans, so
98 * folding 130 ticks at 50 a checkpoint gives two, and the thirty ticks after
99 * them are in `digest` and in no checkpoint. Zero ticks is a run: the digest is
100 * sha256 of nothing and the checkpoints are empty.
101 */
102 export function fold(run: Fold): Folded {
103 if (!Number.isInteger(run.every) || run.every < 1)
104 throw new Error(`a checkpoint covers a whole number of ticks, not ${run.every}`);
105 const whole = new Bun.CryptoHasher("sha256");
106 let span = new Bun.CryptoHasher("sha256");
107 const checkpoints: string[] = [];
108 const ticks = Math.max(0, run.ticks);
109 for (let tick = 0; tick < ticks; tick++) {
110 run.step(tick);
111 const line = run.line();
112 whole.update(line);
113 span.update(line);
114 if ((tick + 1) % run.every === 0) {
115 checkpoints.push(span.digest("hex").slice(0, CHECKPOINT_CHARS));
116 span = new Bun.CryptoHasher("sha256");
117 }
118 }
119 return { digest: whole.digest("hex"), checkpoints, ticks };
120 }
121
122 // --- comparing ------------------------------------------------------------
123
124 /** A digest committed in the repository, and what to call it when it moves. */
125 export type Pin = {
126 /** What this run is called in the first sentence of a complaint. */
127 label: string;
128 /** The constant the whole digest is committed as. */
129 constant: string;
130 digest: string;
131 checkpoints: readonly string[];
132 };
133
134 /**
135 * The half of a complaint that is the caller's: where the digest lives, what
136 * the caller calls the constants and the spans, and what went into the hash.
137 * None of it is knowable from a fold — this module has hashed lines of text.
138 */
139 export type Complaint = {
140 /** The file the digest is committed in, named so a reader can open it. */
141 file: string;
142 /** The constant the checkpoints are committed as, beside `Pin.constant`. */
143 list: string;
144 /** What the run hashed, as a sentence: the field names, in the caller's words. */
145 hashes: string;
146 /** How many ticks one checkpoint covers, for the tick range the message prints. */
147 every: number;
148 /** What one of those spans is called. Pluralised with an `s`; both games say "second". */
149 unit: string;
150 };
151
152 /** Four to a line, quoted and trailing-comma'd: a list to paste into a test. */
153 const list = (checkpoints: readonly string[]): string => {
154 const lines: string[] = [];
155 for (let i = 0; i < checkpoints.length; i += 4)
156 lines.push(` ${checkpoints.slice(i, i + 4).map((s) => `"${s}"`).join(", ")},`);
157 return `[\n${lines.join("\n")}\n]`;
158 };
159
160 /**
161 * Why a run and its pin differ, or null if they do not. A digest that only says
162 * "no" is a wall: this says which span of the run first went another way, which
163 * ticks that was, how much of the run went with it, what was hashed, and prints
164 * the constants to paste once the change is the intended one.
165 */
166 export function compare(run: Folded, pin: Pin, about: Complaint): string | null {
167 if (run.digest === pin.digest && run.checkpoints.length === pin.checkpoints.length) return null;
168 const count = Math.max(run.checkpoints.length, pin.checkpoints.length);
169 let first = -1;
170 let differ = 0;
171 for (let i = 0; i < count; i++) {
172 if (run.checkpoints[i] === pin.checkpoints[i]) continue;
173 if (first < 0) first = i;
174 differ += 1;
175 }
176 const where =
177 first < 0
178 ? `every ${about.unit} matches, so the divergence is in the length of the run`
179 : `${about.unit} ${first + 1} of ${count} differs — the game changed there ` +
180 `(ticks ${first * about.every}..${(first + 1) * about.every - 1}): ` +
181 `pinned ${pin.checkpoints[first] ?? "(nothing)"}, ran ${run.checkpoints[first] ?? "(nothing)"}. ` +
182 `${differ} of ${count} ${about.unit}s differ.`;
183 return (
184 `The ${pin.label} no longer matches the digest pinned in ${about.file}: ${where}\n` +
185 `The run hashes ${about.hashes}.\n` +
186 `If this change to the game is deliberate, say so in the pull and paste these in:\n\n` +
187 `const ${pin.constant} = "${run.digest}";\n` +
188 `const ${about.list} = ${list(run.checkpoints)};\n`
189 );
190 }
src/engine/index.ts +9
@@ -77,6 +77,15 @@ export type {
7777 FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions,
7878 } from "./page.ts";
7979
80 /**
81 * A run folded into a hash: the quantiser, the fold with its checkpoints, and
82 * the complaint that says where two runs parted. Like `fakePage`, it is a tool
83 * for a test or a bench and not for a frame — no committed bundle carries a
84 * byte of it, which `digest.test.ts` reads the files to say.
85 */
86 export { compare, fold, quantise, CHECKPOINT_CHARS, QUANTUM } from "./digest.ts";
87 export type { Complaint, Fold, Folded, Pin } from "./digest.ts";
88
8089 /** A policy network: an observation in, one choice per categorical head out. */
8190 export {
8291 copyPolicy, createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack,
src/games.ts +11
@@ -124,6 +124,17 @@ export type Game = {
124124 * `src/engine/learn.test.ts` reads every committed bundle for `maxGradNorm`,
125125 * `valueCoef`, `lastValues`, `clipFraction` and `policyLoss` and finds none of
126126 * them. Measured 2026-09-20 after #107, on the bundles this branch commits.
127 * #110 moved the determinism harness behind the door — `src/engine/digest.ts`,
128 * the quantiser, the fold and the complaint both games pin themselves with —
129 * and not one of the three bundles gained a byte: app 72,128 raw, range 28,978
130 * raw, example 8,754 raw, each the same number as before it moved. The gzipped
131 * sizes moved a little (app 33,410 → 33,546, range 12,323 → 12,319, example
132 * 4,219 → 4,225) because the minifier handed out different short names over a
133 * module graph that changed shape, which is what happened for #106 and #107
134 * too. A digest is a tool for a test or a bench and never for a frame, and that
135 * it is absent is a test rather than an inference: `src/engine/digest.test.ts`
136 * reads every committed bundle for the sentence that module carries, for the
137 * complaint it writes and for `CryptoHasher`, and finds none of them.
127138 * The range's numbers here are #121's moving plates and #125's painter at once:
128139 * both landed a range bundle and neither swept the other's, so static/ held two
129140 * and every reader of it failed until a plain `bun run build` resolved them.
src/range/pin.test.ts +2 −2
@@ -23,11 +23,11 @@
2323
2424 import { expect, test } from "bun:test";
2525 import {
26 CHECKPOINT_CHARS, compare, fold, play, PLATE_FIELDS, QUANTUM, RUN_FIELDS, SCRIPT, snapshot,
26 compare, fold, play, PLATE_FIELDS, RUN_FIELDS, SCRIPT, snapshot,
2727 TICK, TICKS_PER_SECOND, TRACKING_SCRIPT, trigger, type Pin, type Pinned,
2828 } from "./pin.ts";
2929 import { accuracy, createRun, DRILLS, EYE, shoot, start, step, type Run } from "./targets.ts";
30 import { angleVectors } from "../engine/index.ts";
30 import { angleVectors, CHECKPOINT_CHARS, QUANTUM } from "../engine/index.ts";
3131
3232 /** The run: a named seed, thirty seconds of it, at the range's own 50 Hz. */
3333 const SEED = 20260917;
src/range/pin.ts +48 −86
@@ -7,10 +7,13 @@
77 * as before". Nobody can check that by eye. This module makes it a number: play
88 * a seeded run through a script nobody changes, fold the game's state into a
99 * hash after every tick, and compare the result with the digest committed in
10 * `pin.test.ts`. It is the shooter's instrument in `src/client/replay.ts` built
11 * a second time for the second game — built, not imported: `src/range/` reaches
12 * for the engine, the shell and its own siblings and nothing else, and a
13 * shortcut to the shooter would make that untrue.
10 * `pin.test.ts`. The instrument itself is `src/engine/digest.ts` — the
11 * quantiser, the fold, the checkpoints and the complaint — which is where it
12 * went once this file and `src/client/replay.ts` had each written it out in
13 * full: the range reaches for the engine, the shell and its own siblings and
14 * nothing else, so the only way to stop copying it was to put it behind the
15 * door. What is left here is the range's own — its 50 Hz, its scripts, its
16 * field tables and the doctrine below.
1417 *
1518 * **When the digest moves**, read the failure: it names the first whole second
1619 * of the run whose checkpoint differs. Play that second — the ticks are printed
@@ -42,7 +45,9 @@
4245 import {
4346 EYE, shoot, step as advance, type Run, type Target,
4447 } from "./targets.ts";
45 import { angleVectors } from "../engine/index.ts";
48 import {
49 angleVectors, compare as complain, fold as foldTicks, quantise, type Folded, type Pin,
50 } from "../engine/index.ts";
4651
4752 /** The range's step, and the game's own: 50 Hz, as `main.ts` runs it. */
4853 export const TICK = 1 / 50;
@@ -51,26 +56,15 @@ export const TICKS_PER_SECOND = 50;
5156 // --- what is hashed -------------------------------------------------------
5257
5358 /**
54 * Floats are quantised before they are hashed: multiplied by 1e4, rounded to an
55 * integer, written in decimal.
59 * Every number below goes through the engine's `quantise` before it is hashed:
60 * multiplied by 1e4, rounded, written in decimal. A plate's place comes out of
61 * `lerp` on a seeded rng and a shot is traced along `Math.sin` and `Math.cos`,
62 * and those are library functions whose last bit the standard does not pin — so
63 * a ten-thousandth of a unit is the grid, and `src/engine/digest.ts` says at
64 * length why that is far below what a player feels and far above the noise.
5665 *
57 * Why: a plate's place comes out of `lerp` on a seeded rng and its distance out
58 * of a square root, and a shot is traced along `Math.sin` and `Math.cos` of the
59 * view angles. Those trig calls are library functions, not IEEE-754 operations
60 * — two builds of libm may disagree in the last bit — so hashing raw doubles
61 * would fail this test on somebody else's machine for a reason that is not a
62 * bug. A ten-thousandth of a Source unit is about 2.5 microns of lane and a
63 * ten-thousandth of a second of lifetime: far below anything a player can feel,
64 * far above the noise. A real change moves a plate by whole units or a clock by
65 * whole ticks, and still shows.
66 * The phases, in the order their index is hashed.
6667 */
67 export const QUANTUM = 1e-4;
68 const SCALE = 1 / QUANTUM;
69
70 /** One number, ready to hash. Non-finite values are written as themselves. */
71 const q = (x: number): string => (Number.isFinite(x) ? String(Math.round(x * SCALE)) : `!${x}`);
72
73 /** The phases, in the order their index is hashed. */
7468 const PHASES: Run["phase"][] = ["ready", "live", "over"];
7569
7670 /** Everything hashed about the run itself, in the order it is written. */
@@ -106,9 +100,9 @@ export const PLATE_FIELDS: { name: string; of: (plate: Target) => number }[] = [
106100 * happens to hold them in.
107101 */
108102 export function snapshot(run: Run): string {
109 const head = RUN_FIELDS.map((field) => q(field.of(run)));
103 const head = RUN_FIELDS.map((field) => quantise(field.of(run)));
110104 const plates = run.targets
111 .map((plate) => PLATE_FIELDS.map((field) => q(field.of(plate))).join(","))
105 .map((plate) => PLATE_FIELDS.map((field) => quantise(field.of(plate))).join(","))
112106 .sort();
113107 return `${head.join(",")}|${plates.length}|${plates.join(";")}\n`;
114108 }
@@ -208,38 +202,24 @@ export function trigger(run: Run, script: readonly Aim[] = SCRIPT): (tick: numbe
208202
209203 // --- the run --------------------------------------------------------------
210204
211 export type Pinned = {
212 /** sha256 of every tick of the run, hex. */
213 digest: string;
214 /** One short digest per whole second, for finding where two runs part. */
215 checkpoints: string[];
216 ticks: number;
217 };
218
219 /** How much of each second's sha256 is kept. 48 bits is plenty to point a finger. */
220 export const CHECKPOINT_CHARS = 12;
205 /**
206 * A run of this game, hashed: the engine's `Folded` under the name this file
207 * has always called it, because a digest, a checkpoint a second and a count of
208 * ticks is exactly what comes back.
209 */
210 export type Pinned = Folded;
221211
222212 /**
223213 * Fold `ticks` ticks of `run` into a digest, checkpointing every whole second.
224214 * `step` advances the game however its caller advances it — this only reads the
225215 * state afterwards — so a run stepped some other way (a bench, a bot, a second
226216 * script) is pinned by handing its stepper in.
217 *
218 * The fold is the engine's: this hands it the range's 50 Hz, the caller's
219 * stepper and one line of bytes a tick, and keeps the word `checkpoints`.
227220 */
228221 export function fold(run: Run, ticks: number, step: (tick: number) => void): Pinned {
229 const whole = new Bun.CryptoHasher("sha256");
230 let second = new Bun.CryptoHasher("sha256");
231 const checkpoints: string[] = [];
232 for (let tick = 0; tick < ticks; tick++) {
233 step(tick);
234 const line = snapshot(run);
235 whole.update(line);
236 second.update(line);
237 if ((tick + 1) % TICKS_PER_SECOND === 0) {
238 checkpoints.push(second.digest("hex").slice(0, CHECKPOINT_CHARS));
239 second = new Bun.CryptoHasher("sha256");
240 }
241 }
242 return { digest: whole.digest("hex"), checkpoints, ticks };
222 return foldTicks({ ticks, step, line: () => snapshot(run), every: TICKS_PER_SECOND });
243223 }
244224
245225 /**
@@ -259,45 +239,27 @@ export function play(run: Run, ticks: number, script: readonly Aim[] = SCRIPT):
259239
260240 // --- comparing ------------------------------------------------------------
261241
262 /** A digest committed in the repository, and what to call it when it moves. */
263 export type Pin = { label: string; constant: string; digest: string; checkpoints: readonly string[] };
264
265 const list = (checkpoints: readonly string[]): string => {
266 const lines: string[] = [];
267 for (let i = 0; i < checkpoints.length; i += 4)
268 lines.push(` ${checkpoints.slice(i, i + 4).map((s) => `"${s}"`).join(", ")},`);
269 return `[\n${lines.join("\n")}\n]`;
270 };
242 /**
243 * A digest committed in the repository, and what to call it when it moves: the
244 * engine's `Pin`, whose fields are the ones this file already wrote down.
245 */
246 export type { Pin };
271247
272248 /**
273 * Why a run and its pin differ, or null if they do not. A digest that only says
274 * "no" is a wall: this says which second of the run first went another way,
275 * which ticks that was, how much of the run went with it, and prints the
276 * constants to paste once the change is the intended one.
249 * Why a run and its pin differ, or null if they do not. The engine's `compare`
250 * finds the second that first went another way and prints the constants to
251 * paste; what this file adds is what the engine cannot know — the file the
252 * digests are committed in, the names of those constants, that a checkpoint
253 * here is a second of fifty ticks, and what went into the hash.
277254 */
278255 export function compare(run: Pinned, pin: Pin): string | null {
279 if (run.digest === pin.digest && run.checkpoints.length === pin.checkpoints.length) return null;
280 const count = Math.max(run.checkpoints.length, pin.checkpoints.length);
281 let first = -1;
282 let differ = 0;
283 for (let i = 0; i < count; i++) {
284 if (run.checkpoints[i] === pin.checkpoints[i]) continue;
285 if (first < 0) first = i;
286 differ += 1;
287 }
288 const where =
289 first < 0
290 ? "every second matches, so the divergence is in the length of the run"
291 : `second ${first + 1} of ${count} differs — the game changed there ` +
292 `(ticks ${first * TICKS_PER_SECOND}..${(first + 1) * TICKS_PER_SECOND - 1}): ` +
293 `pinned ${pin.checkpoints[first] ?? "(nothing)"}, ran ${run.checkpoints[first] ?? "(nothing)"}. ` +
294 `${differ} of ${count} seconds differ.`;
295 return (
296 `The ${pin.label} no longer matches the digest pinned in src/range/pin.test.ts: ${where}\n` +
297 `The run hashes ${RUN_FIELDS.map((f) => f.name).join(", ")} and, per standing plate, ` +
298 `${PLATE_FIELDS.map((f) => f.name).join(", ")}.\n` +
299 `If this change to the game is deliberate, say so in the pull and paste these in:\n\n` +
300 `const ${pin.constant} = "${run.digest}";\n` +
301 `const ${pin.constant}_SECONDS = ${list(run.checkpoints)};\n`
302 );
256 return complain(run, pin, {
257 file: "src/range/pin.test.ts",
258 list: `${pin.constant}_SECONDS`,
259 hashes:
260 `${RUN_FIELDS.map((f) => f.name).join(", ")} and, per standing plate, ` +
261 `${PLATE_FIELDS.map((f) => f.name).join(", ")}`,
262 every: TICKS_PER_SECOND,
263 unit: "second",
264 });
303265 }
static/app.436c7e87eb.js → static/app.fc9923a472.js renamed +1 −1

Diff not shown — 119,145 bytes of diff (limit 100,000). +1 −1

View file
static/example.004d95abfe.js → static/example.801f9085bb.js renamed +1 −1

Diff not shown — a line 8,748 characters wide (limit 2,000). +1 −1

View file
static/range.7a769ecf3e.js → static/range.e5be537f51.js renamed +3 −3

Diff not shown — a line 17,026 characters wide (limit 2,000). +3 −3

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs