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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/110-the-determinism-harness-becomes-engine-o → main 1 commit fork
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 | ||
| 185 | 185 | categorical head per choice it may make, and int8 packing for the weights a |
| 186 | 186 | page downloads — the arithmetic only, with nothing about aiming, weapons or |
| 187 | 187 | 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). | |
| 189 | 192 | Three rules keep it a package, and a test holds each: the engine imports |
| 190 | 193 | nothing outside itself, it needs nothing from a browser to load, and |
| 191 | 194 | 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 | ||
| 221 | 224 | they are driven with; from `overlay` `overlay`, `Overlay` and the `Paint` |
| 222 | 225 | a 2D game draws onto; from `scene` the frame and its pool — `createScene`, |
| 223 | 226 | `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. | |
| 226 | 231 | `boundary.test.ts` fails a deep import from outside, a |
| 227 | 232 | module that is not behind the door, and any change to that list of names that |
| 228 | 233 | 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 | ||
| 384 | 389 | atlas and draws the room — so a bundling mistake fails |
| 385 | 390 | `bun test` rather than the preview. The range's rules are pinned the way the |
| 386 | 391 | 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 | |
| 389 | 394 | tuning that changes the game says so, naming the drill and the second of the |
| 390 | 395 | run where it changed. A drill is data: one table in `targets.ts` holds the |
| 391 | 396 | 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 | ||
| 440 | 445 | digest has changed behaviour and is not an extraction, while a deliberate |
| 441 | 446 | change to the simulation updates the digest in the same pull and says what |
| 442 | 447 | 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. | |
| 444 | 452 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 445 | 453 | schema and its queries, `src/result.ts` validates what a browser may claim, |
| 446 | 454 | `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 @@ | ||
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | 23 | import { DE_FORGE } from "./map.ts"; |
| 24 | 24 | import { SHORT } from "./match.ts"; |
| 25 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; | |
| 25 | 26 | import { |
| 26 | CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, | |
| 27 | compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, | |
| 27 | 28 | withoutEntropy, type Pin, type Run, |
| 28 | 29 | } from "./replay.ts"; |
| 29 | 30 | import { createWorld, type Player } from "./world.ts"; |
src/client/replay.ts +35 −71
| @@ -13,8 +13,14 @@ | ||
| 13 | 13 | * MATCH_FIELDS below are the whole list. Effects, the kill feed and the |
| 14 | 14 | * messages are presentation: they would make the digest fragile and would |
| 15 | 15 | * 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. | |
| 16 | 21 | */ |
| 17 | 22 | |
| 23 | import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts"; | |
| 18 | 24 | import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts"; |
| 19 | 25 | import type { MatchState, Phase } from "./match.ts"; |
| 20 | 26 | |
| @@ -24,25 +30,11 @@ export const TICKS_PER_SECOND = 64; | ||
| 24 | 30 | // --- what is hashed ------------------------------------------------------- |
| 25 | 31 | |
| 26 | 32 | /** |
| 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. | |
| 39 | 37 | */ |
| 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 | ||
| 46 | 38 | const PHASES: Phase[] = ["freeze", "live", "planted", "over", "match_over"]; |
| 47 | 39 | |
| 48 | 40 | /** 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 }[] = [ | ||
| 73 | 65 | /** The state of one tick as the bytes that go into the hash. */ |
| 74 | 66 | export function snapshot(world: World): string { |
| 75 | 67 | 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))); | |
| 78 | 70 | return `${parts.join(",")}\n`; |
| 79 | 71 | } |
| 80 | 72 | |
| @@ -158,31 +150,20 @@ export type Run = { | ||
| 158 | 150 | ticks: number; |
| 159 | 151 | }; |
| 160 | 152 | |
| 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 | ||
| 164 | 153 | /** |
| 165 | 154 | * Fold `ticks` ticks of `world` into a digest, checkpointing every second. |
| 166 | 155 | * `step` advances the simulation however its environment advances it; this |
| 167 | 156 | * only reads the state afterwards. The training environment in `src/rl` steps |
| 168 | 157 | * its own way, and gets pinned by handing its stepper in — `src/client` never |
| 169 | 158 | * 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. | |
| 170 | 163 | */ |
| 171 | 164 | 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 }; | |
| 186 | 167 | } |
| 187 | 168 | |
| 188 | 169 | /** 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 | ||
| 196 | 177 | /** A digest committed in the repository, and what to call it when it moves. */ |
| 197 | 178 | export type Pin = { label: string; constant: string; digest: string; seconds: readonly string[] }; |
| 198 | 179 | |
| 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 | ||
| 206 | 180 | /** |
| 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. | |
| 211 | 186 | */ |
| 212 | 187 | 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 | }, | |
| 236 | 200 | ); |
| 237 | 201 | } |
| 238 | 202 |
src/engine/GUIDE.md +38
| @@ -413,6 +413,43 @@ half lives beside your entry: `src/client/stage.test-helper.ts` and | ||
| 413 | 413 | It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts` |
| 414 | 414 | reads both committed bundles to say so. |
| 415 | 415 | |
| 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 | ||
| 416 | 453 | ### net |
| 417 | 454 | |
| 418 | 455 | 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. | ||
| 525 | 562 | | sound recipes | `src/client/audio.ts` | none yet | |
| 526 | 563 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` | |
| 527 | 564 | | 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 | | |
| 528 | 566 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 529 | 567 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | |
| 530 | 568 | | 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", ( | ||
| 278 | 278 | * `backward`, `Adam`, `createAdam`, `applyGrads`, `Rollout`, `createRollout`, |
| 279 | 279 | * `computeAdvantages`, `update`, `UpdateStats`. |
| 280 | 280 | * |
| 281 | * digest — `QUANTUM`, `quantise`, `CHECKPOINT_CHARS`, `Fold`, `fold`, | |
| 282 | * `Folded`, `Pin`, `Complaint`, `compare`. | |
| 283 | * | |
| 281 | 284 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one |
| 282 | 285 | * copy of the surface, read twice, rather than two that drift. |
| 283 | 286 | */ |
| 284 | 287 | 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", | |
| 290 | 294 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape", |
| 291 | 295 | "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3", |
| 292 | 296 | "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", | |
| 300 | 305 | "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "sub", |
| 301 | 306 | "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf", |
| 302 | 307 | "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 { | ||
| 77 | 77 | FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions, |
| 78 | 78 | } from "./page.ts"; |
| 79 | 79 | |
| 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 | ||
| 80 | 89 | /** A policy network: an observation in, one choice per categorical head out. */ |
| 81 | 90 | export { |
| 82 | 91 | copyPolicy, createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack, |
src/games.ts +11
| @@ -124,6 +124,17 @@ export type Game = { | ||
| 124 | 124 | * `src/engine/learn.test.ts` reads every committed bundle for `maxGradNorm`, |
| 125 | 125 | * `valueCoef`, `lastValues`, `clipFraction` and `policyLoss` and finds none of |
| 126 | 126 | * 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. | |
| 127 | 138 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 128 | 139 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 129 | 140 | * 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 @@ | ||
| 23 | 23 | |
| 24 | 24 | import { expect, test } from "bun:test"; |
| 25 | 25 | import { |
| 26 | CHECKPOINT_CHARS, compare, fold, play, PLATE_FIELDS, QUANTUM, RUN_FIELDS, SCRIPT, snapshot, | |
| 26 | compare, fold, play, PLATE_FIELDS, RUN_FIELDS, SCRIPT, snapshot, | |
| 27 | 27 | TICK, TICKS_PER_SECOND, TRACKING_SCRIPT, trigger, type Pin, type Pinned, |
| 28 | 28 | } from "./pin.ts"; |
| 29 | 29 | 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"; | |
| 31 | 31 | |
| 32 | 32 | /** The run: a named seed, thirty seconds of it, at the range's own 50 Hz. */ |
| 33 | 33 | const SEED = 20260917; |
src/range/pin.ts +48 −86
| @@ -7,10 +7,13 @@ | ||
| 7 | 7 | * as before". Nobody can check that by eye. This module makes it a number: play |
| 8 | 8 | * a seeded run through a script nobody changes, fold the game's state into a |
| 9 | 9 | * 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. | |
| 14 | 17 | * |
| 15 | 18 | * **When the digest moves**, read the failure: it names the first whole second |
| 16 | 19 | * of the run whose checkpoint differs. Play that second — the ticks are printed |
| @@ -42,7 +45,9 @@ | ||
| 42 | 45 | import { |
| 43 | 46 | EYE, shoot, step as advance, type Run, type Target, |
| 44 | 47 | } 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"; | |
| 46 | 51 | |
| 47 | 52 | /** The range's step, and the game's own: 50 Hz, as `main.ts` runs it. */ |
| 48 | 53 | export const TICK = 1 / 50; |
| @@ -51,26 +56,15 @@ export const TICKS_PER_SECOND = 50; | ||
| 51 | 56 | // --- what is hashed ------------------------------------------------------- |
| 52 | 57 | |
| 53 | 58 | /** |
| 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. | |
| 56 | 65 | * |
| 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. | |
| 66 | 67 | */ |
| 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. */ | |
| 74 | 68 | const PHASES: Run["phase"][] = ["ready", "live", "over"]; |
| 75 | 69 | |
| 76 | 70 | /** 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 }[] = [ | ||
| 106 | 100 | * happens to hold them in. |
| 107 | 101 | */ |
| 108 | 102 | 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))); | |
| 110 | 104 | 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(",")) | |
| 112 | 106 | .sort(); |
| 113 | 107 | return `${head.join(",")}|${plates.length}|${plates.join(";")}\n`; |
| 114 | 108 | } |
| @@ -208,38 +202,24 @@ export function trigger(run: Run, script: readonly Aim[] = SCRIPT): (tick: numbe | ||
| 208 | 202 | |
| 209 | 203 | // --- the run -------------------------------------------------------------- |
| 210 | 204 | |
| 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; | |
| 221 | 211 | |
| 222 | 212 | /** |
| 223 | 213 | * Fold `ticks` ticks of `run` into a digest, checkpointing every whole second. |
| 224 | 214 | * `step` advances the game however its caller advances it — this only reads the |
| 225 | 215 | * state afterwards — so a run stepped some other way (a bench, a bot, a second |
| 226 | 216 | * 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`. | |
| 227 | 220 | */ |
| 228 | 221 | 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 }); | |
| 243 | 223 | } |
| 244 | 224 | |
| 245 | 225 | /** |
| @@ -259,45 +239,27 @@ export function play(run: Run, ticks: number, script: readonly Aim[] = SCRIPT): | ||
| 259 | 239 | |
| 260 | 240 | // --- comparing ------------------------------------------------------------ |
| 261 | 241 | |
| 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 }; | |
| 271 | 247 | |
| 272 | 248 | /** |
| 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. | |
| 277 | 254 | */ |
| 278 | 255 | 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 | }); | |
| 303 | 265 | } |
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 filestatic/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 filestatic/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
new above
offline