HardYards/web/world/js/rigging.js
type-two 9700b40a30 Wire ratingHint into the failure threshold: the ratings are real (A's ruling)
sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint
(read live from the anchor — dress() mutates in place). Consequence asserted,
not the formula: on site_02's carport line (uniform rated shackles, funnel on,
12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension
(t~29 s into the storm, honest posts hold) and at max. In-suite control pins
the attribution: the same rig with every hint forced to 1 holds 4/4, so the
break is the hint, not the load. Mutation-checked: unwiring the line fails both.

rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint
via _effRating, not bare hw.rating — uniform hardware now flags the genuinely
worst steel instead of the first click. Mutation-checked.

site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs
h.rating * hint >= peak, both front-ends carry ratingHint through (browser off
world.anchors, node dump extended with the live-dumped hints), rows print the
hint. sweep.selftest asserts hints reprice without touching peaks and land
over-ceiling corners in unholdable. Mutation-checked.

Selftest 319/0/0 (315 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:19:01 +10:00

567 lines
23 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 } from './contracts.js';
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
export const DEFAULT_TENSION = 1.0;
/**
* 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 = [
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, 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];
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;
}
setHardware(anchorId, hw) {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
}
/**
* 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;
}
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
if (!this._spend(delta)) return fail('not enough budget');
this.spares = n;
return OK;
}
/**
* Ring-order the picks by angle around their ground-plane centroid, so corner
* i of the cloth grid always maps to a neighbouring anchor. Without it,
* picking anchors in a silly order knots the sail through itself.
*/
_reorder() {
if (this.picks.length < MAX_CORNERS) return;
const byId = new Map(this.picks.map((p) => [p.anchorId, p]));
const ring = orderRing(this.picks.map((p) => this.anchors.find((a) => a.id === p.anchorId)));
this.picks = ring.map((a) => byId.get(a.id));
}
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
// 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);
}
/** 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,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
// 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 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]);
}
function refresh() {
if (!el) return;
const s = session.summary;
// 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' : '';
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
});
const area = quadArea();
el.textContent = [
`PREP — rig four corners $${s.budget} left`,
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
'',
...rows,
'',
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 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;
}