HardYards/tools/site_audit/scorecard.js
type-two 19c39acc8d 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.
2026-07-18 16:29:41 +10:00

288 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* scorecard.js — score a site OBJECT, in-memory, through the real chain.
* [Lane B, SPRINT14 gate 2.1]
*
* Sprint 10 gave the audit two front-ends (audit.mjs, audit.html) and ONE
* engine (sweep.js), on the theory that a tool built to catch reimplemented-
* formula drift must not carry two copies of its own math. Sprint 14 adds a
* THIRD front-end — A's editor, where the yard being scored has never been a
* file — so the run logic that audit.html had grown (build the world, fly the
* winners, pick the best line, judge the separation block) moves HERE, where
* both pages import it. audit.html keeps its rendering and loses its logic.
*
* ── The one thing this module exists to get right ──────────────────────────
*
* The audit's input has always been a site JSON fetched off disk. The editor's
* yard is an object that has never been written down. That is the whole change,
* and it is smaller than it looks: `loadSite()` returns a parsed object, so
* every engine below already took an object — the fetch was the front-end's
* business, never the audit's. `scoreSite({ site })` takes the object; where it
* came from is not this module's problem. A's `EDITOR.siteClone()` hands over
* the canonically-ordered clone that the export writes, so an editor score is a
* score of the bytes you would ship, not of an editor-private object.
*
* ── Why this builds its OWN world, and why that is not a private harness ────
*
* A's editor renders on `createStubWind({ calm: true })`, captured at
* `createWorld` time and deliberately calm: gate 1 is geometry, and a yard you
* can only place a post in during a gale is not authorable. That stub must
* never reach a score — and it would, silently, if this module read
* `EDITOR.world.anchors`, because a tree anchor's `sway` closure samples the
* wind its world was built with, forever. That is C's landmine 2 wearing a new
* hat: the frozen gum tree read q4 at 1.02 against a live 1.24, and a tree rig
* eats the tree's sway as dynamic load. So the scoring world is a SEPARATE
* world, built here from the site object on a re-pointable wind proxy, exactly
* as audit.html has done since Sprint 10 — `use(wind)` re-points the proxy per
* flight so live sway closures sample the storm actually flying.
*
* This is not a fourth wind harness. Every m/s still comes from `windForSite()`
* (C's shared builder) inside sweep.js and gardenfly.js; this module never
* builds a wind, it only owns the proxy the flights re-point. The stub cannot
* reach the score because the scoring world has never seen it.
*
* ── What it does NOT do ────────────────────────────────────────────────────
*
* No I/O. Storm defs arrive parsed, because a storm is content and the caller
* knows where content lives (the editor is on a page with an importmap; the
* node front-end has a filesystem). This module fetches nothing so it can be
* driven from a selftest without a network.
*
* Browser-only: `dress()` needs GLTFLoader and gardenfly needs `document`.
* audit.mjs stays node-side and blind, and still points here for garden truth.
*/
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.js';
import { AUDIT, auditSweep } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/** How many affordable lines get flown. Flights are seconds each; the card is
* on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
* anchors is a hang, not a score. */
export const FLY_CAP = 12;
/**
* Build a world for SCORING from a site object — dressed, on a re-pointable
* wind proxy. Never the editor's own world (see the header).
*
* The proxy starts on a calm placeholder purely so `createWorld` and `dress()`
* have something to call during construction; no score is ever taken against
* it, because every flight calls `use()` first. It is not the editor's stub and
* it is not reachable from one.
*
* @param {object} site parsed/cloned site object (loadSite's shape)
* @returns {Promise<{world, anchors, bed, use, dressed, dressError}>}
*/
export async function buildScoringWorld(site) {
const scene = new THREE.Scene();
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
/** C's bench pattern: re-point the yard's wind at the storm being flown, so
* live tree-sway closures move with it. Handed to every sweep/flight below. */
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
let dressed = false, dressError = null;
if (world.dress) {
try { await world.dress(); dressed = true; }
catch (err) { dressError = err?.message ?? String(err); }
}
return { world, anchors: world.anchors, bed: world.gardenBed, use, dressed, dressError };
}
/**
* The full gauntlet against one site object.
*
* @param {object} o
* @param {object} o.site site object (EDITOR.siteClone() / loadSite())
* @param {object} o.stormDef the storm to sweep and fly
* @param {string} [o.stormName] label only
* @param {object} [o.sepStormDef] storm for the site's pinned separation block;
* omit and separation is judged on stormDef if
* the keys match, else reported unjudged
* @param {number} [o.flyCap]
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
* @returns {Promise<object>} pure data — no DOM, no strings-as-verdicts
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
const built = prebuilt ?? await buildScoringWorld(site);
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
// storm def's `wind.venturi` LOOKS right and never fires; the fix was
// windForSite, and the discipline that came with it is that any front-end
// printing a score must also print whether the funnel was on. Three
// 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 });
// The bare bed — the control every garden number is read against.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
// they are the trap the margin rule exists to name, and a card that hid them
// would be the 91.9-FULL illusion with better CSS.
const flown = new Map();
for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) {
// clean winners fly the CLEAN tiers (what the audit recommends buying);
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
// Aligned by anchorId, never input order — attach reorders picks into ring
// order and a tier quoted against input order arms the wrong corner.
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier]));
flown.set(r.ids.join(','), flyGarden({
anchors, bed, stormDef, siteDef: site, use,
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
}));
}
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
// Best GARDEN line the budget buys — cheapest on ties, and never a marginal
// one. A marginal flight gets reported, not sold.
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
if (!best) return { key, g };
if (g.hp > best.g.hp + 0.05) return { key, g };
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
return best;
}, null);
const bestGarden = pickBest([...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean));
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
// The site's pinned separation target (A's gate-1.4 ruling), on the block's
// OWN storm — the target is site data, not a per-run choice.
let separation = null, sepStormName = null, sepUnjudged = null;
if (site.separation) {
sepStormName = site.separation.stormKey;
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
if (sepStorm) {
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
} else {
// Judging a pinned target on the wrong storm is worse than not judging it.
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;
}
}
return {
site: site.id ?? site.name ?? '(unnamed)',
storm: stormName, dressed, dressError,
venturi, funnelOn: venturi.length > 0,
anchorCount: anchors.length, bed,
cands: cands.length, rows, winners, marginalWinners, verdict,
flown, skipped, flyCap,
bare,
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation, sepStormName, sepUnjudged,
collateral,
MARGIN: AUDIT.MARGIN,
};
}
/**
* The cheapest line that is HONEST — clean at the shop's clean price, and
* proven to beat a bare bed in flight. Sprint 13's lesson priced into one
* helper: "cheapest that holds" sold a $20 rig the sim paid $97 for, so
* cheapness is only a virtue among lines that actually saved something.
*
* @returns {{row, garden, total}|null}
*/
export function cheapestHonest(score) {
const cands = score.winners
.map((r) => ({ row: r, garden: score.flown.get(r.ids.join(',')) ?? null }))
.filter((c) => c.garden && !c.garden.marginal.length && c.garden.hp > score.bare.hp);
if (!cands.length) return null;
cands.sort((a, b) => a.row.cleanHw - b.row.cleanHw);
const best = cands[0];
return { ...best, total: best.row.cleanHw + AUDIT.SPARE_COST };
}
/**
* Every corner, across every flown line, sitting inside the margin band —
* the 15% rule's flag list, deduped by anchor and worst-first.
*
* MANUAL.md records the rule as POLICY while the cause is unfound: a bench
* under-reads the real UI's peaks by ~515%, so a corner that holds on paper
* with 4% headroom is D's 39.8 TATTERED in play. Marginal is not PASS.
*/
export function marginFlags(score) {
const worst = new Map();
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(','),
// 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),
};
}