Lane B S14: collateral exposure on the card + the night-5 invoice line (pool item)

COLLATERAL ON THE CARD. 'Winnable at $80' is an incomplete sentence on a yard
where letting a corner go also bills $180 of carport, so the card now prices
it — through world.collateralFor(), the one resolver (A's structKey fix), never
a second copy of that lookup in a panel. Deduped per STRUCTURE the way main.js
bills ('two beams letting go is one carport gone'): site_02's four carport
anchors read $180 total, not $720. The bill also rides on the margin-flag line
itself, because 'knife-edge' and 'knife-edge AND $180' are different authoring
decisions. An unpriced label renders UNPRICED — not scored, in red, never
'free' — which is the gutter bug's shape and the lie the card exists to prevent.

Verified A's structKey fix rather than assuming it: an editor-shaped carport
(id s1 + collateralKey) prices to $180, a second one (s2) also $180, and the
negative control (id s1, no collateralKey) reads null = not scored. My first
test reported a false alarm because I renamed a SHIPPED spec's id without the
palette's collateralKey — A's editor writes that field explicitly, so the
editor path was never broken.

THE NIGHT-5 INVOICE LINE (pool item, offered S13, accepted). Night 5's garden
is beyond saving by design, and the job sheet was the last surface still
dangling 'up to $45 / paid on what's left of the bed at dawn' — a maximum
nobody can earn, phrased as a rigging problem the player could solve.

NO ECONOMY CHANGE, deliberately. week.js's own comment forbids quote and settle
diverging ('the job sheet promises a number the invoice doesn't pay'), and
zeroing the quote would break that in the opposite direction — settle really
does pay the bonus proportionally. So the NUMBER stays and its CONDITION tells
the truth, which is the job sheet's own idiom:
  nights 1-4  'paid on what's left of the bed at dawn'        (unchanged)
  night 5     'scales with the bed at dawn — and tonight the ice takes it
               whatever you rig'
and the ledger names it too, because an unexplained small number next to
'night lost' reads as the player's failure:
  night 4  'garden bonus — 18% of the bed          +$8'
  night 5  'garden bonus — 18% of the bed, beyond saving from the start  +$8'

Verified in the running game on all five nights: flag fires on 5 only, every
quoted and paid figure identical (garden $45, total $136, bonus $8).

Selftest 374 passed / 0 failed / 0 skipped.
This commit is contained in:
type-two 2026-07-18 16:29:41 +10:00
parent ff7c1f7985
commit 19c39acc8d
4 changed files with 135 additions and 6 deletions

View File

@ -121,7 +121,7 @@ export async function buildScoringWorld(site) {
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { anchors, bed, use, dressed, dressError } = built;
const { world, anchors, bed, use, dressed, dressError } = built;
// The funnel state is REPORTED, never assumed. Sprint 11 shipped an audit
// whose headline was wrong because the venturi lives in the SITE def and a
@ -131,6 +131,29 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
// harnesses got this wrong; the card says it out loud so a fourth can't.
const venturi = site.wind?.venturi ?? [];
// What a lost corner BILLS, resolved through the world's own pricing path.
//
// The card's headline is "winnable at $80", and that sentence is incomplete
// if letting a corner go also costs $180 of carport. Priced here rather than
// in the front-end for the reason everything else is: `world.collateralFor()`
// is the one resolver, it reads site JSON first and the GLB extra second
// (SPRINT14 — A's structKey fix, after id-equality made the first
// editor-made carport free), and a second copy of that lookup in a panel is
// how a yard's trap silently prices to nothing.
//
// `null` from collateralFor means UNPRICED, never free — the house's fascia
// anchors say `collateral:"gutter"` and a gutter had no price for two
// sprints. The card must say "not scored" there, because "free" is the lie
// that makes a dangerous yard look safe.
const collateral = {};
for (const a of anchors) {
if (!a.collateral) continue;
const priced = world?.collateralFor?.(a.collateral) ?? null;
collateral[a.id] = priced
? { key: a.collateral, cost: priced.cost, label: priced.label }
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
const { cands, rows, verdict, winners, marginalWinners } =
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
@ -191,6 +214,7 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation, sepStormName, sepUnjudged,
collateral,
MARGIN: AUDIT.MARGIN,
};
}
@ -226,8 +250,38 @@ export function marginFlags(score) {
for (const r of score.rows) {
for (const c of r.marginal) {
const prev = worst.get(c.id);
if (!prev || c.headroom < prev.headroom) worst.set(c.id, { id: c.id, headroom: c.headroom, peak: c.peak, hint: c.hint, line: r.ids.join(',') });
if (!prev || c.headroom < prev.headroom) {
worst.set(c.id, { id: c.id, headroom: c.headroom, peak: c.peak, hint: c.hint, line: r.ids.join(','),
// what this corner bills if it lets go — the flag and the bill belong
// on the same line, because "knife-edge" and "knife-edge AND $180"
// are different decisions for the person authoring the yard
collateral: score.collateral[c.id] ?? null });
}
}
}
return [...worst.values()].sort((a, b) => a.headroom - b.headroom);
}
/**
* Every priced thing in the yard a lost corner could wreck, deduped by
* structure main.js bills per STRUCTURE, not per corner ("two beams letting
* go is one carport gone, not $360"), so exposure has to dedupe the same way
* or the card invents money.
*
* @returns {{priced: Array, unpriced: Array, total: number}}
*/
export function collateralExposure(score) {
const byKey = new Map();
for (const [anchorId, c] of Object.entries(score.collateral)) {
const e = byKey.get(c.key) ?? { ...c, anchors: [] };
e.anchors.push(anchorId);
byKey.set(c.key, e);
}
const all = [...byKey.values()];
const priced = all.filter((e) => !e.unpriced);
return {
priced,
unpriced: all.filter((e) => e.unpriced),
total: priced.reduce((s, e) => s + e.cost, 0),
};
}

View File

@ -39,7 +39,7 @@
import { loadStorm } from './weather.js';
import { START_BUDGET } from './contracts.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags } from '../../../tools/site_audit/scorecard.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
/** The storms a yard gets judged against. `storm_02_wildnight` leads because
* it is the storm every Sprint-13 number was argued over score against the
@ -308,8 +308,15 @@ function render(out, s, ms) {
kv(cm, 'knife-edge corners', 'none', 'ed-ok');
} else {
for (const f of flags) {
// the bill rides on the same line as the flag: "knife-edge" and
// "knife-edge AND $180 of carport" are different authoring decisions
const bill = f.collateral
? (f.collateral.unpriced
? ` · wrecks ${f.collateral.label} (UNPRICED)`
: ` · wrecks ${f.collateral.label} ${money(f.collateral.cost)}`)
: '';
const row = kv(cm, `${f.id}${f.hint !== 1 ? ` ×${f.hint}` : ''}`,
`${pct(f.headroom)} headroom — ${(f.peak / 1000).toFixed(1)} kN on ${f.line}`, 'ed-warn');
`${pct(f.headroom)} headroom — ${(f.peak / 1000).toFixed(1)} kN on ${f.line}${bill}`, 'ed-warn');
row.title = `worst case across every swept line; first seen on ${f.line}`;
}
}
@ -317,6 +324,32 @@ function render(out, s, ms) {
`A corner within ${pct(s.MARGIN)} of its effective rating breaks in the real game — benches `
+ 'under-read the UIs peaks by 515% and the cause is still unfound, so MANUAL.md '
+ 'carries this as policy. Marginal is not PASS.'));
// ── 6. collateral exposure ───────────────────────────────────────────────
// "Winnable at $80" is an incomplete sentence on a yard where letting a
// corner go bills $180. Priced through world.collateralFor() — the one
// resolver, site JSON first, GLB extra second.
const exp = collateralExposure(s);
const ce = card(out, 'COLLATERAL EXPOSURE', exp.total > 0 ? 'ed-warn' : null);
if (!exp.priced.length && !exp.unpriced.length) {
kv(ce, 'at risk', 'nothing priced in this yard', 'ed-ok');
} else {
for (const e of exp.priced) kv(ce, e.label, `${money(e.cost)} (${e.anchors.join(', ')})`, 'ed-warn');
kv(ce, 'total if all lost', money(exp.total), exp.total > START_BUDGET ? 'ed-err' : 'ed-warn');
for (const e of exp.unpriced) {
kv(ce, e.label, `UNPRICED — not scored (${e.anchors.join(', ')})`, 'ed-err');
}
if (exp.unpriced.length) {
ce.append(el('div', 'ed-note ed-err',
'An unpriced label reads "not scored", never "free" — that distinction is the whole '
+ 'point. A yard whose trap prices to nothing scores as SAFER than it is, which is '
+ 'exactly the lie this card exists to prevent.'));
}
ce.append(el('div', 'ed-note',
'Billed per STRUCTURE, not per corner — two beams letting go is one carport gone, not two. '
+ `Against a ${money(START_BUDGET)} budget, this is what the yard can take off the player `
+ 'on top of the hardware.'));
}
}
boot();

View File

@ -779,7 +779,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up</div>
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">paid on what's left of the bed at dawn</div>
<div class="cond">${q.beyondSaving
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
// cannot reach the win line at any price (week.js's flag, A's S13
// ruling), so "paid on what's left of the bed at dawn" quotes a
// maximum nobody can earn and reads as a rigging problem the
// player could solve. The AMOUNT stays honest — settle() really
// does pay this proportionally, and zeroing it here would make the
// sheet lie in the other direction — but the CONDITION now says
// what kind of night this is, up front, where the brief already
// levels with you. The steel is still yours to save.
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
: "paid on what's left of the bed at dawn"}</div>
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
<div class="cond">nothing of theirs broken</div>
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
@ -939,7 +950,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night
// lost" it reads as the player's failure. It isn't: the bed was
// gone at the forecast (week.js gardenBeyondSaving). Naming that
// HERE, in the ledger, matters more than in the verdict prose —
// the ledger is the part players re-read, and an unexplained small
// number is the most persuasive lie on the card.
w.gardenBeyondSaving ? ', beyond saving from the start' : ''
}</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}

View File

@ -303,6 +303,23 @@ export function createWeek(opts = {}) {
base: PAY.feeFor(gustPeakOf(def)),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
/**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay.
*
* Deliberately a FLAG and not a zero. The comment in settle() below is
* the rule quote and settle must read the same source or the sheet
* promises a number the invoice doesn't pay and zeroing this would
* break it in the opposite direction: settle still pays the bonus
* proportionally (hp is low on a beyond-saving night, not zero), so a
* quoted $0 would be its own lie on paper. The NUMBER is honest. What
* was missing is its CONDITION, which on this night is "and the ice
* takes the bed whatever you rig". The job sheet's own idiom is that
* every amount carries its condition underneath; this is a night where
* the condition is the entire story and the schedule was the last place
* still not telling it.
*/
beyondSaving: j.gardenBeyondSaving ?? false,
get total() { return this.base + this.garden + this.clean; },
};
},
@ -371,6 +388,11 @@ export function createWeek(opts = {}) {
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
// B's pool item: the invoice needs this to explain a small garden bonus
// as the night's design rather than the player's failure. Carried on the
// settlement so the HUD never has to reach back into week state.
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax,
client: job.client, addr: job.addr, site: job.site };
log.push(s);
return s;