Compare commits
2 Commits
ad8db1b709
...
af0d64ffe3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0d64ffe3 | ||
|
|
6825794d98 |
@ -25,6 +25,19 @@
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||
|
||||
Served from the repo root (server.py), so the relative imports resolve.
|
||||
|
||||
SPRINT14: this page's RUN LOGIC moved to scorecard.js and it kept only its
|
||||
rendering. A third front-end arrived (A's editor, gate 2.1, where the yard
|
||||
being scored has never been a file), and the sweep-fly-pick-judge sequence
|
||||
this page had grown was about to exist in two places — which is the exact
|
||||
failure sweep.js was extracted to prevent, one level up. `scoreSite()` takes
|
||||
a site OBJECT; fetching one is this page's business and nobody else's.
|
||||
|
||||
Verified by measurement, not by reading: the refactor was pinned against
|
||||
both shipped sites BEFORE it happened and reproduced them to the digit —
|
||||
backyard_01/wildnight (16 cands, 3 clean, 2 marginal, bare 35.7 tattered,
|
||||
best p1,p2,p3,p4 60.5, sep MEETS held 63.8) and site_02/earlybuster (66
|
||||
cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3).
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>site_audit</title>
|
||||
@ -51,11 +64,10 @@
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { loadSite } from '../../web/world/js/world.js';
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||||
import { AUDIT } from './sweep.js';
|
||||
import { scoreSite, FLY_CAP } from './scorecard.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const siteName = q.get('site') || 'backyard_01';
|
||||
@ -65,41 +77,30 @@ const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
async function run() {
|
||||
// Build the site the way the game does — data in, dressed yard out — on a
|
||||
// RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly
|
||||
// world.anchors THEMSELVES. The old version here remapped every anchor to a
|
||||
// static `sway: () => pos`, which froze the gum tree — the same landmine
|
||||
// that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig
|
||||
// eats the tree's sway as dynamic load). Backyard posts never noticed;
|
||||
// every tr1/t1/t2 line was optimistic.
|
||||
// Fetching the site is this page's job; scoring it is scorecard.js's. The
|
||||
// whole "build a dressed world on a re-pointable wind proxy so live tree-sway
|
||||
// closures sample the storm being flown" dance lives in buildScoringWorld()
|
||||
// now — including the reason it must (a static `sway: () => pos` remap froze
|
||||
// the gum tree and under-read every tree corner: C's landmine 2).
|
||||
const site = await loadSite(siteName);
|
||||
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() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
|
||||
|
||||
// world.anchors, LIVE — sway closures and ratingHint intact. The sweep and
|
||||
// every garden flight below re-point the proxy at their own wind via `use`.
|
||||
const anchors = world.anchors;
|
||||
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
|
||||
const vlist = site.wind?.venturi ?? [];
|
||||
// The separation block names its own storm; load it if it differs.
|
||||
const sepKey = site.separation?.stormKey ?? null;
|
||||
const sepStormDef = sepKey
|
||||
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
|
||||
: null;
|
||||
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
|
||||
const {
|
||||
dressed, cands, rows, verdict, winners, marginalWinners,
|
||||
flown, skipped, bare, separation: sep, sepStormName,
|
||||
} = score;
|
||||
const anchors = { length: score.anchorCount };
|
||||
const bestGarden = score.bestGarden ? { key: score.bestGarden.ids, g: score.bestGarden } : null;
|
||||
const bestMarginal = score.bestMarginal ? { key: score.bestMarginal.ids, g: score.bestMarginal } : null;
|
||||
|
||||
const vlist = score.venturi;
|
||||
const vtxt = vlist.length
|
||||
? vlist.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
|
||||
: 'none';
|
||||
@ -110,55 +111,9 @@ async function run() {
|
||||
`venturi: ${vtxt}\n` +
|
||||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||||
|
||||
// The venturi rides in on the SITE def — windForSite (C's shared builder,
|
||||
// inside sweep.js and gardenfly.js) is the ONE door site wind comes through
|
||||
// now. Three harnesses independently mis-built it; there is no fourth copy.
|
||||
const bed = world.gardenBed;
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
// ── SPRINT13: fly the garden for every line the budget can actually buy ──
|
||||
// The scoring quantity is the FLOWN outcome (gardenfly.js — real attach,
|
||||
// real skyfx exposure, real garden.js), not static cover%. Marginal lines
|
||||
// fly too, deliberately: they are the trap the margin rule exists to name.
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
const FLY_CAP = 12;
|
||||
const flown = new Map();
|
||||
for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) {
|
||||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||||
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId
|
||||
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 - FLY_CAP);
|
||||
|
||||
// best GARDEN line the budget buys — the audit's answer to "what should I
|
||||
// rig", which used to be "the cheapest line that holds" and is now "the line
|
||||
// that saves the garden" — cheapest on ties, and never a marginal one (a
|
||||
// marginal flight is C's 91.9-FULL illusion; it gets reported, not sold).
|
||||
// `rowBy` is the only helper the RENDER still needs (the picking it used to
|
||||
// serve moved into scoreSite).
|
||||
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 cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean);
|
||||
const bestGarden = pickBest(cleanFlown);
|
||||
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
|
||||
|
||||
// ── the site's pinned separation target (A's gate-1.4 ruling), judged on the
|
||||
// block's OWN storm — the target is site data, not a per-run choice.
|
||||
let sep = null, sepStormName = null;
|
||||
if (site.separation) {
|
||||
sepStormName = site.separation.stormKey;
|
||||
const sepStorm = sepStormName === stormName ? stormDef
|
||||
: await loadJSON(`../../web/world/data/storms/${sepStormName}.json`);
|
||||
sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||||
}
|
||||
|
||||
// render rows — cover% is a DIAGNOSTIC column now (static overhead projection
|
||||
// of the taut attach shape); the flown garden column is what scores.
|
||||
@ -261,7 +216,7 @@ async function run() {
|
||||
// a machine-readable line, so this page can also be driven headless-in-browser
|
||||
window.__audit = {
|
||||
site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length,
|
||||
cands: cands.length, verdict,
|
||||
cands, verdict, // scoreSite already returns the COUNT, not the list
|
||||
winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })),
|
||||
marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw,
|
||||
marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })),
|
||||
|
||||
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