Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.
Contract fixes:
- anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
I had it adding sway to pos, which would have flung every
tree-anchored corner to double its coordinates).
- events is an Emitter emitting {type, corner}, not a drained array.
- coverageOver() rects are centre+size, matching world.gardenBed. It
consumes world.sunDir directly: a hit along sunDir means shaded.
- START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
rather than being redeclared here.
Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.
Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.
HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.7 KiB
JavaScript
158 lines
5.7 KiB
JavaScript
/**
|
|
* 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');
|
|
}
|