Lane B S13: consumable spares — close D's free-infinite-spares hole (count half)

D measured it live: interact.js gated the shed-table spare on !player.carrying
alone, never reading the count, so a player who bought zero walked off with
unlimited free shackles mid-storm — the spare economy the ladder cost is
balanced against, free on the spare side, on the public deploy.

My half of the cross-lane seam: RiggingSession gains sparesRemaining (getter)
and takeSpare(). `spares` is the shed-table count, so takeSpare() decrements it
— which also fixes a second bug for free: main.js:720 refunds
session.spares * SPARE_COST as "a spare you never had to use", so you were being
refunded for spares you'd already spent. Decrementing makes that honest with no
change to main.js.

Wiring stays A's (thread the session into wireYardActions) and the gate+consume
stays D's (two lines, posted in THREADS, D offered). Asserted: take-without-buy
fails, table runs dry, consumed spare no longer refundable.

rigging.selftest 21/21.
This commit is contained in:
type-two 2026-07-18 01:09:51 +10:00
parent 7048ad6482
commit 05337c2989
3 changed files with 91 additions and 1 deletions

View File

@ -4710,3 +4710,35 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
C's geometry lands and A exports `createGarden`, I re-run all five nights, wire the sim's garden HP
into the audit as the ranking quantity, re-post the winnable-lines table, and rule on the fuse
lines against real numbers. Ping me. Everything unblocked this sprint is done.
---
[B] 2026-07-18 — ✅ **D, A — the free-infinite-spares hole: my half is landed. The count is now
reachable and the refund bug goes with it.** D's live-storm find (`interact.js` handed out
unlimited free shackles because it never read the count) — thanks for measuring it, that's a
difficulty hole a public player finds in one storm.
`RiggingSession` now has the consume API D asked for:
· **`session.sparesRemaining`** — how many are still on the shed table (getter).
· **`session.takeSpare()`** — removes one, returns `{ok:false, reason:'no spares left on the
table'}` when the table's empty.
`spares` IS the table count (not a purchased total held elsewhere), so `takeSpare()` decrements it —
and that **fixes a second bug for free**: `main.js:720` already refunds `session.spares × SPARE_COST`
as "a spare you never had to use", so before this you were refunded for spares you'd already spent.
Decrementing makes that line honest with **no change to main.js**. `reset()` zeroes it between nights.
**D — the interact.js side, ready to paste** (you offered; it's your file):
```js
// spare_table register:
canUse: (p) => !p.carrying && session.sparesRemaining > 0, // was: !p.carrying
onDone: (p, t) => { if (session.takeSpare().ok) p.pickUp('spare', t); },
```
**A — one thread to pull:** `wireYardActions(interact, {sailRig, world})` at main.js:517 doesn't pass
the session, so interact.js can't see `sparesRemaining` yet. Thread `rigging.session` (or just a
`{sparesRemaining, takeSpare}` view) into `deps` and D's two lines above light up. That's the whole
seam — count (mine, done) → wiring (yours) → gate+consume (D's, ready). Asserted headless in
rigging.selftest ("spares are consumable and finite"): take without buying fails, the table runs
dry, and a consumed spare is no longer refundable. 21/21.
Not in my prompt, but D flagged it to me by name and said fix-before-gate-4, and it's the spare
economy I own — so the count half's done rather than left for the seam to rot.

View File

@ -213,7 +213,7 @@ export class RiggingSession {
return this.tension;
}
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
/** Buy (or sell back) spares in prep. `spares` is the count on the shed table. */
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
@ -222,6 +222,38 @@ export class RiggingSession {
return OK;
}
/**
* How many spares are still on the shed table. [D's ask, SPRINT13]
*
* The read half of the seam D found: interact.js gated the table pickup on
* `!player.carrying` alone and never looked at the count, so a player who
* bought ZERO walked off with unlimited free shackles mid-storm the whole
* "limited hands, two trips" economy the ladder's cost is balanced against was
* free on the spare side, live on the public deploy.
*/
get sparesRemaining() { return this.spares; }
/**
* Take one spare off the table. [D's ask, SPRINT13]
*
* The consume half. `spares` IS the count on the table, not a purchased total
* held elsewhere, so taking one decrements it and that quietly fixes a
* SECOND bug for free: main.js:720 already refunds `session.spares * SPARE_COST`
* as "a spare you never had to use", so before this you were refunded for
* spares you'd already spent. Decrementing here makes that line honest with no
* change to main.js. reset() zeroes the count between nights, so nothing
* carries.
*
* interact.js calls `canUse: () => !p.carrying && session.sparesRemaining > 0`
* and `onDone: () => session.takeSpare()` once A threads the session into
* wireYardActions D has the interact side ready.
*/
takeSpare() {
if (this.spares <= 0) return fail('no spares left on the table');
this.spares -= 1;
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,

View File

@ -223,6 +223,32 @@ test('standing load is null until the quad is closed', () => {
return 'null until four corners, then real';
});
test('spares are consumable and finite — you cannot take one you did not buy', () => {
// D's live-storm find, SPRINT13: interact.js handed out unlimited free spares
// because it never read the count. This is the count's half of the fix — a
// spare economy that can actually run dry.
const s = session();
assert(s.sparesRemaining === 0, 'a fresh session has no spares on the table');
assert(s.takeSpare().ok === false, 'took a spare that was never bought — the QA bug, in one line');
s.setSpares(2);
assert(s.sparesRemaining === 2, `bought 2, table shows ${s.sparesRemaining}`);
assert(s.takeSpare().ok, 'first spare should come off the table');
assert(s.takeSpare().ok, 'second spare should come off the table');
assert(s.sparesRemaining === 0, `table should be empty, shows ${s.sparesRemaining}`);
const dry = s.takeSpare();
assert(!dry.ok && /no spares/.test(dry.reason), `a third take must fail, got ${JSON.stringify(dry)}`);
// The refund half: main.js pays back "a spare you never had to use" as
// session.spares × SPARE_COST. A consumed spare must not still be refunded —
// so a taken spare has to leave the count, not just flip a used flag.
const s2 = session();
s2.setSpares(3);
s2.takeSpare();
assert(s2.spares === 2, `after taking 1 of 3, refundable count must be 2, is ${s2.spares}`);
return 'spares run dry, and a used spare is no longer refundable';
});
test('picks come back ring-ordered however you click them', () => {
const s = session();
// deliberately crossing order: two diagonals first