/** * 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); } /** 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 })), weakest: this.picks.length ? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? 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; }