Lane D S18 gate 1.3: a corner you CUT is not a corner that failed — and gate 1's four unwired seams

THE MONEY. B ruled on the rig that a cut is a decision and emitted no `break`
— but _releaseCorner sets c.broken for a cut exactly as for an overload, and
every ledger surface reads broken. So taking the signature decision billed you
for it five ways: the hardware, the $180 carport you cut the sail TO SAVE
(billed AND wrecked), the gnome's two-corner rule, the clean bonus it voided,
and a free warranty callout tomorrow plus half a star. The invoice would have
printed "You spent the sail to keep the steel" directly above a $180 charge for
the thing you kept. Nobody wrote a wrong line; the join is a flag no verb set
until now. A cut now bills nothing, wrecks nothing, warranties nothing and
costs no standing — and the refund counts it, because "save the anchors" is a
ledger line and not a slogan. `lost` still counts it: the sail IS on the lawn.

THE FOUR SEAMS, all found by playing night 5 and reading the cards, all data or
a name in A's files, all pre-authorised in THREADS, none of them visible to 574
green asserts:
  · B's authored cowboy rig had not landed, so the carport corner ARRIVED DOWN
    and the knife had no object.
  · pay.garden = 0 had not landed; the first emergency invoice paid $38 for a
    bed nobody had a prep phase to protect.
  · rig.breakCorner?.() — the door is called failCorner. An optional call to a
    renamed method is a silent no-op that reads exactly like a call.
  · attach({at, wind}) was never passed: clockSkew read 30.02 on the one night
    that starts mid-storm, and the knife's own invoice line said "9s in" on a
    night you arrive at 30.

And the paper half of John's ruling: at ★0.0 the clamp eats the stand-up
charge, so the card promised a price the game would not take and the invoice
carried "−½★ standing" four rows above "★ 0.0 → ★ 0.0". The documents now print
what will actually be charged. Whether the ruling should have teeth at the
floor is John's, and it is filed with the measurement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 21:29:39 +10:00
parent 008de38766
commit db1977f55d
4 changed files with 297 additions and 40 deletions

View File

@ -444,13 +444,47 @@ export const EMERGENCIES = [
* over the one piece of collateral in this game that a player has already * over the one piece of collateral in this game that a player has already
* been taught to fear. * been taught to fear.
*/ */
/**
* **B's AUTHORED SET, ADOPTED VERBATIM landed by D, and it should have landed at
* integration.** B published it in THREADS with the measurements ([B] gate 1 §5) and A
* pre-authorised the swap in the note above ("revert-and-tell-me applies yours is canon the
* moment it lands"). It never landed, because it is DATA IN A's FILE while B rules the
* semantics. **I found it by playing the night:** the dispatch card read *"CB1 carabiner ON
* THE GROUND"*, which is a different night from the one this sprint is about.
*
* Why the difference IS the gate and not a detail: in the placeholder the CARPORT CORNER
* ARRIVES ALREADY DOWN, so the $180 carport is `theirs` unbillable, unwreckable and there
* is nothing left for the knife to save. DESIGN.md line 165's signature decision had no
* object. In B's set `cb1` is a SHACKLE on the 0.22 beam (704 N effective, holding their roof)
* which the southerly puts over its ceiling from t=35 to t=42, and `tr1b` the 0.88 branch on
* the cheapest steel is the corner that arrived gone. That is B's measured triage window:
* ride it out cb1 blows @35.9 s and takes q1 the carport WRECKED, $180
* cut at t=33 nothing breaks, the carport saved
* 5.9 seconds. Measured by B on the rig; re-flown by D through the shipped game.
*/
rig: [ rig: [
{ anchorId: 'cb1', hw: 'carabiner', broken: true }, { anchorId: 'q3', hw: 'shackle' }, // sound post, still holding
{ anchorId: 'tr1', hw: 'carabiner' }, { anchorId: 'q1', hw: 'carabiner' }, // under-specced
{ anchorId: 'tr1b', hw: 'carabiner' }, { anchorId: 'tr1b', hw: 'carabiner', broken: true }, // 0.88 branch + cheapest steel: already gone
{ anchorId: 'q3', hw: 'carabiner' }, { anchorId: 'cb1', hw: 'shackle' }, // ⚠️ the CARPORT BEAM, hint 0.22
], ],
}, },
/**
* **B's RULING ON THE ECONOMY OF TRIAGE, landed by D same story as the rig above: a ruling
* B made and could not apply, because it is per-job DATA in A's file.** ([B] gate 1 §2.)
*
* **The garden BONUS does not apply on a callout.** It is a per-HP payment for a bed you chose
* hardware to protect; you arrive at t=30 with thirty seconds of somebody else's storm already
* in that bed, under steel you did not buy. Paying per-HP prices the PREVIOUS contractor's
* geometry and docks you for their mistakes. Data, not a branch so A's acceptance criterion
* (§10: `settle()` runs unchanged on an emergency) holds exactly.
*
* **The garden's WIN still applies, and that is the knife's price.** `win = hp >= 50` gates the
* FEE (`PAY.lostNightShare`), so cutting spends garden HP and can spend 65% of the callout
* to buy certainty on their property. Ride it out for the full double rate and gamble their
* carport; cut and take the smaller cheque. The garden is the STAKE, not the wage.
*/
pay: { garden: 0 },
_why: 'THE PAIRING IS THE POOL\'S OWN AUDITED corner_block_southerly (A, S17 gate 1, ' _why: 'THE PAIRING IS THE POOL\'S OWN AUDITED corner_block_southerly (A, S17 gate 1, '
+ 'audit.html): 19 holding lines / 7 marginal / 38 unholdable, cheapest hold $40 ' + 'audit.html): 19 holding lines / 7 marginal / 38 unholdable, cheapest hold $40 '
+ '(tr1,tr1b,q1,q3 → garden 87.6 FULL). ⚠️ THAT AUDIT ANSWERS A DIFFERENT QUESTION and ' + '(tr1,tr1b,q1,q3 → garden 87.6 FULL). ⚠️ THAT AUDIT ANSWERS A DIFFERENT QUESTION and '

View File

@ -81,6 +81,28 @@ const starDelta = (d) => {
return `${d < 0 ? '' : '+'}${whole || !half ? whole : ''}${half ? '½' : ''}`; return `${d < 0 ? '' : '+'}${whole || !half ? whole : ''}${half ? '½' : ''}`;
}; };
/**
* **SPRINT18 gate 1.3 [Lane D] WHAT STANDING A CLIENT UP WILL ACTUALLY COST, WHICH IS NOT
* ALWAYS `REP.STOOD_UP`.** Found by playing the week to 0.0 and taking the emergency anyway.
*
* `settle()` clamps rep at `REP.MIN`, so at 0.0 a ½ debit moves NOTHING and both papers went on
* promising it. On the offer card that is a price the game will not charge; on the invoice it is
* worse, because the same card carries the row *"stood up the Hendersons · −½★ standing"* four lines
* above *"reputation ★ 0.0 → ★ 0.0"*. **The invoice arguing with itself**, which is the defect class
* this repo has spent five sprints hunting, on John's own ruling.
*
* This function is the PAPER half only, and the split is deliberate: whether the ruling should have
* teeth at the floor is a DESIGN question and it is John's (filed in THREADS with the measurement
* at 0.0 a fee-maximiser can dodge every booked night for free again, which is precisely the player
* the ruling was written for). Until he rules, the documents tell the truth about the number.
*
* @param {number} n how many clients would be stood up
* @param {number|null} rep the standing the charge lands against (`wk.rep` / `w.repBefore`)
*/
const standUpCost = (n, rep) => (rep == null
? REP.STOOD_UP * n
: Math.max(REP.MIN, rep + REP.STOOD_UP * n) - rep);
// Load bar colours: green → amber → red. The amber band is deliberately wide; // Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel // a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them. // that before the bar turns red on them.
@ -943,10 +965,15 @@ export function createHud(d) {
as plural night 7's brief says "The Vasilaros place again THEY as plural night 7's brief says "The Vasilaros place again THEY
asked for you by name". The spec's own example wording is "the asked for you by name". The spec's own example wording is "the
Hendersons are expecting you". So: no agreement logic at all. */''} Hendersons are expecting you". So: no agreement logic at all. */''}
${/* SPRINT18 gate 1.3 [D] the cost AS IT WILL BE CHARGED (standUpCost): at 0.0 the
clamp eats it, and a card promising a price the invoice will not take is the same lie
as a card hiding one. The words change with it, because "0★" would be gibberish. */''}
${o.standsUp?.length ? `<div class="line cost"><span>${ ${o.standsUp?.length ? `<div class="line cost"><span>${
o.standsUp.map((s) => s.client ?? 'a client').join(' and ') o.standsUp.map((s) => s.client ?? 'a client').join(' and ')
} are expecting you</span><b>${ } are expecting you</span><b>${
starDelta(REP.STOOD_UP * o.standsUp.length)} standing</b></div>` : ''} standUpCost(o.standsUp.length, wk.rep) === 0
? 'nothing left to dock'
: `${starDelta(standUpCost(o.standsUp.length, wk.rep))} standing`}</b></div>` : ''}
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''} ${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')} ${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
${/* gate 0, clause 3 and if you have done this to THEM before, it is ${/* gate 0, clause 3 and if you have done this to THEM before, it is
@ -1496,9 +1523,14 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="row"><span>you took this callout</span><b>${w.client ?? 'no client'}</b></div> <div class="row"><span>you took this callout</span><b>${w.client ?? 'no client'}</b></div>
${w.stoodUp?.length ${w.stoodUp?.length
? w.stoodUp.map((s) => ` ? w.stoodUp.map((s) => `
<div class="row bad"><span>stood up ${s.client ?? 'a client'} they were expecting you</span><b>${ <div class="row ${standUpCost(1, w.repBefore) === 0 ? '' : 'bad'}"><span>stood up ${
starDelta(REP.STOOD_UP)} standing</b></div> s.client ?? 'a client'} they were expecting you</span><b>${
<div class="cond">No invoice for that one. They will remember the phone call.</div>`).join('') standUpCost(1, w.repBefore) === 0
? 'nothing left to dock'
: `${starDelta(standUpCost(1, w.repBefore))} standing`}</b></div>
<div class="cond">${standUpCost(1, w.repBefore) === 0
? 'Your standing is already on the floor, so this one was free. They will remember the phone call anyway.'
: 'No invoice for that one. They will remember the phone call.'}</div>`).join('')
: '<div class="row"><span>nobody was expecting you — nothing to stand up</span><b>—</b></div>'} : '<div class="row"><span>nobody was expecting you — nothing to stand up</span><b>—</b></div>'}
</div>` : ''} </div>` : ''}
${/* 🚨 SPRINT18 gate 1 THE EMERGENCY'S PAPERWORK. Three things the invoice ${/* 🚨 SPRINT18 gate 1 THE EMERGENCY'S PAPERWORK. Three things the invoice
@ -1521,9 +1553,20 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
${(w.inherited ?? []).map((c) => ` ${(w.inherited ?? []).map((c) => `
<div class="row"><span>${c.anchorId.toUpperCase()} ${c.hw}, already on the ground when you arrived</span><b>not billed</b></div> <div class="row"><span>${c.anchorId.toUpperCase()} ${c.hw}, already on the ground when you arrived</span><b>not billed</b></div>
<div class="cond">That corner is what the callout was for. It is not on you.</div>`).join('')} <div class="cond">That corner is what the callout was for. It is not on you.</div>`).join('')}
${(w.cutAways ?? []).map((c) => ` </div>` : ''}
${/* 🚨 SPRINT18 gate 1.3 [Lane D] **THE KNIFE'S LINE, ON ANY NIGHT.** It shipped inside the
EMERGENCY ATTENDANCE block, so a sail cut loose on an ordinary Tuesday printed NOTHING
the verb is available every night the sail is up, and principle 2 does not have a "only
on callouts" clause. Its own section for the same reason the emergency has one: this is
not a charge and not a bonus, it is a decision, and the ledger below has no row shape for
that. `reason` is printed because B put it in the payload for the paperwork and D fills
it with what the cut was FOR the sentence a player would say out loud. */''}
${w?.cutAways?.length ? `
<div class="sect">THE KNIFE</div>
<div class="sched">
${w.cutAways.map((c) => `
<div class="row"><span>sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} ${ <div class="row"><span>sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} ${
Math.round(c.t)}s in, deliberate</span><b>your call</b></div> Math.round(c.t)}s in, deliberate${c.reason ? ` · for ${c.reason}` : ''}</span><b>your call</b></div>
<div class="cond">You spent the sail to keep the steel. That is the trade the knife is for.</div>`).join('')} <div class="cond">You spent the sail to keep the steel. That is the trade the knife is for.</div>`).join('')}
</div>` : ''} </div>` : ''}
${w?.constraints?.length ? ` ${w?.constraints?.length ? `

View File

@ -223,7 +223,7 @@ export function applyMute(sky, on) {
* *
* @returns {{verdict: string, mode: string}} * @returns {{verdict: string, mode: string}}
*/ */
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSaving = false }) { export function verdictFor({ hp, lost, cut = [], win, dmg, pondPeak, pondDumped, beyondSaving = false }) {
// SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on // SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on
// the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure // the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure
// line and B's summary.weakest tie-break fix. On the bare rating a rated // line and B's summary.weakest tie-break fix. On the bare rating a rated
@ -237,6 +237,33 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : ''; const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
const hailKilled = dmg.hail > dmg.rain; const hailKilled = dmg.hail > dmg.rain;
/**
* 🚨 SPRINT18 gate 1.3 [Lane D] **THE KNIFE'S OWN VERDICT, and without it this function calls a
* deliberate cut "the hail fell where your sail wasn't".**
*
* That is mode `uncovered`, and it is the single worst thing the card could say to a player who
* just made the game's signature decision: it blames placement on a night where the sail came down
* because they cut it down. The verdict's whole charter is that it reads what ACTUALLY happened.
*
* It fires only when nothing of YOURS failed, and that composition rule is deliberate rather than
* tidy: if a corner blew as well, that failure is the louder fact and the modes below say it
* truthfully the cut is still named on the invoice's own line. So the knife speaks when the knife
* is the story, and shuts up when it isn't.
*
* "Nothing of theirs" is true BY CONSTRUCTION here, not by hope: `collateralWalk` prices `yours`,
* and with `yours` empty the gnome's two-corner rule and every structure key are empty too.
*/
if (cut.length && !lost.length) {
const where = cut.map((c) => c.anchorId.toUpperCase()).join(', ');
return win
? { mode: 'cut-held',
verdict: `YOU CUT IT LOOSE at ${where} — and the bed came through anyway. `
+ 'Nothing of theirs is on the ground, and nothing of yours broke.' }
: { mode: 'cut',
verdict: `YOU CUT IT LOOSE at ${where}. The bed went with it — and their property didn't. `
+ 'That was the trade.' };
}
// SPRINT9 decision 1. This case had to come FIRST because it did not used to // SPRINT9 decision 1. This case had to come FIRST because it did not used to
// exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the // exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
// new rule it can be the best night the game has. A sail that tore itself // new rule it can be the best night the game has. A sail that tore itself
@ -353,6 +380,17 @@ export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) {
repaired: broke && !c.broken, repaired: broke && !c.broken,
brokenAtDawn: !!c.broken, brokenAtDawn: !!c.broken,
theirs: theirs.has(c.anchorId), theirs: theirs.has(c.anchorId),
/**
* SPRINT18 gate 1.3 [D] you let this one go on purpose. week.js reads it exactly where it
* already reads `theirs`, and for the same reason: warranty and standing are about work that
* FAILED. A corner still down at dawn because you cut it booked a free callout to go and
* re-hang your own decision, and docked half a star for taking it.
*
* NOTE it does not suppress `broke`/`brokenAtDawn`. Those two are the RECORD the sail really
* is on the ground and the client can see it and a record that edits itself to be flattering
* is the one thing this ledger has never done.
*/
cut: !!c.cut,
}; };
}); });
} }
@ -436,15 +474,83 @@ export function priceOffer(night, { def = null, site = null, rep = 3, remembers
export function answerableFor(corners, emergency = null) { export function answerableFor(corners, emergency = null) {
const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId)); const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
const lost = (corners ?? []).filter((c) => c.broken); const lost = (corners ?? []).filter((c) => c.broken);
/**
* 🚨 **SPRINT18 gate 1.3 [Lane D] A CORNER YOU CUT IS NOT A CORNER THAT FAILED, AND WITHOUT
* THIS LINE THE KNIFE IS A TRAP.**
*
* B's `cutAway` deliberately emits no `break` ("warranty must never chase a corner the player
* chose to let go") but every ledger surface downstream reads `c.broken`, and `_releaseCorner`
* sets it for a cut exactly as it does for an overload. So on the tree as merged, taking DESIGN.md
* line 165's signature decision billed you for it on five surfaces at once: the cut corner's
* hardware went on `bill`, the collateral walk BILLED AND WRECKED the $180 carport you cut the
* sail to save, a four-corner cut tripped the gnome's two-corner rule, `clean` was voided by the
* collateral it caused, and week.js booked warranty and docked standing for every corner you let
* go. The invoice would have printed *"You spent the sail to keep the steel. That is the trade the
* knife is for"* directly above a $180 charge for the thing you kept.
*
* Found by playing it, not by a test the same way every card bug in this repo has been found.
* A is the author of both halves and neither half is wrong; nobody joined them, because the join
* is a flag on an object and there was no verb setting the flag until now.
*
* The ruling, and it is B's extended to every surface that reads a corner: **a cut is a DECISION.
* It bills nothing, wrecks nothing, books no warranty and costs no standing. Its price is the
* garden and through the garden, the night's fee and that is enough.** `lost` still counts it
* (the HUD's corners-intact read must stay honest: the sail IS on the ground), and `cut` is named
* so the paper can say who put it there.
*/
const cut = lost.filter((c) => c.cut);
return { return {
theirs, theirs,
lost, lost,
yours: lost.filter((c) => !theirs.has(c.anchorId)), cut,
yours: lost.filter((c) => !theirs.has(c.anchorId) && !c.cut),
inherited: lost.filter((c) => theirs.has(c.anchorId)) inherited: lost.filter((c) => theirs.has(c.anchorId))
.map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })), .map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })),
}; };
} }
/**
* 🚨 **SPRINT18 gate 1.3 [Lane D] THE COLLATERAL WALK, EXTRACTED.** What a set of failed corners
* costs you in the client's property. Lifted OUT of scoreRun unchanged, for the two reasons
* `answerableFor` and `rigRecordFor` were extracted before it, and one new one:
*
* · scoreRun lives inside `boot()` behind a WebGL context where no assert can reach it.
* · **the knife's HUD read has to promise exactly what the invoice will honour.** The panel says
* "saves the carport $180" before the cut and the invoice charges $0 after it; if those two ever
* came off different arithmetic, the game would be advertising a saving. One walk, two readers
* the same discipline that made `calloutFee` one function for the board, the dispatch and the
* invoice.
*
* PURE, and that is load-bearing: `wreckStructure()` stays at the call site. A HUD asking what a cut
* would save must not swap a structure for its wreck as a side effect of the question.
*
* @param {Array} corners the corners to price `yours` at dawn, or the LIVE ones for the knife
* @param {object} world contracts World (anchor(), collateralFor(), gnome)
* @returns {{items: {what:string,cost:number}[], keys: string[], total: number}}
*/
export function collateralWalk(corners, world) {
const items = [];
const keys = [];
// The gnome is collateral if the sail came down over it (DESIGN.md: the worst debris in any storm
// is your own failed work). Two corners, per the original rule.
if ((corners ?? []).length >= 2 && world?.gnome) {
items.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
}
/**
* Priced per STRUCTURE, not per corner: two beams letting go is one carport gone, not $360.
*/
const taken = new Set();
for (const c of corners ?? []) {
const key = world?.anchor ? world.anchor(c.anchorId)?.collateral : null;
const priced = key && world.collateralFor ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
taken.add(key);
keys.push(key);
items.push({ what: priced.label, cost: priced.cost });
}
return { items, keys, total: items.reduce((s, i) => s + i.cost, 0) };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase machine // Phase machine
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -723,7 +829,18 @@ export async function boot(opts = {}) {
// world into its ground clamp, solid collider, ladder and broom, none of // world into its ground clamp, solid collider, ladder and broom, none of
// which have a setter (Lane D's modules). A site change only happens at a // which have a setter (Lane D's modules). A site change only happens at a
// forecast, never mid-storm, so a clean rebuild is honest and cheap. // forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact }); // SPRINT18 gate 1.3 — D's knife rides in on the opts it already had. Every one of these is a
// GETTER because `game` and `garden` are declared below the first call to this function (the
// TDZ trap documented twice above); the knife reads them at frame time, when they exist.
player = await createPlayer(scene, world, cameraRig, {
wind,
interact,
getPhase: () => game.phase,
getGarden: () => garden,
/** One walk, two readers: what the panel promises is what the invoice honours. */
collateralAtRisk: collateralWalk,
secsLeft: () => STORM_LEN - game.phaseT,
});
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game. // SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
// Done here because the spawn is here: the player and the yard are rebuilt // Done here because the spawn is here: the player and the yard are rebuilt
@ -882,10 +999,15 @@ export async function boot(opts = {}) {
* see (this wire lives inside `boot()` behind a WebGL context). The fallback is * see (this wire lives inside `boot()` behind a WebGL context). The fallback is
* one `??` and it agrees with the argument on every wired path. * one `??` and it agrees with the argument on every wired path.
*/ */
async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric) { /**
* @param {object} [opts] `{at, wind}` B's `attach` door (sail.js). Absent/`at:0` is
* byte-for-byte the old behaviour, so every ordinary night and every bench is untouched.
* See the emergency's note on `onCommit` for the one caller that passes it.
*/
async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric, opts) {
const cloth = fabric ?? rigging?.session?.fabric; const cloth = fabric ?? rigging?.session?.fabric;
if (cloth) rig.setFabric(cloth); if (cloth) rig.setFabric(cloth);
rig.attach(anchorIds, hwChoices, tension); rig.attach(anchorIds, hwChoices, tension, opts);
disposeSailView(); disposeSailView();
sailView = await createSailView(rig); sailView = await createSailView(rig);
scene.add(sailView); scene.add(sailView);
@ -996,7 +1118,27 @@ export async function boot(opts = {}) {
// verbatim; `fabric` is undefined on bare lane/a (their commit() passes it, // verbatim; `fabric` is undefined on bare lane/a (their commit() passes it,
// and that lands at integration) and rigSail falls back to session.fabric so // and that lands at integration) and rigSail falls back to session.fabric so
// the sim flies the right cloth on both branches. See rigSail's note. // the sim flies the right cloth on both branches. See rigSail's note.
onCommit: (ids, hw, tension, fabric) => { void rigSail(ids, hw, tension, fabric); }, /**
* 🚨 SPRINT18 gate 1.3 [D] **`{at, wind}`: THE CLOTH FINALLY ARRIVES IN THE STORM IT IS
* STANDING IN.** C measured it and B fixed it and nobody wired it, so on the merged tree the one
* night that starts mid-storm attached its sail at t=0: measured live by D on the shipped
* emergency, **`rig.clockSkew` read 30.02** while the sky, garden, debris and player were all
* thirty seconds in. B's own number for what that hides: the same rig entering the same storm
* pulls **90 N at t=0 and 655 N at t=40**. The triage that this whole gate is about was being
* judged against a sail feeling a seventh of the load, and the knife's own invoice line said
* *"cut loose 9s in"* on a night you arrived at 30 because the log stamps the SAIL's clock.
*
* The night is asked, not a flag: `week.job.emergency` is a fact the night already owns, so this
* needs no new state and no ordering change (`adoptEmergencyRig` runs before `setPhase`, so
* reading `game.phaseT` here would read the forecast's clock which is how it would go wrong).
* With a `wind` B's `attach` genuinely PRE-FLIES the thirty seconds rather than teleporting the
* cloth into a gale, so the drape and the belly's water are real history.
*/
onCommit: (ids, hw, tension, fabric) => {
const em = week.job?.emergency;
void rigSail(ids, hw, tension, fabric,
em ? { at: em.arriveAt, wind: winds[week.stormKey] } : undefined);
},
onMessage: pushEvent, onMessage: pushEvent,
panel: true, panel: true,
}); });
@ -1047,13 +1189,8 @@ export async function boot(opts = {}) {
* subtitle read this. * subtitle read this.
*/ */
const em = week.job.emergency; const em = week.job.emergency;
const { theirs, lost, yours, inherited } = answerableFor(rig.corners, em); const { theirs, lost, yours, cut, inherited } = answerableFor(rig.corners, em);
const bill = yours.reduce((s, c) => s + c.hw.cost, 0); const bill = yours.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
// worst debris in any storm is your own failed work. `yours`, not `lost`:
// two corners on the ground when you ARRIVED is not the sail you dropped.
if (yours.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
/** /**
* SPRINT11 the carport trap finally BILLS (§gate 1). * SPRINT11 the carport trap finally BILLS (§gate 1).
@ -1072,16 +1209,13 @@ export async function boot(opts = {}) {
* *
* Priced per STRUCTURE, not per corner: two beams letting go is one carport * Priced per STRUCTURE, not per corner: two beams letting go is one carport
* gone, not $360. It bills once and the wreck swaps once. * gone, not $360. It bills once and the wreck swaps once.
*
* SPRINT18 gate 1.3 [D] the ARITHMETIC moved out to `collateralWalk` (pure, exported, above)
* so the knife's HUD read prices what it saves through this exact walk. The WRECK stays here:
* a HUD asking "what would a cut save" must not swap a structure for its wreck.
*/ */
const taken = new Set(); const { items: collateral, keys: wrecked } = collateralWalk(yours, world);
for (const c of yours) { for (const key of wrecked) world.wreckStructure(key); // false offline/graybox — the bill still lands
const key = world.anchor(c.anchorId)?.collateral;
const priced = key ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
taken.add(key);
collateral.push({ what: priced.label, cost: priced.cost });
world.wreckStructure(key); // false offline/graybox — the bill still lands
}
const s = rigging.summary; const s = rigging.summary;
const hp = garden.hp; const hp = garden.hp;
/** /**
@ -1116,7 +1250,7 @@ export async function boot(opts = {}) {
* answerable for; the corner count above it still reports every corner down, * answerable for; the corner count above it still reports every corner down,
* because those are two different facts and both papers say so honestly. * because those are two different facts and both papers say so honestly.
*/ */
const { verdict, mode } = verdictFor({ hp, lost: yours, win, dmg, pondPeak, pondDumped, const { verdict, mode } = verdictFor({ hp, lost: yours, cut, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving }); beyondSaving: week.job.gardenBeyondSaving });
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key // SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
@ -1144,7 +1278,13 @@ export async function boot(opts = {}) {
win, win,
subtitle: yours.length subtitle: yours.length
? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.` ? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: inherited.length // SPRINT18 gate 1.3 [D] — a cut sail must not be headlined "Every corner held." The card
// would then carry that line, a CUT LOOSE row in the ledger and a cut verdict underneath:
// three accounts of one night, one of them false. `cut` before `inherited` because on the
// shipped emergency both are true and the cut is the thing the PLAYER did.
: cut.length
? `You cut it loose at ${cut.map((c) => c.anchorId.toUpperCase()).join(', ')}.`
: inherited.length
// An emergency you held: nothing of yours went, and the corner that was // An emergency you held: nothing of yours went, and the corner that was
// already down stays named rather than quietly counted as a hold. // already down stays named rather than quietly counted as a hold.
? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.` ? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.`
@ -1169,9 +1309,18 @@ export async function boot(opts = {}) {
* somebody else's four rated shackles at dawn, and the biggest earner in the * somebody else's four rated shackles at dawn, and the biggest earner in the
* game would be turning up to other people's disasters and salvaging them. * game would be turning up to other people's disasters and salvaging them.
*/ */
/**
* SPRINT18 gate 1.3 [D] **"LOSE THE SAIL, SAVE THE ANCHORS" IS A LEDGER LINE, NOT A
* SLOGAN.** `!c.broken` alone excluded a CUT corner from the refund, so the knife also quietly
* took your steel: you cut the cloth, and the shackle still bolted to the post undamaged,
* unclippable in thirty seconds at dawn paid you nothing. DESIGN.md line 165 promises the
* opposite in the same breath as the verb, and `bill` already agrees (a cut corner is not
* `yours`, so its hardware is not written off). This is the other half of that sentence.
* On an EMERGENCY it changes nothing none of that steel is yours to unclip either way.
*/
intactHardwareValue: em intactHardwareValue: em
? (rigging.session.spares ?? 0) * SPARE_COST ? (rigging.session.spares ?? 0) * SPARE_COST
: rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0) : rig.corners.filter((c) => !c.broken || c.cut).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST, + (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak, pondPeak,
pondDumped, pondDumped,
@ -1358,9 +1507,26 @@ export async function boot(opts = {}) {
throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners ` throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners `
+ `on ${week.site}. Check every anchorId exists in that yard's site JSON.`); + `on ${week.site}. Check every anchorId exists in that yard's site JSON.`);
} }
// Corners that ARRIVED gone. Feature-detected; paper-only until B's door lands. /**
* Corners that ARRIVED gone.
*
* **SPRINT18 gate 1.3 [D] THE DOOR IS CALLED `failCorner`, AND FOR THIS WHOLE SPRINT THIS
* LINE CALLED A METHOD THAT DOES NOT EXIST.** B opened it ([B] gate 1 §4) under the name
* `failCorner`; this asked for `breakCorner?.()`, and an optional call to a renamed method is a
* silent no-op that reads exactly like a call **the `rigging.setWorld` lesson this file quotes
* elsewhere, arriving through the door built to prevent it.** Measured live:
* `typeof rig.failCorner === 'function'`, `typeof rig.breakCorner === 'undefined'`, and the
* corner every card called "ON THE GROUND" was flying.
*
* A's fallback reasoning ("the authored corner is a $5 carabiner on the carport beam, so it fails
* within seconds through the real overload path") was doing the work and hid the break but it
* only holds for A's placeholder rig. Under B's canon set the arrived-broken corner is `tr1b`, a
* carabiner on a 0.88 branch that does NOT self-destruct, so the dispatch would have promised a
* corner on the ground and the sim would have flown it all night. Still feature-detected, because
* the pattern is right; only the name was wrong.
*/
for (const c of em.rig ?? []) { for (const c of em.rig ?? []) {
if (c.broken) rig.breakCorner?.(c.anchorId, { reason: 'arrived' }); if (c.broken) rig.failCorner?.(c.anchorId);
} }
} }
@ -1577,7 +1743,11 @@ export async function boot(opts = {}) {
// that doesn't wait), and M only once C's bus has a tap — hud owns that // that doesn't wait), and M only once C's bus has a tap — hud owns that
// question, because a help line that lists a dead key is a help line that // question, because a help line that lists a dead key is a help line that
// teaches a stranger the game is broken. // teaches a stranger the game is broken.
hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`); // SPRINT18 gate 1.3 — X is on the help line because a key nobody advertises is a key nobody
// presses, and the knife is the one verb the game has to offer BEFORE the moment it is needed.
// "hold X cut sail" rather than "X cut sail": it is a hold, and the difference matters when the
// window is six seconds long.
hud.setHelp(`WASD move · shift run · E repair/pickup · hold X cut sail · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
} }
if (to === 'aftermath') { if (to === 'aftermath') {
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note // The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note

View File

@ -646,7 +646,13 @@ export function createWeek(opts = {}) {
// for a failure that predates you, which is the exact opposite of what the // for a failure that predates you, which is the exact opposite of what the
// warranty ledger was built to say. main.js's rigRecordFor sets the flag; the // warranty ledger was built to say. main.js's rigRecordFor sets the flag; the
// rep block below skips it for the same reason. // rep block below skips it for the same reason.
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs).map((c) => ({ // 🚨 SPRINT18 gate 1.3 [Lane D] — `!c.cut`: THE KNIFE. B's ruling on `cutAway` in sail.js is
// that a cut is a DECISION and emits no `break`, precisely so "warranty must never chase a
// corner the player chose to let go" — but warranty is booked off `brokenAtDawn`, which a cut
// sets, so without this line the ledger sent you back unpaid tomorrow to re-hang a sail you
// deliberately spent to save the client's carport. Same shape as the `!c.theirs` line above it,
// one verb along.
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).map((c) => ({
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost, anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client, night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
})); }));
@ -940,8 +946,12 @@ export function createWeek(opts = {}) {
// SPRINT18 gate 1 — their corners are not your record. A rig you inherited // SPRINT18 gate 1 — their corners are not your record. A rig you inherited
// mid-storm cannot cost you a clean night for breaking before you got there, // mid-storm cannot cost you a clean night for breaking before you got there,
// and cannot cost you standing for still being down at dawn. // and cannot cost you standing for still being down at dawn.
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs).length; // SPRINT18 gate 1.3 — and a corner you CUT is not your record either. Standing is what the
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs).length; // client thinks of your work; the knife is the work. Its price is the garden (and the fee the
// garden carries), ruled by B and measured by D — not a second, silent bite at the star the
// same night. `!c.cut` twice, for the same reason `!c.theirs` is there twice.
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs && !c.cut).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).length;
const collateralEvents = (run.collateral ?? []).length; const collateralEvents = (run.collateral ?? []).length;
const repBefore = rep; const repBefore = rep;
let repDelta = 0; let repDelta = 0;