The water arc, carried since Sprint 3. Rain (Lane C's rainMmPerHour x the exported RAIN_TIME_COMPRESSION, never hardcoded) lands on each node's horizontal projection, runs down its steepest of 8 neighbours, and pools where it can't get out. A flat sail's belly is a basin water flows into and can't climb from; a hypar drains along its saddle ridge to the low corners and off — so ponding cannot pincer §7, and measured on real yard quads a flat rig holds 12 kg/m² vs a twisted rig's 1.7. The 8-neighbour graph is load-bearing: a 4-way one can't follow the saddle's diagonal ridge, so it trapped water in the gravity belly and a hypar pooled as much as a flat sail. Steepest-GRADIENT descent (not steepest drop) because a diagonal is √2 farther. Gate 1, in asserts: a flat carport rig under a capped wind survives dry (4/4) and dies wet (a corner at t=85s) — the control isolates water from wind — and the broom saves it. Plus dump-on-break, dump-on-tension-up (the turnbuckle counter-play), mass conservation, and a belly-tear safety valve at 4 m of sag. API for D and A, frozen in contracts.js: pondMass(), pondCentroid(), drainPondAt(node, dt) -> kg-on-your-head. session.reset() for A's "play again". On D's tn-1.04 cliff: investigating it IS what surfaced the ponding load regime. The 10 kN "spike" is real physics, not solver divergence — a 155 m² flat sail holding 2100 kg of water genuinely pulls ~21 kN, stays finite, and tracks the water. So no physics-altering clamp (the displacement clamp I tried moved the thesis 39->34%); instead an opt-in `watchDivergence` tripwire that throws with a repro above 80 kN, live in every selftest rig and never false-tripping, and a belly-tear that bounds the runaway physically. Full writeup for D in THREADS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
9.5 KiB
JavaScript
210 lines
9.5 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 } 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`;
|
|
});
|
|
|
|
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('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 };
|