Lane B S14 gate 2.1: SCORE IT — shared scorecard engine + editor panel
scorecard.js: the audit run-logic that audit.html had grown moves into one shared engine both front-ends import (build the scoring world, sweep, fly the winners, pick the best line, judge the separation block). Takes a site OBJECT, not a path — loadSite always returned an object, so the fetch was never the audit's business. Builds its OWN dressed world on a re-pointable wind proxy so the editor's calm stub can never reach a score; every m/s still comes from windForSite() via sweep.js/gardenfly.js. editor_score.js: front-end only, zero audit logic. Mounts at A's reserved order 40, renders the card with A's contract classes, says the funnel state first and loudly, and marks itself stale when the yard changes underneath it.
This commit is contained in:
parent
ad8db1b709
commit
6825794d98
233
tools/site_audit/scorecard.js
Normal file
233
tools/site_audit/scorecard.js
Normal file
@ -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<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 { 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);
|
||||
}
|
||||
322
web/world/js/editor_score.js
Normal file
322
web/world/js/editor_score.js
Normal file
@ -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();
|
||||
Loading…
Reference in New Issue
Block a user