C's gate-3 finding, answered in the file it names: _applyPonding never reads porosity, so cloth and membrane pond identically while setFabric's comment and the cloth blurb promised shedding. Ruled, not silently fixed: every pinned cloth number (backyard separation, the section-7 gates, C's storm_06 pins, the S15 zero-delta lattice) was measured under fabric-blind ponding, and a pond- physics change mid-TEETH moves them all against the one-variable law. The fix (porosity-scaled intake + through-weave leak) wants its own gate with a full gauntlet re-measure; C's soaker is its test case. setFabric's comment carries the ruling, _applyPonding carries the warning, the cloth blurb drops 'sheds water' (a player-facing claim the sim doesn't cash). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
799 lines
36 KiB
JavaScript
799 lines
36 KiB
JavaScript
/**
|
||
* 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, FIXED_DT } from './contracts.js';
|
||
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||
import { hailBlockFor } from './weather.core.js';
|
||
|
||
export { START_BUDGET, SPARE_COST };
|
||
export const MAX_CORNERS = 4;
|
||
export const DEFAULT_TENSION = 1.0;
|
||
|
||
/**
|
||
* Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a
|
||
* purchase. Both cost $0.
|
||
*
|
||
* That pricing is a finding, not laziness. SPRINT6 asked me to "price the
|
||
* difference"; I measured it instead and there is no price at which membrane is
|
||
* a sane buy, because it is strictly dominated as an upgrade:
|
||
*
|
||
* what membrane buys +26% hail blocked — but ONLY on the pea-hail nights
|
||
* (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY
|
||
* ones; on storm_02 (1.3) and the ice night (1.4) a
|
||
* knitted cloth already blocks 100%, because a 2 cm
|
||
* stone cannot pass a 2 mm weave — C's ruling.
|
||
* Plus rain, which is 0.25 of the drain against hail's
|
||
* 5.0, i.e. ~5% of the damage.
|
||
* what membrane costs DOUBLE the wind load (porosity halves the pressure
|
||
* term), and it PONDS — the flat-sail killer, needing
|
||
* the broom.
|
||
*
|
||
* Charge for that and you have shipped a trap. Free, it is the real forecast
|
||
* bet the design describes: take the cloth when the night is windy (which is
|
||
* every night with big hail), take the membrane when the hail is small and the
|
||
* wind is mild and you want the rain off too. If Lane A ever raises rain's drain
|
||
* weight, membrane earns a price and this comment is the place to start.
|
||
*/
|
||
export const FABRIC = [
|
||
// Blurb honesty (SPRINT16): "sheds water" deleted — C measured that ponding
|
||
// is fabric-blind (_applyPonding never reads porosity; both cloths pond
|
||
// identically), and a player-facing claim the sim doesn't cash is the worst
|
||
// kind. Ruled a known simplification in sail.js setFabric's comment.
|
||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, leaks the finest hail' },
|
||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||
];
|
||
export const DEFAULT_FABRIC = FABRIC[0];
|
||
|
||
/**
|
||
* SPRINT16 gate 2.2 — the verdict's fabric sentence, ONLY when the bet
|
||
* provably mattered. The invoice records the fabric every night (the record is
|
||
* unconditional); this is the extra line the aftermath gets to say about it,
|
||
* and it must never guess: dmg.hail can't be split into through-the-weave vs
|
||
* fell-beside-the-sail after the fact, so the note speaks only when the
|
||
* MODEL's own numbers make the claim safe.
|
||
*
|
||
* · Cloth on a night whose stones pass the weave (hailBlockFor < 1 — C's
|
||
* 2 mm-weave ruling) with real hail damage: name the leak. That's the
|
||
* spec's own case, "a membrane night survived in cloth should say what the
|
||
* leak cost". The phrasing claims what is true — that much hail REACHED
|
||
* the bed and the membrane stops those stones — not that membrane would
|
||
* have saved exactly that many HP (some of it may have fallen past the
|
||
* sail's edge, which no fabric fixes).
|
||
* · Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
|
||
* the trade — the pond IS the membrane's price, and the verdict prose for
|
||
* ponded/broomed nights doesn't say which cloth bought it.
|
||
* · Everything else: null. Big-hail nights (both fabrics block 100%), dry
|
||
* nights, clean wins — the row on the invoice is the whole record.
|
||
*
|
||
* Pure, headless-testable; thresholds are data on the function so the asserts
|
||
* and the wiring read the same numbers.
|
||
*
|
||
* @param {object} o
|
||
* @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
|
||
* @param {number} [o.hailSize] storm def's hail.size (1.0 ≈ 1.5 cm stone); 0/absent = no hail tonight
|
||
* @param {object} [o.dmg] garden.damage {hail, rain}, HP
|
||
* @param {number} [o.pondPeak] peak kg of water the sail held tonight
|
||
* @returns {string|null} one sentence for the aftermath card, or null
|
||
*/
|
||
export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
|
||
if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
|
||
if (fabric.porosity > 0) {
|
||
const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
|
||
if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
|
||
return `Flown in ${fabric.name} — stones this fine rattle through the weave, and `
|
||
+ `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
|
||
}
|
||
return null;
|
||
}
|
||
if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
|
||
return `Flown in ${fabric.name} — it stopped the stones and the rain, and held `
|
||
+ `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
|
||
}
|
||
return null;
|
||
}
|
||
/** The leak has to have cost real HP before the verdict brings it up. */
|
||
fabricNoteFor.LEAK_MATTERS_HP = 5;
|
||
/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number —
|
||
* the note and the verdict must agree on when a pond is worth a sentence. */
|
||
fabricNoteFor.POND_MATTERS_KG = 80;
|
||
|
||
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._startBudget = budget;
|
||
this.reset();
|
||
}
|
||
|
||
/**
|
||
* Back to an empty prep phase, same anchors and starting budget. Lane A's
|
||
* "play again" reaches into the state machine to fake a fresh round rather
|
||
* than rebuilding the session, so this owns the field list — add a field
|
||
* above, reset it here.
|
||
*/
|
||
reset() {
|
||
this.budget = this._startBudget;
|
||
this.tension = DEFAULT_TENSION;
|
||
this.spares = 0;
|
||
this.fabric = DEFAULT_FABRIC;
|
||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||
this.picks = [];
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Re-bank the shop for a new night. [Lane A's ask — THREADS, SPRINT10]
|
||
*
|
||
* A had to poke `_startBudget` and then call `reset()` from main.js — their
|
||
* self-described "one private touch" into this module, done loudly in a
|
||
* comment rather than by forking the file. This is that pair, named.
|
||
*
|
||
* It RESETS, deliberately. A new bank only ever arrives at a night boundary
|
||
* (main.js sets it at the forecast), and carrying last night's picks into a
|
||
* re-banked shop would leave four corners rigged that this wallet never paid
|
||
* for — `budget` would say $80 with $20 of hardware already hanging in the
|
||
* yard. "New budget" and "fresh prep phase" are the same event here; splitting
|
||
* them would only move the bug into the caller.
|
||
*/
|
||
setBudget(n) {
|
||
this._startBudget = n;
|
||
return this.reset();
|
||
}
|
||
|
||
/**
|
||
* Re-point the session at a new yard's anchors. [SPRINT11 — the site switch]
|
||
*
|
||
* Picks die with the old site: they are anchor IDs, and `p4` on the backyard
|
||
* is not `p4` on the corner block. Keeping them would rig a quad out of
|
||
* anchors that no longer exist.
|
||
*/
|
||
setAnchors(anchors) {
|
||
this.anchors = anchors;
|
||
return this.reset();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Put specific hardware on a rigged corner, paying the difference.
|
||
*
|
||
* THROWS on hardware that isn't in the shop, where every other failure here
|
||
* returns {ok:false}. The split is deliberate and it is about who made the
|
||
* mistake: 'not rigged' and 'not enough budget' are things a PLAYER does, and
|
||
* the UI tickers the reason. Hardware that isn't in HARDWARE is not reachable
|
||
* by any click — the picking UI only ever cycles the shop's own list — so it
|
||
* can only mean a caller passed the wrong thing, and returning {ok:false} for
|
||
* that just lets a bug walk.
|
||
*
|
||
* It already had: balance.test's own comment records "setHardware fails
|
||
* silently, cheap hardware stays on, p2/p4 tear off" — a loadout that quietly
|
||
* kept its carabiners and then reported a cascade as if it were a finding.
|
||
* The SPRINT12 QA pass hit the same edge from the other side. A wrong tier is
|
||
* invisible in the result (the sim just runs, cheaper), so this is exactly the
|
||
* class of mistake that must not be ignorable.
|
||
*/
|
||
setHardware(anchorId, hw) {
|
||
if (!HARDWARE.includes(hw)) {
|
||
throw new TypeError(
|
||
`setHardware("${anchorId}", ${JSON.stringify(hw?.name ?? hw)}) — not a shop item. ` +
|
||
`Pass a HARDWARE entry (${HARDWARE.map((h) => h.name).join(' / ')}), not a name or a copy: ` +
|
||
'the shop compares by identity. A returned {ok:false} here would leave the old ' +
|
||
'hardware on the corner and the sim would run cheaper without telling anyone.');
|
||
}
|
||
const p = this.pickOf(anchorId);
|
||
if (!p) return fail('not rigged');
|
||
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
||
p.hw = hw;
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the
|
||
* cost is which night you're wrong on, not dollars.
|
||
* @param {object|string} f a FABRIC entry or its id
|
||
*/
|
||
setFabric(f) {
|
||
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
|
||
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
|
||
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
|
||
this.fabric = pick;
|
||
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;
|
||
}
|
||
|
||
/** Buy (or sell back) spares in prep. `spares` is the count on the shed table. */
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* How many spares are still on the shed table. [D's ask, SPRINT13]
|
||
*
|
||
* The read half of the seam D found: interact.js gated the table pickup on
|
||
* `!player.carrying` alone and never looked at the count, so a player who
|
||
* bought ZERO walked off with unlimited free shackles mid-storm — the whole
|
||
* "limited hands, two trips" economy the ladder's cost is balanced against was
|
||
* free on the spare side, live on the public deploy.
|
||
*/
|
||
get sparesRemaining() { return this.spares; }
|
||
|
||
/**
|
||
* Take one spare off the table. [D's ask, SPRINT13]
|
||
*
|
||
* The consume half. `spares` IS the count on the table, not a purchased total
|
||
* held elsewhere, so taking one decrements it — and that quietly fixes a
|
||
* SECOND bug for free: main.js:720 already refunds `session.spares * SPARE_COST`
|
||
* as "a spare you never had to use", so before this you were refunded for
|
||
* spares you'd already spent. Decrementing here makes that line honest with no
|
||
* change to main.js. reset() zeroes the count between nights, so nothing
|
||
* carries.
|
||
*
|
||
* interact.js calls `canUse: () => !p.carrying && session.sparesRemaining > 0`
|
||
* and `onDone: () => session.takeSpare()` once A threads the session into
|
||
* wireYardActions — D has the interact side ready.
|
||
*/
|
||
takeSpare() {
|
||
if (this.spares <= 0) return fail('no spares left on the table');
|
||
this.spares -= 1;
|
||
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}`);
|
||
// Fabric before attach: porosity scales the wind pressure term and decides
|
||
// whether the cloth ponds, so the sail has to know what it's made of before
|
||
// it is built. (It's also half of the wild night's only winnable line —
|
||
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
||
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
||
if (rig.setFabric) rig.setFabric(this.fabric);
|
||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||
}
|
||
|
||
/**
|
||
* What a pick's corner actually fails at, in newtons: hardware rating × the
|
||
* anchor's ratingHint (`?? 1` mirrors sail.js — world.js guarantees the field,
|
||
* but a session can be handed bare test anchors that never met world.js).
|
||
*/
|
||
_effRating(p) {
|
||
const a = this.anchors.find((x) => x.id === p.anchorId);
|
||
return p.hw.rating * (a?.ratingHint ?? 1);
|
||
}
|
||
|
||
/**
|
||
* What each corner carries in STILL AIR at the current tension, newtons.
|
||
* Null until four corners are picked — a quad is the only thing that has a load.
|
||
*
|
||
* D's ask, SPRINT13 ("the first lesson always costs ~$60"): the tension dial
|
||
* has a static price and the panel never showed it. At 1.4 the standing load
|
||
* alone is ~1.4-1.5 kN/corner on a 46 m² quad, which is over every tier the
|
||
* shop sells except a rated shackle on an honest anchor — so a drum-tight sail
|
||
* rips corners off during PREP, on the calm day, before a breath of wind. That
|
||
* reads fair when you can see it coming and daylight robbery when you can't.
|
||
*
|
||
* Still air, not "calm day": this is the number the DIAL owns. Gravity and
|
||
* tension only — no gusts, no forecast, nothing that could be mistaken for a
|
||
* prediction about tonight. The storm multiplies this by ~3-8x and the panel
|
||
* says "standing" for exactly that reason.
|
||
*
|
||
* Cheap enough to call on a click: 15.6 ms of settle on a 10×10 grid (measured),
|
||
* and the result is cached by the caller on (picks, tension, fabric) — hardware
|
||
* doesn't move the cloth, so cycling tiers, the most-clicked action in prep,
|
||
* never recomputes.
|
||
*
|
||
* 4 s of settle, not 2: the cloth arrives at rest through a damped oscillation,
|
||
* and at low tension 2 s still reads ~4% high on the way down (0.195 vs 0.188
|
||
* kN converged). 4 s is inside 1% everywhere measured and costs 8 ms more, once.
|
||
*
|
||
* Cross-checked against the game the honest way: on the corner block's real
|
||
* 46 m² carport quad this reports a worst corner of 1.41 kN at tension 1.4 —
|
||
* D measured "~1.4-1.5 kN/corner at 46 m²" in play, from the other side.
|
||
*/
|
||
standingLoads({ settle = 4.0 } = {}) {
|
||
if (this.picks.length !== MAX_CORNERS) return null;
|
||
const rig = new SailRig({ anchors: this.anchors, gridN: 10, porosity: this.fabric.porosity });
|
||
/**
|
||
* UNBREAKABLE hardware, deliberately — and this is the whole subtlety.
|
||
*
|
||
* The preview measures the LOAD, which is a fact about geometry and the
|
||
* dial; it is not a rehearsal of tonight. Attach it with the player's real
|
||
* tiers and at tension 1.4 the corners genuinely rip during this very
|
||
* settle (D measured it: three go at t≈0.5 on the calm day) — and a broken
|
||
* corner carries nothing, so the panel would print a serene `0.00 kN` for
|
||
* the exact corner about to take the night down. The first version of this
|
||
* did that and the selftest below caught it.
|
||
*
|
||
* So: measure the load unbroken, then let the PANEL compare it against the
|
||
* corner's real effective rating and say "RIPS AT REST". The number stays
|
||
* honest and the warning lands where the player can act on it.
|
||
*/
|
||
const UNBREAKABLE = { name: 'preview', cost: 0, rating: Infinity };
|
||
rig.attach(this.picks.map((p) => p.anchorId), Array(MAX_CORNERS).fill(UNBREAKABLE), this.tension);
|
||
// Still air. `sample` must honour the out-param — sail.js reuses one vector.
|
||
const stillAir = {
|
||
sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }),
|
||
rainMmPerHour: () => 0,
|
||
};
|
||
for (let i = 0, n = Math.round(settle / FIXED_DT); i < n; i++) rig.step(FIXED_DT, stillAir, i * FIXED_DT);
|
||
const out = {};
|
||
for (const c of rig.corners) out[c.anchorId] = c.load;
|
||
return out;
|
||
}
|
||
|
||
/** 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,
|
||
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
||
canStart: this.canStart,
|
||
// `effRating` rides along with the bare one: D's panel-honesty gap — the
|
||
// weak-link arrow reasoned on rating × ratingHint while the printed kN was
|
||
// the bare steel, so a cold player saw four identical 1.2s and one
|
||
// unexplained arrow. The panel prints the effective number now; the bare
|
||
// rating stays for anyone who wants to show the steel's own spec.
|
||
corners: this.picks.map((p) => ({
|
||
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
|
||
effRating: this._effRating(p),
|
||
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
|
||
})),
|
||
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
|
||
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
|
||
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
|
||
// so four identical carabiners flagged whichever was clicked FIRST — the
|
||
// label pointed at click order, not steel, and on the corner block it
|
||
// named the genuinely worst anchor only by luck. Ratings ties still keep
|
||
// the first pick, honestly: if the products are equal the steel really is
|
||
// interchangeable and there is no "genuinely worst" to point at.
|
||
weakest: this.picks.length
|
||
? this.picks.reduce((w, p) => (this._effRating(p) < this._effRating(w) ? p : w)).anchorId
|
||
: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Prep-phase picking UI.
|
||
*
|
||
* Everything above is the rules; this is only the hands. It renders its own
|
||
* anchor markers because the yard has none to raycast against — world.js builds
|
||
* posts and trunks, not pick targets — and marker styling is prep-phase UI, so
|
||
* it belongs to this lane rather than to the terrain.
|
||
*
|
||
* Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
|
||
* shift-LMB to pull it off for a full refund, [ and ] for tension, S for the
|
||
* spare. RMB is left alone — that's the camera's orbit. Enter belongs to Lane
|
||
* A's phase machine, which calls commit() on the way out of prep.
|
||
*
|
||
* @param {object} o
|
||
* @param {object} o.scene THREE.Scene to hang markers in
|
||
* @param {object} o.camera cameraRig.object — what we raycast from
|
||
* @param {Element} o.domElement renderer.domElement — where clicks land
|
||
* @param {object} o.world needs world.anchors
|
||
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
|
||
* @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
|
||
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
|
||
*/
|
||
export async function createRiggingUI({
|
||
scene, camera, domElement, world,
|
||
onCommit, onMessage = () => {}, panel = true,
|
||
} = {}) {
|
||
const THREE = await import('../vendor/three.module.js');
|
||
const session = new RiggingSession({ anchors: world.anchors });
|
||
|
||
// --- markers -----------------------------------------------------------
|
||
const group = new THREE.Group();
|
||
group.visible = false;
|
||
scene.add(group);
|
||
|
||
const DIM = 0x33424c;
|
||
const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
|
||
const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
|
||
// What you click is NOT what you see: the ring's tube is 5 cm, which at yard
|
||
// distance is a couple of pixels and unhittable. Pick against an invisible
|
||
// sphere big enough to mean "that anchor" and let the ring just be the read.
|
||
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
|
||
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
|
||
|
||
const makeMarker = (a) => {
|
||
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
|
||
const ring = new THREE.Mesh(ringGeo, mat);
|
||
const dot = new THREE.Mesh(dotGeo, mat);
|
||
const hit = new THREE.Mesh(pickGeo, pickMat);
|
||
hit.userData.anchorId = a.id;
|
||
const holder = new THREE.Group();
|
||
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
|
||
group.add(holder);
|
||
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
|
||
};
|
||
|
||
/** One marker's GPU-side leftovers. The shared geometries are NOT ours to free. */
|
||
const disposeMarker = (m) => {
|
||
group.remove(m.holder);
|
||
m.mat.dispose();
|
||
m.label.material.map?.dispose(); // each label is its own canvas texture
|
||
m.label.material.dispose();
|
||
};
|
||
|
||
// `let`, not `const`: setWorld rebuilds both on a site change.
|
||
let markers = world.anchors.map(makeMarker);
|
||
let pickTargets = markers.map((m) => m.hit);
|
||
|
||
// --- quad preview ------------------------------------------------------
|
||
// A closed loop through the ring-ordered picks: this is the shape you are
|
||
// about to build, drawn before you commit to it.
|
||
const previewGeo = new THREE.BufferGeometry();
|
||
previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
|
||
const preview = new THREE.Line(
|
||
previewGeo,
|
||
new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
|
||
);
|
||
preview.frustumCulled = false;
|
||
group.add(preview);
|
||
|
||
// --- panel -------------------------------------------------------------
|
||
const el = panel ? document.createElement('div') : null;
|
||
if (el) {
|
||
el.id = 'rigging-panel';
|
||
el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
|
||
background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
|
||
font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
|
||
white-space:pre;pointer-events:none`;
|
||
document.body.appendChild(el);
|
||
}
|
||
|
||
let active = false;
|
||
let hovered = null;
|
||
|
||
const ndc = new THREE.Vector2();
|
||
const ray = new THREE.Raycaster();
|
||
const scratch = new THREE.Vector3();
|
||
|
||
function pickAt(ev) {
|
||
const r = domElement.getBoundingClientRect();
|
||
ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
|
||
ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
|
||
ray.setFromCamera(ndc, camera);
|
||
return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
|
||
}
|
||
|
||
function say(result) {
|
||
if (result && result.ok === false) onMessage(result.reason);
|
||
return result;
|
||
}
|
||
|
||
function onPointerDown(ev) {
|
||
if (!active || ev.button !== 0) return; // RMB is the camera's
|
||
const id = pickAt(ev);
|
||
if (!id) return;
|
||
ev.preventDefault();
|
||
if (!session.isRigged(id)) say(session.rig(id));
|
||
else if (ev.shiftKey) say(session.unrig(id));
|
||
else say(session.cycleHardware(id));
|
||
refresh();
|
||
}
|
||
|
||
function onPointerMove(ev) {
|
||
if (!active) return;
|
||
hovered = pickAt(ev);
|
||
domElement.style.cursor = hovered ? 'pointer' : '';
|
||
}
|
||
|
||
function onKeyDown(ev) {
|
||
if (!active) return;
|
||
if (ev.key === '[') session.setTension(session.tension - 0.05);
|
||
else if (ev.key === ']') session.setTension(session.tension + 0.05);
|
||
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
|
||
else if (ev.key.toLowerCase() === 'f') {
|
||
// The forecast bet, finally reachable. FABRIC and setFabric() shipped in
|
||
// SPRINT6 and the sim honoured porosity all along — but no key, click or
|
||
// HUD line ever reached them, so every rig ever played was DEFAULT_FABRIC.
|
||
// Two entries, so F is a toggle; announce the blurb because the panel
|
||
// line alone doesn't say what the bet IS.
|
||
const next = FABRIC[(FABRIC.indexOf(session.fabric) + 1) % FABRIC.length];
|
||
if (say(session.setFabric(next.id)).ok) onMessage(`${next.name} — ${next.blurb}`);
|
||
}
|
||
else return;
|
||
ev.preventDefault();
|
||
refresh();
|
||
}
|
||
|
||
domElement.addEventListener('pointerdown', onPointerDown);
|
||
domElement.addEventListener('pointermove', onPointerMove);
|
||
addEventListener('keydown', onKeyDown);
|
||
|
||
/** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
|
||
function quadArea() {
|
||
if (session.picks.length !== MAX_CORNERS) return 0;
|
||
const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
|
||
const tri = (a, b, c) =>
|
||
new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
|
||
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
|
||
}
|
||
|
||
/**
|
||
* Standing load per corner, cached on (picks, tension). [D's ask, SPRINT13]
|
||
*
|
||
* The cloth doesn't care what steel hangs off it, so cycling hardware — the
|
||
* most-clicked action in prep — never recomputes. Only closing the quad or
|
||
* moving the dial does, which is exactly when the number changes.
|
||
*/
|
||
let standingKey = null, standingCache = null;
|
||
function standing() {
|
||
const key = `${session.picks.map((p) => p.anchorId).join(',')}|${session.tension.toFixed(2)}|${session.fabric.id}`;
|
||
if (key !== standingKey) { standingKey = key; standingCache = session.standingLoads(); }
|
||
return standingCache;
|
||
}
|
||
|
||
function refresh() {
|
||
if (!el) return;
|
||
const s = session.summary;
|
||
const load = standing();
|
||
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
|
||
// block ships `tr1b` and the column broke the moment a second site existed.
|
||
const rows = world.anchors.map((a) => {
|
||
const pick = session.pickOf(a.id);
|
||
if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)} —`;
|
||
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
|
||
// The EFFECTIVE rating, not the steel's spec — D's panel-honesty gap: the
|
||
// arrow reasoned on rating × hint while this column printed the bare kN,
|
||
// so a carport beam and a house post both read "1.2 kN" and only one of
|
||
// them was telling the truth. This is the number sail.js fails on.
|
||
const corner = s.corners.find((c) => c.anchorId === a.id);
|
||
const eff = (corner.effRating / 1000).toFixed(1);
|
||
// "1.2×0.22" — the hint is shown as the discount it is, so the arrow has
|
||
// a reason on the same line instead of being folded into a smaller number
|
||
// the player can't account for.
|
||
const hint = corner.hint !== 1 ? `×${corner.hint}` : '';
|
||
const st = load?.[a.id] != null ? `${(load[a.id] / 1000).toFixed(2)}` : '—';
|
||
const over = load?.[a.id] != null && load[a.id] > corner.effRating;
|
||
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${st.padStart(4)}/${eff.padEnd(4)}kN${hint.padEnd(6)} $${pick.hw.cost}` +
|
||
`${over ? ' !! RIPS AT REST' : weak}`;
|
||
});
|
||
const area = quadArea();
|
||
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
|
||
// the first lesson always costs ~$60". At 1.4 the standing load alone is
|
||
// over most of the shop. Said as a per-corner average because that's the
|
||
// number the DIAL owns; the per-corner column above is where you find which
|
||
// one is about to let go.
|
||
const vals = load ? Object.values(load) : [];
|
||
const avg = vals.length ? vals.reduce((x, y) => x + y, 0) / vals.length : 0;
|
||
const worst = vals.length ? Math.max(...vals) : 0;
|
||
const standingLine = vals.length
|
||
? `standing ≈${(avg / 1000).toFixed(2)} kN/corner at this tension (worst ${(worst / 1000).toFixed(2)}) — before any wind`
|
||
: null;
|
||
el.textContent = [
|
||
`PREP — rig four corners $${s.budget} left`,
|
||
`tension ${s.tension.toFixed(2)} spare x${s.spares} ${s.fabric.name}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
|
||
'',
|
||
...rows,
|
||
...(standingLine ? ['', standingLine] : []),
|
||
'',
|
||
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
|
||
'click anchor: rig / cycle hw shift-click: remove',
|
||
'[ ] tension S spare F fabric RMB orbit',
|
||
].join('\n');
|
||
}
|
||
|
||
const ui = {
|
||
session,
|
||
get summary() { return { ...session.summary, area: quadArea() }; },
|
||
get canStart() { return session.canStart; },
|
||
get active() { return active; },
|
||
|
||
/**
|
||
* Re-point the whole UI at a new yard. [Lane A's ask — THREADS, SPRINT10]
|
||
*
|
||
* The site switch's last piece. `createRiggingUI` built its markers and its
|
||
* session from `world.anchors` at construction and had no way to re-point
|
||
* them, so on a site change the corner block's markers didn't exist and the
|
||
* panel still listed the backyard. A re-points what they can reach from
|
||
* main.js (`rig.anchors`, `session.anchors`) — which is why night 3 has been
|
||
* riggable from code and from the audit but not from the MOUSE. This is the
|
||
* mouse.
|
||
*
|
||
* A calls it inside `loadSiteInto`, guarded (`rigging.setWorld?.(world)`),
|
||
* so it lights up the day it lands with no change on their side.
|
||
*
|
||
* Markers are rebuilt, not repositioned: the anchor SET changes across sites
|
||
* (12 in the backyard, 10 on the corner block, different ids), so there is no
|
||
* per-marker correspondence to preserve. Each marker owns a material and a
|
||
* canvas-texture label, so the old ones are disposed rather than dropped —
|
||
* five site switches a week, every week, is a real leak otherwise.
|
||
*/
|
||
setWorld(w) {
|
||
world = w;
|
||
for (const m of markers) disposeMarker(m);
|
||
markers = world.anchors.map(makeMarker);
|
||
pickTargets = markers.map((m) => m.hit);
|
||
hovered = null; // a stale id would highlight nothing, forever
|
||
session.setAnchors(world.anchors); // picks are old-site anchor ids — they die here
|
||
preview.visible = false;
|
||
refresh();
|
||
return ui;
|
||
},
|
||
|
||
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
|
||
setActive(on) {
|
||
active = !!on;
|
||
group.visible = active;
|
||
if (el) el.style.display = active ? 'block' : 'none';
|
||
if (!active) domElement.style.cursor = '';
|
||
if (active) refresh();
|
||
return ui;
|
||
},
|
||
|
||
/** Markers ride the anchors, so a tree corner wanders before you even rig it. */
|
||
update(dt, t) {
|
||
if (!active) return;
|
||
for (const m of markers) {
|
||
const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
|
||
m.holder.position.set(p.x, p.y, p.z);
|
||
m.holder.quaternion.copy(camera.quaternion); // rings face the player
|
||
const pick = session.pickOf(m.anchor.id);
|
||
m.mat.color.setHex(pick ? pick.hw.color : DIM);
|
||
const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
|
||
m.ring.scale.setScalar(s);
|
||
m.label.visible = !!pick || hovered === m.anchor.id;
|
||
}
|
||
|
||
const pos = previewGeo.attributes.position;
|
||
if (session.picks.length >= 2) {
|
||
preview.visible = true;
|
||
const n = session.picks.length;
|
||
for (let i = 0; i <= n; i++) {
|
||
const k = session.picks[i % n];
|
||
const a = world.anchors.find((x) => x.id === k.anchorId);
|
||
const p = a.sway ? a.sway(t) : a.pos;
|
||
scratch.set(p.x, p.y, p.z);
|
||
pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||
}
|
||
// degenerate tail so a partial pick doesn't draw a stale segment
|
||
for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||
pos.needsUpdate = true;
|
||
previewGeo.setDrawRange(0, session.picks.length + 1);
|
||
preview.computeLineDistances();
|
||
} else {
|
||
preview.visible = false;
|
||
}
|
||
},
|
||
|
||
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
|
||
commit() {
|
||
if (!session.canStart) {
|
||
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
|
||
return false;
|
||
}
|
||
onCommit(
|
||
session.picks.map((p) => p.anchorId),
|
||
session.picks.map((p) => p.hw),
|
||
session.tension,
|
||
);
|
||
return true;
|
||
},
|
||
|
||
dispose() {
|
||
domElement.removeEventListener('pointerdown', onPointerDown);
|
||
domElement.removeEventListener('pointermove', onPointerMove);
|
||
removeEventListener('keydown', onKeyDown);
|
||
scene.remove(group);
|
||
ringGeo.dispose(); dotGeo.dispose(); pickGeo.dispose(); pickMat.dispose(); previewGeo.dispose();
|
||
preview.material.dispose();
|
||
for (const m of markers) disposeMarker(m); // same teardown setWorld uses
|
||
el?.remove();
|
||
},
|
||
};
|
||
|
||
refresh();
|
||
return ui;
|
||
}
|
||
|
||
/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
|
||
function makeLabel(THREE, text) {
|
||
const c = document.createElement('canvas');
|
||
c.width = 128; c.height = 64;
|
||
const g = c.getContext('2d');
|
||
g.font = 'bold 40px ui-monospace, Menlo, monospace';
|
||
g.textAlign = 'center';
|
||
g.textBaseline = 'middle';
|
||
g.lineWidth = 6;
|
||
g.strokeStyle = '#0d1418';
|
||
g.strokeText(text, 64, 32);
|
||
g.fillStyle = '#dde5ea';
|
||
g.fillText(text, 64, 32);
|
||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
|
||
}));
|
||
sprite.position.set(0, 0.55, 0);
|
||
sprite.scale.set(0.8, 0.4, 1);
|
||
return sprite;
|
||
}
|