/** * rigging.selftest.js — assert suite for the prep-phase economy. [Lane B] * * Same shape as sail.selftest.js: exports RIGGING_TESTS as [name, fn] pairs so * one set of asserts runs under both Lane A's selftest.html (via * js/tests/b.test.js) and node. */ import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js'; import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js'; import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js'; import { factoryExtras } from './ratings.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; /** Lane A's real yard (THREADS: "yard layout is now FACT"), trimmed to what the economy needs. */ export const ANCHORS = [ { id: 'h1', type: 'house', pos: { x: -5, y: 2.6, z: -9.9 } }, { id: 'h2', type: 'house', pos: { x: 0, y: 2.6, z: -9.9 } }, { id: 'h3', type: 'house', pos: { x: 5, y: 2.6, z: -9.9 } }, { id: 't1', type: 'tree', pos: { x: -9, y: 3.2, z: 2 } }, { id: 't2', type: 'tree', pos: { x: 8, y: 3.1, z: -2 } }, { id: 'p1', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 } }, { id: 'p2', type: 'post', pos: { x: 5.3, y: 3.9, z: 8 } }, ].map((a) => ({ ...a, sway: () => a.pos })); const session = () => new RiggingSession({ anchors: ANCHORS }); const TESTS = []; const test = (name, fn) => TESTS.push([name, fn]); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; test('rigging four corners charges the cheapest hardware each', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) assert(s.rig(id).ok, `rig ${id} failed`); assert(s.budget === START_BUDGET - 4 * CARABINER.cost, `budget $${s.budget}`); assert(s.canStart, 'four corners should be startable'); return `$${START_BUDGET} -> $${s.budget} after four carabiners`; }); test('a sail has four corners, not five', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); const r = s.rig('t1'); assert(!r.ok && r.reason === 'a sail has four corners', `fifth corner allowed: ${JSON.stringify(r)}`); assert(s.budget === START_BUDGET - 4 * CARABINER.cost, 'refused corner should not be charged'); return 'fifth pick refused and not charged'; }); test('hardware cycles up, charging only the difference', () => { const s = session(); s.rig('h1'); assert(s.cycleHardware('h1').ok, 'cycle to shackle failed'); assert(s.pickOf('h1').hw === SHACKLE, 'expected shackle'); assert(s.budget === START_BUDGET - SHACKLE.cost, `budget $${s.budget} should be $${START_BUDGET - SHACKLE.cost}`); s.cycleHardware('h1'); assert(s.pickOf('h1').hw === RATED, 'expected rated shackle'); assert(s.budget === START_BUDGET - RATED.cost, `budget $${s.budget}`); return `carabiner -> shackle -> rated, paid $${RATED.cost} total`; }); test('cycling past the top tier wraps and refunds', () => { const s = session(); s.rig('h1'); s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated s.cycleHardware('h1'); // -> wraps to carabiner assert(s.pickOf('h1').hw === CARABINER, 'expected wrap back to carabiner'); assert(s.budget === START_BUDGET - CARABINER.cost, `budget $${s.budget} — wrap should refund the difference`); return `wrapped and refunded back to $${s.budget}`; }); test('unrig refunds exactly what the corner cost', () => { const s = session(); s.rig('h1'); s.cycleHardware('h1'); s.cycleHardware('h1'); // rated, $30 assert(s.unrig('h1').ok, 'unrig failed'); assert(s.budget === START_BUDGET, `budget $${s.budget} should be back to $${START_BUDGET}`); assert(!s.isRigged('h1'), 'h1 should be free again'); return 'full refund, no leak'; }); test('spares cost real money and refund', () => { const s = session(); assert(s.setSpares(1).ok, 'buying a spare failed'); assert(s.budget === START_BUDGET - SPARE_COST, `budget $${s.budget}`); s.setSpares(0); assert(s.budget === START_BUDGET && s.spares === 0, 'selling the spare back should restore budget'); return `spare costs $${SPARE_COST}, refunds clean`; }); test('budget is a real wall', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20, $60 left s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated, $25 more, $35 left s.cycleHardware('h3'); s.cycleHardware('h3'); // -> rated, $25 more, $10 left s.cycleHardware('p1'); // -> shackle, $10, $0 left const broke = s.cycleHardware('p2'); assert(!broke.ok && broke.reason === 'not enough budget', `overspend allowed: ${JSON.stringify(broke)}`); assert(s.budget === 0, `budget $${s.budget}`); assert(s.pickOf('p2').hw === CARABINER, 'refused upgrade should not have applied'); return 'refused the upgrade that would have gone negative'; }); // DESIGN.md: "good hardware everywhere is unaffordable. You *will* field one // dodgy corner — the game is choosing which one." If this ever passes, the // central economic tension of the game is gone and the budget is decoration. // contracts.js's HARDWARE comment names this as the shape retuning had to keep. test('you cannot afford good hardware on all four corners', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); let upgraded = 0; for (const id of ['h1', 'h3', 'p1', 'p2']) if (s.setHardware(id, RATED).ok) upgraded++; assert(upgraded < 4, `all four corners got rated shackles with $${START_BUDGET} — no compromise left to make`); assert(upgraded >= 2, `only ${upgraded} rated corners affordable — budget may be too tight to be interesting`); return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`; }); // ---- the site switch (SPRINT11 — Lane A's setBudget/setWorld asks) ---------- test('setBudget re-banks the shop and clears the night before it', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20 of carabiners hanging assert(s.budget === START_BUDGET - 20, `budget $${s.budget}`); s.setBudget(150); // a new night, a fatter wallet assert(s.budget === 150, `re-banked budget is $${s.budget}, expected $150`); // The bug this exists to stop: a re-banked wallet still holding last night's // rig would read $150 with four corners already up — hardware nobody paid for. assert(s.picks.length === 0, `${s.picks.length} picks survived the re-bank — this wallet never paid for them`); // and the new bank has to STICK through a reset, or "play again" hands back $80 s.rig('h1'); s.reset(); assert(s.budget === 150, `reset fell back to $${s.budget} — setBudget must move the START budget, not just the balance`); return `re-banked to $150, prep phase clean, sticks through reset`; }); test('setAnchors moves the session to a new yard and drops the old picks', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // the corner block: a different anchor SET, and no h1/p1 anywhere in it const CORNER_BLOCK = [ { id: 'q1', type: 'post', pos: { x: -3.95, y: 3.94, z: -2.82 } }, { id: 'q3', type: 'post', pos: { x: 2.25, y: 3.99, z: 4.5 } }, { id: 'cb1', type: 'post', pos: { x: -8.41, y: 2.29, z: -3 } }, { id: 'cp2', type: 'post', pos: { x: -8.41, y: 1.68, z: -5.61 } }, ].map((a) => ({ ...a, sway: () => a.pos })); s.setAnchors(CORNER_BLOCK); // `p4` on the backyard is not `p4` on the corner block, and `h1` is nowhere: // picks are anchor IDs, so carrying them across a site rigs a quad from anchors // that do not exist. assert(s.picks.length === 0, `${s.picks.length} backyard picks followed the session to the corner block`); assert(!s.rig('h1').ok, 'h1 is a backyard anchor and must not rig on the corner block'); assert(s.rig('cb1').ok, 'cb1 is a corner-block anchor and should rig'); 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('spares are consumable and finite — you cannot take one you did not buy', () => { // D's live-storm find, SPRINT13: interact.js handed out unlimited free spares // because it never read the count. This is the count's half of the fix — a // spare economy that can actually run dry. const s = session(); assert(s.sparesRemaining === 0, 'a fresh session has no spares on the table'); assert(s.takeSpare().ok === false, 'took a spare that was never bought — the QA bug, in one line'); s.setSpares(2); assert(s.sparesRemaining === 2, `bought 2, table shows ${s.sparesRemaining}`); assert(s.takeSpare().ok, 'first spare should come off the table'); assert(s.takeSpare().ok, 'second spare should come off the table'); assert(s.sparesRemaining === 0, `table should be empty, shows ${s.sparesRemaining}`); const dry = s.takeSpare(); assert(!dry.ok && /no spares/.test(dry.reason), `a third take must fail, got ${JSON.stringify(dry)}`); // The refund half: main.js pays back "a spare you never had to use" as // session.spares × SPARE_COST. A consumed spare must not still be refunded — // so a taken spare has to leave the count, not just flip a used flag. const s2 = session(); s2.setSpares(3); s2.takeSpare(); assert(s2.spares === 2, `after taking 1 of 3, refundable count must be 2, is ${s2.spares}`); return 'spares run dry, and a used spare is no longer refundable'; }); test('picks come back ring-ordered however you click them', () => { const s = session(); // deliberately crossing order: two diagonals first for (const id of ['h1', 'p2', 'h3', 'p1']) s.rig(id); const ids = s.picks.map((p) => p.anchorId); // a valid ring puts h1 opposite p2 (they are diagonal across the yard) const opposite = ids[(ids.indexOf('h1') + 2) % 4]; assert(opposite === 'p2', `h1 should sit opposite p2 in the ring, got ${ids.join(',')}`); return `clicked h1,p2,h3,p1 -> ring ${ids.join(' -> ')}`; }); test('tension clamps to the rigging range', () => { const s = session(); assert(s.setTension(99) === TENSION_MAX, 'over-tight should clamp'); assert(s.setTension(0) === TENSION_MIN, 'over-loose should clamp'); s.setTension(1.15); assert(s.tension === 1.15, 'in-range tension should pass through'); return `clamped to ${TENSION_MIN}..${TENSION_MAX}`; }); test('commit hands a working rig to the sim', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); s.setHardware('h1', RATED); s.setTension(1.1); const rig = s.commit(new SailRig({ anchors: ANCHORS })); assert(rig.rigged, 'rig should be rigged'); assert(rig.corners.length === 4, 'rig should have four corners'); assert(rig.tension === 1.1, `rig tension ${rig.tension}`); assert(rig.corners.find((c) => c.anchorId === 'h1').hw === RATED, 'h1 should have carried its rated shackle into the sim'); const wind = { sample: () => ({ x: 0, y: 0, z: 12 }) }; for (let i = 0; i < 240; i++) rig.step(1 / 60, wind, i / 60); assert(rig.corners.every((c) => Number.isFinite(c.load)), 'committed rig went NaN'); return `committed and stepped 4 s clean over the real yard, max load ${(rig.maxLoad() / 1000).toFixed(2)} kN`; }); test('commit refuses an unfinished rig', () => { const s = session(); s.rig('h1'); s.rig('h3'); let threw = false; try { s.commit(new SailRig({ anchors: ANCHORS })); } catch { threw = true; } assert(threw, 'committing two corners should throw'); return 'two corners refused'; }); test('summary names the weak link for the HUD', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); s.setHardware('h1', RATED); s.setHardware('h3', SHACKLE); s.setHardware('p1', SHACKLE); const sum = s.summary; assert(sum.weakest === 'p2', `weakest should be the lone carabiner p2, got ${sum.weakest}`); assert(sum.corners.length === 4, 'summary should list four corners'); return `weak link flagged: ${sum.weakest}, $${sum.budget} left`; }); test("weak link is rating × ratingHint, not click order (D's #7)", () => { // Corner-block shape: uniform hardware, one anchor made of lies. The old // reduce compared bare hw.rating with strict <, so four identical carabiners // flagged whichever was clicked FIRST — on site_02 it pointed at cb1 only // because D happened to click cb1 first. The label must follow the product // sail.js actually fails on: hw.rating × anchor.ratingHint. const anchors = [ { id: 'q1', type: 'post', pos: { x: -3, y: 4, z: -3 }, ratingHint: 1 }, { id: 'q2', type: 'post', pos: { x: 3, y: 4, z: -3 }, ratingHint: 1 }, { id: 'q3', type: 'post', pos: { x: 3, y: 4, z: 3 }, ratingHint: 1 }, { id: 'cb1', type: 'carport', pos: { x: -3, y: 2.3, z: 3 }, ratingHint: 0.22 }, ].map((a) => ({ ...a, sway: () => a.pos })); const s = new RiggingSession({ anchors }); // click the honest steel FIRST — the pre-fix code flags q1 here, forever for (const id of ['q1', 'q2', 'q3', 'cb1']) assert(s.rig(id).ok, `rig ${id} failed`); assert(s.summary.weakest === 'cb1', `four identical carabiners: the weak link is the 0.22 carport beam, got ${s.summary.weakest}`); // And better hardware on the lying anchor can make it genuinely NOT the weak // link: rated 6500 × 0.22 = 1430 N still beats carabiner 1200 × 1.0 — the // label follows the product, not the price tag and not the hint alone. s.setHardware('cb1', RATED); assert(s.summary.weakest === 'q1', `rated@0.22 (1430 N) out-rates carabiner@1.0 (1200 N) — expected q1, got ${s.summary.weakest}`); return 'weak link tracks rating × hint through hardware changes'; }); test('reset() returns a used session to a fresh prep phase', () => { const s = session(); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); s.setHardware('h1', RATED); s.setTension(1.2); s.setSpares(1); assert(s.budget < START_BUDGET, 'setup should have spent money'); s.reset(); assert(s.budget === START_BUDGET, `budget not restored: $${s.budget}`); assert(s.picks.length === 0, 'picks not cleared'); assert(s.tension === 1.0 && s.spares === 0, 'tension/spares not reset'); // and it's actually usable again, not just zeroed assert(s.rig('t1').ok && s.canStart === false, 'session not rig-able after reset'); return 'budget, picks, tension, spares all fresh; rig-able again'; }); test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechanic)', () => { const s = session(); assert(s.fabric.id === 'cloth', `default fabric should be cloth, got ${s.fabric.id}`); // the exact call the F key makes: cycle to the other entry by id const next = FABRIC[(FABRIC.indexOf(s.fabric) + 1) % FABRIC.length]; assert(s.setFabric(next.id).ok, 'cycling to membrane refused'); assert(s.fabric.id === 'membrane' && s.fabric.porosity === 0, 'membrane not applied'); assert(s.budget === START_BUDGET, 'fabric is a bet, not a purchase — budget must not move'); assert(s.summary.fabric.id === 'membrane', 'the panel reads fabric off summary and must see the change'); // wrap-around brings it home, and garbage is refused loudly assert(s.setFabric(FABRIC[(FABRIC.indexOf(s.fabric) + 1) % FABRIC.length].id).ok && s.fabric.id === 'cloth', 'cycle should wrap back to cloth'); assert(s.setFabric('hessian').ok === false, 'unknown fabric must refuse, not default'); return 'cloth -> membrane -> cloth, $0 both ways, hessian refused'; }); // --- SPRINT16 gate 2.2: the fabric on the record --------------------------- // The invoice row is unconditional wiring (main.js/hud.js, verified live); // what this pins is the VERDICT's fabric sentence — fabricNoteFor speaks only // when the bet provably mattered, and stays silent everywhere a sentence // would be a guess. Storm hail sizes are the shipped ones: southerly pea hail // 0.7 (cloth blocks 74% — hailBlockFor's measured number), wild night 1.3 and // ice night 1.4 (both fabrics block 100%). test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => { const [CLOTH, MEMBRANE] = FABRIC; // the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } }); assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`); assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`); // big stones cannot pass the weave — cloth blocked 100%, hail damage came // from where the sail wasn't, and blaming the weave for it would be a lie assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null, 'cloth blamed for big-stone hail — hailBlockFor(1.3, 0.3) is 1.0, nothing leaked'); // a leak that cost under the threshold is not worth a verdict sentence assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null, 'a trivial leak got a sentence — the threshold is not being read'); // membrane's price is the pond, and only a pond the verdict itself would name const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 }); assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`); assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null, 'membrane got a sentence over a pond the verdict would not even mention'); // no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed'); assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent'); assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw'); return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`; }); // ---------- SPRINT17 gate 2: client constraints — A's shapes, this session's teeth ---------- // The literals below are A's seam contract verbatim (THREADS 2026-07-21): the // session must enforce exactly the shape the board ships, not a friendlier // cousin of it. If A's shapes move, these move in the same commit or go red. const HOUSE_BAN = { kind: 'noAnchorFamily', family: 'house', label: 'nothing attached to the house', says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.', }; const CAP_45 = { kind: 'budgetCap', cap: 45, label: 'client caps the rig at $45', says: 'Cheapest option that does the job. Anything over forty-five and I\'ll be querying the invoice.', }; const FAKE_RIG = { setFabric() {}, attach: () => 'attached' }; test('gate 2: the house ban refuses the pick in the CLIENT\'s words, charges nothing, bans a family not a yard', () => { const s = session().setConstraints([HOUSE_BAN]); for (const id of ['h1', 'h2', 'h3']) { const r = s.rig(id); assert(!r.ok, `${id} rigged under a house ban`); assert(r.reason === HOUSE_BAN.says, `the refusal must be the client's own words for the ticker, got "${r.reason}"`); } assert(s.budget === START_BUDGET, 'a refused pick was charged'); assert(s.picks.length === 0, 'a refused pick was kept'); // the rest of the yard stays open — the ban is a family, not a lockout for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, `${id} refused without being banned`); assert(s.canStart, 'four honest corners must still close the quad under a house ban'); assert(s.commit(FAKE_RIG) === 'attached', 'a legal rig failed to commit under the ban'); return 'h1/h2/h3 refused with the client talking, t/p corners rigged and committed'; }); test('gate 2: the cap is a wall at spend time — every path, exact at the boundary, refunds always free', () => { const s = session().setConstraints([CAP_45]); for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, 'carabiners must fit any sane cap'); assert(s.setHardware('t1', SHACKLE).ok, 'upgrade inside the cap refused'); // $30 assert(s.setSpares(1).ok, 'spend TO the cap refused — the cap is a ceiling, not a strict bound'); // $45, exact assert(s.spent === CAP_45.cap, `expected exactly $${CAP_45.cap} spent, got $${s.spent}`); // every remaining spend path refuses, in the client's words, charging nothing for (const [what, r] of [ ['cycleHardware', s.cycleHardware('t2')], ['setHardware', s.setHardware('t2', RATED)], ['setSpares', s.setSpares(2)], ]) { assert(!r.ok, `${what} spent past the client's cap`); assert(r.reason === CAP_45.says, `${what} refusal must be the client's words, got "${r.reason}"`); } assert(s.spent === CAP_45.cap && s.pickOf('t2').hw === CARABINER && s.spares === 1, 'a refused spend changed state anyway'); // the way BACK under the cap is never blocked — refund, then respend assert(s.setSpares(0).ok, 'a refund was blocked by the cap'); // $30 assert(s.setHardware('t2', SHACKLE).ok, 'freed room under the cap refused'); // $40 assert(s.commit(FAKE_RIG) === 'attached', 'a rig at $40 under a $45 cap failed to commit'); return `spent to $${CAP_45.cap} exactly, three paths refused past it, refund freed $10 of room`; }); test('gate 2: commit is belt-and-braces — a caller who walked around the doors gets a THROW, not an invoice', () => { // Both states below are unreachable through the session's own API (the // refusal tests above prove the doors), so these poke the internals the way // only a buggy caller could — and commit must refuse to attach the sail. const s = session().setConstraints([HOUSE_BAN]); for (const id of ['t1', 't2', 'p1']) s.rig(id); s.picks.push({ anchorId: 'h1', hw: CARABINER }); // banned steel, no door used let threw = null; try { s.commit(FAKE_RIG); } catch (e) { threw = e; } assert(threw && /h1/.test(threw.message), 'commit attached a sail to banned steel'); const s2 = session().setConstraints([CAP_45]); for (const id of ['t1', 't2', 'p1', 'p2']) s2.rig(id); s2.picks[0].hw = RATED; s2.picks[1].hw = RATED; // $70 of steel, budget never asked s2.budget = START_BUDGET - 2 * RATED.cost - 2 * CARABINER.cost; threw = null; try { s2.commit(FAKE_RIG); } catch (e) { threw = e; } assert(threw && new RegExp(`\\$${CAP_45.cap}`).test(threw.message), 'commit attached a sail spent past the client\'s cap'); return 'banned pick and cap overspend both throw at commit, naming the term broken'; }); test('gate 2: the constraint door is a checked enum, and the terms survive reset but not setConstraints([])', () => { const throwsOn = (list) => { try { session().setConstraints(list); return false; } catch { return true; } }; assert(throwsOn([{ kind: 'noSwearing', label: 'x', says: 'y' }]), 'unknown kind accepted — the enum is decoration (the carport-typed-as-a-post scar)'); assert(throwsOn([{ kind: 'budgetCap', cap: 0, label: 'x', says: 'y' }]), 'cap $0 accepted — a soft-lock'); assert(throwsOn([{ kind: 'budgetCap', cap: NaN, label: 'x', says: 'y' }]), 'cap NaN accepted — NaN compares false and caps nothing'); assert(throwsOn([{ kind: 'noAnchorFamily', family: 'house' }]), 'a constraint with no client words accepted — empty ticker, empty papers'); assert(throwsOn([{ kind: 'noAnchorFamily', label: 'x', says: 'y' }]), 'a ban with no family accepted — bans nothing'); // persistence: reset() is "play again" on the SAME night, so the client's // terms ride through it (the _startBudget pattern); [] is the only clear, // which is why the wiring contract is setConstraints(job?.constraints ?? []) const s = session().setConstraints([HOUSE_BAN]); s.reset(); const r = s.rig('h1'); assert(!r.ok && r.reason === HOUSE_BAN.says, 'reset() dropped the client\'s terms — "play again" rigged the house'); s.setConstraints([]); assert(s.rig('h1').ok, 'setConstraints([]) did not clear the ban — the next client inherited it'); return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly'; }); // --- SPRINT17 gate 3 support: THE CORRODED TIER PRICES HONESTLY ------------- // E landed the corroded post (rating_hint 0.55, its own ANCHOR_TYPE word, a // $45 collateral); the gates-0.2/0.3/2 pass DEFERRED gate 3, and this is it. // B owns the pricing surfaces the card reads, so the number the shop charges // for a corroded corner is pinned HERE, off E's real resolver — not a // hand-typed 0.55. The collateral half ($45 through world.collateralFor → the // exposure line) is pinned in gardenfly.selftest, where a dressed world exists; // this is the STEEL half: a corroded post is weaker, so the same load costs // MORE steel to hold, which is what "prices honestly" means and is B's tier // math (_effRating), not E's mesh. // // ⚠️ CROSS-LANE SEAM. The corroded tier lives on lane/e; on lane/b in // isolation `factoryExtras('corroded_post')` is null (no gen entry, no GLB). // These pins SKIP WITH A DISCLOSURE on a bare lane/b and go live the moment // lane/e merges — the "build against the shape as pushed" seam the sprint runs // on. Measured GREEN on the scratch merge (lane/a + lane/e): hint 0.55. const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post')); const CORRODED_SKIP = 'SKIPPED — corroded tier is on lane/e, absent on bare lane/b; this pin is ' + 'inert here and goes live at integration (measured GREEN on the scratch merge, hint 0.55)'; test('gate 3: the corroded tier is 0.55, read off E\'s resolver not a literal', () => { if (!CORRODED_TIER_LIVE) return CORRODED_SKIP; const ex = factoryExtras('corroded_post'); assert(ex && ex.ratingHint === 0.55, `factoryExtras('corroded_post') gave ${JSON.stringify(ex)} — the card reads this for a node-less ` + 'corroded placement, and if E re-rules the number it must move here, not be re-typed'); assert(ex.collateral === 'corroded_post', 'the corroded anchor carries no collateral key — the exposure line would read "not scored" over a $45 trap'); return `corroded_post → hint ${ex.ratingHint}, collateral "${ex.collateral}"`; }); test('gate 3: a corroded corner costs MORE steel than a sound post at the same load', () => { if (!CORRODED_TIER_LIVE) return CORRODED_SKIP; // _effRating is what the panel's arrow and the audit's tier both price // against: hardware.rating × the anchor's ratingHint. A corroded post fails // at 0.55 of the steel's spec, so a load a carabiner holds on a sound post // may need a shackle on a corroded one. Built off E's real hint. const hint = factoryExtras('corroded_post').ratingHint; const SOUND = { id: 'ps', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 }, sway: () => ({ x: -6.4, y: 3.9, z: 7.4 }) }; const COR = { id: 'pc', type: 'corroded_post', pos: { x: 5.3, y: 3.9, z: 8 }, ratingHint: hint, sway: () => ({ x: 5.3, y: 3.9, z: 8 }) }; const s = new RiggingSession({ anchors: [SOUND, COR, ...ANCHORS] }); s.rig('ps'); s.rig('pc'); s.rig('h1'); s.rig('t1'); const sound = s._effRating(s.pickOf('ps')); const cor = s._effRating(s.pickOf('pc')); // Both start on carabiners (rating 1200): the sound post holds 1200 N, the // corroded holds 1200 × 0.55 = 660 N. Same steel, less strength — measured. assert(sound === CARABINER.rating, `sound post effRating ${sound}, expected the carabiner's ${CARABINER.rating}`); assert(Math.abs(cor - CARABINER.rating * hint) < 1e-9, `corroded effRating ${cor}, expected ${CARABINER.rating} × ${hint} = ${CARABINER.rating * hint}`); assert(cor < sound, 'a corroded post held as much as a sound one — the trap is priced as safe steel, which inverts it'); // The tier consequence: a load in the corroded gap (660–1200 N) is HELD by a // carabiner on the sound post and OVER a carabiner on the corroded one. const load = 900; assert(load < sound && load > cor, `pick a load in the corroded gap (${cor}..${sound} N) so the tier difference is real`); return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`; }); export const RIGGING_TESTS = TESTS; export function runRiggingSelftest() { const results = TESTS.map(([name, fn]) => { try { return { name, pass: true, detail: fn() || '' }; } catch (e) { return { name, pass: false, detail: e.message }; } }); return { pass: results.every((r) => r.pass), results }; } function report(out) { const lines = out.results.map( (r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}` ); return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`; } if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) { const out = runRiggingSelftest(); console.log(report(out)); process.exit(out.pass ? 0 : 1); } export { report };