diff --git a/web/world/js/board.js b/web/world/js/board.js index 78f16a7..e707511 100644 --- a/web/world/js/board.js +++ b/web/world/js/board.js @@ -444,13 +444,47 @@ export const EMERGENCIES = [ * over the one piece of collateral in this game that a player has already * 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: [ - { anchorId: 'cb1', hw: 'carabiner', broken: true }, - { anchorId: 'tr1', hw: 'carabiner' }, - { anchorId: 'tr1b', hw: 'carabiner' }, - { anchorId: 'q3', hw: 'carabiner' }, + { anchorId: 'q3', hw: 'shackle' }, // sound post, still holding + { anchorId: 'q1', hw: 'carabiner' }, // under-specced + { anchorId: 'tr1b', hw: 'carabiner', broken: true }, // 0.88 branch + cheapest steel: already gone + { 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, ' + '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 ' diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 73b8d38..30c03c7 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -81,6 +81,28 @@ const starDelta = (d) => { 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; // 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. @@ -943,10 +965,15 @@ export function createHud(d) { 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 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 ? `
${ o.standsUp.map((s) => s.client ?? 'a client').join(' and ') } are expecting you${ - starDelta(REP.STOOD_UP * o.standsUp.length)} standing
` : ''} + standUpCost(o.standsUp.length, wk.rep) === 0 + ? 'nothing left to dock' + : `${starDelta(standUpCost(o.standsUp.length, wk.rep))} standing`}` : ''} ${cons.length ? `
⚖ ${cons.map((c) => c.label).join(' · ')}
` : ''} ${cons.map((c) => `
“${c.says}”
`).join('')} ${/* 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) {
you took this callout${w.client ?? 'no client'}
${w.stoodUp?.length ? w.stoodUp.map((s) => ` -
stood up ${s.client ?? 'a client'} — they were expecting you${ - starDelta(REP.STOOD_UP)} standing
-
No invoice for that one. They will remember the phone call.
`).join('') +
stood up ${ + s.client ?? 'a client'} — they were expecting you${ + standUpCost(1, w.repBefore) === 0 + ? 'nothing left to dock' + : `${starDelta(standUpCost(1, w.repBefore))} standing`}
+
${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.'}
`).join('') : '
nobody was expecting you — nothing to stand up
'} ` : ''} ${/* 🚨 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) => `
${c.anchorId.toUpperCase()} — ${c.hw}, already on the ground when you arrivednot billed
That corner is what the callout was for. It is not on you.
`).join('')} - ${(w.cutAways ?? []).map((c) => ` + ` : ''} + ${/* 🚨 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 ? ` +
THE KNIFE
+
+ ${w.cutAways.map((c) => `
sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} — ${ - Math.round(c.t)}s in, deliberateyour call
+ Math.round(c.t)}s in, deliberate${c.reason ? ` · for ${c.reason}` : ''}your call
You spent the sail to keep the steel. That is the trade the knife is for.
`).join('')} ` : ''} ${w?.constraints?.length ? ` diff --git a/web/world/js/main.js b/web/world/js/main.js index c66fc3a..cf18f37 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -223,7 +223,7 @@ export function applyMute(sky, on) { * * @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 // 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 @@ -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 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 // 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 @@ -353,6 +380,17 @@ export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) { repaired: broke && !c.broken, brokenAtDawn: !!c.broken, 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) { const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId)); 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 { theirs, 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)) .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 // --------------------------------------------------------------------------- @@ -723,7 +829,18 @@ export async function boot(opts = {}) { // 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 // 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. // 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 * 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; if (cloth) rig.setFabric(cloth); - rig.attach(anchorIds, hwChoices, tension); + rig.attach(anchorIds, hwChoices, tension, opts); disposeSailView(); sailView = await createSailView(rig); scene.add(sailView); @@ -996,7 +1118,27 @@ export async function boot(opts = {}) { // 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 // 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, panel: true, }); @@ -1047,13 +1189,8 @@ export async function boot(opts = {}) { * subtitle read this. */ 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 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). @@ -1072,16 +1209,13 @@ export async function boot(opts = {}) { * * Priced per STRUCTURE, not per corner: two beams letting go is one carport * 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(); - for (const c of yours) { - 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 { items: collateral, keys: wrecked } = collateralWalk(yours, world); + for (const key of wrecked) world.wreckStructure(key); // false offline/graybox — the bill still lands const s = rigging.summary; 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, * 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 }); // 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, subtitle: yours.length ? `${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 // 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.` @@ -1169,9 +1309,18 @@ export async function boot(opts = {}) { * 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. */ + /** + * 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 ? (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, pondPeak, 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 ` + `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 ?? []) { - 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 // question, because a help line that lists a dead key is a help line that // 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') { // The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note diff --git a/web/world/js/week.js b/web/world/js/week.js index 57a9cfc..ff509fd 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -646,7 +646,13 @@ export function createWeek(opts = {}) { // 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 // 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, 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 // 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. - const brokeCount = rigRec.filter((c) => c.broke && !c.theirs).length; - const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs).length; + // SPRINT18 gate 1.3 — and a corner you CUT is not your record either. Standing is what the + // 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 repBefore = rep; let repDelta = 0;