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:
parent
ff7c1f7985
commit
19c39acc8d
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user