diff --git a/THREADS.md b/THREADS.md index a00cef5..e2674ee 100644 --- a/THREADS.md +++ b/THREADS.md @@ -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. diff --git a/web/world/js/hud.js b/web/world/js/hud.js index fe4f340..af0f58d 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -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(nΒ²) 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. diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index 3d4fde6..75e055f 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -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 mΒ² 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 mΒ² 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 tβ‰ˆ0.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', diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index 922b373..2833e7f 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -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 diff --git a/web/world/js/sail.js b/web/world/js/sail.js index d30ec41..614a46d 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -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, diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index e520f43..2036f34 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -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);