Lane B S13: gate-3 smalls — rig.t was a determinism bug, + prep readouts

Four of the QA/D "smalls", one of which was not small.

rig.t never reset between nights: main.js builds one SailRig at boot and
re-attach()es it each night while step() runs in every phase, so the clock ran
on and night 2 sampled its storm from wherever night 1's aftermath left off —
30-50% weaker, past the end of the curve, and offset by however long the invoice
was on screen. A determinism break. Fixed in attach() (zeroes t and _acc);
asserted as "two identical nights, one rig -> byte-equal traces", mutation-checked.

standing-kN preview (D's "$60 blind"): the prep panel shows each corner's
still-air load vs its effective rating, and a summary line, so the tension dial's
static cost is visible before you pay. At 1.4, p4 reads 1.62/1.2 kN !! RIPS AT
REST. session.standingLoads() measures on unbreakable hardware (measuring load,
not rehearsing failure). Cross-checks D's in-play 46m2 number (1.41 kN) exactly.

Panel honesty (D): prep list prints effective rating with the hint shown as its
discount (cb1 0.12/0.3 kN×0.22) — the weak-link arrow now explains itself.

setHardware() throws on non-shop hardware (incl. lookalikes — the shop compares
by identity) instead of silently no-opping; player-reachable failures still
return {ok:false}. balance.test documents the old silent-fail bite.

BLOWN label collision (hud.js — A's file, flagged in THREADS): screen-space
de-collision stacks overlapping corner bars. Verified by projecting cb1/cb2's
real positions through a swept camera — 6/72 viewpoints collide (all ~12m, where
the QA played), thresholds catch exactly those.

Gate 1.2 and the fuse-line stretch stay held on C's geometry fix + A's garden
export. Withdrew my night-1 flag (the card says it's the tutorial night by
design); the ice-night win-line question stands.

selftest 339/0/0 (335 + 4 new).
This commit is contained in:
type-two 2026-07-18 01:04:02 +10:00
parent e839f5afe3
commit 7048ad6482
6 changed files with 402 additions and 7 deletions

View File

@ -4645,3 +4645,68 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
(4-5 are already bank-funded) the fee/bonus retune proposal comes with the table as evidence — A rules.
**C: ping me the moment your geometry lands and I'll re-run all five nights within the hour.** The
probe is built and parameterised; it's one URL per night.
---
[B] 2026-07-18 — 🔧 **GATE 3 SMALLS — four landed, plus a correction to my own gate-1 flag. One of the
"smalls" was a determinism bug, not a nit.**
**`rig.t` never reset between nights — and it was breaking the sim, not just the panel.** D's pool
item, filed as cosmetic; it isn't. main.js builds ONE SailRig at boot and re-`attach()`es it every
night, and `step()` runs every frame in EVERY phase once rigged — so the sim clock ran on through
the aftermath and the forecast card, and night 2 opened its storm wherever night 1 left off.
Measured, wild night, p1..p4 rated:
· clock reset (as authored): p4 **3.65** kN p2 2.78 p3 1.62 p1 0.98
· clock carried to t=110: p4 **2.60** kN p2 1.92 p3 0.81 p1 0.54
Every night after the first flew a storm 30-50% weaker than C authored — sampled past the end of
its own baseCurve, so no build and no peak — and the offset was however long you stared at the
invoice, so it wasn't even the same wrong storm twice. **A determinism break in a repo whose whole
sim rests on not having one.** Fixed in `sail.js` `attach()` (zeroes `this.t` and `this._acc`);
commit → attach → storm all land in one keypress, so t=0 at attach is t=0 at the first storm frame.
Asserted as the PROPERTY (two identical nights on one rig → byte-equal traces), mutation-checked by
deleting the reset (night 2 diverges at sample 0). sail.selftest 40/40.
**standing-kN preview (D's "first lesson costs $60 blind").** The prep panel now shows each corner's
still-air load against its EFFECTIVE rating, plus a summary line: `standing ≈1.03 kN/corner at this
tension (worst 1.62) — before any wind`. Crank tension 1.0→1.4 on the backyard cover quad and p4
goes `0.23/1.2``1.62/1.2 kN !! RIPS AT REST` — the drum-tight prep-rip is visible before you pay
for it. `session.standingLoads()` measures the load on UNBREAKABLE hardware (measuring the load, not
rehearsing the failure — attach it with the real tiers and the corner rips during the settle and
reads a serene 0 N; the selftest caught exactly that). Cross-checked against D: the 46 m² carport
quad reads worst-corner 1.41 kN at tension 1.4; D measured "~1.4-1.5 kN/corner at 46 m²" in play.
Two harnesses, same number. Cached on (picks, tension, fabric) so cycling hardware never recomputes.
**D's paired panel-honesty gap, same fix.** The prep list printed the bare hardware kN while the
weak-link arrow reasoned on rating × hint — "four equal 1.2s and one unexplained arrow". The column
now prints the effective rating and shows the hint as the discount it is: `cb1 carabiner 0.12/0.3
kN×0.22 <- weak link`. The arrow has its reason on the same line.
**`setHardware()` no longer no-ops silently.** It THROWS on hardware that isn't a shop item (a
programmer error — no click can produce it), where player-reachable failures ('not rigged', 'not
enough budget') still return {ok:false}. balance.test's own comment records the old bite: "setHardware
fails silently, cheap hardware stays on, p2/p4 tear off" — a cascade misread as a finding. Also
catches a LOOKALIKE (a copy of a real tier): the shop compares by identity.
**BLOWN label collision (in hud.js, which is A's file — flagging that I touched it).** The prompt
filed this under me as "marker layout is rigging.js", but the corner bars live in hud.js's update
loop, ~80 lines above A's card system. I added a screen-space de-collision in the bar loop only
(stack bars whose clip-space labels overlap; deterministic, O(n²) on 4). **A — if this fights your
splash-card work, back it out and it's a 20-line hunk I'll re-land wherever you prefer; nothing else
of mine depends on it.** Verified by projecting cb1/cb2's real dressed positions through a Three
camera swept around the yard: 6 of 72 player viewpoints collide (all at ~12 m, which is where the QA
played from), and the thresholds catch exactly those and nothing closer. No headless assert — it
needs a live camera — but the sweep is in my scratch notes if anyone wants it.
**↩ Correction to my 2026-07-21 gate-1 flag #2 (night 1's sail worth +1.5 HP).** I called that a
problem; it's intended. Night 1's job card reads "Nothing tonight can hurt the garden. Learn the
anchors." — it's the tutorial night by design, so +1.5 HP is correct, not a gap. **Flag #1 stands:
the ice night's garden tops out at 27.7 HP against WIN≥50 — that's still a real question for A once
C's geometry lands.** Withdrawing the night-1 half so A isn't chasing a non-bug.
[B] 2026-07-18 — ⏳ **Gate 1.2 (audit predicts the sim) and the mixed-hardware stretch are both HELD on
C's shadow-geometry fix + A's garden-model export.** No point re-ranking the audit or sweeping fuse
lines against a garden model that's about to change under me — I'd be measuring twice and shipping
the wrong number. `garden_probe.html` is built and parameterised (one URL per night); the moment
C's geometry lands and A exports `createGarden`, I re-run all five nights, wire the sim's garden HP
into the audit as the ranking quantity, re-post the winnable-lines table, and rule on the fuse
lines against real numbers. Ping me. Everything unblocked this sprint is done.

View File

@ -334,6 +334,18 @@ export function createHud(d) {
const quad = new THREE.PlaneGeometry(1, 1);
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
const BAR_W = 0.9, BAR_H = 0.1;
/**
* When two corner bars count as "on top of each other", in clip space (NDC
* spans -1..1 across the viewport), and how far apart to stack them.
*
* Measured off the collision the QA pass actually hit CB1/CB2 on the carport
* beam, viewed from where a player stands to rig them. A bar is ~0.9 world
* units wide and scaled to hold constant screen size, so these are roughly
* "one bar wide, one line tall" and don't need to track the bar's dimensions.
* Y is the tighter of the two on purpose: labels stack vertically, so a pair
* side-by-side is already readable and only a vertical overlap is mush.
*/
const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34;
const bars = [];
function ensureBars(n) {
@ -391,6 +403,14 @@ export function createHud(d) {
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _cam = new THREE.Vector3();
// Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where
// each bar landed in clip space, so bars that typeset on top of each other can
// be stacked instead. Pooled rather than reallocated — this runs every frame —
// and each entry carries `live`, because a corner that has no position this
// frame must not leave last frame's ghost for the others to stack against.
const _ndc = new THREE.Vector3();
const _placed = [];
const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false }));
// --- helpers ------------------------------------------------------------
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
@ -473,7 +493,7 @@ export function createHud(d) {
const c = d.rig.corners[i];
const b = bars[i];
const p = cornerPos(i);
if (!p) { b.holder.visible = false; continue; }
if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; }
_p.set(p.x, p.y, p.z);
b.holder.position.copy(_p);
@ -486,7 +506,43 @@ export function createHud(d) {
// out there rather than in a corner of the screen. Clamped so it doesn't
// balloon when you walk right up to a corner to repair it.
const dist = _p.distanceTo(_cam);
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
const scale = Math.max(0.75, Math.min(3.2, dist / 7));
b.holder.scale.setScalar(scale);
/**
* Stack bars that would typeset on top of each other. [B's flag, SPRINT13]
*
* The QA pass: "CB1/CB2 died together and typeset on top of each other".
* The carport's two beam anchors are 2.82 m apart and the sail hangs
* between them, so from most of the yard their bars land in the same
* handful of pixels and they are exactly the pair most likely to blow
* together, because they share a beam and a 0.22 ratingHint. The one
* moment the HUD has to be legible ("both beam corners went, that's why
* the sail is on the lawn") was the one moment it turned to mush.
*
* Screen space, not world space: whether two labels collide is a fact
* about the camera, and two corners a metre apart are unreadable from
* the fence and perfectly clear from underneath. Compared at the anchor
* point rather than after nudging, so the test is order-independent and
* a stack of three can't chase itself up the screen. Deterministic
* corner index order, no clock and O() on n=4.
*
* The offset rides `scale` so the gap is constant on screen at any range,
* the same reason the bar itself is scaled.
*/
_ndc.copy(b.holder.position).project(d.camera);
const onScreen = _ndc.z < 1; // behind the camera projects to nonsense
let clash = 0;
if (onScreen) {
for (let k = 0; k < i; k++) {
const o = _placed[k];
if (!o || !o.live) continue;
if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++;
}
}
const slot = placedSlot(i);
slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen;
if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale;
// SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
// rating × ratingHint, because that's the number sail.js fails on now.

View File

@ -12,8 +12,8 @@
* the bottom for the seam it will plug into.
*/
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
@ -161,10 +161,34 @@ export class RiggingSession {
return OK;
}
/**
* Put specific hardware on a rigged corner, paying the difference.
*
* THROWS on hardware that isn't in the shop, where every other failure here
* returns {ok:false}. The split is deliberate and it is about who made the
* mistake: 'not rigged' and 'not enough budget' are things a PLAYER does, and
* the UI tickers the reason. Hardware that isn't in HARDWARE is not reachable
* by any click the picking UI only ever cycles the shop's own list so it
* can only mean a caller passed the wrong thing, and returning {ok:false} for
* that just lets a bug walk.
*
* It already had: balance.test's own comment records "setHardware fails
* silently, cheap hardware stays on, p2/p4 tear off" a loadout that quietly
* kept its carabiners and then reported a cascade as if it were a finding.
* The SPRINT12 QA pass hit the same edge from the other side. A wrong tier is
* invisible in the result (the sim just runs, cheaper), so this is exactly the
* class of mistake that must not be ignorable.
*/
setHardware(anchorId, hw) {
if (!HARDWARE.includes(hw)) {
throw new TypeError(
`setHardware("${anchorId}", ${JSON.stringify(hw?.name ?? hw)}) — not a shop item. ` +
`Pass a HARDWARE entry (${HARDWARE.map((h) => h.name).join(' / ')}), not a name or a copy: ` +
'the shop compares by identity. A returned {ok:false} here would leave the old ' +
'hardware on the corner and the sim would run cheaper without telling anyone.');
}
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
@ -232,6 +256,66 @@ export class RiggingSession {
return p.hw.rating * (a?.ratingHint ?? 1);
}
/**
* What each corner carries in STILL AIR at the current tension, newtons.
* Null until four corners are picked a quad is the only thing that has a load.
*
* D's ask, SPRINT13 ("the first lesson always costs ~$60"): the tension dial
* has a static price and the panel never showed it. At 1.4 the standing load
* alone is ~1.4-1.5 kN/corner on a 46 quad, which is over every tier the
* shop sells except a rated shackle on an honest anchor so a drum-tight sail
* rips corners off during PREP, on the calm day, before a breath of wind. That
* reads fair when you can see it coming and daylight robbery when you can't.
*
* Still air, not "calm day": this is the number the DIAL owns. Gravity and
* tension only no gusts, no forecast, nothing that could be mistaken for a
* prediction about tonight. The storm multiplies this by ~3-8x and the panel
* says "standing" for exactly that reason.
*
* Cheap enough to call on a click: 15.6 ms of settle on a 10×10 grid (measured),
* and the result is cached by the caller on (picks, tension, fabric) hardware
* doesn't move the cloth, so cycling tiers, the most-clicked action in prep,
* never recomputes.
*
* 4 s of settle, not 2: the cloth arrives at rest through a damped oscillation,
* and at low tension 2 s still reads ~4% high on the way down (0.195 vs 0.188
* kN converged). 4 s is inside 1% everywhere measured and costs 8 ms more, once.
*
* Cross-checked against the game the honest way: on the corner block's real
* 46 carport quad this reports a worst corner of 1.41 kN at tension 1.4
* D measured "~1.4-1.5 kN/corner at 46 m²" in play, from the other side.
*/
standingLoads({ settle = 4.0 } = {}) {
if (this.picks.length !== MAX_CORNERS) return null;
const rig = new SailRig({ anchors: this.anchors, gridN: 10, porosity: this.fabric.porosity });
/**
* UNBREAKABLE hardware, deliberately and this is the whole subtlety.
*
* The preview measures the LOAD, which is a fact about geometry and the
* dial; it is not a rehearsal of tonight. Attach it with the player's real
* tiers and at tension 1.4 the corners genuinely rip during this very
* settle (D measured it: three go at t0.5 on the calm day) and a broken
* corner carries nothing, so the panel would print a serene `0.00 kN` for
* the exact corner about to take the night down. The first version of this
* did that and the selftest below caught it.
*
* So: measure the load unbroken, then let the PANEL compare it against the
* corner's real effective rating and say "RIPS AT REST". The number stays
* honest and the warning lands where the player can act on it.
*/
const UNBREAKABLE = { name: 'preview', cost: 0, rating: Infinity };
rig.attach(this.picks.map((p) => p.anchorId), Array(MAX_CORNERS).fill(UNBREAKABLE), this.tension);
// Still air. `sample` must honour the out-param — sail.js reuses one vector.
const stillAir = {
sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }),
rainMmPerHour: () => 0,
};
for (let i = 0, n = Math.round(settle / FIXED_DT); i < n; i++) rig.step(FIXED_DT, stillAir, i * FIXED_DT);
const out = {};
for (const c of rig.corners) out[c.anchorId] = c.load;
return out;
}
/** Everything the HUD needs to draw the prep panel, in one read. */
get summary() {
return {
@ -241,7 +325,16 @@ export class RiggingSession {
spares: this.spares,
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
canStart: this.canStart,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
// `effRating` rides along with the bare one: D's panel-honesty gap — the
// weak-link arrow reasoned on rating × ratingHint while the printed kN was
// the bare steel, so a cold player saw four identical 1.2s and one
// unexplained arrow. The panel prints the effective number now; the bare
// rating stays for anyone who wants to show the steel's own spec.
corners: this.picks.map((p) => ({
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
effRating: this._effRating(p),
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
})),
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
@ -407,23 +500,63 @@ export async function createRiggingUI({
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
}
/**
* Standing load per corner, cached on (picks, tension). [D's ask, SPRINT13]
*
* The cloth doesn't care what steel hangs off it, so cycling hardware the
* most-clicked action in prep never recomputes. Only closing the quad or
* moving the dial does, which is exactly when the number changes.
*/
let standingKey = null, standingCache = null;
function standing() {
const key = `${session.picks.map((p) => p.anchorId).join(',')}|${session.tension.toFixed(2)}|${session.fabric.id}`;
if (key !== standingKey) { standingKey = key; standingCache = session.standingLoads(); }
return standingCache;
}
function refresh() {
if (!el) return;
const s = session.summary;
const load = standing();
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
// block ships `tr1b` and the column broke the moment a second site existed.
const rows = world.anchors.map((a) => {
const pick = session.pickOf(a.id);
if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)}`;
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
// The EFFECTIVE rating, not the steel's spec — D's panel-honesty gap: the
// arrow reasoned on rating × hint while this column printed the bare kN,
// so a carport beam and a house post both read "1.2 kN" and only one of
// them was telling the truth. This is the number sail.js fails on.
const corner = s.corners.find((c) => c.anchorId === a.id);
const eff = (corner.effRating / 1000).toFixed(1);
// "1.2×0.22" — the hint is shown as the discount it is, so the arrow has
// a reason on the same line instead of being folded into a smaller number
// the player can't account for.
const hint = corner.hint !== 1 ? `×${corner.hint}` : '';
const st = load?.[a.id] != null ? `${(load[a.id] / 1000).toFixed(2)}` : '—';
const over = load?.[a.id] != null && load[a.id] > corner.effRating;
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${st.padStart(4)}/${eff.padEnd(4)}kN${hint.padEnd(6)} $${pick.hw.cost}` +
`${over ? ' !! RIPS AT REST' : weak}`;
});
const area = quadArea();
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
// the first lesson always costs ~$60". At 1.4 the standing load alone is
// over most of the shop. Said as a per-corner average because that's the
// number the DIAL owns; the per-corner column above is where you find which
// one is about to let go.
const vals = load ? Object.values(load) : [];
const avg = vals.length ? vals.reduce((x, y) => x + y, 0) / vals.length : 0;
const worst = vals.length ? Math.max(...vals) : 0;
const standingLine = vals.length
? `standing ≈${(avg / 1000).toFixed(2)} kN/corner at this tension (worst ${(worst / 1000).toFixed(2)}) — before any wind`
: null;
el.textContent = [
`PREP — rig four corners $${s.budget} left`,
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
'',
...rows,
...(standingLine ? ['', standingLine] : []),
'',
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
'click anchor: rig / cycle hw shift-click: remove',

View File

@ -156,6 +156,73 @@ test('setAnchors moves the session to a new yard and drops the old picks', () =>
return 'session followed the site; stale picks did not';
});
test('setHardware THROWS on hardware the shop does not sell', () => {
// SPRINT13, QA pass: it returned {ok:false} and callers that didn't check
// sailed on with the old tier still hanging. balance.test's own comment
// records the bite — "setHardware fails silently, cheap hardware stays on,
// p2/p4 tear off" — a cascade reported as a finding. A wrong tier is
// invisible in the result, so this one must not be ignorable.
const s = session();
s.rig('h1');
let threw = null;
try { s.setHardware('h1', { name: 'titanium', cost: 0, rating: 99999 }); }
catch (e) { threw = e; }
assert(threw, 'a hardware object that is not in HARDWARE was accepted silently');
assert(threw instanceof TypeError, `expected a TypeError, got ${threw?.constructor?.name}`);
assert(s.pickOf('h1').hw === CARABINER, 'the refused tier must not have been applied');
// A LOOKALIKE is the real trap: same shape, same numbers, wrong identity.
let threw2 = null;
try { s.setHardware('h1', { ...CARABINER }); } catch (e) { threw2 = e; }
assert(threw2, 'a copy of a real HARDWARE entry was accepted — the shop compares by identity');
// and the player-reachable failures stay {ok:false}, not throws
assert(s.setHardware('p3', RATED).ok === false, 'an unrigged corner should fail, not throw');
return 'unknown tiers throw; player-reachable failures still return {ok:false}';
});
test('standing load: the tension dial has a price, and the panel can see it', () => {
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
// the first lesson always costs ~$60" — at 1.4 the standing load ALONE rips
// corners off during prep, on the calm day. This is the number that makes
// that visible before the money is spent.
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
s.setTension(0.7);
const loose = s.standingLoads();
s.setTension(1.4);
const tight = s.standingLoads();
assert(loose && tight, 'a closed quad must have standing loads');
assert(Object.keys(tight).length === 4, `expected 4 corners, got ${Object.keys(tight).length}`);
for (const id of Object.keys(tight)) {
assert(Number.isFinite(tight[id]) && tight[id] > 0, `${id}: standing load ${tight[id]} is not a real load`);
// The whole point: drum-tight costs more at rest than loose does. If this
// ever ties, the dial is decoration and D's $60 lesson teaches nothing.
assert(tight[id] > loose[id],
`${id}: standing load at tension 1.4 (${tight[id].toFixed(0)} N) is not above tension 0.7 ` +
`(${loose[id].toFixed(0)} N) — the dial has no static price, so the prep readout is a lie`);
}
// still air means still air: no wind, no gust, nothing that could be mistaken
// for a forecast. Deterministic — same picks and dial, same number, always.
s.setTension(1.4);
const again = s.standingLoads();
for (const id of Object.keys(tight)) {
assert(Math.abs(again[id] - tight[id]) < 1e-9, `${id}: standing load is not deterministic`);
}
const avg = Object.values(tight).reduce((a, b) => a + b, 0) / 4;
return `tension 0.7 -> 1.4 lifts the standing load to ≈${(avg / 1000).toFixed(2)} kN/corner, repeatably`;
});
test('standing load is null until the quad is closed', () => {
const s = session();
assert(s.standingLoads() === null, 'no picks should have no standing load');
for (const id of ['h1', 'h3', 'p1']) s.rig(id);
assert(s.standingLoads() === null, 'three corners is not a sail — there is no load to read');
s.rig('p2');
assert(s.standingLoads() !== null, 'four corners is a sail and must report');
return 'null until four corners, then real';
});
test('picks come back ring-ordered however you click them', () => {
const s = session();
// deliberately crossing order: two diagonals first

View File

@ -185,6 +185,39 @@ export class SailRig {
const ring = orderRing(picked);
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
/**
* A new sail is a new clock. [SPRINT13 D's pool nit, and it wasn't a nit]
*
* `this.t` is the sim's OWN fixed-step clock: step() burns ragged frame dt
* into SIM_DT chunks and samples the wind at this.t, which is the whole
* reason a 144 Hz browser and a fast-forwarded selftest produce byte-equal
* traces. But it was only ever zeroed in the constructor, and main.js builds
* ONE SailRig at boot and re-attach()es it every night while step() runs
* every frame in EVERY phase (storm, aftermath, the forecast card) the
* moment `rigged` is true.
*
* So night 2 opened its storm with the clock wherever night 1's aftermath
* left it, and sampled the storm curve from there. Measured on the wild
* night, p1..p4 with rated shackles:
*
* clock reset (as authored) p4 3.65 kN p2 2.78 p3 1.62 p1 0.98
* carried t=110 (night 2) p4 2.60 kN p2 1.92 p3 0.81 p1 0.54
*
* Every night after the first flew a storm 30-50% weaker than the one C
* authored past the end of its own baseCurve, so it read the tail breeze
* flat, with no build and no peak. Worse, the offset was however long the
* player spent reading the invoice, so it wasn't even the same wrong storm
* twice: a determinism break in a repo whose whole sim is built on not
* having one.
*
* Zeroed here rather than in main.js because attach() is the one door every
* caller comes through (boot, the picking adapter, balance.test, the audit),
* and commit attach game.advance() storm all land in a single keypress,
* so t=0 at attach IS t=0 at the first storm frame.
*/
this.t = 0;
this._acc = 0;
this.corners = ring.map((a) => ({
anchorId: a.id,
anchor: a,

View File

@ -317,6 +317,47 @@ test('determinism: variable frame dt matches fixed dt', () => {
return 'ragged frame times converge on the fixed-dt trace';
});
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
// night, and step() runs in EVERY phase once rigged — so the clock ran on
// through the aftermath and the forecast card, and night 2 opened its storm
// wherever night 1 left off, sampling the curve past its own end. The offset
// was however long the player read the invoice for, so it wasn't even the
// same wrong storm twice.
//
// Asserted as the PROPERTY that broke rather than `t === 0`: two identical
// nights on one rig must produce identical load traces. That stays true if
// the clock is ever reset somewhere else, and it goes red the moment the
// reset leaves attach() — which is the mutation-check (delete `this.t = 0`
// from attach and the second trace diverges on sample 0).
const trace = (r) => {
const w = makeStubWind({ seed: 7, stormLen: 30 });
const out = [];
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
return out;
};
const r = rig(HEIGHTS_HYPAR);
const night1 = trace(r);
// the aftermath: the sail is still up and still being stepped while the
// player reads their invoice. This is the frame budget that used to poison
// the next night.
const calm = makeStubWind({ seed: 7, stormLen: 30 });
for (let i = 0; i < Math.round(20 / SIM_DT); i++) r.step(SIM_DT, calm, i * SIM_DT);
r.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0); // night 2, same rig object
const night2 = trace(r);
assert(night1.length === night2.length, `night 1 traced ${night1.length} samples, night 2 ${night2.length}`);
for (let i = 0; i < night1.length; i++) {
assert(night1[i] === night2[i],
`night 2 diverged from night 1 at sample ${i}: ${night1[i]} vs ${night2[i]}` +
'the rig carried its clock across attach(), so night 2 is flying a different storm ' +
'(and one that depends on how long the aftermath was on screen)');
}
return `${night1.length} load samples identical across a re-attach — same storm both nights`;
});
test('tension dial changes load (drum tight shock-loads)', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);