HardYards/web/world/js/rigging.selftest.js
type-two 4cdb2fead8 Lane B S17 gate 3: the corroded tier prices honestly in the card
The piece the 0.2/0.3/2 pass explicitly deferred. Pins E's corroded tier
through the card's own pricing surfaces, off E's real resolver (not a
hand-typed 0.55):

- STEEL half (rigging.selftest): factoryExtras('corroded_post') -> hint 0.55
  + collateral key; _effRating on a corroded corner is rating x 0.55, so the
  same load a carabiner holds on a sound post is over a carabiner on a
  corroded one. The rust costs a tier.
- COLLATERAL half (gardenfly.selftest, real dressed world): a node-less
  corroded placement -> collateralFor =  'the corroded post', summed by
  collateralExposure -- through the resolver, not the baked GLB value direct
  (the carport-to-null shortcut).

Cross-lane seam: the tier lives on lane/e, so both pins skip-with-disclosure
on bare lane/b (SKIPPED, visible in the report) and go live at integration.
Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
). Browser 485/3/0, node sail 47/47 rigging 29/29.

Test-only + THREADS; adopted the landed 0.2/0.3/2 unchanged rather than
overwrite pushed work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:59:53 +10:00

582 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (6601200 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 };