diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js index f3fa50d..11b8e70 100644 --- a/tools/site_audit/scorecard.js +++ b/tools/site_audit/scorecard.js @@ -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), + }; +} diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js index 267a121..4bd0457 100644 --- a/web/world/js/editor_score.js +++ b/web/world/js/editor_score.js @@ -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 UI’s peaks by 5–15% 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(); diff --git a/web/world/js/hud.js b/web/world/js/hud.js index f0eb975..0b947ee 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -779,7 +779,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
callout fee$${q.base}
the night, rigged and stood up
garden bonusup to $${q.garden}
-
paid on what's left of the bed at dawn
+
${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"}
clean site$${q.clean}
nothing of theirs broken
the night, done right$${q.total}
@@ -939,7 +950,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { const money = w ? `
${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}+$${w.fee}
-
garden bonus — ${r.hp.toFixed(0)}% of the bed+$${w.bonus}
+
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' : '' + }+$${w.bonus}
${cleanRow}
gear recovered+$${w.refund}
${collateralRows} diff --git a/web/world/js/week.js b/web/world/js/week.js index 67007da..4ef8000 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -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;