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

forked from trevato/cs · proposes

A seeded range run is pinned: one digest, so a renderer and a movement tuning cannot change the game #61

Merged opened this

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

Merged

torque/cs-engine:torque/53-a-seeded-range-run-is-pinned-one-digest → main 1 commit fork

  • Merged. torque/53-a-seeded-range-run-is-pinned-one-digest was merged into main as 2d1baf8.

Review

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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs