HardYards/web/world/js/rigging.js
type-two f7a22eabb8 Lane B S16: ponding fabric-blindness RULED a known simplification — the comments stop lying
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>
2026-07-20 20:48:34 +10:00

799 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}