Lane C S18: the mid-storm entry seam — a crate is a pure function of its event
The emergency callout arrives at t≈30, which makes a NON-ZERO START a first-class
case for the storm clock. Measured before writing anything, and the debris module
failed two ways that had nothing to do with the callout:
· the same storm flown twice through one module spawned DIFFERENT crates
(wildnight height 1.582 m then 1.165 m). main.js builds one debris module at
boot and clear()s it per phase change; clear() rewound the leaf accumulators
and not the piece PRNG. Live on main today — night 2 of the seven-night week
never flew night 1's crates, and no test looked.
· an entry at t=45, past the wildnight's t=38 event, gave every LATER crate the
skipped crate's height, spin axis and phase — the stream sat 5 draws behind.
Skipping history corrupted the future, not just the present.
One cause: a crate's jitter came off a shared stream whose POSITION encoded how
much history had happened. Keyed on the event itself now, so a crate is a pure
function of its event — the move weather.core.js's header already argues for.
debris.enterAt(t0, dt) for the one part that is a genuine path integral: the
ambient leaf layer. A t=0 run has 6 leaves crossing the grass at t=30 and a cold
entry has ZERO, staying becalmed ~7 s while the 2.5 s EMA climbs off zero —
DESIGN.md line 149 makes that moving grass the gust front's tell, and gate 1's
whole premise is that the grass is the only briefing a callout gets. It runs the
ladder rather than skipping it, and the ladder ACCUMULATES: i*dt is the more
accurate ladder and therefore the wrong one (1770 of 1800 steps disagree, first
at step six), because main.js, fixedLoop and sail.js all accumulate.
Pins: +5 (522 -> 527). Wind order-independence broadened to every storm and five
scalar observables plus the exact callout ladder, and a BACKWARDS peak scan
cross-checking what the dispatch will quote. All four mutations went red; the
first draft of the stormStats case could not fail (it memoises per def, so it was
comparing an object to itself) and was rewritten rather than shipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e6d4c36daa
commit
afb61b2926
@ -46,7 +46,39 @@ export function createDebris(o = {}) {
|
||||
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
|
||||
// Flat fallback so this still runs against a graybox yard.
|
||||
const groundAt = o.heightAt || (() => o.groundY ?? 0);
|
||||
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
|
||||
|
||||
/**
|
||||
* A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18]
|
||||
*
|
||||
* This was `const rand = rng(seed ^ 0x5eed1e)` — one stream, drawn from inside
|
||||
* spawn(). A stream's POSITION encodes how many pieces have already spawned,
|
||||
* so history leaked into every later crate, and it cost us two determinism
|
||||
* breaks measured on the wildnight (crate heights, m):
|
||||
*
|
||||
* the same storm twice through one module night 1 y 1.582 night 2 y 1.165
|
||||
* (main.js builds ONE debris module at boot and clear()s it per phase
|
||||
* change; clear() rewound leafDist/leafEma and NOT this stream, so night 2
|
||||
* of the seven-night week flew different crates than night 1 — live on
|
||||
* main today, no emergency callout required.)
|
||||
* an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413
|
||||
* t=45 y 1.623 sx 0.581 ph 0.839
|
||||
* (every crate after a skipped event wore the SKIPPED crate's clothes: the
|
||||
* stream sat 5 draws behind. Skipping history corrupted the future, not
|
||||
* just the present — the thing that makes a mid-storm arrival unpinnable.)
|
||||
*
|
||||
* Keyed on `ev.t` (stable authored data, unique per event in every shipped
|
||||
* storm) so the crate at t=38 is the same crate whether you watched the first
|
||||
* 38 seconds or arrived at 30. Manual spawns — A's debug keys, tuning — have no
|
||||
* `ev.t` and fall back to the spawn time, which differs per keypress.
|
||||
*
|
||||
* This is the same move weather.core.js's header argues for: determinism
|
||||
* STRUCTURAL, not a promise. A crate is now a pure function of its event.
|
||||
*/
|
||||
const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0;
|
||||
const eventRng = (ev, t) => {
|
||||
const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000);
|
||||
return rng((seedBase ^ Math.imul(key, 374761393)) | 0);
|
||||
};
|
||||
|
||||
const pieces = [];
|
||||
const w = new THREE.Vector3();
|
||||
@ -122,6 +154,7 @@ export function createDebris(o = {}) {
|
||||
function spawn(ev, t) {
|
||||
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
|
||||
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
|
||||
const rand = eventRng(ev, t); // this crate's own stream — see eventRng above
|
||||
|
||||
// upwind of the yard, offset sideways, so it crosses the whole thing
|
||||
const d = wind.dirAt(t);
|
||||
@ -282,6 +315,57 @@ export function createDebris(o = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ARRIVE MID-STORM at t0. [SPRINT18 — the emergency callout's entry]
|
||||
*
|
||||
* The storm ran; you just weren't there. Three classes of state, and only one
|
||||
* of them can be teleported:
|
||||
*
|
||||
* 1. closed-form in t — the wind field, rain rate, hail rate. Measured
|
||||
* order-independent (sample t=30 cold, or after walking 0→30, or after
|
||||
* scrambling 90/0/45/12/88/30: byte-identical). Needs nothing.
|
||||
* 2. a pure function of its own event — crate jitter, as of eventRng above.
|
||||
* Needs nothing, now.
|
||||
* 3. a genuine path integral — `leafDist` (metres travelled downwind) and
|
||||
* `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and
|
||||
* pretending otherwise is what made the arrival read wrong: measured on
|
||||
* the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a
|
||||
* cold entry has 0 — and stays becalmed for ~7 s while the EMA climbs
|
||||
* off zero. DESIGN.md line 149 makes the moving grass the gust front's
|
||||
* tell, and gate 1's whole premise is that the sky and the grass are the
|
||||
* only briefing a callout gets. Losing the tell for the first seven
|
||||
* seconds robs exactly the read the dispatch is asking the player to make.
|
||||
*
|
||||
* So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and
|
||||
* nothing more — the same ladder, the same code path, no shortcut. It is
|
||||
* cheap because the ambient layer is two scalars and a wind sample (crates
|
||||
* spawn, fly and despawn honestly on the way past). Do NOT "optimise" this
|
||||
* into a teleport; the pin in c.test.js exists to catch that, and the
|
||||
* negative control beside it records what the teleport actually costs.
|
||||
*
|
||||
* ⚠️ The ladder ACCUMULATES — `t += dt`, not `i * dt`. I wrote it as `i * dt`
|
||||
* first (exact multiplication, no drift) and the pin went red: 6 leaves in
|
||||
* both yards, in different places. `i * dt` is the MORE ACCURATE ladder and
|
||||
* that is precisely why it is wrong — main.js's `phaseT += dt`, testkit's
|
||||
* `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up
|
||||
* that multiplies reproduces a storm the game never flies. Measured: over
|
||||
* 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a
|
||||
* different `t` on 1770 of those 1800 steps, and they first disagree at step
|
||||
* SIX (0.09999999999999999 against 0.1). A number gathered from a harness
|
||||
* that is a hair better than the game's is still a number from the wrong
|
||||
* harness — the class of bug this repo hunts, in its smallest possible form.
|
||||
*
|
||||
* @param {number} t0 storm time to arrive at, seconds
|
||||
* @param {number} dt fixed step — pass contracts.FIXED_DT, the same ladder
|
||||
* the caller will keep stepping on
|
||||
*/
|
||||
enterAt(t0, dt) {
|
||||
const steps = Math.round(t0 / dt);
|
||||
let t = 0;
|
||||
for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; }
|
||||
return t;
|
||||
},
|
||||
|
||||
/** Drop everything (phase change, restart). */
|
||||
clear() {
|
||||
for (const p of pieces) despawn(p);
|
||||
|
||||
@ -208,6 +208,138 @@ export default async function run(t) {
|
||||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half.
|
||||
//
|
||||
// The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND
|
||||
// is the same storm however you got to t0 (it is closed-form, measured
|
||||
// order-independent). Debris was not, and it cost two determinism breaks that
|
||||
// I measured before writing a line — both recorded in debris.js beside the fix:
|
||||
//
|
||||
// · the same storm flown twice through one module spawned DIFFERENT crates
|
||||
// (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris
|
||||
// module at boot and clear()s it per phase change; clear() rewound the leaf
|
||||
// accumulators and not the piece PRNG. Live on main today — night 2 of the
|
||||
// seven-night week never flew night 1's crates, and no test looked.
|
||||
// · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate
|
||||
// the skipped crate's height, spin axis and phase — the stream sat 5 draws
|
||||
// behind. Skipping history corrupted the FUTURE, not just the present.
|
||||
//
|
||||
// Both had one cause: a crate's jitter came off a shared stream whose POSITION
|
||||
// encoded how much history had happened. It is now keyed on the event itself.
|
||||
// These three cases are what stop it coming back.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Every piece the storm spawns, snapshotted AT BIRTH. */
|
||||
const crateLog = (dbg, from, to, dt = FIXED_DT) => {
|
||||
const born = [];
|
||||
const seen = new Set();
|
||||
for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) {
|
||||
const time = i * dt;
|
||||
dbg.step(dt, time, {});
|
||||
for (const p of dbg.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
// y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and
|
||||
// were never in doubt. Round only for the message — compare exactly.
|
||||
born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
return born;
|
||||
};
|
||||
const sameCrates = (a, b) => a.length === b.length && a.every((p, i) =>
|
||||
p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx
|
||||
&& p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase);
|
||||
const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)';
|
||||
|
||||
t.test('a crate is a pure function of its event, not of what came before it', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
|
||||
// two independently built modules on the same seed
|
||||
const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
assert(sameCrates(one, two),
|
||||
`${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`);
|
||||
|
||||
// and the same module flown twice, clear()ed between: night 1 vs night 2
|
||||
const reused = createDebris({ wind: createWind(def) });
|
||||
const night1 = crateLog(reused, 0, dur);
|
||||
reused.clear();
|
||||
const night2 = crateLog(reused, 0, dur);
|
||||
assert(sameCrates(night1, night2),
|
||||
`${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
|
||||
// the reference: watch the whole storm, and count how many crates are born
|
||||
// at or after each candidate entry time
|
||||
const whole = [];
|
||||
const seen = new Set();
|
||||
const ref = mk();
|
||||
for (let i = 0; i < Math.round(dur / FIXED_DT); i++) {
|
||||
const time = i * FIXED_DT;
|
||||
ref.step(FIXED_DT, time, {});
|
||||
for (const p of ref.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
|
||||
for (const t0 of [10, 30, 45, 60]) {
|
||||
const want = whole.filter((p) => p.at >= t0);
|
||||
const got = crateLog(mk(), t0, dur);
|
||||
assert(sameCrates(want, got),
|
||||
`${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => {
|
||||
// DESIGN.md line 149: a gust front is readable because a wave of motion
|
||||
// crosses the grass a beat before it hits. The callout has NO forecast paper
|
||||
// (gate 1), so that motion is not decoration — it is the briefing. `leafDist`
|
||||
// and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are
|
||||
// the one part of my modules a mid-storm entry cannot teleport: measured, a
|
||||
// t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying
|
||||
// becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them.
|
||||
const def = storms.storm_02_wildnight;
|
||||
const T0 = 30;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) });
|
||||
const same = (a, b) => a.n === b.n && a.at.every((v, i) =>
|
||||
v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]);
|
||||
|
||||
const watched = mk();
|
||||
fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {}));
|
||||
const want = look(watched);
|
||||
assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`);
|
||||
|
||||
const arrived = mk();
|
||||
arrived.enterAt(T0, FIXED_DT);
|
||||
const got = look(arrived);
|
||||
assert(same(want, got),
|
||||
`enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`);
|
||||
|
||||
// NEGATIVE CONTROL. Without this the case above could be satisfied by a
|
||||
// do-nothing enterAt on a layer that happened to warm instantly. The naive
|
||||
// path — fresh module, first step lands at t0 — must be measurably wrong, and
|
||||
// is: 0 leaves against 6. This is also the guard against somebody later
|
||||
// "optimising" enterAt into a teleport.
|
||||
const cold = mk();
|
||||
cold.step(FIXED_DT, T0, {});
|
||||
assert(!same(want, look(cold)),
|
||||
`a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`);
|
||||
});
|
||||
|
||||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||||
|
||||
@ -901,6 +901,95 @@ export function weatherCases(storms) {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY SEAM.
|
||||
//
|
||||
// The emergency callout spawns the player at t≈30 in a storm nobody briefed
|
||||
// them on. That makes a NON-ZERO START a first-class case for the storm clock,
|
||||
// and "a number gathered from the wrong harness" is the exact class of bug this
|
||||
// repo hunts. The whole callout stands on one property: an entry at t0 must see
|
||||
// the storm a t=0 run sees at t0.
|
||||
//
|
||||
// For the WIND that property is structural — weather.core.js's header promises
|
||||
// everything is a closed-form function of (pos, t) — but promised is not pinned,
|
||||
// and the field is not as stateless as it looks: buildGustTimeline and the
|
||||
// advected-drift table are both built at construction, uniformSpeed early-exits
|
||||
// on a sorted scan, and stormStats memoises into a WeakMap. Any one of those
|
||||
// growing a cursor would make the storm depend on the order it was watched in.
|
||||
// Below: cold vs walked vs scrambled, and two independently built fields.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('the wind at t=30 does not care how you got there (the callout seam)', () => {
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const read = (f, t) => [f.uniformSpeed(t), f.gustOnly(t), f.dirAt(t), f.rainAt(t), f.hailAt(t)];
|
||||
const T0 = 30;
|
||||
|
||||
const cold = createWindField(def);
|
||||
const want = read(cold, T0);
|
||||
|
||||
// walked: every step a t=0 run would have taken, in order, first
|
||||
const walked = createWindField(def);
|
||||
for (let i = 0; i < Math.round(T0 / DT); i++) read(walked, i * DT);
|
||||
const got = read(walked, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === got[i],
|
||||
`${name}: field #${i} at t=${T0} reads ${got[i]} after being walked 0→30 but ${want[i]} cold — the wind remembers being watched, so a mid-storm entry cannot reproduce a t=0 run`);
|
||||
}
|
||||
|
||||
// scrambled: backwards, past the end, and repeated
|
||||
const scrambled = createWindField(def);
|
||||
for (const t of [90, 0, 45.5, 12, 88, T0, 1, T0, -5]) read(scrambled, t);
|
||||
const sc = read(scrambled, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === sc[i],
|
||||
`${name}: field #${i} at t=${T0} moved after out-of-order sampling — ${sc[i]} vs ${want[i]}`);
|
||||
}
|
||||
|
||||
// two independently constructed fields must agree: the gust timeline and
|
||||
// the drift table are drawn from a PRNG at build, so this is the pin that
|
||||
// says "built twice" is the same storm as "built once".
|
||||
const twin = read(createWindField(def), T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === twin[i],
|
||||
`${name}: field #${i} at t=${T0} differs between two freshly built fields — ${twin[i]} vs ${want[i]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the storm the dispatch quotes is the storm a BACKWARDS scan finds', () => {
|
||||
// Two harnesses, one number — the repo's oldest rule, aimed at the entry seam.
|
||||
// stormStats scans FORWARD from 0 and everything worded (the dispatch, the
|
||||
// offer band, the honesty gate) is built on its peaks. A mid-storm arrival is
|
||||
// the first consumer that reads the field at a t nobody walked up to, so scan
|
||||
// it in the one order no production code uses — downwards from the end — and
|
||||
// insist on the same peak. A field that remembers being watched fails here and
|
||||
// nowhere else: caught the resume-cursor mutation on uniformSpeed (14.6 m/s
|
||||
// against 18.58 on the wildnight, a 21% understatement of the gust peak).
|
||||
//
|
||||
// Deliberately NOT `stormStats(def)` twice: it memoises per def in a WeakMap,
|
||||
// so the second call hands back the SAME OBJECT and comparing them is
|
||||
// comparing an object to itself. That version of this case could not fail.
|
||||
const STEP = 0.05;
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const s = stormStats(def);
|
||||
const f = createWindField(def);
|
||||
const dur = def.duration ?? 90;
|
||||
let gustPeak = 0, rainPeak = 0, hailPeak = 0;
|
||||
for (let t = Math.floor(dur / STEP) * STEP; t >= 0; t -= STEP) {
|
||||
gustPeak = Math.max(gustPeak, f.uniformSpeed(t));
|
||||
rainPeak = Math.max(rainPeak, f.rainAt(t));
|
||||
hailPeak = Math.max(hailPeak, f.hailAt(t));
|
||||
}
|
||||
// Same grid, opposite direction: this is an equality, not a tolerance.
|
||||
assert(Math.abs(gustPeak - s.gustPeak) < 1e-9,
|
||||
`${name}: scanned backwards the gust peak is ${gustPeak.toFixed(3)} m/s, but the dispatch quotes ${s.gustPeak.toFixed(3)} — the field depends on the order it was read`);
|
||||
assert(Math.abs(rainPeak - s.rainPeak) < 1e-9,
|
||||
`${name}: backwards rain peak ${rainPeak.toFixed(4)} vs quoted ${s.rainPeak.toFixed(4)}`);
|
||||
assert(Math.abs(hailPeak - s.hailPeak) < 1e-9,
|
||||
`${name}: backwards hail peak ${hailPeak.toFixed(4)} vs quoted ${s.hailPeak.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user