HardYards/web/world/js/rigging.selftest.js
type-two 9700b40a30 Wire ratingHint into the failure threshold: the ratings are real (A's ruling)
sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint
(read live from the anchor — dress() mutates in place). Consequence asserted,
not the formula: on site_02's carport line (uniform rated shackles, funnel on,
12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension
(t~29 s into the storm, honest posts hold) and at max. In-suite control pins
the attribution: the same rig with every hint forced to 1 holds 4/4, so the
break is the hint, not the load. Mutation-checked: unwiring the line fails both.

rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint
via _effRating, not bare hw.rating — uniform hardware now flags the genuinely
worst steel instead of the first click. Mutation-checked.

site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs
h.rating * hint >= peak, both front-ends carry ratingHint through (browser off
world.anchors, node dump extended with the live-dumped hints), rows print the
hint. sweep.selftest asserts hints reprice without touching peaks and land
over-ceiling corners in unholdable. Mutation-checked.

Selftest 319/0/0 (315 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:19:01 +10:00

278 lines
13 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 } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.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('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';
});
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 };