/** * rigging.selftest.js — assert suite for the prep-phase economy. [Lane B] * * Same shape as sail.selftest.js: DOM-free, three-free, runs under node today * and imports cleanly into Lane A's selftest.html later. */ import { RiggingSession, START_BUDGET, SPARE_COST } from './rigging.js'; import { SailRig, HARDWARE, TENSION_MIN, TENSION_MAX } from './sail.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; // A yard-ish spread of anchors: house edge, two trees, two posts, at the sort // of heights PLAN3D's world will actually offer. const ANCHORS = [ { id: 'h1', type: 'house', pos: { x: -3, y: 3.0, z: -6 } }, { id: 'h2', type: 'house', pos: { x: 3, y: 3.0, z: -6 } }, { id: 't1', type: 'tree', pos: { x: -5, y: 4.4, z: 1 } }, { id: 't2', type: 'tree', pos: { x: 5, y: 4.1, z: 2 } }, { id: 'p1', type: 'post', pos: { x: -4, y: 2.6, z: 5 } }, { id: 'p2', type: 'post', pos: { x: 4, y: 2.6, z: 5 } }, ]; const session = () => new RiggingSession({ anchors: ANCHORS }); const results = []; function test(name, fn) { try { const detail = fn(); results.push({ name, pass: true, detail: detail || '' }); } catch (e) { results.push({ name, pass: false, detail: e.message }); } } function assert(cond, msg) { if (!cond) throw new Error(msg); } export function runRiggingSelftest() { results.length = 0; test('rigging four corners charges the cheapest hardware each', () => { const s = session(); for (const id of ['h1', 'h2', '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', 'h2', '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', 'h2', 'p1', 'p2']) s.rig(id); // $20, $60 left s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated, $25 more, $35 left s.cycleHardware('h2'); s.cycleHardware('h2'); // -> 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. test('you cannot afford good hardware on all four corners', () => { const s = session(); for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); let upgraded = 0; for (const id of ['h1', 'h2', '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`; }); 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', 'h2', 'p1']) s.rig(id); const ids = s.picks.map((p) => p.anchorId); // valid rings are rotations/reflections; assert no anchor sits opposite its // true neighbour, i.e. h1 is never adjacent-across from p2 const i = ids.indexOf('h1'); const opposite = ids[(i + 2) % 4]; assert(opposite === 'p2', `h1 should sit opposite p2 in the ring, got ${ids.join(',')}`); return `clicked h1,p2,h2,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', 'h2', '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}`); const h1 = rig.corners.find((c) => c.anchorId === 'h1'); assert(h1.hw === RATED, 'h1 should have carried its rated shackle into the sim'); // and it must actually simulate 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, max load ${(rig.maxLoad() / 1000).toFixed(2)} kN`; }); test('commit refuses an unfinished rig', () => { const s = session(); s.rig('h1'); s.rig('h2'); 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', 'h2', 'p1', 'p2']) s.rig(id); s.setHardware('h1', RATED); s.setHardware('h2', 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`; }); const pass = results.every((r) => r.pass); return { 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, ANCHORS };