GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on wording and their every-card rule adopted). The audit always flew shade cloth and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob (sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the fabric and whether tonight's stones pass its weave (same hailBlockFor the sim charges), and the SCORE IT header prints 'fabric · shade cloth (sweep charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the fabric is the bet, and the audit does not place it. GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as posted), this lane's teeth. · rigging.js: validateNightConstraint (checked enum, both ends of the seam), setConstraints (survives reset like _startBudget; [] is the only clear). The house ban refuses the pick with the CLIENT'S WORDS as the ticker reason; the cap refuses at SPEND time on every path (rig / cycle / set / spares / fabric), exact at the boundary, refunds always free — and commit is belt-and-braces: a caller who walked around the doors gets a THROW. The cap covers the whole invoice, spares included — the client queries the invoice, not the hardware line. · sweep.js: applyNightConstraints, ONE function both drivers share — bans shrink the candidate list (a card that recommends a forbidden line is the card lying), the cap re-prices affordable/clean without touching a load. · scoreSite carries constraints/budget/constrainedOut; the card prints the client's terms next to funnel and fabric, and tells 'no geometry' from 'client forbids all of it'. · spent getter now measures against _startBudget (the re-banked session was measuring spend against the default bank). Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4 session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS: session.setConstraints(week.job?.constraints ?? []) at the night boundary.
370 lines
18 KiB
JavaScript
370 lines
18 KiB
JavaScript
/**
|
||
* 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 { FABRIC } from '../../web/world/js/rigging.js';
|
||
import { hailBlockFor } from '../../web/world/js/weather.core.js';
|
||
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
|
||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||
|
||
/**
|
||
* SPRINT17 gate 0.3 — the score's fabric charter, SAID OUT LOUD. [B, with D's
|
||
* soaker finding as the brief and A consulted on the card wording.]
|
||
*
|
||
* Every audit number is flown on knitted shade cloth (AUDIT.POROSITY — one
|
||
* knob, sweep.js and gardenfly.js both read it). Sound charter, but the card
|
||
* never NAMED it, and on the one night where fabric is the whole answer the
|
||
* silence lied by omission: the soaker's card said "✓ WINNABLE at $80" over a
|
||
* garden table where every cloth line reads DEAD, and a reader without the
|
||
* charter in their head concludes the night is broken. The fix is the same
|
||
* discipline as the funnel state: any front-end printing a score also prints
|
||
* what fabric it flew — and on a storm whose stones pass the weave (C's 2 mm
|
||
* ruling, the same hailBlockFor the sim charges), it says the F key is not in
|
||
* the card.
|
||
*
|
||
* Pure data from pure inputs, browser-free, so the selftest can pin it against
|
||
* real storm defs without a flight.
|
||
*
|
||
* @param {object|null} stormDef parsed storm def (hail.size in storm units)
|
||
* @returns {{ fabric: {id,name,porosity}, leaks: boolean }}
|
||
* `fabric` — the charter entry (matched out of rigging's FABRIC by the
|
||
* porosity actually flown, so a charter retune renames the card by itself);
|
||
* `leaks` — true when tonight's stones pass this weave, i.e. the un-swept
|
||
* membrane would stop stones the swept cloth lets through.
|
||
*/
|
||
export function fabricCharter(stormDef) {
|
||
const f = FABRIC.find((x) => x.porosity === AUDIT.POROSITY)
|
||
?? { id: 'custom', name: `porosity ${AUDIT.POROSITY}`, porosity: AUDIT.POROSITY };
|
||
const hailSize = stormDef?.hail?.size ?? 0;
|
||
return {
|
||
fabric: { id: f.id, name: f.name, porosity: f.porosity },
|
||
leaks: hailSize > 0 && hailBlockFor(hailSize, AUDIT.POROSITY) < 1,
|
||
};
|
||
}
|
||
|
||
/** How many affordable lines get flown. Flights are seconds each; the card is
|
||
* on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
|
||
* anchors is a hang, not a score. */
|
||
export const FLY_CAP = 12;
|
||
|
||
/**
|
||
* Build a world for SCORING from a site object — dressed, on a re-pointable
|
||
* wind proxy. Never the editor's own world (see the header).
|
||
*
|
||
* The proxy starts on a calm placeholder purely so `createWorld` and `dress()`
|
||
* have something to call during construction; no score is ever taken against
|
||
* it, because every flight calls `use()` first. It is not the editor's stub and
|
||
* it is not reachable from one.
|
||
*
|
||
* @param {object} site parsed/cloned site object (loadSite's shape)
|
||
* @returns {Promise<{world, anchors, bed, use, dressed, dressError}>}
|
||
*/
|
||
export async function buildScoringWorld(site) {
|
||
const scene = new THREE.Scene();
|
||
|
||
let currentWind = {
|
||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||
gustTelegraph: () => null, eventsBetween: () => [],
|
||
};
|
||
const windProxy = {
|
||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||
rainAt: (t) => currentWind.rainAt(t),
|
||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||
setSheltersFromTrees() {},
|
||
};
|
||
/** C's bench pattern: re-point the yard's wind at the storm being flown, so
|
||
* live tree-sway closures move with it. Handed to every sweep/flight below. */
|
||
const use = (w) => { currentWind = w; };
|
||
|
||
const world = createWorld(scene, { wind: windProxy, site });
|
||
|
||
let dressed = false, dressError = null;
|
||
if (world.dress) {
|
||
try { await world.dress(); dressed = true; }
|
||
catch (err) { dressError = err?.message ?? String(err); }
|
||
}
|
||
|
||
return { world, anchors: world.anchors, bed: world.gardenBed, use, dressed, dressError };
|
||
}
|
||
|
||
/**
|
||
* The full gauntlet against one site object.
|
||
*
|
||
* @param {object} o
|
||
* @param {object} o.site site object (EDITOR.siteClone() / loadSite())
|
||
* @param {object} o.stormDef the storm to sweep and fly
|
||
* @param {string} [o.stormName] label only
|
||
* @param {object} [o.sepStormDef] storm for the site's pinned separation block;
|
||
* omit and separation is judged on stormDef if
|
||
* the keys match, else reported unjudged
|
||
* @param {number} [o.flyCap]
|
||
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
|
||
* @param {function} [o.onProgress] called ({ phase, done, total, label }) as
|
||
* work completes — phases 'sweep' (per candidate flight),
|
||
* 'fly' (per garden flight, bare first), 'separation'.
|
||
* SPRINT15 gate 2.1: the 75–82 s blocking run made the
|
||
* editor batch; the caller renders this so the page tells
|
||
* the truth about progress instead of looking crashed.
|
||
* @param {number} [o.yieldEvery] work units per event-loop yield (default
|
||
* 1). MUST NOT change any number — the scorecard selftest
|
||
* perturbs it and demands identical results.
|
||
* @returns {Promise<object>} pure data — no DOM, no strings-as-verdicts
|
||
*/
|
||
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
|
||
onProgress = null, yieldEvery = 1, constraints = [] }) {
|
||
const built = prebuilt ?? await buildScoringWorld(site);
|
||
const { world, anchors, bed, use, dressed, dressError } = built;
|
||
|
||
// The funnel state is REPORTED, never assumed. Sprint 11 shipped an audit
|
||
// whose headline was wrong because the venturi lives in the SITE def and a
|
||
// storm def's `wind.venturi` LOOKS right and never fires; the fix was
|
||
// windForSite, and the discipline that came with it is that any front-end
|
||
// printing a score must also print whether the funnel was on. Three
|
||
// harnesses got this wrong; the card says it out loud so a fourth can't.
|
||
const venturi = site.wind?.venturi ?? [];
|
||
|
||
// What a lost corner BILLS, resolved through the world's own pricing path.
|
||
//
|
||
// The card's headline is "winnable at $80", and that sentence is incomplete
|
||
// if letting a corner go also costs $180 of carport. Priced here rather than
|
||
// in the front-end for the reason everything else is: `world.collateralFor()`
|
||
// is the one resolver, it reads site JSON first and the GLB extra second
|
||
// (SPRINT14 — A's structKey fix, after id-equality made the first
|
||
// editor-made carport free), and a second copy of that lookup in a panel is
|
||
// how a yard's trap silently prices to nothing.
|
||
//
|
||
// `null` from collateralFor means UNPRICED, never free — the house's fascia
|
||
// anchors say `collateral:"gutter"` and a gutter had no price for two
|
||
// sprints. The card must say "not scored" there, because "free" is the lie
|
||
// that makes a dangerous yard look safe.
|
||
const collateral = {};
|
||
for (const a of anchors) {
|
||
if (!a.collateral) continue;
|
||
const priced = world?.collateralFor?.(a.collateral) ?? null;
|
||
collateral[a.id] = priced
|
||
? { key: a.collateral, cost: priced.cost, label: priced.label }
|
||
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
|
||
}
|
||
|
||
// gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms —
|
||
// bans shrink the candidate list before a single flight, the cap decides
|
||
// affordability. A card that recommends a forbidden line is the card lying.
|
||
const { cands, rows, verdict, winners, marginalWinners, budget, constrainedOut } =
|
||
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery, constraints });
|
||
|
||
// Garden flights: the bare-bed control first, then every line the budget can
|
||
// buy. The flight list is known up front so the progress tick has an honest
|
||
// denominator (bare + capped lines + the separation pair if pinned).
|
||
const toFly = [...winners, ...marginalWinners].slice(0, flyCap);
|
||
const flyTotal = 1 + toFly.length;
|
||
let flyDone = 0;
|
||
const tickFly = async (label) => {
|
||
flyDone += 1;
|
||
onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label });
|
||
if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
|
||
};
|
||
|
||
// The bare bed — the control every garden number is read against.
|
||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||
await tickFly('bare bed');
|
||
|
||
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
|
||
// they are the trap the margin rule exists to name, and a card that hid them
|
||
// would be the 91.9-FULL illusion with better CSS.
|
||
const flown = new Map();
|
||
for (const r of toFly) {
|
||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||
// Aligned by anchorId, never input order — attach reorders picks into ring
|
||
// order and a tier quoted against input order arms the wrong corner.
|
||
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier]));
|
||
flown.set(r.ids.join(','), flyGarden({
|
||
anchors, bed, stormDef, siteDef: site, use,
|
||
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
|
||
}));
|
||
await tickFly(r.ids.join(','));
|
||
}
|
||
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
|
||
|
||
// Best GARDEN line the budget buys — cheapest on ties, and never a marginal
|
||
// one. A marginal flight gets reported, not sold.
|
||
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
|
||
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
|
||
if (!best) return { key, g };
|
||
if (g.hp > best.g.hp + 0.05) return { key, g };
|
||
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
|
||
return best;
|
||
}, null);
|
||
const bestGarden = pickBest([...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean));
|
||
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
|
||
|
||
// The site's pinned separation target (A's gate-1.4 ruling), on the block's
|
||
// OWN storm — the target is site data, not a per-run choice.
|
||
let separation = null, sepStormName = null, sepUnjudged = null;
|
||
if (site.separation) {
|
||
sepStormName = site.separation.stormKey;
|
||
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
|
||
if (sepStorm) {
|
||
// Two flights (held + bare) on the block's own storm — announced before
|
||
// they run, because they are the one chunk left with no tick inside it.
|
||
onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName });
|
||
await yieldToEventLoop();
|
||
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||
onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName });
|
||
} else {
|
||
// Judging a pinned target on the wrong storm is worse than not judging it.
|
||
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;
|
||
}
|
||
}
|
||
|
||
// The charter the whole card is denominated in (gate 0.3): reported next to
|
||
// the funnel state, computed from the storm actually scored — never assumed.
|
||
const charter = fabricCharter(stormDef);
|
||
|
||
return {
|
||
site: site.id ?? site.name ?? '(unnamed)',
|
||
storm: stormName, dressed, dressError,
|
||
venturi, funnelOn: venturi.length > 0,
|
||
fabric: charter.fabric, fabricLeaks: charter.leaks,
|
||
// gate 2: the terms this card was scored under, echoed so every front-end
|
||
// can print them — a constrained score that doesn't SAY so is the soaker
|
||
// silence again, one gate over. `budget` is what decided affordable/clean.
|
||
constraints, budget, constrainedOut,
|
||
anchorCount: anchors.length, bed,
|
||
cands: cands.length, rows, winners, marginalWinners, verdict,
|
||
flown, skipped, flyCap,
|
||
bare,
|
||
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
|
||
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
|
||
separation, sepStormName, sepUnjudged,
|
||
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
|
||
// one records why in its JSON; the card prints it so NONE PINNED reads as
|
||
// "measured and refused, here's the fight" rather than "nobody tried".
|
||
separationFinding: site._separation_finding ?? null,
|
||
collateral,
|
||
MARGIN: AUDIT.MARGIN,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The cheapest line that is HONEST — clean at the shop's clean price, and
|
||
* proven to beat a bare bed in flight. Sprint 13's lesson priced into one
|
||
* helper: "cheapest that holds" sold a $20 rig the sim paid −$97 for, so
|
||
* cheapness is only a virtue among lines that actually saved something.
|
||
*
|
||
* @returns {{row, garden, total}|null}
|
||
*/
|
||
export function cheapestHonest(score) {
|
||
const cands = score.winners
|
||
.map((r) => ({ row: r, garden: score.flown.get(r.ids.join(',')) ?? null }))
|
||
.filter((c) => c.garden && !c.garden.marginal.length && c.garden.hp > score.bare.hp);
|
||
if (!cands.length) return null;
|
||
cands.sort((a, b) => a.row.cleanHw - b.row.cleanHw);
|
||
const best = cands[0];
|
||
return { ...best, total: best.row.cleanHw + AUDIT.SPARE_COST };
|
||
}
|
||
|
||
/**
|
||
* Every corner, across every flown line, sitting inside the margin band —
|
||
* the 15% rule's flag list, deduped by anchor and worst-first.
|
||
*
|
||
* MANUAL.md records the rule as POLICY while the cause is unfound: a bench
|
||
* under-reads the real UI's peaks by ~5–15%, so a corner that holds on paper
|
||
* with 4% headroom is D's 39.8 TATTERED in play. Marginal is not PASS.
|
||
*/
|
||
export function marginFlags(score) {
|
||
const worst = new Map();
|
||
for (const r of score.rows) {
|
||
for (const c of r.marginal) {
|
||
const prev = worst.get(c.id);
|
||
if (!prev || c.headroom < prev.headroom) {
|
||
worst.set(c.id, { id: c.id, headroom: c.headroom, peak: c.peak, hint: c.hint, line: r.ids.join(','),
|
||
// what this corner bills if it lets go — the flag and the bill belong
|
||
// on the same line, because "knife-edge" and "knife-edge AND $180"
|
||
// are different decisions for the person authoring the yard
|
||
collateral: score.collateral[c.id] ?? null });
|
||
}
|
||
}
|
||
}
|
||
return [...worst.values()].sort((a, b) => a.headroom - b.headroom);
|
||
}
|
||
|
||
/**
|
||
* Every priced thing in the yard a lost corner could wreck, deduped by
|
||
* structure — main.js bills per STRUCTURE, not per corner ("two beams letting
|
||
* go is one carport gone, not $360"), so exposure has to dedupe the same way
|
||
* or the card invents money.
|
||
*
|
||
* @returns {{priced: Array, unpriced: Array, total: number}}
|
||
*/
|
||
export function collateralExposure(score) {
|
||
const byKey = new Map();
|
||
for (const [anchorId, c] of Object.entries(score.collateral)) {
|
||
const e = byKey.get(c.key) ?? { ...c, anchors: [] };
|
||
e.anchors.push(anchorId);
|
||
byKey.set(c.key, e);
|
||
}
|
||
const all = [...byKey.values()];
|
||
const priced = all.filter((e) => !e.unpriced);
|
||
return {
|
||
priced,
|
||
unpriced: all.filter((e) => e.unpriced),
|
||
total: priced.reduce((s, e) => s + e.cost, 0),
|
||
};
|
||
}
|