diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js new file mode 100644 index 0000000..f3fa50d --- /dev/null +++ b/tools/site_audit/scorecard.js @@ -0,0 +1,233 @@ +/** + * 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} 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 { 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 ?? []; + + 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, + 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(',') }); + } + } + return [...worst.values()].sort((a, b) => a.headroom - b.headroom); +} diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js new file mode 100644 index 0000000..267a121 --- /dev/null +++ b/web/world/js/editor_score.js @@ -0,0 +1,322 @@ +/** + * editor_score.js — the SCORE IT panel. [Lane B, SPRINT14 gate 2.1] + * + * One button in A's editor runs the whole audit gauntlet against the yard + * currently on the glass, in-page, on demand, and renders the answer as a card: + * winnable lines at $80 with the funnel state SAID OUT LOUD, the cheapest + * honest line, held-vs-bare garden separation, and the 15%-rule margin flags. + * + * ── This file's entire job is to be a front-end ───────────────────────────── + * + * It contains no audit logic and no wind. The gauntlet is `scorecard.js`, which + * is the same engine `audit.html` runs; the wind inside it is `windForSite()`, + * which is C's shared builder and the only door site wind comes through. If you + * find yourself about to compute a load, a coverage or a m/s in this file, that + * is the fourth harness arriving and Sprint 13 says it will be wrong in a new + * way. Put it in the engine where both front-ends get it. + * + * ── What it deliberately does NOT read ────────────────────────────────────── + * + * `EDITOR.world`. Not once. A's page renders on a calm stub wind (correctly — + * gate 1 is geometry), and a tree anchor's sway closure samples the wind its + * world was built with forever, so scoring off the editor's anchors would + * freeze every tree at calm and under-read every tree corner exactly the way + * C's landmine 2 did (q4: 1.02 frozen vs 1.24 live). The score builds its own + * dressed world from `EDITOR.siteClone()` — which is A's contract taken + * literally ("that is what B feeds the audit"), and has the bonus that the + * yard scored is the yard exported, byte for byte. + * + * ── Registration ──────────────────────────────────────────────────────────── + * + * Self-registering on import: no exports anyone must call, no edit to + * editor.html beyond the one import line that loads it (A's ask-1 seam, + * THREADS 2026-07-18). Mounts at reserved order 40 via `EDITOR.mountPanel`, + * fills `body`, and styles with A's contract classes only. + * + * Because it imports A's page, this module cannot run standalone on lane/b. + * That is by design and is flagged in THREADS rather than worked around. + */ + +import { loadStorm } from './weather.js'; +import { START_BUDGET } from './contracts.js'; +import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags } 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 + * one people remember. */ +const STORMS = [ + 'storm_02_wildnight', + 'storm_01_gentle', + 'storm_02b_icenight', + 'storm_03_southerly', + 'storm_03b_earlybuster', +]; + +const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +}; + +/** A `.ed-card-row`: dim key on the left, value on the right. */ +function kv(parent, key, value, cls) { + const row = el('div', 'ed-card-row'); + row.append(el('span', 'ed-kv', key)); + row.append(el('span', cls || null, value)); + parent.append(row); + return row; +} + +function card(parent, head, headCls) { + const c = el('div', 'ed-card'); + const h = el('div', `ed-card-head${headCls ? ' ' + headCls : ''}`, head); + c.append(h); + parent.append(c); + return c; +} + +const pct = (x) => `${(x * 100).toFixed(0)}%`; +const money = (n) => `$${n}`; + +function boot() { + const EDITOR = globalThis.EDITOR; + if (!EDITOR) { + console.error('[score] EDITOR missing — editor_score.js must be imported AFTER createEditor() resolves.'); + return; + } + + const { body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 }); + body.replaceChildren(); + + // --- controls ------------------------------------------------------------ + const rowStorm = el('div', 'ed-row'); + rowStorm.append(el('span', 'ed-label', 'storm')); + const sel = el('select', 'ed-sel'); + for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s)); + rowStorm.append(sel); + body.append(rowStorm); + + const rowBtn = el('div', 'ed-row'); + const btn = el('button', 'ed-btn primary', 'SCORE IT'); + rowBtn.append(btn); + body.append(rowBtn); + + const out = el('div'); + body.append(out); + + const note = el('div', 'ed-note', + 'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s ' + + 'view, whose wind is a calm stub. Every number flies windForSite() and the real ' + + 'commit→attach chain. Takes a few seconds; that is the point.'); + body.append(note); + + // A score describes the yard as it was WHEN SCORED. The moment anything + // moves, the card is a claim about a yard that no longer exists — so it says + // so rather than quietly ageing on screen. (Same instinct as A clearing the + // overlay on rebuild: one place remembers staleness, nobody else has to.) + let stale = false; + const markStale = () => { + if (!out.firstChild || stale) return; + stale = true; + const warn = el('div', 'ed-card-head ed-warn', '⚠ YARD CHANGED SINCE THIS SCORE — re-run'); + out.prepend(warn); + }; + EDITOR.on('change', markStale); + EDITOR.on('siteload', markStale); + + async function run() { + stale = false; + btn.disabled = true; + btn.textContent = 'scoring…'; + out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…')); + // yield so the button repaints before we block the thread for seconds + await new Promise((r) => setTimeout(r, 0)); + + const t0 = performance.now(); + try { + const site = EDITOR.siteClone(); + const stormName = sel.value; + const stormDef = await loadStorm(stormName); + + // The separation block names its OWN storm — judging A's pinned target on + // whatever storm the dropdown happens to show would be a different claim + // wearing the target's name. + const sepKey = site.separation?.stormKey ?? null; + const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null; + + const built = await buildScoringWorld(site); + const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built }); + render(out, score, performance.now() - t0); + globalThis.__editorScore = score; // for the selftest + console spelunking + } catch (err) { + out.replaceChildren(); + const c = card(out, 'SCORE FAILED', 'ed-err'); + c.append(el('div', 'ed-note', String(err?.stack ?? err))); + console.error('[score]', err); + } finally { + btn.disabled = false; + btn.textContent = 'SCORE IT'; + } + } + + btn.addEventListener('click', run); +} + +/** Render a scorecard result. Pure DOM from pure data — no scoring here. */ +function render(out, s, ms) { + out.replaceChildren(); + + // ── the header card: WHAT WAS SCORED, and the funnel state, loudly ──────── + // Sprint 11 shipped a headline built with the funnel off, and the number + // looked entirely reasonable. Three harnesses made that mistake because + // nothing printed the funnel state next to the score. This card does, first, + // before any result — a score whose weather you can't see is a rumour. + const head = card(out, s.funnelOn ? 'FUNNEL ON' : 'NO FUNNEL', s.funnelOn ? 'ed-warn' : null); + kv(head, 'yard', s.site); + kv(head, 'storm', s.storm ?? '—'); + kv(head, 'venturi', s.funnelOn + ? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ') + : 'none declared in site.wind'); + kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`, + s.dressed ? 'ed-ok' : 'ed-err'); + if (!s.dressed) { + head.append(el('div', 'ed-note ed-err', + `dress() failed (${s.dressError ?? 'unknown'}) — these are GRAYBOX positions and every ` + + 'ratingHint silently became 1.0. That is the missing-importmap gotcha; the numbers below ' + + 'are not what ships. Fix the page before believing this card.')); + } + kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`); + kv(head, 'took', `${(ms / 1000).toFixed(1)} s`); + + // ── 1. winnability at $80 ──────────────────────────────────────────────── + const V = s.verdict; + const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err'; + const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY', + unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code; + const cw = card(out, `${vhead} — at ${money(START_BUDGET)}`, vcls); + kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`); + kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err'); + kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null); + + if (V.code === 'no-cover') { + cw.append(el('div', 'ed-note ed-err', + 'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an ' + + 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — ' + + 'a yard failing here is failing on geometry, not on the heuristic.)')); + } else if (V.code === 'marginal-only') { + const b = V.best; + cw.append(el('div', 'ed-note ed-warn', + `Every affordable line carries a corner inside the ${pct(s.MARGIN)} break-in-game band. ` + + `Best is ${b.ids.join(',')} at ${money(b.hw)} — on this bench it holds; in the real game it is ` + + 'the wild night’s 39.8 TATTERED. Clean would cost ' + + `${b.cleanHw != null ? money(b.cleanHw) : 'steel over the shop ceiling'}.`)); + } else if (!V.ok) { + const b = V.best; + cw.append(el('div', 'ed-note ed-err', + `No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a ` + + `${money(START_BUDGET)} budget` + + (b.unholdable.length + ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop’s ceiling at any price.` + : '.'))); + } + + // ── 2. the cheapest HONEST line ────────────────────────────────────────── + // Not the cheapest that holds: Sprint 13 proved that quantity sells rigs the + // sim then charges you for. Cheapest among lines that are clean AND beat a + // bare bed in flight. + const ch = cheapestHonest(s); + const cc = card(out, 'CHEAPEST HONEST LINE', ch ? 'ed-ok' : 'ed-err'); + if (ch) { + kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' 📌 pinned' : '')); + kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? ` (holds at ${money(ch.row.hw)})` : '')); + kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn'); + kv(cc, 'area', `${ch.row.area.toFixed(0)} m²`); + kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`, + ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err'); + kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`); + if (ch.total > START_BUDGET) { + cc.append(el('div', 'ed-note ed-warn', + `Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare — ` + + 'a night with no spare is a repair the player cannot make.')); + } + } else { + cc.append(el('div', 'ed-note ed-err', + 'No line is BOTH clean and better than leaving the bed bare. Either nothing clean is ' + + 'buyable, or the clean lines that are buyable do not actually save the garden — which is ' + + 'the wild night’s intended reading, and a bug on any other night.')); + } + + // ── 3. the garden: held vs bare (gardenfly) ────────────────────────────── + const cg = card(out, 'GARDEN — HELD vs BARE (flown)'); + kv(cg, 'bare bed', `${s.bare.hp} ${s.bare.state.toUpperCase()}`, + s.bare.state === 'dead' ? 'ed-err' : s.bare.state === 'tattered' ? 'ed-warn' : 'ed-ok'); + if (s.bestGarden) { + kv(cg, 'best clean', `${s.bestGarden.ids} → ${s.bestGarden.hp} ${s.bestGarden.state.toUpperCase()}`, + s.bestGarden.state === 'full' ? 'ed-ok' : 'ed-warn'); + kv(cg, 'separation', `${s.bestGarden.hp - s.bare.hp >= 0 ? '+' : ''}${(s.bestGarden.hp - s.bare.hp).toFixed(1)} HP`, + s.bestGarden.hp - s.bare.hp > 0 ? 'ed-ok' : 'ed-err'); + kv(cg, 'by hail / rain', `${s.bestGarden.byHail} / ${s.bestGarden.byRain}`); + } else { + kv(cg, 'best clean', 'none buyable', 'ed-err'); + } + if (s.bestMarginal && (!s.bestGarden || s.bestMarginal.hp > s.bestGarden.hp)) { + cg.append(el('div', 'ed-note ed-warn', + `⚠ The best-reading line ${s.bestMarginal.ids} is MARGINAL: ` + + `${s.bestMarginal.hp} ${s.bestMarginal.state.toUpperCase()} on paper, inside the ` + + `${pct(s.MARGIN)} band. Treat it as the trap, not the answer — it is the shape of result ` + + 'that shipped a 91.9 FULL the player experienced as 39.8 TATTERED.')); + } + if (s.skipped) { + cg.append(el('div', 'ed-note', `${s.skipped} affordable line(s) beyond the ${s.flyCap}-flight cap were not flown.`)); + } + + // ── 4. the pinned separation target ────────────────────────────────────── + const sep = s.separation; + if (sep) { + const cs = card(out, `SEPARATION TARGET — ${sep.ok ? 'MEETS' : 'FAILS'}`, sep.ok ? 'ed-ok' : 'ed-err'); + kv(cs, 'storm', s.sepStormName); + kv(cs, 'held', `${sep.held.hp} ${sep.held.state.toUpperCase()} (${money(sep.held.cost)}, ${sep.held.cornersLost}/4 lost)`, + sep.heldOk ? 'ed-ok' : 'ed-err'); + kv(cs, 'bare', String(sep.bare.hp), sep.bareOk ? 'ed-ok' : 'ed-err'); + kv(cs, 'held wins?', sep.heldWin ? 'yes' : 'no', sep.heldWin ? 'ed-ok' : 'ed-err'); + if (sep.ok && !sep.heldClean) { + cs.append(el('div', 'ed-note ed-warn', + '⚠ Meets the target, but the pinned line is KNIFE-EDGED: ' + + sep.held.marginal.map((id) => { + const p = sep.held.peaks.find((x) => x.id === id); + return `${id} ${pct(p.headroom)} headroom (${p.peakN}/${p.effN} N)`; + }).join(', ') + + `, inside the ${pct(s.MARGIN)} band. The pin is A’s ruling and this card does not ` + + 'overrule it — but nobody has PLAYED this line.')); + } + } else if (s.sepUnjudged) { + const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn'); + cs.append(el('div', 'ed-note', s.sepUnjudged)); + } else { + const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn'); + cs.append(el('div', 'ed-note', + 'This yard declares no `separation` block, so there is no held-vs-bare target to judge it ' + + 'against. A shipping site should pin one: it is the difference between "a sail helps here" ' + + 'and "a sail is the point of this night".')); + } + + // ── 5. margin flags — the 15% rule ─────────────────────────────────────── + const flags = marginFlags(s); + const cm = card(out, `MARGIN FLAGS — the ${pct(s.MARGIN)} rule`, flags.length ? 'ed-warn' : 'ed-ok'); + if (!flags.length) { + kv(cm, 'knife-edge corners', 'none', 'ed-ok'); + } else { + for (const f of flags) { + 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'); + row.title = `worst case across every swept line; first seen on ${f.line}`; + } + } + cm.append(el('div', 'ed-note', + `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.')); +} + +boot();