Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A seeded round is pinned: a golden digest proves an engine extraction changed nothing #22
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/19-a-seeded-round-is-pinned-a-golden-digest → main 1 commit fork
3 files
changed +457
−0
README.md +10
| @@ -75,6 +75,16 @@ the rest. | ||
| 75 | 75 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 76 | 76 | the algorithm, with its backward pass checked against finite differences. |
| 77 | 77 | `scripts/train.ts` runs them and writes `src/client/weights.ts`. |
| 78 | - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel | |
| 79 | in the arena, each stepped through one fixed script of inputs and hashed tick | |
| 80 | by tick (positions, velocities, view angles, health, armour, duck and | |
| 81 | magazine, plus the phase, the timer and the score, all quantised to 1e-4 so a | |
| 82 | CPU's last bit cannot fail the test), against digests committed in | |
| 83 | `src/client/replay.test.ts`. The rule is that an extraction which moves a | |
| 84 | digest has changed behaviour and is not an extraction, while a deliberate | |
| 85 | change to the simulation updates the digest in the same pull and says what | |
| 86 | moved; a failure names the first second at which the two runs part and prints | |
| 87 | the constants to paste. | |
| 78 | 88 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 79 | 89 | schema and its queries, `src/result.ts` validates what a browser may claim. |
| 80 | 90 | - `src/pages/shell.ts` — the document every game is served inside, and nothing |
src/client/replay.test.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * The golden digests: what this simulation does, pinned. | |
| 3 | * | |
| 4 | * Two runs, both seeded, both stepped through the same script in | |
| 5 | * `replay.ts`: the whole game — ten players, the match rules, the bomb, the | |
| 6 | * bots and their policy — and the duel in the training arena, so an | |
| 7 | * extraction that moves the environment PPO learned in is caught as loudly as | |
| 8 | * one that moves the game. | |
| 9 | * | |
| 10 | * If one of these fails, read the message: it names the first second of play | |
| 11 | * at which the run and the pin part company, and prints the constants to | |
| 12 | * paste in. Paste them only when the change to the simulation was deliberate, | |
| 13 | * and say in the pull what moved and why. An extraction that changes a digest | |
| 14 | * is not an extraction. | |
| 15 | * | |
| 16 | * Measured: the two pinned runs together are 0.52 s (the round 0.46 s — ten | |
| 17 | * bots running a policy every fourth tick for forty seconds is the work — and | |
| 18 | * the duel 0.06 s), and this whole file, short runs and all, is 0.66 s of the | |
| 19 | * suite's 3.3 s. | |
| 20 | */ | |
| 21 | ||
| 22 | import { expect, test } from "bun:test"; | |
| 23 | import { SHORT } from "./match.ts"; | |
| 24 | import { | |
| 25 | CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, | |
| 26 | withoutEntropy, type Pin, type Run, | |
| 27 | } from "./replay.ts"; | |
| 28 | import { createWorld, type Player } from "./world.ts"; | |
| 29 | import { createArena, reset, stepInput } from "../rl/arena.ts"; | |
| 30 | ||
| 31 | const SECONDS = 40; | |
| 32 | const TICKS = SECONDS * TICKS_PER_SECOND; | |
| 33 | ||
| 34 | /** | |
| 35 | * Forty seconds of a five-a-side round on de_forge, seed 7. Computed on | |
| 36 | * 06a3c81 ("One page shell serves both games"), the commit this branch left | |
| 37 | * main at, before anything in this pull touched the tree. | |
| 38 | */ | |
| 39 | const ROUND = "6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0"; | |
| 40 | const ROUND_SECONDS = [ | |
| 41 | "9e65233c0d48", "aa3a9181fd4e", "7c4010b39595", "f1f17242e53a", | |
| 42 | "f60445aeab1c", "fe6db21f4254", "6a9cb307907d", "0d04a4981ebb", | |
| 43 | "4f5b67b31f3a", "1143cc644fa5", "233dfe9b86ad", "a484e4d8c88a", | |
| 44 | "a8def47d1078", "edc047a03760", "041e88d4a21a", "7d1355c2c4e7", | |
| 45 | "5612f8126e48", "da03b727e727", "f11d1891e25d", "dd54b431fcf4", | |
| 46 | "fdd3cb238ea2", "44a94744ba5f", "51aafcbd7178", "87b3d8a4f92c", | |
| 47 | "1c7e703d3c31", "022fc11cab00", "b7da2d1b042c", "2aecf3498845", | |
| 48 | "65a47f51d197", "793d4cc2982f", "f7e5296e6308", "cefff31fc624", | |
| 49 | "ef1a5a248bc6", "d655548a346b", "fe852048d7cd", "d35a992a9295", | |
| 50 | "83ddecae01bf", "adb7eefdf1f4", "fd307b97235e", "0c623d45e867", | |
| 51 | ]; | |
| 52 | ||
| 53 | /** | |
| 54 | * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the | |
| 55 | * scripted opponent, resetting each time one of them dies. Computed on the | |
| 56 | * same commit, 06a3c81. | |
| 57 | */ | |
| 58 | const DUEL = "5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6"; | |
| 59 | const DUEL_SECONDS = [ | |
| 60 | "4b602f465cc3", "55bd03b39724", "7ed7a3e9852c", "384c7656b58c", | |
| 61 | "d5a4fed56e37", "0c14ed3d20b0", "fb4abcb5a49a", "5cd40562c219", | |
| 62 | "21d1d1268ed9", "8a09b3475552", "8926d65beaf0", "60f7da994999", | |
| 63 | "4c87374025da", "7700baee5ede", "fe59251327c2", "85c8b4f5851d", | |
| 64 | "9ddcbb06870b", "ef4346bb917d", "701b38b7cc85", "7b0310352ead", | |
| 65 | "9a7977b11907", "368b58be4235", "e7a36f9aa08e", "214a8b8bff24", | |
| 66 | "0d20594c6069", "ba43b863d3cc", "3cfebac1c436", "c5f40350e06c", | |
| 67 | "a13f1ee2bf59", "657dd44ce153", "79e2aee250d0", "67c57043bc04", | |
| 68 | "2fa20867e8c7", "f25307c5968c", "e36ece4c4588", "14525727cbb2", | |
| 69 | "3e8043af606f", "5b20a5d42233", "955040d49339", "d2053b7c791c", | |
| 70 | ]; | |
| 71 | ||
| 72 | const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS }; | |
| 73 | const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS }; | |
| 74 | ||
| 75 | /** The round, exactly as the pin was taken. */ | |
| 76 | const roundRun = (): Run => withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), TICKS)); | |
| 77 | ||
| 78 | /** | |
| 79 | * The duel, stepped the arena's own way: the learner takes the script, the | |
| 80 | * opponent is the scripted bot the policy trains against, and an episode that | |
| 81 | * ends is reset, which is where the arena draws every number it draws. | |
| 82 | */ | |
| 83 | const duelRun = (): Run => | |
| 84 | withoutEntropy(() => { | |
| 85 | const arena = createArena(4242); | |
| 86 | const next = reader(); | |
| 87 | return fold(arena.world, TICKS, (tick) => { | |
| 88 | if (stepInput(arena, next(tick, arena.world, arena.learner), TICK).done) reset(arena); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | const must = (run: Run, pin: Pin): void => { | |
| 93 | const complaint = compare(run, pin); | |
| 94 | if (complaint !== null) throw new Error(complaint); | |
| 95 | }; | |
| 96 | ||
| 97 | test("a seeded ten-bot round plays exactly as it played", () => { | |
| 98 | const run = roundRun(); | |
| 99 | must(run, ROUND_PIN); | |
| 100 | expect(run.digest).toBe(ROUND); | |
| 101 | expect(run.ticks).toBe(TICKS); | |
| 102 | expect(run.seconds).toEqual(ROUND_SECONDS); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a seeded duel in the training arena plays exactly as it played", () => { | |
| 106 | const run = duelRun(); | |
| 107 | must(run, DUEL_PIN); | |
| 108 | expect(run.digest).toBe(DUEL); | |
| 109 | expect(run.seconds.length).toBe(SECONDS); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the same seed is the same run, and another seed is another run", () => { | |
| 113 | const four = 4 * TICKS_PER_SECOND; | |
| 114 | const a = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four)); | |
| 115 | const b = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four)); | |
| 116 | const other = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 8), four)); | |
| 117 | expect(a.digest).toBe(b.digest); | |
| 118 | expect(a.digest).not.toBe(other.digest); | |
| 119 | // The first four seconds of the long run are the first four seconds, too. | |
| 120 | expect(a.seconds).toEqual(ROUND_SECONDS.slice(0, 4)); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a move the game could not feel leaves the digest alone; one it could moves it", () => { | |
| 124 | const world = createWorld(SHORT, "me", "ct", 5, 7); | |
| 125 | const before = snapshot(world); | |
| 126 | world.me.pos[0] += 2e-6; // a fiftieth of the quantum: libm noise, not behaviour | |
| 127 | expect(snapshot(world)).toBe(before); | |
| 128 | world.me.pos[0] += 1e-3; // ten quanta: still invisible to a person, caught anyway | |
| 129 | expect(snapshot(world)).not.toBe(before); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a nudged simulation is a different run from the first second on", () => { | |
| 133 | const four = 4 * TICKS_PER_SECOND; | |
| 134 | const nudged = withoutEntropy(() => { | |
| 135 | const world = createWorld(SHORT, "me", "ct", 5, 7); | |
| 136 | (world.players[3] as Player).pos[1] += 1; // one unit, on one bot, before the first tick | |
| 137 | return digest(world, four); | |
| 138 | }); | |
| 139 | expect(nudged.seconds[0]).not.toBe(ROUND_SECONDS[0]); | |
| 140 | const complaint = compare(nudged, { ...ROUND_PIN, seconds: ROUND_SECONDS.slice(0, 4) }) as string; | |
| 141 | expect(complaint).toContain("second 1 of 4"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a mismatch says which second went another way, and what to paste", () => { | |
| 145 | const pinned: Pin = { | |
| 146 | label: "toy", constant: "TOY", digest: "aaaa", | |
| 147 | seconds: ["one0000000000", "two0000000000", "three000000000"], | |
| 148 | }; | |
| 149 | const run: Run = { | |
| 150 | digest: "bbbb", ticks: 3 * TICKS_PER_SECOND, | |
| 151 | seconds: ["one0000000000", "changed000000", "three000000000"], | |
| 152 | }; | |
| 153 | const complaint = compare(run, pinned) as string; | |
| 154 | expect(complaint).toContain("second 2 of 3"); | |
| 155 | expect(complaint).toContain("ticks 64..127"); | |
| 156 | expect(complaint).toContain("1 of 3 seconds differ"); | |
| 157 | expect(complaint).toContain("pinned two0000000000, ran changed000000"); | |
| 158 | expect(complaint).toContain('const TOY = "bbbb";'); | |
| 159 | expect(complaint).toContain('"changed000000"'); | |
| 160 | expect(compare({ ...run, digest: pinned.digest, seconds: [...pinned.seconds] }, pinned)).toBe(null); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the run is seeded: reaching for entropy or the clock throws, and is put back", () => { | |
| 164 | const random = Math.random; | |
| 165 | const now = Date.now; | |
| 166 | expect(() => withoutEntropy(() => Math.random())).toThrow(/Math\.random/); | |
| 167 | expect(() => withoutEntropy(() => Date.now())).toThrow(/Date\.now/); | |
| 168 | expect(() => withoutEntropy(() => performance.now())).toThrow(/performance\.now/); | |
| 169 | expect(Math.random).toBe(random); | |
| 170 | expect(Date.now).toBe(now); | |
| 171 | expect(Math.random()).toBeGreaterThanOrEqual(0); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a checkpoint is a short digest, one a second, and the whole run is a full one", () => { | |
| 175 | expect(ROUND_SECONDS.length).toBe(SECONDS); | |
| 176 | expect(DUEL_SECONDS.length).toBe(SECONDS); | |
| 177 | for (const second of [...ROUND_SECONDS, ...DUEL_SECONDS]) { | |
| 178 | expect(second.length).toBe(CHECKPOINT_CHARS); | |
| 179 | expect(second).toMatch(/^[0-9a-f]+$/); | |
| 180 | } | |
| 181 | expect(ROUND.length).toBe(64); | |
| 182 | expect(DUEL.length).toBe(64); | |
| 183 | expect(new Set(ROUND_SECONDS).size).toBe(SECONDS); | |
| 184 | }); |
src/client/replay.ts added +263
| @@ -0,0 +1,263 @@ | ||
| 1 | /** | |
| 2 | * A replay: one seeded round, one fixed script of inputs, one sha256. | |
| 3 | * | |
| 4 | * Every refactor in this repository ends with the claim "the game plays | |
| 5 | * exactly as before", and nobody can check that by eye. This module makes it | |
| 6 | * a number. Step a seeded world through a script nobody changes, fold the | |
| 7 | * simulation's state into a hash after every tick, and compare the result with | |
| 8 | * a digest committed in `replay.test.ts`. An extraction that moves the digest | |
| 9 | * moved behaviour; a deliberate change to the simulation moves it on purpose | |
| 10 | * and updates the constant in the same pull. | |
| 11 | * | |
| 12 | * What is hashed is the simulation and nothing else — PLAYER_FIELDS and | |
| 13 | * MATCH_FIELDS below are the whole list. Effects, the kill feed and the | |
| 14 | * messages are presentation: they would make the digest fragile and would | |
| 15 | * catch nothing the state does not already catch. | |
| 16 | */ | |
| 17 | ||
| 18 | import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts"; | |
| 19 | import type { MatchState, Phase } from "./match.ts"; | |
| 20 | ||
| 21 | export const TICK = 1 / 64; | |
| 22 | export const TICKS_PER_SECOND = 64; | |
| 23 | ||
| 24 | // --- what is hashed ------------------------------------------------------- | |
| 25 | ||
| 26 | /** | |
| 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. | |
| 39 | */ | |
| 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 | const PHASES: Phase[] = ["freeze", "live", "planted", "over", "match_over"]; | |
| 47 | ||
| 48 | /** Everything hashed about a player, in the order it is written. */ | |
| 49 | export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [ | |
| 50 | { name: "pos.x", of: (p) => p.pos[0] }, | |
| 51 | { name: "pos.y", of: (p) => p.pos[1] }, | |
| 52 | { name: "pos.z", of: (p) => p.pos[2] }, | |
| 53 | { name: "vel.x", of: (p) => p.vel[0] }, | |
| 54 | { name: "vel.y", of: (p) => p.vel[1] }, | |
| 55 | { name: "vel.z", of: (p) => p.vel[2] }, | |
| 56 | { name: "yaw", of: (p) => p.yaw }, | |
| 57 | { name: "pitch", of: (p) => p.pitch }, | |
| 58 | { name: "health", of: (p) => p.health }, | |
| 59 | { name: "armor", of: (p) => p.armor }, | |
| 60 | { name: "duckAmount", of: (p) => p.duckAmount }, | |
| 61 | // -1 for a weapon that has no magazine (the knife), so a switch still shows. | |
| 62 | { name: "mag", of: (p) => p.ammo[p.weapon]?.mag ?? -1 }, | |
| 63 | ]; | |
| 64 | ||
| 65 | /** Everything hashed about the match. */ | |
| 66 | export const MATCH_FIELDS: { name: string; of: (m: MatchState) => number }[] = [ | |
| 67 | { name: "phase", of: (m) => PHASES.indexOf(m.phase) }, | |
| 68 | { name: "timer", of: (m) => m.timer }, | |
| 69 | { name: "score.t", of: (m) => m.score.t }, | |
| 70 | { name: "score.ct", of: (m) => m.score.ct }, | |
| 71 | ]; | |
| 72 | ||
| 73 | /** The state of one tick as the bytes that go into the hash. */ | |
| 74 | export function snapshot(world: World): string { | |
| 75 | 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))); | |
| 78 | return `${parts.join(",")}\n`; | |
| 79 | } | |
| 80 | ||
| 81 | // --- the script ----------------------------------------------------------- | |
| 82 | ||
| 83 | /** | |
| 84 | * One beat of the run: from `atTick` on, this is the whole input the person is | |
| 85 | * holding, exactly as a keyboard holds it until a key goes up. `buy` is the | |
| 86 | * one thing a person does that is not an Input — it happens once, on that | |
| 87 | * tick, and the world refuses it outside the freeze. | |
| 88 | */ | |
| 89 | export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem }; | |
| 90 | ||
| 91 | /** | |
| 92 | * Forty seconds of somebody playing: eight seconds of freeze with a buy in | |
| 93 | * it, then walking, strafing, a jump, a burst of fire, a reload, a use on the | |
| 94 | * bomb site and a crouch. The ticks are round numbers so a failure at "second | |
| 95 | * 12" points at a line here. | |
| 96 | */ | |
| 97 | export const SCRIPT: Beat[] = [ | |
| 98 | { atTick: 0, input: {} }, | |
| 99 | { atTick: 64, input: {}, buy: "helmet" }, // 1000 of the 800 start money: refused, and pinned as refused | |
| 100 | { atTick: 96, input: {}, buy: "armor" }, // 650: affordable, and taken | |
| 101 | { atTick: 128, input: { forward: 1 } }, // frozen: the world must ignore this | |
| 102 | { atTick: 512, input: { forward: 1 } }, // the round goes live | |
| 103 | { atTick: 640, input: { forward: 1, side: 1 } }, // strafe out of spawn | |
| 104 | { atTick: 704, input: { forward: 1, side: 1, jump: true } }, // one jump | |
| 105 | { atTick: 712, input: { forward: 1, walk: true } }, // walking is quiet and slow | |
| 106 | { atTick: 832, input: { forward: 1 } }, | |
| 107 | // A burst. The USP is semi-automatic — one shot a press — so the burst is | |
| 108 | // three presses, which is what a person's finger does and what walks the | |
| 109 | // spray pattern along: recoil, the seeded accuracy roll and shotIndex. | |
| 110 | { atTick: 960, input: { fire: true } }, | |
| 111 | { atTick: 968, input: {} }, | |
| 112 | { atTick: 976, input: { fire: true } }, | |
| 113 | { atTick: 984, input: {} }, | |
| 114 | { atTick: 992, input: { fire: true } }, | |
| 115 | { atTick: 1024, input: {} }, | |
| 116 | { atTick: 1088, input: { reload: true } }, | |
| 117 | { atTick: 1096, input: { forward: 1, side: -1 } }, | |
| 118 | { atTick: 1280, input: { use: true } }, // plant or defuse, whichever side is standing there | |
| 119 | { atTick: 1408, input: { forward: 1, duck: true } }, | |
| 120 | { atTick: 1536, input: { forward: 1, fire: true } }, // firing on the move: the moving-spread term | |
| 121 | { atTick: 1552, input: { forward: 1 } }, | |
| 122 | { atTick: 1568, input: { forward: 1, fire: true } }, | |
| 123 | { atTick: 1600, input: { forward: -1 } }, | |
| 124 | { atTick: 1792, input: { side: 1, walk: true } }, | |
| 125 | { atTick: 1984, input: { forward: 1, jump: true } }, | |
| 126 | { atTick: 1992, input: { forward: 1 } }, | |
| 127 | { atTick: 2176, input: { fire: true, duck: true } }, | |
| 128 | { atTick: 2240, input: { use: true } }, | |
| 129 | { atTick: 2368, input: { forward: 1, side: 1 } }, | |
| 130 | { atTick: 2496, input: {} }, | |
| 131 | ]; | |
| 132 | ||
| 133 | /** | |
| 134 | * A cursor over the script: hand it a tick and it returns the input held at | |
| 135 | * that tick, taking any buy on the way past. The input is a fresh object each | |
| 136 | * beat, never mutated, so a step cannot smuggle state into the next one. | |
| 137 | */ | |
| 138 | export function reader(script: readonly Beat[] = SCRIPT): (tick: number, world: World, player: Player) => Input { | |
| 139 | let at = 0; | |
| 140 | let input: Input = { ...NO_INPUT }; | |
| 141 | return (tick: number, world: World, player: Player): Input => { | |
| 142 | while (at < script.length && (script[at] as Beat).atTick <= tick) { | |
| 143 | const beat = script[at++] as Beat; | |
| 144 | input = { ...NO_INPUT, ...beat.input }; | |
| 145 | if (beat.buy !== undefined) buy(world, player, beat.buy); | |
| 146 | } | |
| 147 | return input; | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 151 | // --- the run -------------------------------------------------------------- | |
| 152 | ||
| 153 | export type Run = { | |
| 154 | /** sha256 of every tick of the run, hex. */ | |
| 155 | digest: string; | |
| 156 | /** One short digest per whole second, for finding where two runs part. */ | |
| 157 | seconds: string[]; | |
| 158 | ticks: number; | |
| 159 | }; | |
| 160 | ||
| 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 | /** | |
| 165 | * Fold `ticks` ticks of `world` into a digest, checkpointing every second. | |
| 166 | * `step` advances the simulation however its environment advances it; this | |
| 167 | * only reads the state afterwards. The training environment in `src/rl` steps | |
| 168 | * its own way, and gets pinned by handing its stepper in — `src/client` never | |
| 169 | * imports `src/rl`, and this module is not going to be the file that starts. | |
| 170 | */ | |
| 171 | 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 }; | |
| 186 | } | |
| 187 | ||
| 188 | /** The whole game: ten players, the match rules, the bomb, the bots. */ | |
| 189 | export function digest(world: World, ticks: number, script: readonly Beat[] = SCRIPT): Run { | |
| 190 | const next = reader(script); | |
| 191 | return fold(world, ticks, (tick) => update(world, TICK, next(tick, world, world.me))); | |
| 192 | } | |
| 193 | ||
| 194 | // --- comparing ------------------------------------------------------------ | |
| 195 | ||
| 196 | /** A digest committed in the repository, and what to call it when it moves. */ | |
| 197 | export type Pin = { label: string; constant: string; digest: string; seconds: readonly string[] }; | |
| 198 | ||
| 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 | /** | |
| 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. | |
| 211 | */ | |
| 212 | 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` | |
| 236 | ); | |
| 237 | } | |
| 238 | ||
| 239 | // --- proving it is seeded ------------------------------------------------- | |
| 240 | ||
| 241 | /** | |
| 242 | * Run `body` with every source of entropy taken away: a simulation that is | |
| 243 | * seeded never asks the platform for a number or for the time, and one that | |
| 244 | * does cannot be pinned at all. Everything is put back, whatever happens. | |
| 245 | */ | |
| 246 | export function withoutEntropy<T>(body: () => T): T { | |
| 247 | const refuse = (what: string) => (): never => { | |
| 248 | throw new Error(`the replay reached for ${what}: a pinned run must take every number from its seed`); | |
| 249 | }; | |
| 250 | const realRandom = Math.random; | |
| 251 | const realNow = Date.now; | |
| 252 | const realPerformance = performance.now; | |
| 253 | Math.random = refuse("Math.random"); | |
| 254 | Date.now = refuse("Date.now"); | |
| 255 | performance.now = refuse("performance.now"); | |
| 256 | try { | |
| 257 | return body(); | |
| 258 | } finally { | |
| 259 | Math.random = realRandom; | |
| 260 | Date.now = realNow; | |
| 261 | performance.now = realPerformance; | |
| 262 | } | |
| 263 | } |
new above
offline