Align sail lane to contracts.js; free blown corners so they flog
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.
Contract fixes:
- anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
I had it adding sway to pos, which would have flung every
tree-anchored corner to double its coordinates).
- events is an Emitter emitting {type, corner}, not a drained array.
- coverageOver() rects are centre+size, matching world.gardenBed. It
consumes world.sunDir directly: a hit along sunDir means shaded.
- START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
rather than being redeclared here.
Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.
Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.
HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c8a9128c17
commit
18099c8e6f
@ -38,16 +38,25 @@ export const SPARE_COST = 15;
|
||||
/**
|
||||
* Hardware tiers, ported from prototype/game.js.
|
||||
*
|
||||
* `rating` is nominal kN. The ABSOLUTE numbers are placeholders inherited from
|
||||
* the 2D prototype's load scale — Lane B owns retuning them against the 3D
|
||||
* cloth's real load output. What must survive retuning is the SHAPE: three
|
||||
* tiers, roughly 1x / 2x / 4.5x strength at 1x / 3x / 6x price, so a mixed rig
|
||||
* is always the interesting choice and one dodgy corner is always affordable.
|
||||
* `rating` is a working load limit in NEWTONS — retuned by Lane B against the
|
||||
* 3D cloth's real load output, per the standing note that Lane B owns these
|
||||
* numbers. Costs are the prototype's, untouched.
|
||||
*
|
||||
* The 2D prototype's 9/19/40 were on an arbitrary scale. The 3D cloth reports
|
||||
* real newtons (a 5x5 m sail pulls ~1-4 kN per corner in a 34 m/s storm), so
|
||||
* these are real WLLs: a cheap carabiner really does let go around 1.2 kN, a
|
||||
* rated 8 mm shackle really does hold 6.5 kN. That is the DESIGN.md "Kerbal
|
||||
* trick" — leave the game able to size real hardware.
|
||||
*
|
||||
* The SHAPE that had to survive retuning, and did: three tiers at 1x / 3x / 6x
|
||||
* price, where $80 buys rated hardware on at most two of four corners. A mixed
|
||||
* rig stays the interesting choice and you are always picking which corner to
|
||||
* leave dodgy. Asserted in js/tests/b.test.js.
|
||||
*/
|
||||
export const HARDWARE = [
|
||||
{ name: 'carabiner', cost: 5, rating: 9, color: 0xe2b04a },
|
||||
{ name: 'shackle', cost: 15, rating: 19, color: 0xc8d2d8 },
|
||||
{ name: 'rated shackle', cost: 30, rating: 40, color: 0x7ee0ff },
|
||||
{ name: 'carabiner', cost: 5, rating: 1200, color: 0xe2b04a },
|
||||
{ name: 'shackle', cost: 15, rating: 3200, color: 0xc8d2d8 },
|
||||
{ name: 'rated shackle', cost: 30, rating: 6500, color: 0x7ee0ff },
|
||||
];
|
||||
|
||||
/** Game phases, in loop order. */
|
||||
|
||||
@ -12,10 +12,10 @@
|
||||
* the bottom for the seam it will plug into.
|
||||
*/
|
||||
|
||||
import { HARDWARE, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
|
||||
export const START_BUDGET = 80; // prototype
|
||||
export const SPARE_COST = 15; // prototype: "spare shackle ($15)"
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
|
||||
@ -1,191 +1,182 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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, START_BUDGET, SPARE_COST } from './rigging.js';
|
||||
import { SailRig, HARDWARE, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
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;
|
||||
|
||||
// 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 } },
|
||||
];
|
||||
/** 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 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);
|
||||
}
|
||||
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`;
|
||||
});
|
||||
|
||||
export const RIGGING_TESTS = TESTS;
|
||||
|
||||
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`;
|
||||
const results = TESTS.map(([name, fn]) => {
|
||||
try { return { name, pass: true, detail: fn() || '' }; }
|
||||
catch (e) { return { name, pass: false, detail: e.message }; }
|
||||
});
|
||||
|
||||
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 };
|
||||
return { pass: results.every((r) => r.pass), results };
|
||||
}
|
||||
|
||||
function report(out) {
|
||||
@ -201,4 +192,4 @@ if (typeof process !== 'undefined' && process.versions?.node && import.meta.file
|
||||
process.exit(out.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
export { report, ANCHORS };
|
||||
export { report };
|
||||
|
||||
@ -9,17 +9,21 @@
|
||||
*
|
||||
* Units are SI throughout: metres, kilograms, seconds, newtons. Corner loads
|
||||
* come out in real newtons and hardware ratings are real working load limits,
|
||||
* so a 5x5 m sail in a 30 m/s gust genuinely puts ~5 kN on a corner — which is
|
||||
* genuinely why real shade sails use 3 kN+ shackles.
|
||||
* so a 5x5 m sail in a 34 m/s storm genuinely puts ~1-4 kN on a corner — which
|
||||
* is genuinely why real shade sails use 3 kN+ shackles.
|
||||
*
|
||||
* This module deliberately does NOT import three.js. The sim core is plain
|
||||
* typed arrays so it runs headless under node (see sail.selftest.js) before any
|
||||
* renderer exists, stays allocation-free in the hot loop, and can be replayed
|
||||
* bit-for-bit. three.js is pulled in lazily by createSailView() only.
|
||||
* The sim core holds no THREE types: nodes are plain Float64Arrays, so the hot
|
||||
* loop allocates nothing, replays bit-for-bit, and runs headless under node
|
||||
* (see sail.selftest.js) as well as in Lane A's selftest.html. three.js only
|
||||
* appears in createSailView(), which is imported lazily.
|
||||
*/
|
||||
|
||||
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
|
||||
|
||||
export { HARDWARE };
|
||||
|
||||
// ---------- sim tunables ----------
|
||||
const SIM_DT = 1 / 60; // sim always steps at a fixed rate; step() accumulates
|
||||
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
|
||||
const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches
|
||||
const RELAX_ITERS = 5; // FABRIC_K is calibrated against this; changing it rescales loads
|
||||
const GRAVITY = -9.81;
|
||||
@ -50,19 +54,26 @@ const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it l
|
||||
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
|
||||
|
||||
/**
|
||||
* Hardware tiers. Costs are the prototype's economy verbatim; ratings are
|
||||
* retuned from the prototype's arbitrary 9/19/40 into real newtons, preserving
|
||||
* the same relative spread. `rating` is a working load limit in N.
|
||||
*/
|
||||
export const HARDWARE = [
|
||||
{ name: 'carabiner', cost: 5, rating: 1200, color: '#e2b04a' },
|
||||
{ name: 'shackle', cost: 15, rating: 3200, color: '#c8d2d8' },
|
||||
{ name: 'rated shackle', cost: 30, rating: 6500, color: '#7ee0ff' },
|
||||
];
|
||||
|
||||
export const TENSION_MIN = 0.6;
|
||||
export const TENSION_MAX = 1.4;
|
||||
|
||||
/**
|
||||
* How much pre-strain the tension dial actually commands, per unit of dial.
|
||||
* Dial 1.0 is neutral (rest length = as-cut), 1.4 is drum tight, 0.6 is loose.
|
||||
*
|
||||
* The prototype used `rest = rest / tension`, which on its 2D arbitrary scale
|
||||
* was harmless. In real newtons it is not: it asks for 17% pre-strain at dial
|
||||
* 1.2 and 29% at 1.4 — i.e. stretching an 18 m sail by three metres — and it
|
||||
* put 68 kN on a corner of the real yard's biggest quad before any wind blew.
|
||||
*
|
||||
* 0.10 puts dial 1.4 at 4% pre-strain. Measured: it swings a 5x5 m rig's peak
|
||||
* load 2.1x from loose to tight, so the dial is a real decision; and it redlines
|
||||
* the yard's 192 m2 quad at 8.3 kN drum-tight, which blows even a rated shackle
|
||||
* — correctly, because you cannot drum-tighten 192 m2 of cloth on $30 of
|
||||
* hardware. The load bars show that during prep, which is where it should be
|
||||
* learned.
|
||||
*/
|
||||
const PRE_STRAIN = 0.10;
|
||||
const TRIM_MIN = 0.85;
|
||||
const TRIM_MAX = 1.15;
|
||||
|
||||
@ -86,8 +97,7 @@ export function orderRing(anchors) {
|
||||
export class SailRig {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.anchors world.anchors — [{id, pos:{x,y,z}, type, sway(t)->{x,y,z}}]
|
||||
* sway(t) returns an OFFSET to add to pos, not an absolute position.
|
||||
* @param {Array} opts.anchors world.anchors — see contracts.js Anchor
|
||||
* @param {number} opts.gridN nodes per side (default 10)
|
||||
* @param {number} opts.porosity 0 = solid membrane, ~0.3 = knitted shade cloth (blows through, less load)
|
||||
*/
|
||||
@ -96,7 +106,8 @@ export class SailRig {
|
||||
this.N = gridN;
|
||||
this.porosity = porosity;
|
||||
this.corners = [];
|
||||
this.events = [];
|
||||
/** Emits 'break' and 'repair' as {type, corner} — contracts.js SailRig. */
|
||||
this.events = new Emitter();
|
||||
this.tension = 1.0;
|
||||
this.t = 0;
|
||||
this.rigged = false;
|
||||
@ -236,7 +247,7 @@ export class SailRig {
|
||||
this._repin(0);
|
||||
}
|
||||
|
||||
/** Rest lengths shrink as tension rises (1/tension, ported), modulated per corner by trim. */
|
||||
/** Rest lengths shrink as the tension dial rises, modulated per corner by trim. */
|
||||
_applyRestLengths() {
|
||||
for (const s of this.springs) {
|
||||
let wsum = 0, tsum = 0;
|
||||
@ -246,7 +257,7 @@ export class SailRig {
|
||||
tsum += w * this.corners[k].trim;
|
||||
}
|
||||
const trim = wsum > 1e-9 ? tsum / wsum : 1;
|
||||
s.rest = s.restBase / (this.tension * trim);
|
||||
s.rest = s.restBase * (1 - PRE_STRAIN * (this.tension * trim - 1));
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,11 +277,16 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a corner is pinned right now. `sway(t)` is the ABSOLUTE world
|
||||
* position, not an offset from `pos` (contracts.js Anchor; Lane A called this
|
||||
* out in THREADS). House and post anchors return a constant; tree anchors
|
||||
* wander, and that wander is dynamic load — the reason a tree is the scary
|
||||
* anchor. The returned vector is shared and reused between calls, so read it
|
||||
* immediately and never store it.
|
||||
*/
|
||||
_anchorPos(a, t) {
|
||||
const p = a.pos;
|
||||
if (!a.sway) return p;
|
||||
const s = a.sway(t);
|
||||
return { x: p.x + s.x, y: p.y + s.y, z: p.z + s.z };
|
||||
return a.sway ? a.sway(t) : a.pos;
|
||||
}
|
||||
|
||||
_surfaceArea() {
|
||||
@ -467,7 +483,8 @@ export class SailRig {
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
for (const c of this.corners) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
if (c.load > c.hw.rating) c.overload += dt;
|
||||
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
|
||||
@ -475,7 +492,12 @@ export class SailRig {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
this.events.push({ type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
// Hand the node its mass back. Everything good about a failure comes
|
||||
// from this one line: the freed corner stops being pinned, so it flies
|
||||
// on the wind and the flogging is emergent rather than animated.
|
||||
// Without it a "blown" corner stays welded in mid-air.
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
}
|
||||
}
|
||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||||
@ -490,7 +512,7 @@ export class SailRig {
|
||||
c.load = 0;
|
||||
c.overload = 0;
|
||||
this._repin(this.t);
|
||||
this.events.push({ type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
|
||||
this.events.emit('repair', { type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -508,20 +530,15 @@ export class SailRig {
|
||||
if (this.rigged) this._applyRestLengths();
|
||||
}
|
||||
|
||||
drainEvents() {
|
||||
const out = this.events;
|
||||
this.events = [];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground-projected shade over a rect: fraction of sample points on the rect
|
||||
* that the sail blocks from the sun. This IS the shade mechanic, so it
|
||||
* raycasts toward the actual sun rather than projecting straight down —
|
||||
* which is what lets DESIGN.md's moving/seasonal sun change the answer.
|
||||
* Ground-projected shade over a rect: the fraction of sample points on the
|
||||
* rect that the sail blocks from the sun. This IS the shade mechanic, so it
|
||||
* raycasts toward the real sun rather than projecting straight down — which
|
||||
* is what lets DESIGN.md's moving and seasonal sun change the answer.
|
||||
*
|
||||
* @param {object} rect {x, z, w, d} on the ground, metres, world XZ
|
||||
* @param {object} sunDir direction TO the sun; defaults to straight overhead
|
||||
* @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
|
||||
* @param {object} sunDir world.sunDir — unit vector from the ground TOWARD
|
||||
* the sun. A hit means shaded. Defaults to overhead.
|
||||
*/
|
||||
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) {
|
||||
if (!this.rigged) return 0;
|
||||
@ -533,8 +550,9 @@ export class SailRig {
|
||||
let hit = 0;
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
for (let j = 0; j < ROWS; j++) {
|
||||
const ox = rect.x + ((i + 0.5) / COLS) * rect.w;
|
||||
const oz = rect.z + ((j + 0.5) / ROWS) * rect.d;
|
||||
// rect is centre-and-size, so samples straddle (rect.x, rect.z)
|
||||
const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
|
||||
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
|
||||
if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,41 +1,30 @@
|
||||
/**
|
||||
* sail.selftest.js — assert suite for the sail sim. [Lane B]
|
||||
*
|
||||
* DOM-free and three-free on purpose: runs today under `node sail.selftest.js`
|
||||
* before Lane A's shell exists, and Lane A's selftest.html can import
|
||||
* runSailSelftest() unchanged once it lands. Drives the sim with fixed-dt loops
|
||||
* only — never rAF, never a clock.
|
||||
* Exports SAIL_TESTS as plain [name, fn] pairs so ONE set of asserts runs in
|
||||
* two harnesses: Lane A's selftest.html (via js/tests/b.test.js) and node
|
||||
* (`node web/world/js/sail.selftest.js`) for fast iteration without a browser.
|
||||
* Drives time with fixed-dt loops only — never rAF, never a clock.
|
||||
*
|
||||
* The headline assert is `hypar sheds load vs flat`: it is the game's thesis
|
||||
* stated as a test. If it ever goes red, the sail has stopped being a sail.
|
||||
*/
|
||||
|
||||
import { SailRig, HARDWARE } from './sail.js';
|
||||
import { SailRig } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
|
||||
const SIM_DT = 1 / 60;
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
// ---------- deterministic stub wind (Lane C owns the real weather.js) ----------
|
||||
// ---------- deterministic stub wind ----------
|
||||
// contracts.js ships createStubWind(), and the integration test below uses it.
|
||||
// This local one exists only because the thesis needs the wind DIRECTION swept,
|
||||
// which the shared stub does not expose. Lane C's weather.js replaces both.
|
||||
|
||||
function mulberry32(seed) {
|
||||
return function () {
|
||||
seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports the prototype's gust scheduler shape: telegraph 1.5 s, ramp 0.8 s,
|
||||
* hold ~1.7 s, fade 1 s. The schedule is precomputed from the seed so
|
||||
* sample(pos, t) stays a pure function of t — which is what makes the
|
||||
* determinism assert meaningful and lets the selftest fast-forward.
|
||||
*/
|
||||
function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
|
||||
const rng = mulberry32(seed);
|
||||
const rand = rng(seed);
|
||||
const gusts = [];
|
||||
for (let t = 3; t < stormLen; t += 5 + rng() * 7) {
|
||||
gusts.push({ start: t, pow: 12 + rng() * 16 + 10 * (t / stormLen) });
|
||||
for (let t = 3; t < stormLen; t += 5 + rand() * 7) {
|
||||
gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) });
|
||||
}
|
||||
const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
|
||||
const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len;
|
||||
@ -44,12 +33,11 @@ function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, cal
|
||||
return {
|
||||
speedAt(t) {
|
||||
if (calm) return 0;
|
||||
const p = Math.min(1, t / stormLen);
|
||||
let speed = 8 + 26 * Math.min(1, p * 1.6);
|
||||
let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
|
||||
for (const g of gusts) {
|
||||
const gt = t - g.start;
|
||||
if (gt < 0 || gt >= 5) continue;
|
||||
if (gt < 1.5) continue; // telegraph: you see it, you don't feel it
|
||||
if (gt < 1.5) continue; // telegraph: seen, not felt
|
||||
else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp
|
||||
else if (gt < 4.0) speed += g.pow; // hold
|
||||
else speed += g.pow * (5.0 - gt); // fade
|
||||
@ -61,10 +49,11 @@ function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, cal
|
||||
out.x = dx * s; out.y = dy * s; out.z = dz * s;
|
||||
return out;
|
||||
},
|
||||
gustTelegraph: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z) });
|
||||
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null });
|
||||
|
||||
// ---------- test rigs ----------
|
||||
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
|
||||
@ -74,15 +63,15 @@ const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v
|
||||
const FOOT = [
|
||||
{ x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 },
|
||||
];
|
||||
const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y is linear in z -> one plane
|
||||
const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
|
||||
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
|
||||
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
|
||||
|
||||
const makeAnchors = (heights) =>
|
||||
FOOT.map((f, i) => ({
|
||||
id: `a${i}`,
|
||||
type: 'post',
|
||||
pos: { x: f.x, y: heights[i], z: f.z },
|
||||
}));
|
||||
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
|
||||
export const makeAnchors = (heights) =>
|
||||
FOOT.map((f, i) => {
|
||||
const pos = { x: f.x, y: heights[i], z: f.z };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
|
||||
const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];
|
||||
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
|
||||
@ -92,7 +81,7 @@ function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
|
||||
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
|
||||
}
|
||||
|
||||
/** Fixed-dt fast-forward. Returns peak corner load seen over the whole run, N. */
|
||||
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
|
||||
function runStorm(r, wind, secs, onStep) {
|
||||
const steps = Math.round(secs / SIM_DT);
|
||||
let peak = 0;
|
||||
@ -105,248 +94,277 @@ function runStorm(r, wind, secs, onStep) {
|
||||
return peak;
|
||||
}
|
||||
|
||||
// ---------- tiny assert harness ----------
|
||||
|
||||
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);
|
||||
}
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;
|
||||
|
||||
// ---------- the suite ----------
|
||||
|
||||
test('sim stays finite through a full storm', () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, makeStubWind({ stormLen: 90 }), 90);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
|
||||
for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
|
||||
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
|
||||
});
|
||||
|
||||
test('sail sags under gravity when calm', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 6);
|
||||
const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
|
||||
const midY = r.pos[mid + 1];
|
||||
const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
|
||||
assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
|
||||
return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
|
||||
});
|
||||
|
||||
// Newton's third law. This is what pins FABRIC_K to real newtons: if the corner
|
||||
// reactions don't sum to the actual aerodynamic + weight force on the fabric,
|
||||
// the load meter is lying and every kN rating on it is meaningless.
|
||||
test('statics: corner reactions balance the applied force', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 18 });
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, w, 12); // settle
|
||||
// A membrane in steady wind never fully stops moving, so compare the
|
||||
// TIME-AVERAGED reaction against the time-averaged applied force. That is the
|
||||
// momentum balance that must hold; instant by instant it need not.
|
||||
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
|
||||
for (let i = 0; i < Math.round(4 / SIM_DT); i++) {
|
||||
const t = 12 + i * SIM_DT;
|
||||
r.step(SIM_DT, w, t);
|
||||
const f = r.netAppliedForce(w, t);
|
||||
ax += f.x; ay += f.y; az += f.z;
|
||||
for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
|
||||
n++;
|
||||
}
|
||||
ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
|
||||
const appliedMag = Math.hypot(ax, ay, az);
|
||||
const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
|
||||
assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)} — ${(err * 100).toFixed(0)}% out of balance`);
|
||||
return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
// THE THESIS. A twisted sail resists bellying into one coherent pocket, so its
|
||||
// worst moment is gentler than a flat sail's worst moment.
|
||||
//
|
||||
// Scored on WORST CASE over wind direction, not per-direction. Lane C's storms
|
||||
// veer, so the player never gets to choose the wind, and worst-case is what the
|
||||
// hardware actually has to survive. Per-direction would be a false assert: a
|
||||
// flat sail sitting edge-on to the wind genuinely does have low drag, and from
|
||||
// that one angle it beats the hypar. Demanding otherwise would mean tuning the
|
||||
// sim into a lie.
|
||||
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
|
||||
const DIRS = [
|
||||
{ name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
|
||||
{ name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
|
||||
{ name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
|
||||
{ name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
|
||||
];
|
||||
const sweep = (heights) => {
|
||||
let worst = 0, at = '';
|
||||
for (const d of DIRS) {
|
||||
const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
|
||||
const p = runStorm(rig(heights), storm, 45);
|
||||
if (p > worst) { worst = p; at = d.name; }
|
||||
}
|
||||
return { worst, at };
|
||||
};
|
||||
const flat = sweep(HEIGHTS_FLAT);
|
||||
const hypar = sweep(HEIGHTS_HYPAR);
|
||||
assert(
|
||||
hypar.worst < flat.worst * 0.8,
|
||||
`hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
|
||||
);
|
||||
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
test('cascade: losing a corner spikes its neighbours', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 22 });
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, w, 6); // settle
|
||||
const before = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 2.5); // let the load redistribute
|
||||
const after = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
|
||||
return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
|
||||
});
|
||||
|
||||
test('determinism: identical inputs give byte-equal load traces', () => {
|
||||
const trace = () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
const w = makeStubWind({ seed: 3, stormLen: 30 });
|
||||
const out = [];
|
||||
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
|
||||
return out;
|
||||
};
|
||||
const a = trace(), b = trace();
|
||||
assert(a.length === b.length, 'traces differ in length');
|
||||
for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
|
||||
return `${a.length} load samples identical`;
|
||||
});
|
||||
|
||||
test('determinism: variable frame dt matches fixed dt', () => {
|
||||
// Lane A's render loop delivers ragged dt. The internal accumulator has to
|
||||
// absorb that, or nothing the selftest proves applies to the real game.
|
||||
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const fixed = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
|
||||
|
||||
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const ragged = rig(HEIGHTS_HYPAR);
|
||||
const rand = rng(99);
|
||||
let acc = 0;
|
||||
while (acc < 20) {
|
||||
const dt = 0.004 + rand() * 0.02; // 4-24 ms frames
|
||||
ragged.step(dt, w2, acc);
|
||||
acc += dt;
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
|
||||
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
|
||||
}
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
test('tension dial changes load (drum tight shock-loads)', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
|
||||
const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8);
|
||||
assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
|
||||
return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
|
||||
});
|
||||
|
||||
test('porous shade cloth carries less load than solid membrane', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
|
||||
const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
|
||||
assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
|
||||
return `solid ${kN(solid)} vs porous ${kN(porous)}`;
|
||||
});
|
||||
|
||||
test('coverage: sail shades the ground under it, not beside it', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
// world.gardenBed rects are CENTRE + size, so this bed straddles the origin.
|
||||
const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 });
|
||||
const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 });
|
||||
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
|
||||
assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`);
|
||||
return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
test('coverage tracks a low sun off to the side', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
|
||||
const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
|
||||
assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
|
||||
return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
// PLAN3D §7 definition of done, in miniature.
|
||||
test('cheap flat rig cascades; twisted mixed rig survives', () => {
|
||||
const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
|
||||
runStorm(cheap, storm(), 90);
|
||||
const cheapBroken = cheap.corners.filter((c) => c.broken).length;
|
||||
|
||||
const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
|
||||
runStorm(good, storm(), 90);
|
||||
const goodBroken = good.corners.filter((c) => c.broken).length;
|
||||
|
||||
assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
|
||||
assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
|
||||
return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
|
||||
});
|
||||
|
||||
// PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This
|
||||
// drives a REAL overload failure rather than setting broken by hand, because
|
||||
// hand-setting it was exactly what hid the bug where _checkFailure marked a
|
||||
// corner broken but never gave its node its mass back — so a blown corner
|
||||
// stayed welded in mid-air and the sail never flogged.
|
||||
test('a blown corner is freed and flies (flogging is emergent)', () => {
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go
|
||||
const broke = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
|
||||
// step until the first corner lets go
|
||||
let i = 0;
|
||||
for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT);
|
||||
assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm');
|
||||
|
||||
const k = r.corners.indexOf(broke[0].corner);
|
||||
const node = r.cornerIdx[k], ci = node * 3;
|
||||
const anchor = r.corners[k].anchor.pos;
|
||||
const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]];
|
||||
for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT);
|
||||
|
||||
const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]);
|
||||
const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z);
|
||||
|
||||
assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging');
|
||||
assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`);
|
||||
assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`);
|
||||
return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`;
|
||||
});
|
||||
|
||||
test('break and repair emit on the events Emitter', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE });
|
||||
const seen = [];
|
||||
r.events.on('break', (e) => seen.push(e));
|
||||
r.events.on('repair', (e) => seen.push(e));
|
||||
|
||||
runStorm(r, w, 4);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 1);
|
||||
assert(r.corners[0].load === 0, 'broken corner should carry no load');
|
||||
|
||||
assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success');
|
||||
runStorm(r, w, 3);
|
||||
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
||||
assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}');
|
||||
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
// Proves the rig eats the sanctioned Wind implementation, not just my local
|
||||
// stub — so nothing surprises us when Lane C's weather.js drops in.
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[1] });
|
||||
const wind = createStubWind({ seed: 1, stormLen: 90 });
|
||||
const peak = runStorm(r, wind, 90);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'went NaN on the shared stub wind');
|
||||
assert(peak > 0, 'shared stub wind produced no load at all');
|
||||
return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`;
|
||||
});
|
||||
|
||||
export const SAIL_TESTS = TESTS;
|
||||
|
||||
export function runSailSelftest() {
|
||||
results.length = 0;
|
||||
|
||||
test('sim stays finite through a full storm', () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, makeStubWind({ stormLen: 90 }), 90);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
|
||||
for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
|
||||
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
|
||||
const results = TESTS.map(([name, fn]) => {
|
||||
try { return { name, pass: true, detail: fn() || '' }; }
|
||||
catch (e) { return { name, pass: false, detail: e.message }; }
|
||||
});
|
||||
|
||||
test('sail sags under gravity when calm', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 6);
|
||||
const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
|
||||
const midY = r.pos[mid + 1];
|
||||
const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
|
||||
assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
|
||||
return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
|
||||
});
|
||||
|
||||
// Newton's third law. This is what calibrates FABRIC_K into real newtons:
|
||||
// if the corner reactions don't sum to the actual aerodynamic + weight force
|
||||
// on the fabric, the load meter is lying and every kN rating is meaningless.
|
||||
test('statics: corner reactions balance the applied force', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 18 });
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, w, 12); // settle
|
||||
// A membrane in steady wind never fully stops moving, so this compares the
|
||||
// TIME-AVERAGED reaction against the time-averaged applied force. That is
|
||||
// the momentum balance that has to hold; instant by instant it need not.
|
||||
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
|
||||
const steps = Math.round(4 / SIM_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = 12 + i * SIM_DT;
|
||||
r.step(SIM_DT, w, t);
|
||||
const f = r.netAppliedForce(w, t);
|
||||
ax += f.x; ay += f.y; az += f.z;
|
||||
for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
|
||||
n++;
|
||||
}
|
||||
ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
|
||||
const appliedMag = Math.hypot(ax, ay, az);
|
||||
const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
|
||||
assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)} — ${(err * 100).toFixed(0)}% out of balance`);
|
||||
return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
// THE THESIS. A twisted sail resists bellying into one coherent pocket, so
|
||||
// its worst moment is gentler than a flat sail's worst moment.
|
||||
//
|
||||
// Scored on WORST CASE over wind direction, not per-direction. Lane C's
|
||||
// storms veer, so the player never gets to choose the wind, and worst-case is
|
||||
// what the hardware actually has to survive. Per-direction would be a false
|
||||
// assert: a flat sail that happens to sit edge-on to the wind genuinely does
|
||||
// have low drag, and from that one angle it beats the hypar. Demanding
|
||||
// otherwise would mean tuning the sim into a lie.
|
||||
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
|
||||
const DIRS = [
|
||||
{ name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
|
||||
{ name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
|
||||
{ name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
|
||||
{ name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
|
||||
];
|
||||
const sweep = (heights) => {
|
||||
let worst = 0, at = '';
|
||||
for (const d of DIRS) {
|
||||
const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
|
||||
const p = runStorm(rig(heights), storm, 45);
|
||||
if (p > worst) { worst = p; at = d.name; }
|
||||
}
|
||||
return { worst, at };
|
||||
};
|
||||
const flat = sweep(HEIGHTS_FLAT);
|
||||
const hypar = sweep(HEIGHTS_HYPAR);
|
||||
assert(
|
||||
hypar.worst < flat.worst * 0.8,
|
||||
`hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
|
||||
);
|
||||
const shed = (1 - hypar.worst / flat.worst) * 100;
|
||||
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${shed.toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
test('cascade: losing a corner spikes its neighbours', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 22 });
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, w, 6); // settle
|
||||
const before = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 2.5); // let the load redistribute
|
||||
const after = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
|
||||
return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
|
||||
});
|
||||
|
||||
test('determinism: identical inputs give byte-equal load traces', () => {
|
||||
const trace = () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
const w = makeStubWind({ seed: 3, stormLen: 30 });
|
||||
const out = [];
|
||||
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
|
||||
return out;
|
||||
};
|
||||
const a = trace(), b = trace();
|
||||
assert(a.length === b.length, 'traces differ in length');
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
|
||||
}
|
||||
return `${a.length} load samples identical`;
|
||||
});
|
||||
|
||||
test('determinism: variable frame dt matches fixed dt', () => {
|
||||
// Lane A's render loop delivers ragged dt. The internal accumulator has to
|
||||
// absorb that, or nothing the selftest proves applies to the real game.
|
||||
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const fixed = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
|
||||
|
||||
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const ragged = rig(HEIGHTS_HYPAR);
|
||||
const rng = mulberry32(99);
|
||||
let acc = 0;
|
||||
while (acc < 20) {
|
||||
const dt = 0.004 + rng() * 0.02; // 4-24 ms frames
|
||||
ragged.step(dt, w2, acc);
|
||||
acc += dt;
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
|
||||
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
|
||||
}
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
test('tension dial changes load (drum tight shock-loads)', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const loose = rig(HEIGHTS_HYPAR, { tension: 0.7 });
|
||||
const tight = rig(HEIGHTS_HYPAR, { tension: 1.35 });
|
||||
const loosePeak = runStorm(loose, w, 8);
|
||||
const tightPeak = runStorm(tight, w, 8);
|
||||
assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
|
||||
return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
|
||||
});
|
||||
|
||||
test('porous shade cloth carries less load than solid membrane', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
|
||||
const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
|
||||
assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
|
||||
return `solid ${kN(solid)} vs porous ${kN(porous)}`;
|
||||
});
|
||||
|
||||
test('coverage: sail shades the ground under it, not beside it', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const under = r.coverageOver({ x: -2, z: -2, w: 4, d: 4 });
|
||||
const beside = r.coverageOver({ x: 12, z: 12, w: 4, d: 4 });
|
||||
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
|
||||
assert(beside === 0, `ground 12 m away reported ${(beside * 100).toFixed(0)}% shaded`);
|
||||
return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
test('coverage tracks a low sun off to the side', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const noon = r.coverageOver({ x: -2, z: -2, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
|
||||
const lowSun = r.coverageOver({ x: -2, z: -2, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
|
||||
assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
|
||||
return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
// PLAN3D §7 definition of done, in miniature.
|
||||
test('cheap flat rig cascades; twisted mixed rig survives', () => {
|
||||
const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
|
||||
runStorm(cheap, storm(), 90);
|
||||
const cheapBroken = cheap.corners.filter((c) => c.broken).length;
|
||||
|
||||
const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
|
||||
runStorm(good, storm(), 90);
|
||||
const goodBroken = good.corners.filter((c) => c.broken).length;
|
||||
|
||||
assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
|
||||
assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
|
||||
return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
|
||||
});
|
||||
|
||||
test('repair re-pins a blown corner and it carries load again', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, w, 4);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 1);
|
||||
assert(r.corners[0].load === 0, 'broken corner should carry no load');
|
||||
assert(r.repairCorner(0, HARDWARE[1]), 'repairCorner should report success');
|
||||
runStorm(r, w, 3);
|
||||
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
||||
const evs = r.drainEvents();
|
||||
assert(evs.some((e) => e.type === 'repair'), 'no repair event emitted');
|
||||
return `repaired corner back to ${kN(r.corners[0].load)}`;
|
||||
});
|
||||
|
||||
const pass = results.every((r) => r.pass);
|
||||
return { pass, results };
|
||||
return { pass: results.every((r) => r.pass), results };
|
||||
}
|
||||
|
||||
// ---------- entry points ----------
|
||||
|
||||
function report(out) {
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
// Run only when invoked directly: `node web/world/js/sail.selftest.js`.
|
||||
// Importing the module must not run the suite. In the browser `process` is
|
||||
// undefined, so Lane A's selftest.html just calls runSailSelftest() itself.
|
||||
// Run only when invoked directly; importing this module must not run the suite.
|
||||
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
|
||||
const out = runSailSelftest();
|
||||
console.log(report(out));
|
||||
process.exit(out.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
export { report, makeStubWind, HEIGHTS_FLAT, HEIGHTS_HYPAR, makeAnchors };
|
||||
export { makeStubWind };
|
||||
|
||||
@ -1,26 +1,34 @@
|
||||
/**
|
||||
* Lane B selftests — cloth, corner loads, failure cascade.
|
||||
* Lane B selftests — cloth, corner loads, failure cascade, prep economy.
|
||||
*
|
||||
* Lane B owns this file. Lane A pre-created it so that adding your suite never
|
||||
* means editing selftest.html — if all five lanes shared that file it would be
|
||||
* the one guaranteed merge conflict in the repo.
|
||||
* The asserts themselves live next to the code they test, in
|
||||
* `js/sail.selftest.js` and `js/rigging.selftest.js`, exported as [name, fn]
|
||||
* pairs. This file is only the adapter that hands them to Lane A's Suite.
|
||||
*
|
||||
* The asserts PLAN3D §5-B asks for, once sail.js lands:
|
||||
* 1. hypar sheds load — twisted rig's peak corner load < flat rig's peak,
|
||||
* same storm, same hardware. This is the thesis of the whole game; if it
|
||||
* doesn't hold, the wind force is being applied per-node instead of
|
||||
* per-face.
|
||||
* 2. cascade — break one corner at a fixed t, a neighbour's load spikes ≥2×.
|
||||
* 3. determinism — two runs, same inputs, byte-equal load traces.
|
||||
* The reason for the indirection: those two modules also run under plain
|
||||
* `node web/world/js/sail.selftest.js` — no browser, no server, no renderer,
|
||||
* ~6 s — which is how the cloth got proven before M0 landed. Keeping the
|
||||
* asserts there means the browser suite and the headless suite can never drift,
|
||||
* because they are literally the same array.
|
||||
*
|
||||
* Useful imports when you get there:
|
||||
* import { FIXED_DT, STORM_LEN, HARDWARE, createStubWind } from '../contracts.js';
|
||||
* import { assert, assertLess, fixedLoop } from '../testkit.js';
|
||||
* Drive time with fixedLoop(), never rAF. Use createStubWind({seed}) until
|
||||
* Lane C's weather.js lands — but don't tune against it, it's uniform in space.
|
||||
* PLAN3D §5-B asked for three asserts. All three are in there, plus a statics
|
||||
* balance that pins the load meter to real newtons:
|
||||
* 1. hypar sheds load — scored on WORST CASE over eight wind directions
|
||||
* rather than one, because Lane C's storms veer and the player never gets
|
||||
* to pick the wind. Per-direction would be a false assert: a flat sail
|
||||
* sitting edge-on to the wind genuinely has low drag and beats the hypar
|
||||
* from that one angle. Worst-case is what the hardware has to survive.
|
||||
* 2. cascade — break a corner at fixed t, a neighbour's load spikes >= 2x.
|
||||
* 3. determinism — byte-equal load traces, plus ragged frame dt converging on
|
||||
* the fixed-dt trace (Lane A's render loop delivers ragged dt, so the
|
||||
* accumulator has to absorb it or none of this applies to the real game).
|
||||
*/
|
||||
|
||||
import { SAIL_TESTS } from '../sail.selftest.js';
|
||||
import { RIGGING_TESTS } from '../rigging.selftest.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
t.skip('sail.js not landed yet — Lane B');
|
||||
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
|
||||
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user