From c8a9128c17b84ce02344f1f0639a8d555ccdc330 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:31:34 +1000 Subject: [PATCH] Add prep-phase rigging economy and ring ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the prototype's economy verbatim: $80 budget, $5/$15/$30 hardware tiers, $15 spare, tension 0.6-1.4. Adds unrig-with-refund, which the prototype lacked — a misclick there was unrecoverable, and a full refund costs the economy nothing. RiggingSession holds all the rules and is three-free and DOM-free, so it tests headless. The picking UI is left as an explicit seam: it needs Lane A's camera and anchor markers to raycast against, which do not exist yet. One assert encodes a design invariant rather than a code fact: $80 must not buy rated shackles on all four corners. DESIGN.md's economic tension is that you always field one dodgy corner and choose which one; if that test ever passes, the budget has become decoration. Co-Authored-By: Claude Opus 4.8 --- web/world/js/rigging.js | 157 ++++++++++++++++++++++++ web/world/js/rigging.selftest.js | 204 +++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 web/world/js/rigging.js create mode 100644 web/world/js/rigging.selftest.js diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js new file mode 100644 index 0000000..e9a6a30 --- /dev/null +++ b/web/world/js/rigging.js @@ -0,0 +1,157 @@ +/** + * 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, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js'; + +export const START_BUDGET = 80; // prototype +export const SPARE_COST = 15; // prototype: "spare shackle ($15)" +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'); +} diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js new file mode 100644 index 0000000..101c2b7 --- /dev/null +++ b/web/world/js/rigging.selftest.js @@ -0,0 +1,204 @@ +/** + * rigging.selftest.js — assert suite for the prep-phase economy. [Lane B] + * + * Same shape as sail.selftest.js: DOM-free, three-free, runs under node today + * and imports cleanly into Lane A's selftest.html later. + */ + +import { RiggingSession, START_BUDGET, SPARE_COST } from './rigging.js'; +import { SailRig, HARDWARE, TENSION_MIN, TENSION_MAX } from './sail.js'; + +const [CARABINER, SHACKLE, RATED] = HARDWARE; + +// A yard-ish spread of anchors: house edge, two trees, two posts, at the sort +// of heights PLAN3D's world will actually offer. +const ANCHORS = [ + { id: 'h1', type: 'house', pos: { x: -3, y: 3.0, z: -6 } }, + { id: 'h2', type: 'house', pos: { x: 3, y: 3.0, z: -6 } }, + { id: 't1', type: 'tree', pos: { x: -5, y: 4.4, z: 1 } }, + { id: 't2', type: 'tree', pos: { x: 5, y: 4.1, z: 2 } }, + { id: 'p1', type: 'post', pos: { x: -4, y: 2.6, z: 5 } }, + { id: 'p2', type: 'post', pos: { x: 4, y: 2.6, z: 5 } }, +]; + +const session = () => new RiggingSession({ anchors: ANCHORS }); + +const results = []; +function test(name, fn) { + try { + const detail = fn(); + results.push({ name, pass: true, detail: detail || '' }); + } catch (e) { + results.push({ name, pass: false, detail: e.message }); + } +} +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +export function runRiggingSelftest() { + results.length = 0; + + test('rigging four corners charges the cheapest hardware each', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) assert(s.rig(id).ok, `rig ${id} failed`); + assert(s.budget === START_BUDGET - 4 * CARABINER.cost, `budget $${s.budget}`); + assert(s.canStart, 'four corners should be startable'); + return `$${START_BUDGET} -> $${s.budget} after four carabiners`; + }); + + test('a sail has four corners, not five', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); + const r = s.rig('t1'); + assert(!r.ok && r.reason === 'a sail has four corners', `fifth corner allowed: ${JSON.stringify(r)}`); + assert(s.budget === START_BUDGET - 4 * CARABINER.cost, 'refused corner should not be charged'); + return 'fifth pick refused and not charged'; + }); + + test('hardware cycles up, charging only the difference', () => { + const s = session(); + s.rig('h1'); + assert(s.cycleHardware('h1').ok, 'cycle to shackle failed'); + assert(s.pickOf('h1').hw === SHACKLE, 'expected shackle'); + assert(s.budget === START_BUDGET - SHACKLE.cost, `budget $${s.budget} should be $${START_BUDGET - SHACKLE.cost}`); + s.cycleHardware('h1'); + assert(s.pickOf('h1').hw === RATED, 'expected rated shackle'); + assert(s.budget === START_BUDGET - RATED.cost, `budget $${s.budget}`); + return `carabiner -> shackle -> rated, paid $${RATED.cost} total`; + }); + + test('cycling past the top tier wraps and refunds', () => { + const s = session(); + s.rig('h1'); + s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated + s.cycleHardware('h1'); // -> wraps to carabiner + assert(s.pickOf('h1').hw === CARABINER, 'expected wrap back to carabiner'); + assert(s.budget === START_BUDGET - CARABINER.cost, `budget $${s.budget} — wrap should refund the difference`); + return `wrapped and refunded back to $${s.budget}`; + }); + + test('unrig refunds exactly what the corner cost', () => { + const s = session(); + s.rig('h1'); + s.cycleHardware('h1'); s.cycleHardware('h1'); // rated, $30 + assert(s.unrig('h1').ok, 'unrig failed'); + assert(s.budget === START_BUDGET, `budget $${s.budget} should be back to $${START_BUDGET}`); + assert(!s.isRigged('h1'), 'h1 should be free again'); + return 'full refund, no leak'; + }); + + test('spares cost real money and refund', () => { + const s = session(); + assert(s.setSpares(1).ok, 'buying a spare failed'); + assert(s.budget === START_BUDGET - SPARE_COST, `budget $${s.budget}`); + s.setSpares(0); + assert(s.budget === START_BUDGET && s.spares === 0, 'selling the spare back should restore budget'); + return `spare costs $${SPARE_COST}, refunds clean`; + }); + + test('budget is a real wall', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); // $20, $60 left + s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated, $25 more, $35 left + s.cycleHardware('h2'); s.cycleHardware('h2'); // -> rated, $25 more, $10 left + s.cycleHardware('p1'); // -> shackle, $10, $0 left + const broke = s.cycleHardware('p2'); + assert(!broke.ok && broke.reason === 'not enough budget', `overspend allowed: ${JSON.stringify(broke)}`); + assert(s.budget === 0, `budget $${s.budget}`); + assert(s.pickOf('p2').hw === CARABINER, 'refused upgrade should not have applied'); + return 'refused the upgrade that would have gone negative'; + }); + + // DESIGN.md: "good hardware everywhere is unaffordable. You *will* field one + // dodgy corner — the game is choosing which one." If this ever passes, the + // central economic tension of the game is gone and the budget is decoration. + test('you cannot afford good hardware on all four corners', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); + let upgraded = 0; + for (const id of ['h1', 'h2', 'p1', 'p2']) { + if (s.setHardware(id, RATED).ok) upgraded++; + } + assert(upgraded < 4, `all four corners got rated shackles with $${START_BUDGET} — no compromise left to make`); + assert(upgraded >= 2, `only ${upgraded} rated corners affordable — budget may be too tight to be interesting`); + return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`; + }); + + test('picks come back ring-ordered however you click them', () => { + const s = session(); + // deliberately crossing order: two diagonals first + for (const id of ['h1', 'p2', 'h2', 'p1']) s.rig(id); + const ids = s.picks.map((p) => p.anchorId); + // valid rings are rotations/reflections; assert no anchor sits opposite its + // true neighbour, i.e. h1 is never adjacent-across from p2 + const i = ids.indexOf('h1'); + const opposite = ids[(i + 2) % 4]; + assert(opposite === 'p2', `h1 should sit opposite p2 in the ring, got ${ids.join(',')}`); + return `clicked h1,p2,h2,p1 -> ring ${ids.join(' -> ')}`; + }); + + test('tension clamps to the rigging range', () => { + const s = session(); + assert(s.setTension(99) === TENSION_MAX, 'over-tight should clamp'); + assert(s.setTension(0) === TENSION_MIN, 'over-loose should clamp'); + s.setTension(1.15); + assert(s.tension === 1.15, 'in-range tension should pass through'); + return `clamped to ${TENSION_MIN}..${TENSION_MAX}`; + }); + + test('commit hands a working rig to the sim', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); + s.setHardware('h1', RATED); + s.setTension(1.1); + const rig = s.commit(new SailRig({ anchors: ANCHORS })); + assert(rig.rigged, 'rig should be rigged'); + assert(rig.corners.length === 4, 'rig should have four corners'); + assert(rig.tension === 1.1, `rig tension ${rig.tension}`); + const h1 = rig.corners.find((c) => c.anchorId === 'h1'); + assert(h1.hw === RATED, 'h1 should have carried its rated shackle into the sim'); + // and it must actually simulate + const wind = { sample: () => ({ x: 0, y: 0, z: 12 }) }; + for (let i = 0; i < 240; i++) rig.step(1 / 60, wind, i / 60); + assert(rig.corners.every((c) => Number.isFinite(c.load)), 'committed rig went NaN'); + return `committed and stepped 4 s clean, max load ${(rig.maxLoad() / 1000).toFixed(2)} kN`; + }); + + test('commit refuses an unfinished rig', () => { + const s = session(); + s.rig('h1'); s.rig('h2'); + let threw = false; + try { s.commit(new SailRig({ anchors: ANCHORS })); } catch { threw = true; } + assert(threw, 'committing two corners should throw'); + return 'two corners refused'; + }); + + test('summary names the weak link for the HUD', () => { + const s = session(); + for (const id of ['h1', 'h2', 'p1', 'p2']) s.rig(id); + s.setHardware('h1', RATED); s.setHardware('h2', SHACKLE); s.setHardware('p1', SHACKLE); + const sum = s.summary; + assert(sum.weakest === 'p2', `weakest should be the lone carabiner p2, got ${sum.weakest}`); + assert(sum.corners.length === 4, 'summary should list four corners'); + return `weak link flagged: ${sum.weakest}, $${sum.budget} left`; + }); + + const pass = results.every((r) => r.pass); + return { pass, results }; +} + +function report(out) { + const lines = out.results.map( + (r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}` + ); + return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`; +} + +if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) { + const out = runRiggingSelftest(); + console.log(report(out)); + process.exit(out.pass ? 0 : 1); +} + +export { report, ANCHORS };