One landing, three gates and Lane C's clock finding. (Not split: sail.js,
rigging.js and balance.test.js each carry work for more than one gate and a
per-hunk split would have been fiction.)
GATE 2 — THE VARIABLE IS `porosity`, AND THE LIAR WAS THE GAME.
`rig.setFabric()` has exactly one call site, `RiggingSession.commit(rig)`, and
the shipped commit path never reaches it: the rigging UI's commit() calls
onCommit and main.js's rigSail goes straight to rig.attach(). So
session.commit(rig) runs in TESTS AND BENCHES ONLY, and the sim's rig (built
once at boot as `new SailRig({anchors})`, porosity defaulting to 0) has flown
the waterproof membrane every night of every playtest while the panel, the
SCORE IT card, the HUD row, the invoice and every audit number said shade
cloth. The F key moved the paperwork and never the physics.
shade cloth 0.30 (what every card promised) 0/4 lost p2 3.07 p3 1.78
membrane 0 (what the sim actually flew) 2/4 lost p2 3.48 p3 3.90
Row two is D's cold play AND the pin's own _playedReference (hp 55.7, p2 then
p3) to the decimal, reproduced in the shipped game. It also retires C's
"benches under-read the real UI by 5-15%, cause unfound": with the fabric held
equal, the bench and the FULL game chain agree to three decimals, two sites,
two storms. Debris hits on the sail: 0 — D's candidate ruled out by
measurement. Fixed on my side: commit() carries the fabric as onCommit's 4th
argument; dev_rigging.html picks it up, which is the shape of the one-line
pickup filed for A's rigSail.
GATE 3 — THE CLEAN BOUNDARY IS THE FUSE, NOT A PERCENTAGE. Same flight, two
corners, identical $15 shackles, same 3.2 kN rating:
p1 3.528 kN = 10.3% OVER -> HELD (over for 0.150 s)
p2 3.493 kN = 9.2% OVER -> BROKE (over for 0.400 s)
p1 peaks HIGHER and survives, so "peak within X% of rating" is wrong at every
X. sail.js never used peak — it burns a 0.4 s overload fuse. OVERLOAD_SECS /
OVERLOAD_RECOVER are exported and priceCandidate replays them per corner per
tier, so the audit and the sim now answer "will it hold" with one arithmetic.
AUDIT.MARGIN stops deciding anything and says why in its own comment.
GATE 1 — THE RIG YOU DIDN'T BUILD, AND THE KNIFE. `_releaseCorner` is now the
one definition of a corner letting go (it was open-coded twice); on top of it:
`failCorner` for a corner that arrived down, `cutAway` for DESIGN.md line 165
emitting A's requested `{t, corners, reason}`, and `session.inherit()` which
adopts somebody else's rig for $0 and keeps it out of the refund — because
week.js pays back half of every intact corner, so counting a stranger's steel
as yours would have the ledger PUNISHING the player for every corner they cut.
Neither a cut nor an inherited corner emits `break`: warranty must not chase a
corner you chose to let go or one you never hung.
LANE C's CLOCK FINDING, fixed. `_substep` samples the wind at `this.t`, which
attach() zeroes — safe only while the caller's clock starts with it, exactly as
attach()'s own comment says. An emergency opens at t=30, so the cloth would fly
the storm's first 60 s under a sky flying its last 60. attach() now takes
{at, wind} (seeded, or genuinely pre-flown so the drape and the pond are real),
seedClock() is the late door, and `clockSkew` makes any future drift readable
instead of silent — measured 90 N vs 655 N for the same rig entering at t=0 vs
t=40, which is the size of what was hiding.
+15 asserts, all node-verified; the two gate-2 pins mutation-checked both ways.
1077 lines
50 KiB
JavaScript
1077 lines
50 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 });
|
||
|
||
/**
|
||
* SPRINT17 gate 2 [B] — CLIENT CONSTRAINTS: A's data, this file's teeth.
|
||
*
|
||
* The shapes are A's seam contract (THREADS 2026-07-21, posted before either
|
||
* side was load-bearing), verbatim:
|
||
*
|
||
* { kind:'noAnchorFamily', family:'house', label:'…', says:'…' }
|
||
* { kind:'budgetCap', cap:45, label:'…', says:'…' }
|
||
*
|
||
* `label` is what the papers print (A's half); `says` is the client's own
|
||
* voice, and it is what this session's refusals return — the ticker on a
|
||
* refused pick is the CLIENT talking, not the software. `family` is an
|
||
* ANCHOR_TYPE to refuse; `cap` is a hard ceiling on the night's SPEND (the
|
||
* client caps the invoice, not your wallet), enforced where the spending
|
||
* happens and asserted again at commit.
|
||
*
|
||
* The enum is CHECKED on both ends of the seam on purpose: A's
|
||
* validateConstraint guards the data source (board.js), this guards the
|
||
* enforcement door — a constraint that arrives here malformed is a caller bug
|
||
* (setHardware's class, so it THROWS and names the problem), never a player
|
||
* mistake to ticker at.
|
||
*/
|
||
export const CONSTRAINT_KINDS = Object.freeze(['noAnchorFamily', 'budgetCap']);
|
||
export function validateNightConstraint(c) {
|
||
if (!c || typeof c !== 'object') {
|
||
throw new TypeError(`night constraint must be an object, got ${JSON.stringify(c)}`);
|
||
}
|
||
if (!CONSTRAINT_KINDS.includes(c.kind)) {
|
||
throw new TypeError(
|
||
`unknown constraint kind "${c.kind}" — known: ${CONSTRAINT_KINDS.join(', ')}. `
|
||
+ 'An unenforced enum is decoration (the carport-typed-as-a-post scar); if A shipped a '
|
||
+ 'new kind, the session needs its enforcement written before a night can carry it.');
|
||
}
|
||
if (typeof c.says !== 'string' || !c.says || typeof c.label !== 'string' || !c.label) {
|
||
throw new TypeError(
|
||
`constraint "${c.kind}" is missing says/label — a constraint with no client words `
|
||
+ 'renders an empty quote block on two papers and an empty refusal ticker here.');
|
||
}
|
||
if (c.kind === 'noAnchorFamily' && (typeof c.family !== 'string' || !c.family)) {
|
||
throw new TypeError('noAnchorFamily with no family bans nothing — decoration.');
|
||
}
|
||
if (c.kind === 'budgetCap' && !(Number.isFinite(c.cap) && c.cap > 0)) {
|
||
throw new TypeError(`budgetCap cap must be a positive number, got ${JSON.stringify(c.cap)}`);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
export class RiggingSession {
|
||
/**
|
||
* @param {object} opts
|
||
* @param {Array} opts.anchors world.anchors — [{id, pos, type, sway?}]
|
||
* @param {number} opts.budget starting cash
|
||
* @param {Array} opts.constraints the night's client constraints (A's
|
||
* shapes, validated here) — see setConstraints
|
||
*/
|
||
constructor({ anchors = [], budget = START_BUDGET, constraints = [] } = {}) {
|
||
this.anchors = anchors;
|
||
this._startBudget = budget;
|
||
this.setConstraints(constraints);
|
||
this.reset();
|
||
}
|
||
|
||
/**
|
||
* The client's terms for tonight (gate 2). Validated at the door — a
|
||
* malformed constraint throws with the kind named, because by the time one
|
||
* reaches the session it has already passed A's board validator, so a bad
|
||
* one here is a caller bug, not data.
|
||
*
|
||
* SURVIVES reset() deliberately, like `_startBudget`: "play again" on the
|
||
* same night replays the same client. The wiring contract (filed for A, the
|
||
* setBudget pattern): main.js calls
|
||
* `session.setConstraints(week.job?.constraints ?? [])` at the same night
|
||
* boundary that re-banks the shop — the `?? []` is what clears the previous
|
||
* client's terms, so an unconstrained night cannot inherit a ban.
|
||
*/
|
||
setConstraints(list = []) {
|
||
for (const c of list) validateNightConstraint(c);
|
||
this._constraints = Object.freeze([...list]);
|
||
return this;
|
||
}
|
||
get constraints() { return this._constraints; }
|
||
|
||
/** The ban that covers this anchor, or null. (gate 2, noAnchorFamily) */
|
||
_banFor(anchor) {
|
||
return this._constraints.find((c) => c.kind === 'noAnchorFamily' && c.family === anchor.type) ?? null;
|
||
}
|
||
|
||
/**
|
||
* The cap refusal for spending `amount` more, or null. (gate 2, budgetCap)
|
||
*
|
||
* Checked at SPEND time, not only at commit: a cap the player discovers
|
||
* four corners deep is a restart wearing a rule's name, and the refusal
|
||
* reason is the client's own words so the ticker reads as the client
|
||
* querying the invoice, mid-shop. Refunds are always allowed — the way BACK
|
||
* under the cap must never be blocked by the cap.
|
||
*/
|
||
_capRefusal(amount) {
|
||
if (!(amount > 0)) return null;
|
||
const c = this._constraints.find((x) => x.kind === 'budgetCap');
|
||
if (!c) return null;
|
||
return (this.spent + amount > c.cap) ? fail(c.says) : null;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
|
||
// Against _startBudget, not the global (gate 2's incidental fix): the two
|
||
// are identical on every shipped night, but a re-banked session measuring
|
||
// its spend against the DEFAULT bank would misprice the cap check the
|
||
// moment those diverge — the same class of quiet lie as the frozen sway.
|
||
get spent() { return this._startBudget - 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');
|
||
// gate 2: the house ban — refused in the CLIENT's words. The ticker is the
|
||
// client talking ("Nothing goes on the house…"), not the software.
|
||
const ban = this._banFor(a);
|
||
if (ban) return fail(ban.says);
|
||
if (this.isRigged(anchorId)) return fail('already rigged');
|
||
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
|
||
const capNo = this._capRefusal(HARDWARE[0].cost);
|
||
if (capNo) return capNo;
|
||
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
|
||
this.picks.push({ anchorId, hw: HARDWARE[0] });
|
||
this._reorder();
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* ⚠️ SPRINT18 GATE 1 — THE RIG YOU DIDN'T BUILD. Adopt somebody else's work.
|
||
* [B rules the semantics; installs Lane A's `night.emergency.rig` data]
|
||
*
|
||
* An emergency callout drops you at t=30 under a sail you never quoted, never
|
||
* bought and never hung. Lane A's spine wanted to install it through `rig()` +
|
||
* `setHardware()` — the public-moves discipline, which is right — but those
|
||
* moves are a SHOP, and putting someone else's rig through the shop charges the
|
||
* player for it. Two things then go wrong, and the second one is the bad one:
|
||
*
|
||
* 1. the wallet. $40 of another contractor's shackles comes off tonight's
|
||
* bank, for steel that was already in the yard when the phone rang.
|
||
* 2. THE REFUND INVERTS THE KNIFE. week.js pays back half of every intact
|
||
* corner's hardware (`intactHardwareValue` × refundShare). If the inherited
|
||
* steel counts as the player's, then every corner they CUT costs them
|
||
* money — and DESIGN.md's signature decision becomes a thing the ledger
|
||
* punishes you for getting right. A rule that pays you to let a stranger's
|
||
* sail take out a glasshouse is not a hard trade-off, it's a bug.
|
||
*
|
||
* So: adopted corners cost $0, are flagged `inherited`, and are excluded from
|
||
* `intactHardwareValue`. The steel is real to the PHYSICS (its rating is what
|
||
* fails) and invisible to the WALLET, which is exactly what "someone else's
|
||
* work" means. `spent`/`budget` never move, so the invoice's spend line reads
|
||
* $0 on a callout with no branch anywhere in the ledger (A's acceptance §10).
|
||
*
|
||
* Client terms are still honoured — a house ban still refuses, because the ban
|
||
* is about what may be TOUCHED, not about who paid. A refusal here is a
|
||
* content bug (the author hung a sail off banned steel), so it comes back as a
|
||
* result for the caller to surface, not a throw: A installs this at the same
|
||
* boundary `setConstraints` uses and can ticker it.
|
||
*
|
||
* @param {{anchorId: string, hw: string, broken?: boolean}[]} spec A's shape:
|
||
* `hw` is a HARDWARE *name*, since this arrives from authored JSON
|
||
* @returns {{ok: boolean, reason?: string, broken?: string[]}} `broken` lists
|
||
* the corners that must arrive DOWN — hand them to `SailRig.failCorner`
|
||
* after commit (the pond and the freed node are the sail's business, not the
|
||
* session's)
|
||
*/
|
||
inherit(spec) {
|
||
if (!Array.isArray(spec) || spec.length !== MAX_CORNERS) {
|
||
return fail(`an inherited rig is ${MAX_CORNERS} corners, got ${Array.isArray(spec) ? spec.length : typeof spec}`);
|
||
}
|
||
const before = { budget: this.budget, picks: this.picks };
|
||
this.picks = [];
|
||
const broken = [];
|
||
for (const s of spec) {
|
||
const a = this.anchors.find((x) => x.id === s.anchorId);
|
||
if (!a) { this.picks = before.picks; return fail(`inherit: no anchor ${s.anchorId} in this yard`); }
|
||
const ban = this._banFor(a);
|
||
if (ban) { this.picks = before.picks; return fail(`inherit: ${s.anchorId} is ${a.type} steel and the client's terms say "${ban.label}"`); }
|
||
const hw = HARDWARE.find((h) => h.name === s.hw);
|
||
if (!hw) {
|
||
this.picks = before.picks;
|
||
return fail(`inherit: "${s.hw}" is not shop hardware — the yard sells ${HARDWARE.map((h) => h.name).join(', ')}`);
|
||
}
|
||
if (this.picks.some((p) => p.anchorId === s.anchorId)) {
|
||
this.picks = before.picks;
|
||
return fail(`inherit: ${s.anchorId} named twice`);
|
||
}
|
||
this.picks.push({ anchorId: s.anchorId, hw, inherited: true });
|
||
if (s.broken) broken.push(s.anchorId);
|
||
}
|
||
this._reorder();
|
||
// the money never moved, and that is the whole point
|
||
this.budget = before.budget;
|
||
return { ...OK, broken };
|
||
}
|
||
|
||
/**
|
||
* What the player would get back for hardware still standing at dawn — with
|
||
* inherited steel excluded, because you cannot sell a shackle you never
|
||
* bought. main.js reads `rig.corners` for this today; a callout needs the
|
||
* SESSION's view, which is the only one that knows who paid.
|
||
*/
|
||
intactHardwareValue(rig) {
|
||
const corners = rig?.corners ?? [];
|
||
return corners.reduce((sum, c) => {
|
||
if (c.broken) return sum;
|
||
const p = this.pickOf(c.anchorId);
|
||
return p?.inherited ? sum : sum + c.hw.cost;
|
||
}, 0);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* SPRINT18: an INHERITED corner refunds nothing — pulling somebody else's
|
||
* shackle off their post is not a trip to the returns counter.
|
||
*/
|
||
unrig(anchorId) {
|
||
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
|
||
if (i < 0) return fail('not rigged');
|
||
if (!this.picks[i].inherited) 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];
|
||
const price = this._priceOfChange(p, next);
|
||
const capNo = this._capRefusal(price);
|
||
if (capNo) return capNo;
|
||
if (!this._spend(price)) return fail('not enough budget');
|
||
p.hw = next;
|
||
p.inherited = false; // you paid for this one; it is yours now
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* What changing a corner's hardware costs. [SPRINT18 gate 1]
|
||
*
|
||
* Normally the difference — the shop takes the old piece back, because you
|
||
* bought it here ten seconds ago. On an INHERITED corner there is nothing to
|
||
* trade in: the carabiner on that post belongs to whoever rigged it last, and
|
||
* crediting its $5 against your shackle would have the player selling somebody
|
||
* else's steel to fund their own. So an inherited corner is priced at FULL
|
||
* whack, and replacing it is exactly as expensive as triage should feel.
|
||
*/
|
||
_priceOfChange(pick, next) {
|
||
return pick.inherited ? next.cost : next.cost - pick.hw.cost;
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
const price = this._priceOfChange(p, hw);
|
||
const capNo = this._capRefusal(price);
|
||
if (capNo) return capNo;
|
||
if (!this._spend(price)) return fail('not enough budget');
|
||
p.hw = hw;
|
||
p.inherited = false; // paid for, so it counts as yours at dawn
|
||
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');
|
||
const capNo = this._capRefusal(pick.cost - this.fabric.cost);
|
||
if (capNo) return capNo; // both fabrics are $0 today; the cap covers the day one isn't
|
||
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;
|
||
// gate 2: the cap covers spares too — the client caps the INVOICE, and the
|
||
// spare is on it. "$40 of steel + a $15 spare" under a $45 cap is the
|
||
// constraint's own squeeze, said out loud here rather than discovered at
|
||
// settlement.
|
||
const capNo = this._capRefusal(delta);
|
||
if (capNo) return capNo;
|
||
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}`);
|
||
// gate 2, the promise the sprint doc names ("enforces the budget cap at
|
||
// commit"): by now the refusals above have made these states unreachable
|
||
// through the session's own API, so reaching one is a caller walking
|
||
// around the doors — setHardware's class. THROW, don't ticker: a sail
|
||
// attached over the client's terms is a bug on its way to an invoice.
|
||
for (const p of this.picks) {
|
||
const a = this.anchors.find((x) => x.id === p.anchorId);
|
||
const ban = a && this._banFor(a);
|
||
if (ban) {
|
||
throw new Error(
|
||
`commit: ${p.anchorId} is ${a.type} steel and the client's terms say "${ban.label}" — `
|
||
+ 'a pick got past the rig() refusal. Whoever built these picks skipped the session.');
|
||
}
|
||
}
|
||
const cap = this._constraints.find((c) => c.kind === 'budgetCap');
|
||
if (cap && this.spent > cap.cap) {
|
||
throw new Error(
|
||
`commit: $${this.spent} spent under the client's $${cap.cap} cap — `
|
||
+ 'a spend got past the session\'s cap refusals.');
|
||
}
|
||
// 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,
|
||
// SPRINT18 gate 1: whose steel this is. The dispatch card wants to say
|
||
// "somebody else's carabiner on a 0.88 branch" and the invoice wants to
|
||
// leave it out of the refund; both need the flag, not an inference.
|
||
inherited: !!p.inherited,
|
||
})),
|
||
// $0 on an ordinary night; on a callout, what the previous contractor left
|
||
// hanging. A number the dispatch can print instead of a spend line.
|
||
inheritedValue: this.picks.reduce((s, p) => s + (p.inherited ? p.hw.cost : 0), 0),
|
||
// 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, fabric) => void — Lane A's
|
||
* rigSail. The FOURTH argument is the FABRIC entry, and it is
|
||
* not decoration — see commit() below for the sprint-18 gate-2
|
||
* finding that put it there.
|
||
* @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.
|
||
*
|
||
* ⚠️ SPRINT18 GATE 2 — THE FABRIC RIDES ALONG NOW, AND HERE IS WHY.
|
||
*
|
||
* `RiggingSession.commit(rig)` sets the fabric on the sail (`rig.setFabric`)
|
||
* and it is the ONLY place in the repo that does. This function — the
|
||
* shipped commit path, the one the player's Enter key reaches — does not
|
||
* call it: it calls `onCommit`, and Lane A's `rigSail` goes straight to
|
||
* `rig.attach()`. So `session.commit(rig)` runs in TESTS AND BENCHES ONLY,
|
||
* and the sim's rig (built once at main.js boot as `new SailRig({anchors})`,
|
||
* porosity defaulting to 0) has flown the WATERPROOF MEMBRANE every night of
|
||
* every playtest, while the panel, the SCORE IT card, the HUD row, the
|
||
* invoice and every audit number said "shade cloth".
|
||
*
|
||
* Measured, sprint 18 (the wildnight pin p5/p1/p2/p3, backyard_01):
|
||
* shade cloth 0.30 (what every card promised) 0/4 lost · p2 3.07 · p3 1.78
|
||
* membrane 0 (what the sim actually flew) 2/4 lost · p2 3.48 · p3 3.90
|
||
* The second row IS D's cold play and IS the pin's own `_playedReference`
|
||
* (hp 55.7, p2 then p3) — reproduced in the shipped game to the decimal.
|
||
* Two harnesses, one variable: `porosity`. The GAME was the liar; the bench
|
||
* flew what the card sold. This also retires C's "benches under-read the
|
||
* real UI by 5–15%, cause unfound": with the fabric held equal, the bench and
|
||
* the full game chain agree to three decimals on both sites.
|
||
*
|
||
* So the fabric is now an ARGUMENT, not a thing the caller must remember to
|
||
* go and fetch. Lane A's one-line pickup is filed in THREADS; until it
|
||
* lands, `rigSail` dropping a 4th argument is exactly as broken as it was
|
||
* before and no more, which is why this is safe to ship on its own.
|
||
*/
|
||
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,
|
||
session.fabric,
|
||
);
|
||
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;
|
||
}
|