Compare commits
5 Commits
f495a9855c
...
703dbc499f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703dbc499f | ||
|
|
18099c8e6f | ||
|
|
c8a9128c17 | ||
|
|
06ec4cbea2 | ||
|
|
8d76340f49 |
101
THREADS.md
101
THREADS.md
@ -70,9 +70,110 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
character is still reachable; Running / Falling / Crouch-To-Stand / crank / dig are not sourced.
|
||||
Don't spend a day looking — answer is needed from whoever wrote §2.
|
||||
|
||||
[A] 2026-07-16 — ⚠️ **WORKTREE COLLISION — happened today, please fix.** Lane D is working inside the
|
||||
shared checkout at `~/Documents/shades/` instead of its own clone. Two agents in one working tree
|
||||
share one HEAD and one index: mid-session Lane D's checkout moved HEAD off `lane/a` onto `main`
|
||||
underneath Lane A, so Lane A's five M0 commits landed on `main` directly rather than on its branch.
|
||||
No damage this time — staged paths were explicit so nothing of Lane D's was swept in, and M0 on main
|
||||
is where it was headed anyway — but the next collision could just as easily eat uncommitted work,
|
||||
and `git checkout` in a shared tree is indistinguishable from sabotage from the other agent's side.
|
||||
Lane A has moved out to `~/Documents/shades-laneA/`. **Lane D: please clone to
|
||||
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
|
||||
do this; it's the one house rule that has to hold or the whole lane model stops working.
|
||||
|
||||
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
|
||||
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
|
||||
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
|
||||
§0's clone path is what people are actually using. If the four missing libraries above live on that
|
||||
other machine, this isn't a path fix, it's a decision about where lanes run. Flagging rather than
|
||||
guessing.
|
||||
|
||||
[B] 2026-07-16 — **sail.js + rigging.js landed on `lane/b`, rebased on M0.** `checkContract('sailRig')`
|
||||
conforms; `js/tests/b.test.js` runs 28 asserts green. 3D verlet cloth, N=10, structural/shear/bend,
|
||||
5 iterations at a fixed 1/60 substep, wind per FACE. `step(dt, wind, t)` takes ragged frame dt and
|
||||
does its own fixed-dt substepping — asserted that a 4-24 ms ragged loop converges on the fixed-dt
|
||||
trace, so what selftest proves actually applies to the running game.
|
||||
|
||||
[B] 2026-07-16 — **⚠️ UNITS CHANGED — Lane A (HUD) read this one.** `corner.load` and `hw.rating` are in
|
||||
NEWTONS now, not the prototype's arbitrary scale. I retuned `HARDWARE` in contracts.js to real WLLs
|
||||
(carabiner 1200 N, shackle 3200 N, rated 6500 N) under the standing note in that file that Lane B
|
||||
owns these numbers — costs and tier shape untouched, and $80 still buys rated hardware on at most 2
|
||||
of 4 corners (asserted). **HUD: show `load/1000` as kN.** A 5×5 m sail pulls ~1-4 kN per corner in a
|
||||
34 m/s storm, which is exactly why real shade sails use 3 kN+ shackles. That's DESIGN.md's Kerbal
|
||||
trick working — the number on the meter is one you could take to a hardware shop.
|
||||
|
||||
[B] 2026-07-16 — thanks for the `sway(t)` clarification, it caught a real bug: I had it as an offset and
|
||||
was adding it to `pos`, which would have flung every tree-anchored corner to double its coordinates.
|
||||
Also consuming `world.sunDir` and `world.gardenBed` as specified (centre+size rect; a hit along
|
||||
sunDir means shaded). One nit: `coverageOver()` starts its rays at y=0 rather than `heightAt(x,z)`.
|
||||
On ±0.3 m terrain under a 3 m sail that's ~0.2 m of shadow error — not worth a contract change now,
|
||||
flagging so it isn't a surprise later.
|
||||
|
||||
[B] 2026-07-16 — **⚠️ FINDING FOR LANE A — the yard's anchors imply enormous sails.** Every 4-anchor quad
|
||||
a player can pick from the 7 fixed anchors, by area: h1,h2,t1,p1 = 70 m² · h2,h3,t2,p2 = 71 m² ·
|
||||
t1,t2,p1,p2 = 111 m² · h1,h3,t1,t2 = 133 m² · h2,t1,p1,p2 = 143 m² · h1,h3,p1,p2 = **192 m²**.
|
||||
Real domestic shade sails are 20-50 m², and DESIGN.md itself pictures "a 30 m² kite". Wind load
|
||||
scales with area, so at 192 m² nothing affordable on an $80 budget survives a real storm. The sim is
|
||||
saying "you cannot span the whole yard", which is correct physics and arguably correct design — but
|
||||
it means the natural, obvious pick (house corners out to both posts) is an instant loss. Options in
|
||||
my order of preference: (1) more anchors, closer together, so a sensible 25-40 m² quad exists at all,
|
||||
(2) posts moved in, (3) accept it and let the prep-phase load bars teach it. Not my call — flagging
|
||||
with numbers rather than guessing. Nothing blocks on it; M1 is playable either way.
|
||||
|
||||
[B] 2026-07-16 — **❓ OPEN — the flat-horizontal loophole. Needs Lane C, or the water spike.** DESIGN.md's
|
||||
core tension is "big, flat, low = great shade, death in a storm". My sim disagrees, and it is right
|
||||
to. Peak corner load over 8 wind directions, same footprint: flat *pitched* 3.06 kN, hypar 1.86 kN,
|
||||
flat *horizontal* **1.14 kN** — the lowest of all three. A horizontal plate in horizontal wind
|
||||
genuinely has almost no drag. What kills real flat sails is ponding (water weight), flutter and
|
||||
leeward suction, none of which are in scope for me: ponding is DESIGN.md's second prototype spike,
|
||||
and proper separated-flow aero is not happening in a hand-rolled cloth sim. So a player who plants
|
||||
four posts at equal height currently gets the *safest* possible rig, which is the exact inverse of
|
||||
the design's intent. Not fixable inside sail.js. Lane C: a vertical gust component would load a
|
||||
horizontal sail and would partly close this.
|
||||
|
||||
[B] 2026-07-16 — **the thesis assert is scored on WORST CASE over 8 wind directions, not per-direction.**
|
||||
PLAN3D §5-B says "twisted peak < flat peak, same storm". Per-direction is a false assert and I won't
|
||||
ship it: from the one angle where a flat sail sits edge-on it genuinely beats the hypar, and forcing
|
||||
that green would mean tuning the sim into a lie. Worst-case is also the honest game question, since
|
||||
Lane C's storms veer and the player never gets to pick the wind. Result: flat worst 3.06 kN (from S)
|
||||
vs hypar worst 1.86 kN (from N) — the hypar sheds 39% off its worst moment. Thesis holds.
|
||||
|
||||
[B] 2026-07-16 — two notes for whoever next reads sail.js, because both look "simplifiable" and aren't.
|
||||
(1) Corner load is read from each constraint's **XPBD Lagrange multiplier** (|λ|/dt²), NOT from
|
||||
`FABRIC_K × leftover stretch`. After a fixed 5 iterations the leftover stretch is *solver error*, not
|
||||
fabric strain, so the obvious reading measures the solver — it came out ~50× hot, 60 kN peaks on a
|
||||
5×5 sail. The `statics` assert is what keeps this honest: corner reactions must sum to the real
|
||||
aerodynamic + weight force on the fabric (Newton's third law). It balances to 8.3%. If someone
|
||||
"simplifies" the load reading, that assert is what goes red. (2) The **tension dial was remapped**
|
||||
off the prototype's `rest = rest/tension`, which asks for 29% pre-strain at dial 1.4 and put 68 kN on
|
||||
a corner before any wind blew. It is now a real pre-strain (0.10/dial → 4% at 1.4).
|
||||
|
||||
[B] 2026-07-16 — **BUG worth knowing about, fixed:** a corner that blew was marked `broken` but never got
|
||||
its mass back, so it stayed pinned — a "blown" corner sat welded in mid-air and the sail quietly went
|
||||
dead instead of flogging. PLAN3D §5-B wants flogging emergent from the freed node, and it is now. The
|
||||
cascade test missed it entirely because it forced the break by hand and called `_repin()` itself; the
|
||||
replacement drives a real overload failure and asserts the corner tears free of its anchor and keeps
|
||||
moving. Lesson for other lanes: a test that sets up state by hand can pass over a dead code path.
|
||||
|
||||
[B] 2026-07-16 — **Lane D — your API is ready.** `sailRig.repairCorner(i, hw)` re-pins a blown corner
|
||||
(your 2.5 s hold-E; returns false if it isn't broken). `sailRig.trimCorner(i, ±delta)` is the
|
||||
per-corner turnbuckle (your 1.2 s hold; clamps 0.85-1.15, scales rest lengths near that corner only).
|
||||
Both emit on `sailRig.events`. Spare count lives on `RiggingSession.spares` — gate `canUse()` on it
|
||||
and decrement on use.
|
||||
|
||||
[B] 2026-07-16 — **Lane A — wiring the rendered sail.** `const view = await createSailView(rig);
|
||||
scene.add(view);` then `view.update()` each frame after `rig.step()`. Returns a THREE.Group,
|
||||
double-sided, `castShadow` on — the shadow IS the product. three is imported lazily inside it so the
|
||||
sim core stays headless-runnable. Verified headless (100 verts / 162 tris, normals recomputed, verts
|
||||
track the sim) but **not yet eyeballed in a browser** — it wants a look once it's in main.js. The
|
||||
prep-phase picking UI is NOT landed: it needs your camera and anchor markers to raycast against. All
|
||||
the rules behind it are done and tested in `RiggingSession` (rigging.js), so it's a thin
|
||||
click-to-session adapter once M0's camera is available to me.
|
||||
|
||||
[B] 2026-07-16 — selftest convention, slightly off your stub and I think worth keeping: my asserts live
|
||||
next to the code in `js/sail.selftest.js` and `js/rigging.selftest.js` as exported `[name, fn]`
|
||||
arrays, and `js/tests/b.test.js` is a 3-line adapter that feeds them to your Suite. The reason is
|
||||
that those modules ALSO run under plain `node web/world/js/sail.selftest.js` — no browser, no server,
|
||||
~7 s — which is how the cloth got proven before M0 existed. Same array both ways, so the two
|
||||
harnesses cannot drift. `contracts.js` importing three is no obstacle to that: node imports
|
||||
`three.module.js` fine.
|
||||
|
||||
@ -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. */
|
||||
|
||||
157
web/world/js/rigging.js
Normal file
157
web/world/js/rigging.js
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* rigging.js — prep-phase rig selection and hardware economy. [Lane B]
|
||||
*
|
||||
* The money half of the sail. Ports the prototype's economy verbatim ($80
|
||||
* budget, $5/$15/$30 hardware, $15 spare) and adds the state machine around it:
|
||||
* which anchors are rigged, what hangs at each corner, how tight, how many
|
||||
* spares in the bag.
|
||||
*
|
||||
* Kept three-free and DOM-free like sail.js so it is testable headless. The
|
||||
* picking/DOM layer is deliberately NOT here yet — it needs Lane A's camera and
|
||||
* anchor markers, which do not exist at time of writing; see createRiggingUI at
|
||||
* the bottom for the seam it will plug into.
|
||||
*/
|
||||
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
const fail = (reason) => ({ ok: false, reason });
|
||||
|
||||
export class RiggingSession {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.anchors world.anchors — [{id, pos, type, sway?}]
|
||||
* @param {number} opts.budget starting cash
|
||||
*/
|
||||
constructor({ anchors = [], budget = START_BUDGET } = {}) {
|
||||
this.anchors = anchors;
|
||||
this.budget = budget;
|
||||
this.tension = DEFAULT_TENSION;
|
||||
this.spares = 0;
|
||||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||||
this.picks = [];
|
||||
}
|
||||
|
||||
get spent() { return START_BUDGET - this.budget; }
|
||||
get canStart() { return this.picks.length === MAX_CORNERS; }
|
||||
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
|
||||
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
|
||||
|
||||
/** Charge (or refund, when amount is negative) against the budget. */
|
||||
_spend(amount) {
|
||||
if (this.budget - amount < 0) return false;
|
||||
this.budget -= amount;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rig a corner at an anchor, starting on the cheapest hardware (prototype). */
|
||||
rig(anchorId) {
|
||||
const a = this.anchors.find((x) => x.id === anchorId);
|
||||
if (!a) return fail('no such anchor');
|
||||
if (this.isRigged(anchorId)) return fail('already rigged');
|
||||
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
|
||||
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
|
||||
this.picks.push({ anchorId, hw: HARDWARE[0] });
|
||||
this._reorder();
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrig a corner and refund its hardware. Not in the prototype (which had no
|
||||
* way back from a misclick) but it is a pure refund, so it costs the economy
|
||||
* nothing and saves the player a restart.
|
||||
*/
|
||||
unrig(anchorId) {
|
||||
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
|
||||
if (i < 0) return fail('not rigged');
|
||||
this.budget += this.picks[i].hw.cost;
|
||||
this.picks.splice(i, 1);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Cycle a corner's hardware to the next tier, paying (or refunding) the difference. */
|
||||
cycleHardware(anchorId) {
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
|
||||
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
|
||||
p.hw = next;
|
||||
return OK;
|
||||
}
|
||||
|
||||
setHardware(anchorId, hw) {
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
|
||||
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
||||
p.hw = hw;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
||||
setTension(v) {
|
||||
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
||||
return this.tension;
|
||||
}
|
||||
|
||||
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
|
||||
setSpares(n) {
|
||||
n = Math.max(0, Math.floor(n));
|
||||
const delta = (n - this.spares) * SPARE_COST;
|
||||
if (!this._spend(delta)) return fail('not enough budget');
|
||||
this.spares = n;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring-order the picks by angle around their ground-plane centroid, so corner
|
||||
* i of the cloth grid always maps to a neighbouring anchor. Without it,
|
||||
* picking anchors in a silly order knots the sail through itself.
|
||||
*/
|
||||
_reorder() {
|
||||
if (this.picks.length < MAX_CORNERS) return;
|
||||
const byId = new Map(this.picks.map((p) => [p.anchorId, p]));
|
||||
const ring = orderRing(this.picks.map((p) => this.anchors.find((a) => a.id === p.anchorId)));
|
||||
this.picks = ring.map((a) => byId.get(a.id));
|
||||
}
|
||||
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
/** Everything the HUD needs to draw the prep panel, in one read. */
|
||||
get summary() {
|
||||
return {
|
||||
budget: this.budget,
|
||||
spent: this.spent,
|
||||
tension: this.tension,
|
||||
spares: this.spares,
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
weakest: this.picks.length
|
||||
? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prep-phase picking UI.
|
||||
*
|
||||
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
|
||||
* anchor markers to raycast against, none of which exist yet. RiggingSession
|
||||
* above holds all the rules and is fully tested, so this stays a thin
|
||||
* click-to-session adapter once M0 lands. See THREADS.md.
|
||||
*/
|
||||
export async function createRiggingUI() {
|
||||
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
|
||||
}
|
||||
195
web/world/js/rigging.selftest.js
Normal file
195
web/world/js/rigging.selftest.js
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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`;
|
||||
});
|
||||
|
||||
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 };
|
||||
635
web/world/js/sail.js
Normal file
635
web/world/js/sail.js
Normal file
@ -0,0 +1,635 @@
|
||||
/**
|
||||
* sail.js — shade sail cloth simulation, corner loads, hardware failure. [Lane B]
|
||||
*
|
||||
* A 3D verlet cloth on a bilinear patch between 4 anchors. Wind pressure is
|
||||
* applied per FACE, not per node, which is the whole point: a twisted (hypar)
|
||||
* sail turns most of its faces edge-on to the wind and sheds load, while a flat
|
||||
* one presents every face square-on and catches everything. That difference is
|
||||
* the game's thesis and it is asserted in sail.selftest.js.
|
||||
*
|
||||
* 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 34 m/s storm genuinely puts ~1-4 kN on a corner — which
|
||||
* is genuinely why real shade sails use 3 kN+ shackles.
|
||||
*
|
||||
* 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 = 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;
|
||||
|
||||
// ---------- aerodynamics ----------
|
||||
// 0.5 * air density (1.225) * flat-plate drag coefficient (~1.4).
|
||||
// Newtons per m^2 of face area per (m/s)^2 of normal-on airflow.
|
||||
const PRESSURE_COEFF = 0.86;
|
||||
const TANGENT_COEFF = 0.02; // skin friction dragging along the face
|
||||
const MAX_NORMAL_SPEED = 45; // clamp on the normal-on component, m/s — stability in extreme gusts
|
||||
|
||||
// ---------- fabric ----------
|
||||
const FABRIC_DENSITY = 0.32; // kg/m^2, typical knitted shade cloth
|
||||
// Axial stiffness of one grid spring, N/m — roughly E*t*width/length for
|
||||
// knitted HDPE mesh. Fed to the solver as a compliance (1/k), not used to
|
||||
// convert stretch into force: see _measureLoads for why that distinction is
|
||||
// the whole ballgame.
|
||||
const FABRIC_K = 100000;
|
||||
const K_COMPRESS = 0.08; // cloth resists stretch hard, compression barely (from prototype)
|
||||
const K_BEND = 0.04;
|
||||
const COMP_STRETCH = 1 / FABRIC_K;
|
||||
const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
|
||||
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
|
||||
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
|
||||
|
||||
// ---------- failure ----------
|
||||
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
|
||||
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
|
||||
|
||||
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;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/**
|
||||
* Order 4 anchors into a non-self-intersecting ring by angle around their
|
||||
* centroid, projected onto the ground plane. Ported from the prototype's
|
||||
* orderRing; without it, picking corners in a silly order knots the sail.
|
||||
*/
|
||||
export function orderRing(anchors) {
|
||||
const n = anchors.length;
|
||||
let cx = 0, cz = 0;
|
||||
for (const a of anchors) { cx += a.pos.x; cz += a.pos.z; }
|
||||
cx /= n; cz /= n;
|
||||
return [...anchors].sort(
|
||||
(a, b) => Math.atan2(a.pos.z - cz, a.pos.x - cx) - Math.atan2(b.pos.z - cz, b.pos.x - cx)
|
||||
);
|
||||
}
|
||||
|
||||
export class SailRig {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @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)
|
||||
*/
|
||||
constructor({ anchors = [], gridN = 10, porosity = 0 } = {}) {
|
||||
this.anchors = anchors;
|
||||
this.N = gridN;
|
||||
this.porosity = porosity;
|
||||
this.corners = [];
|
||||
/** Emits 'break' and 'repair' as {type, corner} — contracts.js SailRig. */
|
||||
this.events = new Emitter();
|
||||
this.tension = 1.0;
|
||||
this.t = 0;
|
||||
this.rigged = false;
|
||||
this._acc = 0;
|
||||
// scratch, reused every face to keep the hot loop allocation-free
|
||||
this._probe = { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rig the sail across 4 anchors.
|
||||
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
|
||||
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
|
||||
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
|
||||
*/
|
||||
attach(anchorIds, hwChoices, tension = 1.0) {
|
||||
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
|
||||
|
||||
const picked = anchorIds.map((id) => {
|
||||
const a = this.anchors.find((x) => x.id === id);
|
||||
if (!a) throw new Error(`unknown anchor "${id}"`);
|
||||
return a;
|
||||
});
|
||||
const hwById = new Map(anchorIds.map((id, i) => [id, hwChoices[i] || HARDWARE[0]]));
|
||||
|
||||
const ring = orderRing(picked);
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
this.corners = ring.map((a) => ({
|
||||
anchorId: a.id,
|
||||
anchor: a,
|
||||
hw: hwById.get(a.id),
|
||||
load: 0,
|
||||
peakLoad: 0,
|
||||
overload: 0,
|
||||
broken: false,
|
||||
trim: 1.0,
|
||||
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
|
||||
}));
|
||||
|
||||
this._build(ring);
|
||||
this.rigged = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
_build(ring) {
|
||||
const N = this.N;
|
||||
const nodeCount = N * N;
|
||||
this.pos = new Float64Array(nodeCount * 3);
|
||||
this.prev = new Float64Array(nodeCount * 3);
|
||||
this.force = new Float64Array(nodeCount * 3);
|
||||
this.invMass = new Float64Array(nodeCount);
|
||||
|
||||
// Bilinear patch across the 4 corners. Because the anchors sit at different
|
||||
// heights, this initial surface is already a hypar — the sim just relaxes it.
|
||||
const [c0, c1, c2, c3] = ring.map((a) => a.pos);
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
const fu = u / (N - 1), fv = v / (N - 1);
|
||||
const i = (v * N + u) * 3;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const ax = ['x', 'y', 'z'][k];
|
||||
const top = (1 - fu) * c0[ax] + fu * c1[ax];
|
||||
const bot = (1 - fu) * c3[ax] + fu * c2[ax];
|
||||
this.pos[i + k] = this.prev[i + k] = (1 - fv) * top + fv * bot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (u, v) => v * N + u;
|
||||
this.cornerIdx = [idx(0, 0), idx(N - 1, 0), idx(N - 1, N - 1), idx(0, N - 1)];
|
||||
|
||||
// springs: structural + shear carry load; bend only resists folding
|
||||
this.springs = [];
|
||||
const link = (a, b, kind) => {
|
||||
const ax = a * 3, bx = b * 3;
|
||||
const dx = this.pos[bx] - this.pos[ax];
|
||||
const dy = this.pos[bx + 1] - this.pos[ax + 1];
|
||||
const dz = this.pos[bx + 2] - this.pos[ax + 2];
|
||||
this.springs.push({ a, b, restBase: Math.hypot(dx, dy, dz), rest: 0, kind });
|
||||
};
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
if (u < N - 1) link(idx(u, v), idx(u + 1, v), 'struct');
|
||||
if (v < N - 1) link(idx(u, v), idx(u, v + 1), 'struct');
|
||||
if (u < N - 1 && v < N - 1) {
|
||||
link(idx(u, v), idx(u + 1, v + 1), 'shear');
|
||||
link(idx(u + 1, v), idx(u, v + 1), 'shear');
|
||||
}
|
||||
if (u < N - 2) link(idx(u, v), idx(u + 2, v), 'bend');
|
||||
if (v < N - 2) link(idx(u, v), idx(u, v + 2), 'bend');
|
||||
}
|
||||
}
|
||||
|
||||
// XPBD Lagrange multipliers, one per spring, reset every substep
|
||||
this.lambda = new Float64Array(this.springs.length);
|
||||
|
||||
// Springs meeting each corner, kept as {spring, index} so the load meter can
|
||||
// look up each one's multiplier. Bend springs are included: the hardware
|
||||
// physically carries every element that touches it, and leaving them out
|
||||
// under-reports the reaction and breaks the statics balance.
|
||||
this.cornerSprings = this.cornerIdx.map((ci) =>
|
||||
this.springs
|
||||
.map((s, si) => ({ s, si }))
|
||||
.filter(({ s }) => s.a === ci || s.b === ci)
|
||||
);
|
||||
|
||||
// triangles: wind acts per face, and coverage raycasts against these
|
||||
this.tris = new Uint16Array((N - 1) * (N - 1) * 6);
|
||||
let ti = 0;
|
||||
for (let v = 0; v < N - 1; v++) {
|
||||
for (let u = 0; u < N - 1; u++) {
|
||||
const a = idx(u, v), b = idx(u + 1, v), c = idx(u + 1, v + 1), d = idx(u, v + 1);
|
||||
this.tris[ti++] = a; this.tris[ti++] = b; this.tris[ti++] = c;
|
||||
this.tris[ti++] = a; this.tris[ti++] = c; this.tris[ti++] = d;
|
||||
}
|
||||
}
|
||||
|
||||
// grid-space proximity of every node to each corner, for per-corner trim
|
||||
this._cornerWeight = [];
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const cu = [0, N - 1, N - 1, 0][k], cv = [0, 0, N - 1, N - 1][k];
|
||||
const w = new Float64Array(nodeCount);
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
const dist = Math.hypot(u - cu, v - cv) / (N - 1);
|
||||
w[idx(u, v)] = Math.max(0, 1 - dist);
|
||||
}
|
||||
}
|
||||
this._cornerWeight.push(w);
|
||||
}
|
||||
|
||||
this.area = this._surfaceArea();
|
||||
const mass = (FABRIC_DENSITY * this.area) / nodeCount;
|
||||
this.nodeMass = mass;
|
||||
this.invMass.fill(1 / mass);
|
||||
|
||||
this._applyRestLengths();
|
||||
this._repin(0);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const w = this._cornerWeight[k][s.a] + this._cornerWeight[k][s.b];
|
||||
wsum += w;
|
||||
tsum += w * this.corners[k].trim;
|
||||
}
|
||||
const trim = wsum > 1e-9 ? tsum / wsum : 1;
|
||||
s.rest = s.restBase * (1 - PRE_STRAIN * (this.tension * trim - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned corners are infinite-mass so springs stretch honestly against them. */
|
||||
_repin(t) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
const ci = this.cornerIdx[k];
|
||||
if (c.broken) {
|
||||
this.invMass[ci] = 1 / this.nodeMass; // freed node — flogging falls out of this
|
||||
continue;
|
||||
}
|
||||
this.invMass[ci] = 0;
|
||||
const p = this._anchorPos(c.anchor, t);
|
||||
this.pos[ci * 3] = p.x; this.pos[ci * 3 + 1] = p.y; this.pos[ci * 3 + 2] = p.z;
|
||||
this.prev[ci * 3] = p.x; this.prev[ci * 3 + 1] = p.y; this.prev[ci * 3 + 2] = p.z;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return a.sway ? a.sway(t) : a.pos;
|
||||
}
|
||||
|
||||
_surfaceArea() {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
|
||||
const e1x = this.pos[b] - this.pos[a], e1y = this.pos[b + 1] - this.pos[a + 1], e1z = this.pos[b + 2] - this.pos[a + 2];
|
||||
const e2x = this.pos[c] - this.pos[a], e2y = this.pos[c + 1] - this.pos[a + 1], e2z = this.pos[c + 2] - this.pos[a + 2];
|
||||
const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
||||
total += Math.hypot(nx, ny, nz) * 0.5;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the sim. Accumulates real time and burns it in fixed SIM_DT chunks,
|
||||
* so a variable-rate render loop and a fast-forwarded selftest produce
|
||||
* identical traces. Never reads a clock.
|
||||
*
|
||||
* @param {number} dt seconds elapsed since last call
|
||||
* @param {object} wind { sample(pos, t) -> {x,y,z} }
|
||||
* @param {number} t world time, seconds
|
||||
*/
|
||||
step(dt, wind, t) {
|
||||
if (!this.rigged) return;
|
||||
this._acc += dt;
|
||||
let n = 0;
|
||||
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
|
||||
this._substep(SIM_DT, wind, this.t);
|
||||
this._acc -= SIM_DT;
|
||||
this.t += SIM_DT;
|
||||
n++;
|
||||
}
|
||||
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
|
||||
}
|
||||
|
||||
_substep(dt, wind, t) {
|
||||
this._accumulateWind(wind, t, dt);
|
||||
this._integrate(dt);
|
||||
this.lambda.fill(0); // XPBD multipliers are per-substep
|
||||
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
|
||||
this._pinCorners(t);
|
||||
this._measureLoads(dt);
|
||||
this._checkFailure(dt);
|
||||
}
|
||||
|
||||
/** Wind force per FACE — the hypar mechanic lives here. */
|
||||
_accumulateWind(wind, t, dt) {
|
||||
const pos = this.pos, prev = this.prev, F = this.force;
|
||||
F.fill(0);
|
||||
const coeff = PRESSURE_COEFF * (1 - this.porosity);
|
||||
const tanCoeff = TANGENT_COEFF * (1 - this.porosity);
|
||||
const invDt = 1 / dt;
|
||||
const probe = this._probe;
|
||||
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const ia = this.tris[i] * 3, ib = this.tris[i + 1] * 3, ic = this.tris[i + 2] * 3;
|
||||
|
||||
const e1x = pos[ib] - pos[ia], e1y = pos[ib + 1] - pos[ia + 1], e1z = pos[ib + 2] - pos[ia + 2];
|
||||
const e2x = pos[ic] - pos[ia], e2y = pos[ic + 1] - pos[ia + 1], e2z = pos[ic + 2] - pos[ia + 2];
|
||||
// |cross| is twice the area and its direction is the face normal
|
||||
let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
||||
const len = Math.hypot(nx, ny, nz);
|
||||
if (len < 1e-9) continue;
|
||||
const area = len * 0.5;
|
||||
nx /= len; ny /= len; nz /= len;
|
||||
|
||||
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
|
||||
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
|
||||
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
|
||||
const w = wind.sample(probe, t);
|
||||
|
||||
// Relative wind, not absolute: as the cloth accelerates downwind the load
|
||||
// bleeds off by itself. This is what stops flogging from exploding.
|
||||
const vx = (pos[ia] - prev[ia] + pos[ib] - prev[ib] + pos[ic] - prev[ic]) / 3 * invDt;
|
||||
const vy = (pos[ia + 1] - prev[ia + 1] + pos[ib + 1] - prev[ib + 1] + pos[ic + 1] - prev[ic + 1]) / 3 * invDt;
|
||||
const vz = (pos[ia + 2] - prev[ia + 2] + pos[ib + 2] - prev[ib + 2] + pos[ic + 2] - prev[ic + 2]) / 3 * invDt;
|
||||
const rx = w.x - vx, ry = w.y - vy, rz = w.z - vz;
|
||||
|
||||
const d = clamp(rx * nx + ry * ny + rz * nz, -MAX_NORMAL_SPEED, MAX_NORMAL_SPEED);
|
||||
// d*|d| rather than d^2: keeps the v^2 magnitude but points the force the
|
||||
// way the wind is actually blowing. A sail is double-sided.
|
||||
const p = coeff * area * d * Math.abs(d);
|
||||
|
||||
const tx = (rx - nx * d) * tanCoeff * area;
|
||||
const ty = (ry - ny * d) * tanCoeff * area;
|
||||
const tz = (rz - nz * d) * tanCoeff * area;
|
||||
|
||||
const fx = (nx * p + tx) / 3, fy = (ny * p + ty) / 3, fz = (nz * p + tz) / 3;
|
||||
F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz;
|
||||
F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz;
|
||||
F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz;
|
||||
}
|
||||
}
|
||||
|
||||
_integrate(dt) {
|
||||
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
|
||||
const dt2 = dt * dt;
|
||||
for (let n = 0; n < im.length; n++) {
|
||||
if (im[n] === 0) continue; // pinned
|
||||
const i = n * 3;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const a = F[i + k] * im[n] + (k === 1 ? GRAVITY : 0);
|
||||
const x = pos[i + k];
|
||||
const nx = x + (x - prev[i + k]) * VEL_DAMP + a * dt2;
|
||||
prev[i + k] = x;
|
||||
pos[i + k] = nx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XPBD constraint solve. The plain-PBD version of this is simpler, but its
|
||||
* position corrections carry no force information — the leftover stretch
|
||||
* after a fixed iteration count is solver error, not fabric strain, so
|
||||
* reading load off it measures the solver. XPBD's Lagrange multiplier lambda
|
||||
* is the real constraint impulse, so lambda/dt^2 is a genuine newton value
|
||||
* and the corner reactions balance the applied wind by construction.
|
||||
*/
|
||||
_relax(dt2) {
|
||||
const pos = this.pos, im = this.invMass, lam = this.lambda;
|
||||
for (let si = 0; si < this.springs.length; si++) {
|
||||
const s = this.springs[si];
|
||||
const wa = im[s.a], wb = im[s.b];
|
||||
const w = wa + wb;
|
||||
if (w === 0) continue; // both ends pinned
|
||||
const ia = s.a * 3, ib = s.b * 3;
|
||||
const dx = pos[ib] - pos[ia], dy = pos[ib + 1] - pos[ia + 1], dz = pos[ib + 2] - pos[ia + 2];
|
||||
const d = Math.hypot(dx, dy, dz);
|
||||
if (d < 1e-9) continue;
|
||||
const C = d - s.rest;
|
||||
const compliance = s.kind === 'bend' ? COMP_BEND : C > 0 ? COMP_STRETCH : COMP_COMPRESS;
|
||||
const at = compliance / dt2;
|
||||
const dLambda = (-C - at * lam[si]) / (w + at);
|
||||
lam[si] += dLambda;
|
||||
// grad C is -n for node a and +n for node b, with n = (b - a)/d
|
||||
const nx = dx / d, ny = dy / d, nz = dz / d;
|
||||
pos[ia] -= nx * dLambda * wa; pos[ia + 1] -= ny * dLambda * wa; pos[ia + 2] -= nz * dLambda * wa;
|
||||
pos[ib] += nx * dLambda * wb; pos[ib + 1] += ny * dLambda * wb; pos[ib + 2] += nz * dLambda * wb;
|
||||
}
|
||||
}
|
||||
|
||||
_pinCorners(t) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
const ci = this.cornerIdx[k] * 3;
|
||||
const p = this._anchorPos(c.anchor, t);
|
||||
this.pos[ci] = p.x; this.pos[ci + 1] = p.y; this.pos[ci + 2] = p.z;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner load = magnitude of the VECTOR sum of the tensions in the springs
|
||||
* meeting that corner, each read off its XPBD multiplier as |lambda|/dt^2.
|
||||
*
|
||||
* Reading tension as FABRIC_K * leftover-stretch instead looks equivalent and
|
||||
* is not: after a fixed 5 iterations the leftover stretch is solver error, so
|
||||
* that number measures the solver rather than the fabric and comes out ~50x
|
||||
* hot. The multiplier is the actual constraint impulse, which is why the
|
||||
* statics assert balances.
|
||||
*
|
||||
* The vector sum (rather than a scalar total) is what
|
||||
* makes DESIGN.md's anchor-angle mechanic fall out for free: edges pulling in
|
||||
* nearly the same direction add up, edges pulling apart partly cancel — so a
|
||||
* pinched corner really does multiply its own load.
|
||||
*/
|
||||
_measureLoads(dt) {
|
||||
const pos = this.pos, lam = this.lambda;
|
||||
const invDt2 = 1 / (dt * dt);
|
||||
const alpha = 1 - Math.exp(-dt / LOAD_TAU);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) { c.load = 0; c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; continue; }
|
||||
const ci = this.cornerIdx[k], cix = ci * 3;
|
||||
let sx = 0, sy = 0, sz = 0;
|
||||
for (const { s, si } of this.cornerSprings[k]) {
|
||||
if (lam[si] >= 0) continue; // slack or compressed fabric pulls on nothing
|
||||
const tension = -lam[si] * invDt2; // the multiplier IS the impulse; /dt^2 makes it newtons
|
||||
const o = (s.a === ci ? s.b : s.a) * 3;
|
||||
const dx = pos[o] - pos[cix], dy = pos[o + 1] - pos[cix + 1], dz = pos[o + 2] - pos[cix + 2];
|
||||
const d = Math.hypot(dx, dy, dz);
|
||||
if (d < 1e-9) continue;
|
||||
sx += (dx / d) * tension; sy += (dy / d) * tension; sz += (dz / d) * tension;
|
||||
}
|
||||
c.loadVec.x += (sx - c.loadVec.x) * alpha;
|
||||
c.loadVec.y += (sy - c.loadVec.y) * alpha;
|
||||
c.loadVec.z += (sz - c.loadVec.z) * alpha;
|
||||
const raw = Math.hypot(sx, sy, sz);
|
||||
c.load += (raw - c.load) * alpha;
|
||||
if (c.load > c.peakLoad) c.peakLoad = c.load;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
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);
|
||||
if (c.overload > OVERLOAD_SECS) {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
// 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; }
|
||||
}
|
||||
|
||||
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
|
||||
repairCorner(index, hw = HARDWARE[1]) {
|
||||
const c = this.corners[index];
|
||||
if (!c || !c.broken) return false;
|
||||
c.broken = false;
|
||||
c.hw = hw;
|
||||
c.load = 0;
|
||||
c.overload = 0;
|
||||
this._repin(this.t);
|
||||
this.events.emit('repair', { type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turnbuckle trim at ONE corner (Lane D, 1.2 s hold). Tightens/eases locally. */
|
||||
trimCorner(index, delta) {
|
||||
const c = this.corners[index];
|
||||
if (!c) return false;
|
||||
c.trim = clamp(c.trim + delta, TRIM_MIN, TRIM_MAX);
|
||||
this._dirtyRest = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
if (this.rigged) this._applyRestLengths();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
|
||||
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
|
||||
if (dy <= 0.01) return 0; // sun at or below the horizon casts no useful shade
|
||||
|
||||
const COLS = 6, ROWS = 4; // prototype sampled 6x4 over the garden
|
||||
let hit = 0;
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
for (let j = 0; j < ROWS; j++) {
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
return hit / (COLS * ROWS);
|
||||
}
|
||||
|
||||
/** Moller-Trumbore against every face; 162 tris, cheap enough to not bother accelerating. */
|
||||
_rayHitsSail(ox, oy, oz, dx, dy, dz) {
|
||||
const pos = this.pos;
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
|
||||
const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
|
||||
const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
|
||||
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
|
||||
const det = e1x * px + e1y * py + e1z * pz;
|
||||
if (Math.abs(det) < 1e-9) continue; // ray parallel to the face
|
||||
const inv = 1 / det;
|
||||
const tx = ox - pos[a], ty = oy - pos[a + 1], tz = oz - pos[a + 2];
|
||||
const u = (tx * px + ty * py + tz * pz) * inv;
|
||||
if (u < 0 || u > 1) continue;
|
||||
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
|
||||
const v = (dx * qx + dy * qy + dz * qz) * inv;
|
||||
if (v < 0 || u + v > 1) continue;
|
||||
const hitT = (e2x * qx + e2y * qy + e2z * qz) * inv;
|
||||
if (hitT > 1e-6) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sum of the aerodynamic + weight force on the whole sail, N. Used by the statics assert. */
|
||||
netAppliedForce(wind, t) {
|
||||
this._accumulateWind(wind, t, SIM_DT);
|
||||
let fx = 0, fy = 0, fz = 0;
|
||||
for (let n = 0; n < this.invMass.length; n++) {
|
||||
fx += this.force[n * 3];
|
||||
fy += this.force[n * 3 + 1] + GRAVITY * this.nodeMass;
|
||||
fz += this.force[n * 3 + 2];
|
||||
}
|
||||
return { x: fx, y: fy, z: fz };
|
||||
}
|
||||
|
||||
maxLoad() {
|
||||
let m = 0;
|
||||
for (const c of this.corners) if (c.load > m) m = c.load;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* three.js view over a rig. Imported lazily so the sim core above stays
|
||||
* headless-runnable; call this only from the browser, after Lane A's vendor/
|
||||
* exists. Returns a THREE.Group to add to the scene, with update() per frame.
|
||||
*/
|
||||
export async function createSailView(rig, { color = 0xd8c48a } = {}) {
|
||||
const THREE = await import('../vendor/three.module.js');
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const verts = new Float32Array(rig.pos.length);
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true; // the shadow IS the product
|
||||
mesh.receiveShadow = true;
|
||||
mesh.frustumCulled = false; // it flogs well outside its initial bounds
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.add(mesh);
|
||||
group.update = () => {
|
||||
verts.set(rig.pos);
|
||||
geo.attributes.position.needsUpdate = true;
|
||||
geo.computeVertexNormals();
|
||||
geo.computeBoundingSphere();
|
||||
};
|
||||
group.update();
|
||||
return group;
|
||||
}
|
||||
370
web/world/js/sail.selftest.js
Normal file
370
web/world/js/sail.selftest.js
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* sail.selftest.js — assert suite for the sail sim. [Lane B]
|
||||
*
|
||||
* 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 } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
// ---------- 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 makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
|
||||
const rand = rng(seed);
|
||||
const gusts = [];
|
||||
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;
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
|
||||
return {
|
||||
speedAt(t) {
|
||||
if (calm) return 0;
|
||||
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: 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
|
||||
}
|
||||
return speed;
|
||||
},
|
||||
sample(pos, t) {
|
||||
const s = this.speedAt(t);
|
||||
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), gustTelegraph: () => null });
|
||||
|
||||
// ---------- test rigs ----------
|
||||
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
|
||||
// Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted
|
||||
// hypar). Any load difference is therefore purely geometry, nothing else.
|
||||
|
||||
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 },
|
||||
];
|
||||
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
|
||||
|
||||
/** 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 };
|
||||
|
||||
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
|
||||
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
|
||||
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
r.step(SIM_DT, wind, i * SIM_DT);
|
||||
const m = r.maxLoad();
|
||||
if (m > peak) peak = m;
|
||||
if (onStep) onStep(r, i);
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
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() {
|
||||
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 };
|
||||
}
|
||||
|
||||
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; 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 { 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