From 60771875d69b443397f36d7897da603cbc95c71c Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 21:30:05 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20D=20S18=20gate=201.3:=20the=20knife's=20?= =?UTF-8?q?pins=20=E2=80=94=20including=20one=20for=20the=20feature-detect?= =?UTF-8?q?=20that=20lied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten asserts in d.test.js. The reach rule (a post cleat yes, the carport beam never, up a ladder never), the hold and its three interruptions, the read with both halves of the trade priced through main.js's OWN collateral walk, and the money split with a vacuity guard in both directions — the same four corners cut cost $0 and blown cost $205, because a pin where both cases read zero is a pin that is not working. Two that exist because of what playing the night found. The shipped emergency's rig is pinned as DATA — cb1 is a shackle and it is FLYING when you arrive, because a carport corner already on the ground is a receipt and not a decision. And every rig door main.js feature-detects on that path is asserted to EXIST: failCorner, cutAway, seedClock, setFabric, plus attach's {at}. A feature-detect is only safe if something checks the feature — `?.()` on a renamed method ran silently for a whole sprint and looked exactly like a call. Co-Authored-By: Claude Fable 5 --- web/world/js/tests/d.test.js | 346 ++++++++++++++++++++++++++++++++++- 1 file changed, 344 insertions(+), 2 deletions(-) diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index bb6af42..a97e913 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -23,14 +23,24 @@ import { loadStorm, createWind } from '../weather.js'; // SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-law pins at the bottom of this // file read the ladder. week.js is contracts-only underneath, so this stays // headless-safe. -import { NIGHTS, nightAt } from '../week.js'; +import { NIGHTS, nightAt, REP } from '../week.js'; // SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL // (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a // site file this suite merely fetch()ed raw could rot invalid and stay green). // loadSite pulls world.js which pulls THREE — browser-only, same as the GLB // facts everywhere else in this repo; the node runner never imports this file. -import { POOL, exposureOf } from '../board.js'; +import { POOL, exposureOf, EMERGENCIES } from '../board.js'; +// SPRINT18 gate 1.3 — the knife's physics half, imported for ONE reason: to check that the doors +// main.js feature-detects on the emergency path exist. See that test. +import { SailRig } from '../sail.js'; import { loadSite } from '../world.js'; +// SPRINT18 gate 1.3 — the knife. knife.js is headless-importable for the same reason ladder.js is +// (its GLB-free, and the panel guards on `typeof document`); the ledger halves come from main.js, +// which a.test and c.test already import, because scoreRun lives behind a WebGL context and the only +// way to assert its rulings is to have them extracted as pure functions. +import { createKnife, createVerbPanel, cuttable, KNIFE_TUNE } from '../knife.js'; +import { answerableFor, collateralWalk, rigRecordFor, verdictFor } from '../main.js'; +import { createWeek } from '../week.js'; const DT = FIXED_DT; @@ -1328,4 +1338,336 @@ export default async function run(t) { assertEq(rustItem.cost, 45, 'the rust is the cheapest structure you can break — the stake E sized the gamble in'); }); + + // ================================================================ SPRINT18 gate 1.3 — THE KNIFE + // + // DESIGN.md line 165, and the signature decision under load: release, cut, or ride it out. B built + // `rig.cutAway`; these pin the VERB — where it reaches, what interrupts it, what the read promises, + // and (the half that would have shipped as a trap) what it costs on the paperwork. + // + // A note on the fixtures: they are the SHIPPED emergency's own geometry, not invented numbers. + // site_02's cowboy rig is q3/q1/tr1b/cb1, and cb1 is a carport BEAM — `work:'bracket'`, y=2.36, + // ratingHint 0.22, the corner that takes the $180 carport at t=35.9. It is the one corner the knife + // must refuse and the whole reason the verb is a whole-sail cut. + + /** A rig shaped like sail.js's: corners with `broken`/`cut`, and a cutAway that behaves like B's. */ + function fakeRig(corners) { + const cuts = []; + return { + rigged: true, + corners, + cuts, + cutAway(ids = null, reason = null) { + const want = ids ?? corners.map((c) => c.anchorId); + const cut = []; + for (const id of want) { + const c = corners.find((x) => x.anchorId === id); + if (!c || c.broken) continue; + c.broken = true; c.cut = true; cut.push(id); + } + if (!cut.length) return { ok: false, reason: 'the sail is already down' }; + cuts.push({ corners: cut, reason }); + return { ok: true, cut }; + }, + }; + } + /** The corner block's real anchors, with the beam's mechanism as E baked it. */ + const YARD = { + anchors: [ + { id: 'q1', type: 'post', work: 'cloth', pos: { x: -3.5, y: 3.95, z: -2.5 } }, + { id: 'q3', type: 'post', work: 'cloth', pos: { x: 2.0, y: 3.95, z: 4.0 } }, + { id: 'tr1b', type: 'tree', pos: { x: -6.0, y: 5.05, z: 3.0 } }, + { id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -7.0, y: 2.36, z: -3.0 }, collateral: 'carport' }, + ], + gnome: { collateralValue: 25 }, + anchor(id) { return this.anchors.find((a) => a.id === id) ?? null; }, + collateralFor(key) { return key === 'carport' ? { cost: 180, label: 'the carport' } : null; }, + }; + const fourCorners = () => [ + { anchorId: 'q3', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } }, + { anchorId: 'q1', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } }, + { anchorId: 'tr1b', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } }, + { anchorId: 'cb1', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } }, + ]; + const knifeAt = (p, rig, extra = {}) => createKnife({ + player: p, getRig: () => rig, getWorld: () => YARD, + collateralAtRisk: collateralWalk, ...extra, + }); + /** Stand on a cleat. The cleat is the anchor's x/z at ground level — see knife.js. */ + const standAt = (p, id) => { const a = YARD.anchor(id); p.pos.x = a.pos.x; p.pos.z = a.pos.z; }; + + t.test('knife: it reaches a post cleat from the grass and NEVER the bracket', () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const corners = fourCorners(); + const K = knifeAt(p, fakeRig(corners)); + + standAt(p, 'q3'); + assertEq(K.target()?.anchorId, 'q3', 'standing at the post cleat, the knife is offered'); + standAt(p, 'tr1b'); + assertEq(K.target()?.anchorId, 'tr1b', 'and at the tree strop'); + + // THE ONE THAT MATTERS. cb1 is the corner about to take the $180 carport, and it is a bracket at + // 2.36 m — the same rule that costs a fascia repair two trips and a ladder. You cannot cut there, + // which is why the verb releases the WHOLE sail from a corner you can reach. + standAt(p, 'cb1'); + assertEq(K.target(), null, + 'the carport BEAM is bracket work — a knife cannot reach it, and having no time to fetch the ' + + 'ladder is the emergency\'s whole premise'); + assert(!cuttable(corners[3], YARD.anchor('cb1')), 'cuttable() states the same rule directly'); + // and the read says WHICH TOOL, rather than going silent — the greyed-prompt rule, S13 + assert(/ladder/.test(K.read().whyNot ?? ''), 'the refusal names the tool it would need'); + + // out of range of everything: still a sail, still no cut + p.pos.x = 0; p.pos.z = 0; + assertEq(K.target(), null, 'mid-yard is not at a cleat'); + // up the ladder: both hands are on the rungs + standAt(p, 'q3'); + p.climbY = 2.35; + assertEq(K.target(), null, 'you cannot cut from up a ladder'); + }); + + t.test('knife: hold X for the full 1.2 s and the WHOLE sail goes — once', () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const rig = fakeRig(fourCorners()); + const K = knifeAt(p, rig); + standAt(p, 'q3'); + + // half a hold is no cut, and that is the deliberation the verb is made of + fixedLoop(KNIFE_TUNE.cutSecs * 0.5, DT, (dt, tt) => K.step(dt, tt, true)); + assertEq(rig.cuts.length, 0, 'half a hold cuts nothing'); + assert(K.cutting, 'and the radial is running'); + + fixedLoop(KNIFE_TUNE.cutSecs * 0.6, DT, (dt, tt) => K.step(dt, 1 + tt, true)); + assertEq(rig.cuts.length, 1, 'the full hold cuts'); + assertEq(rig.cuts[0].corners.length, 4, + 'the WHOLE sail — DESIGN.md line 165, and B\'s cutAway() with no argument'); + assertEq(rig.cuts[0].reason, 'the carport', + 'the paperwork carries what it was FOR: the most expensive thing those corners were holding up'); + + // one press, one cut. Without the latch the held key re-arms and the ticker fills with refusals. + fixedLoop(3, DT, (dt, tt) => K.step(dt, 5 + tt, true)); + assertEq(rig.cuts.length, 1, 'a held key must not try again every 1.2 s'); + assertEq(K.read().state, 'none', 'and with no live corner there is nothing left to offer'); + }); + + t.test('knife: letting go, walking off, or being put down all abort the cut', () => { + const start = () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const rig = fakeRig(fourCorners()); + const K = knifeAt(p, rig); + standAt(p, 'q3'); + fixedLoop(KNIFE_TUNE.cutSecs * 0.7, DT, (dt, tt) => K.step(dt, tt, true)); + assert(K.progress > 0.5, 'mid-cut'); + return { p, rig, K }; + }; + + const a = start(); + a.K.step(DT, 1, false); // let go + assertEq(a.K.progress, 0, 'releasing X abandons the cut'); + const b = start(); + b.p.pos.x += 4; // walk off the cleat + b.K.step(DT, 1, true); + assertEq(b.K.progress, 0, 'walking away abandons it too'); + const c = start(); + c.p.knockdown(1, 1, 0); // the storm votes + c.K.step(DT, 1, true); + assertEq(c.K.progress, 0, 'a gust that floors you mid-cut takes the cut with it'); + assertEq(c.p.state, 'knocked', + 'and the knife did NOT stomp the state the world just set — interact.js owns `busy`, ' + + 'nothing else writes it'); + // ...nor can a cut START while the player is locked in something else + const d = start(); + d.p.setState('busy', 2); + fixedLoop(2, DT, (dt, tt) => d.K.step(dt, 3 + tt, true)); + assertEq(d.rig.cuts.length, 0, 'you cannot cut mid-crank or mid-poke'); + }); + + t.test('knife: the read prints BOTH halves of the trade, and the saves side is priced', () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const rig = fakeRig(fourCorners()); + const K = knifeAt(p, rig, { getGarden: () => ({ hp: 84 }), secsLeft: () => 52.4 }); + standAt(p, 'q3'); + const r = K.read(); + assertEq(r.state, 'ready'); + // $205: the carport 180 + the gnome 25 on the two-corner rule. The number the OFFER CARD printed + // as "at risk" that morning — same walk, so it cannot disagree with the invoice. + assertEq(r.savesTotal, 205, 'the saves side is the collateral the live corners are still holding'); + assertEq(r.hp, 84, 'and the costs side is the bed'); + assertEq(r.secsLeft, 52, 'for however long is left of the storm'); + + // The trade must survive on the glass, not just in the object — this panel is the thing that was + // missing for five sprints, so it gets an assert rather than a screenshot. + const panel = createVerbPanel({}); + panel.update({ target: { id: 'rerig_0' }, usable: true, label: 're-rig corner', holding: false, progress: 0 }, r); + const text = panel.el.textContent; + assert(text.includes('[E] re-rig corner'), `the hold-E prompt reaches the glass: ${text}`); + assert(/CUT THE SAIL LOOSE/.test(text), 'so does the knife'); + assert(text.includes('$205') && text.includes('the carport'), 'with what it saves, named and priced'); + assert(/84%/.test(text) && /52s/.test(text), 'and what it costs, in the same breath'); + panel.dispose(); + }); + + // ---------------------------------------------------------------- the money (D's own filing, fixed) + t.test('knife (the money): a corner you CUT is not a corner you are answerable for', () => { + // The bug this pins: `_releaseCorner` sets `broken` for a cut exactly as for an overload, and + // every ledger surface reads `broken`. So the shipped tree billed the knife on five surfaces at + // once — the hardware, the $180 carport you cut the sail TO SAVE, the gnome's two-corner rule, + // the clean bonus it voided, and (in week.js) warranty plus standing. + const cut = fourCorners(); + cut.forEach((c) => { c.broken = true; c.cut = true; }); + const a = answerableFor(cut, null); + assertEq(a.lost.length, 4, 'the record still says four corners are down — the sail IS on the lawn'); + assertEq(a.yours.length, 0, 'and none of them is a failure of yours'); + assertEq(a.cut.length, 4, 'they are named as cut, so the paper can say who put them there'); + assertEq(collateralWalk(a.yours, YARD).total, 0, + 'THE HEADLINE: cutting the sail to save the carport does not then bill you for the carport'); + + // The vacuity guard, and it is the whole reason this test can be trusted: the SAME four corners + // broken by the storm bill everything. If both cases read $0 the walk is simply not working. + const blown = fourCorners(); + blown.forEach((c) => { c.broken = true; }); + const b = answerableFor(blown, null); + assertEq(b.yours.length, 4, 'an overload is still yours'); + assertEq(collateralWalk(b.yours, YARD).total, 205, 'and it still costs the carport and the gnome'); + + // one cut, one blown: the ledger splits them by name, not by count + const mixed = fourCorners(); + mixed[3].broken = true; // cb1 blew — the carport goes, honestly + mixed[0].broken = true; mixed[0].cut = true; // q3 cut afterwards + const m = answerableFor(mixed, null); + assertEq(m.yours.map((c) => c.anchorId).join(','), 'cb1', 'the one that failed is yours'); + assertEq(m.cut.map((c) => c.anchorId).join(','), 'q3', 'the one you cut is not'); + assertEq(collateralWalk(m.yours, YARD).total, 180, + 'the carport bills (its corner really did let go) and the gnome does not — one corner, not two'); + }); + + t.test('knife (the money): the walk that prices the panel is the walk that bills the invoice', () => { + // Not "they agree today" — they are the SAME FUNCTION, injected. This asserts the wire, because a + // second copy of the arithmetic is how the board came to advertise $57 against a $114 dispatch. + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const rig = fakeRig(fourCorners()); + const K = knifeAt(p, rig); + standAt(p, 'q3'); + const promised = K.read(); + K.step(KNIFE_TUNE.cutSecs + DT, 0, true); + assertEq(rig.cuts.length, 1, 'cut'); + const billed = collateralWalk(answerableFor(rig.corners, null).yours, YARD); + assertEq(promised.savesTotal, 205, 'the panel promised $205 off the table'); + assertEq(billed.total, 0, 'and the invoice honoured it to the cent'); + // PURE: asking the question must not wreck the yard. (The wreck stays at scoreRun's call site.) + let wrecks = 0; + collateralWalk(fourCorners(), { ...YARD, anchor: YARD.anchor.bind(YARD), + collateralFor: YARD.collateralFor, wreckStructure: () => { wrecks++; } }); + assertEq(wrecks, 0, 'the walk never swaps a structure for its wreck — the HUD asks it every frame'); + }); + + t.test('knife: the verdict says you cut it — and stands aside when something of yours failed too', () => { + const dmg = { hail: 30, rain: 4 }; + const cut = fourCorners(); + cut.forEach((c) => { c.broken = true; c.cut = true; }); + const a = answerableFor(cut, null); + + // The bug: with `lost` empty and hp under the line, verdictFor reached `uncovered` and told a + // player who had just cut the sail down that "the hail fell where your sail wasn't". That is the + // one sentence the verdict must never say here — it blames placement for a decision. + const lostBed = verdictFor({ hp: 31, lost: a.yours, cut: a.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 }); + assertEq(lostBed.mode, 'cut', `a cut night is its own mode, not '${lostBed.mode}'`); + assert(/CUT IT LOOSE/.test(lostBed.verdict), 'and it says so'); + assert(!/sail wasn/.test(lostBed.verdict), 'never "the hail fell where your sail wasn\'t"'); + assert(/didn't/.test(lostBed.verdict) || /their property/.test(lostBed.verdict), + 'it names what the bed bought: their property'); + + const heldBed = verdictFor({ hp: 62, lost: a.yours, cut: a.cut, win: true, dmg, pondPeak: 0, pondDumped: 0 }); + assertEq(heldBed.mode, 'cut-held', 'cut early enough and the bed can still make it'); + + // And the composition rule: a corner of YOURS on the ground is the louder fact, so the failure + // modes keep speaking and the knife stays a line on the ledger. + const mixed = fourCorners(); + mixed[3].broken = true; + mixed[0].broken = true; mixed[0].cut = true; + const m = answerableFor(mixed, null); + const both = verdictFor({ hp: 20, lost: m.yours, cut: m.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 }); + assertEq(both.mode, 'corner', 'one corner of yours went: that verdict is true and it wins'); + assert(/CB1/.test(both.verdict), 'and it names the corner that actually let go'); + + // no knife, no change: every pre-existing mode is untouched by the new argument + const plain = verdictFor({ hp: 31, lost: [], cut: [], win: false, dmg, pondPeak: 0, pondDumped: 0 }); + assertEq(plain.mode, 'uncovered', 'a night with no cut still reads exactly as it did'); + }); + + t.test('knife: the shipped emergency has something to CUT FOR — B\'s authored rig, not the placeholder', () => { + // The night's whole premise, as DATA, because that is where it was wrong. On the merged tree the + // rig was still A's flagged first cut with `cb1` (the carport beam) ARRIVING ALREADY DOWN — which + // makes the $180 carport `theirs`, unbillable and unwreckable, and leaves DESIGN.md line 165's + // signature decision with no object. Found by playing the night and reading the dispatch card. + const em = EMERGENCIES.find((e) => e.id === 'corner_block_emergency'); + assert(em, 'the shipped emergency is still on the board'); + const by = (id) => em.emergency.rig.find((c) => c.anchorId === id); + assert(by('cb1'), 'the carport beam is in the cowboy\'s rig — it is the bait the client has been ' + + 'selling since night 3'); + assertEq(by('cb1').hw, 'shackle', + 'a SHACKLE on the 0.22 beam: 704 N holding their roof, over its ceiling t=35..42 (B, measured). ' + + 'A carabiner there fails in the first second and there is nothing left to decide'); + assert(!by('cb1').broken, + 'AND IT IS FLYING WHEN YOU ARRIVE. A carport corner already on the ground is not a knife ' + + 'decision, it is a receipt — this is the assert that stops the placeholder coming back'); + const down = em.emergency.rig.filter((c) => c.broken).map((c) => c.anchorId); + assertEq(down.join(','), 'tr1b', + 'exactly one corner arrives gone, and it is the 0.88 branch on the cheapest steel'); + // B's ruling, which is also data in A's file and also had not landed. + assertEq(em.pay?.garden, 0, + 'the garden BONUS is off on a callout (B): you cannot be paid per-HP for a bed you had no prep ' + + 'phase to protect. The garden WIN stays, which is what makes the knife cost something'); + }); + + t.test('knife: every rig door the emergency path feature-detects actually EXISTS', () => { + // ⚠️ This is the pin for the bug that cost this sprint its arrived-broken corner: main.js called + // `rig.breakCorner?.(id)` and B had named the door `failCorner`, so an optional call to a + // nonexistent method ran silently, forever, looking exactly like a call. The repo has a name for + // it — D's own `rigging.setWorld` lesson, quoted in main.js — and it still happened, because + // `?.()` makes a rename invisible. A feature-detect is only safe if something checks the feature. + for (const door of ['failCorner', 'cutAway', 'seedClock', 'setFabric']) { + assertEq(typeof SailRig.prototype[door], 'function', + `SailRig has no ${door}() — main.js feature-detects this door on the emergency path, so a ` + + 'rename here is a silent no-op, not an error'); + } + // and the pre-fly argument, which is how the cloth gets into the storm it is standing in + assert(/\{\s*at\s*=\s*0/.test(SailRig.prototype.attach.toString()), + 'attach() no longer takes {at} — the emergency\'s sail would fly the first second of a storm ' + + 'it arrived 30s into (measured: clockSkew 30.02)'); + }); + + t.test('knife: warranty and standing do not chase a corner you chose to let go', () => { + const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } }; + const cutRec = () => { + const cs = fourCorners(); + cs.forEach((c) => { c.broken = true; c.cut = true; }); + return rigRecordFor(cs); + }; + assert(cutRec().every((c) => c.cut && c.brokenAtDawn), + 'the record is honest: cut AND down at dawn, both true, neither hidden'); + + // night 1: cut the lot. night 2: the sheet must book NO warranty off it. + const w = createWeek(); + const s1 = w.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0, + rigRecord: cutRec(), cutAways: [{ t: 33, corners: ['q3', 'q1', 'tr1b', 'cb1'], reason: 'the carport' }] }, def, 0); + assertEq(s1.warranty.length, 0, 'nothing to warranty on the night you cut'); + assert(!s1.repReasons.some((r) => /down at dawn/.test(r)), + `standing is not docked for the cut: ${s1.repReasons.join(' · ')}`); + assertEq(s1.repDelta, REP.CLEAN, + 'a night where nothing FAILED is a clean night — the knife\'s price is the garden, not the star'); + w.advance(); + const s2 = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0); + assertEq(s2.warranty.length, 0, + 'and no free callout tomorrow to go and re-hang a sail you deliberately spent'); + + // the vacuity guard: the same four corners BLOWN book four warranty lines and cost the standing + const w2 = createWeek(); + const blownRec = () => { const cs = fourCorners(); cs.forEach((c) => { c.broken = true; }); return rigRecordFor(cs); }; + const b1 = w2.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0, rigRecord: blownRec() }, def, 0); + assert(b1.repDelta < 0, 'four blown corners DO cost standing'); + w2.advance(); + const b2 = w2.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0); + assertEq(b2.warranty.length, 4, 'and they DO book four warranty lines — the pin is not vacuous'); + }); }