fabricNoteFor() (rigging.js, pure, headless-tested): one verdict sentence
when the bet provably mattered — cloth on a night whose stones pass the
2mm-weave ruling with real hail damage names the leak; membrane past the
verdict's own 80 kg pond threshold names the trade; silent everywhere a
sentence would be a guess (big stones, dry nights, unknown fabric).
scoreRun carries {fabric, fabricNote}; the invoice grows an unconditional
'fabric flown' row and the verdict prints the note under itself (existing
verdict prose byte-untouched — a.test's verdictMode pins unaffected).
Prep-summary half already shipped in the fresh-eyes review (panel line 2).
Mutations red-then-green: leak gate inverted; pond threshold dropped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
22 KiB
JavaScript
419 lines
22 KiB
JavaScript
/**
|
||
* 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';
|
||
|
||
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`;
|
||
});
|
||
|
||
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 };
|