/** * 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; 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.budget = budget; this.tension = DEFAULT_TENSION; this.spares = 0; /** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */ this.picks = []; } 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; } /** 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}`); 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, 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. * * Deliberately unimplemented: it needs Lane A's camera, renderer canvas and * anchor markers to raycast against, none of which exist yet. RiggingSession * above holds all the rules and is fully tested, so this stays a thin * click-to-session adapter once M0 lands. See THREADS.md. */ export async function createRiggingUI() { throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md'); }