/** * 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, auditSweepAsync, yieldToEventLoop } 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 * @param {function} [o.onProgress] called ({ phase, done, total, label }) as * work completes — phases 'sweep' (per candidate flight), * 'fly' (per garden flight, bare first), 'separation'. * SPRINT15 gate 2.1: the 75–82 s blocking run made the * editor batch; the caller renders this so the page tells * the truth about progress instead of looking crashed. * @param {number} [o.yieldEvery] work units per event-loop yield (default * 1). MUST NOT change any number — the scorecard selftest * perturbs it and demands identical results. * @returns {Promise} pure data — no DOM, no strings-as-verdicts */ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null, onProgress = null, yieldEvery = 1 }) { 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 } = await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery }); // Garden flights: the bare-bed control first, then every line the budget can // buy. The flight list is known up front so the progress tick has an honest // denominator (bare + capped lines + the separation pair if pinned). const toFly = [...winners, ...marginalWinners].slice(0, flyCap); const flyTotal = 1 + toFly.length; let flyDone = 0; const tickFly = async (label) => { flyDone += 1; onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label }); if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop(); }; // The bare bed — the control every garden number is read against. const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use }); await tickFly('bare bed'); // 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 toFly) { // 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)), })); await tickFly(r.ids.join(',')); } 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) { // Two flights (held + bare) on the block's own storm — announced before // they run, because they are the one chunk left with no tick inside it. onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName }); await yieldToEventLoop(); separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use }); onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName }); } 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, // SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused // one records why in its JSON; the card prints it so NONE PINNED reads as // "measured and refused, here's the fight" rather than "nobody tried". separationFinding: site._separation_finding ?? null, 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 ~5–15%, 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), }; }