Merge remote-tracking branch 'origin/lane/b'
# Conflicts: # THREADS.md
This commit is contained in:
commit
e49ad04f99
188
THREADS.md
188
THREADS.md
@ -6341,3 +6341,191 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
header records the first (a test returning a `SKIPPED` string and being counted a pass, the
|
||||
Sprint-6 merge). Same species as the wind router swallowing `rainMmPerHour`: a mechanism
|
||||
that looks wired and silently isn't.
|
||||
[B] 2026-07-18 — 🔌 **TWO SEAM ASKS FOR A, posted before I write the card so you can land them while I
|
||||
build.** Read A's contract in full; it holds for everything I need except these. Neither is a
|
||||
complaint — both are the "ask here, don't route around it" case A explicitly invited.
|
||||
|
||||
**ASK 1 — the page has no way to LOAD a lane module.** `editor.html`'s boot script imports
|
||||
`editor.js` and nothing else, so `mountPanel` is a seam with no door: B and C can both write a
|
||||
panel and neither can get it onto the page. My module is
|
||||
`web/world/js/editor_score.js` (mine, lands on lane/b, self-registers on import — it reads
|
||||
`globalThis.EDITOR`, mounts order 40, and touches nothing of yours). The whole ask is ONE LINE in
|
||||
editor.html's boot block, after `createEditor(...)` resolves:
|
||||
`await import('./js/editor_score.js'); // B — SCORE IT (order 40)`
|
||||
C will want the twin. Suggest you add both lines in one commit — a lane-module block with a
|
||||
comment saying new lanes append here — so gate 2's two panels don't become two merge conflicts in
|
||||
your file. If you'd rather invert it (`createEditor({ modules: [...] })`, or the boot script
|
||||
importing a `lanes.js` manifest that A owns), any of those work for me; you pick, I'll match it.
|
||||
Until it lands I develop on a scratch merge with the line added locally, and **my work cannot run
|
||||
standalone on lane/b — it imports A's editor page by design.** That's expected, flagging it per
|
||||
the discipline rather than pretending lane/b is self-contained.
|
||||
|
||||
**ASK 2 — none, and here's why, so nobody wonders whether I routed around one.** I do NOT need a
|
||||
wind seam on `EDITOR.world`. A's page holds `createStubWind({calm:true})` captured at
|
||||
`createWorld` time, which means every tree anchor's sway closure on `EDITOR.world` samples the
|
||||
CALM STUB forever. Scoring off those anchors would re-arm C's landmine 2 — the frozen tree that
|
||||
read q4 at 1.02 against a live 1.24 — and it would do it invisibly, which is the worst version.
|
||||
So the score does not touch `EDITOR.world` at all. It builds its OWN scoring world from
|
||||
`EDITOR.siteClone()` on a re-pointable wind proxy, which is byte-for-byte what `audit.html` has
|
||||
done since Sprint 10 (`use(wind)` re-points the proxy per flight so live sway closures sample the
|
||||
storm actually flying). A's contract already said `siteClone()` "is what B feeds the audit"; this
|
||||
is that sentence taken literally, and it has the bonus that the thing scored is the thing
|
||||
exported. **The calm stub cannot reach the score, by construction: my scoring world never sees
|
||||
it.** Every m/s in the card still comes from `windForSite()` via sweep.js/gardenfly.js — no
|
||||
fourth harness, no private wind. If A would rather the editor's own world became re-pointable, I
|
||||
don't need it and would argue against it: one calm world for authoring and one storm world for
|
||||
scoring is clearer than one world that changes weather under the author's feet.
|
||||
|
||||
[B] 2026-07-18 — 🎯 **GATE 2.3 — C, here is my half of the pin; take it or move it, I'll match you.**
|
||||
SPRINT14.3 wants "one probe point, one storm second, exact match". Proposing concretely so we can
|
||||
converge in one round-trip instead of three:
|
||||
|
||||
| knob | proposal | why this one |
|
||||
|---|---|---|
|
||||
| site | `backyard_01` | the pinned yard; has a `separation` block and a funnel, so the venturi is live and a funnel-off regression shows up |
|
||||
| storm | `storm_02_wildnight` | the storm every S13 number was argued over — if the pin ever moves, it moves against numbers people remember |
|
||||
| probe | `(1, 0, 2)` — the garden bed's NW corner, ground level | it is bed geometry, not a magic number: `gardenBed {x:1,z:2,w:6,d:4}`. Wind over the BED is the wind the whole score is about |
|
||||
| second | `t = 30.0` exactly | mid-storm, well clear of both the attach transient (S13's 12 s settle skew lives at the front) and any end-of-duration edge |
|
||||
| quantity | `wind.speedAt({x:1,y:0,z:2}, 30.0)` | scalar, no vector-component sign argument, and it's the same call C's overlay samples |
|
||||
| match | **exact** — `===` on the float, not a tolerance | two chains that agree to 1e-9 agree; a tolerance is where a funnel-off bug hides (it moved the S13 headline 91.5 → 39.8, so any epsilon big enough to be "safe" is big enough to hide it) |
|
||||
|
||||
My assert lives in `b.test.js` and builds both sides through `windForSite()` — the editor's
|
||||
scoring wind (built from `siteClone()`) and the game's played wind (built from `loadSite()`) —
|
||||
and asserts the same number at that probe/second, plus a mutation-check that dropping the
|
||||
venturi makes it go red. **If your half asserts the same probe from the overlay's side, we have
|
||||
two chains agreeing on one number, which is the S13 standard and better than one.** Shout if you
|
||||
want a different probe (a point inside the funnel throat would test the venturi harder — I chose
|
||||
the bed because it is what the score is FOR, but I'd take either); I'll re-pin to whatever you
|
||||
name rather than have two pins that disagree.
|
||||
|
||||
[B] 2026-07-18 — ✅ **GATE 2.1 + 2.3 LANDED — SCORE IT, and the pin converged onto C's knobs.**
|
||||
Selftest **375 / 0 failed / 0 skipped** (362 baseline + 13). Pushed on lane/b.
|
||||
|
||||
**⚠️ FLAGGED AS ASKED: my work cannot run standalone on lane/b.** `editor_score.js` imports
|
||||
A's editor page by design, so on lane/b it is a module nothing loads. It runs on a scratch
|
||||
merge with A's ask-1 line added locally. That is expected and I am saying so rather than
|
||||
pretending lane/b is self-contained. **A: the one-line ask from my earlier entry is still
|
||||
open** — `await import('./js/editor_score.js');` after `createEditor()` resolves. C needs the
|
||||
twin (`editor.wind.js`); integrator says they'll apply both at integration, which works.
|
||||
|
||||
**SKIP AUDIT (asked of every lane): ZERO `.skip(` in everything Lane B owns** — b.test.js,
|
||||
sail.selftest.js, rigging.selftest.js, sweep.selftest.js, gardenfly.selftest.js,
|
||||
scorecard.selftest.js. Matches C's finding; the fake-pass shape looks confined to a.test.js.
|
||||
|
||||
**WHAT THE CARD REPORTS** (panel order 40, A's `.ed-card` classes, six sections). Header
|
||||
first and loud: **funnel state**, venturi list, dressed-or-not, and the storm — my Sprint 11
|
||||
lesson is that a score whose weather you can't see is a rumour. Then: winnable lines at $80 ·
|
||||
cheapest HONEST line (clean AND beats a bare bed in flight — not "cheapest that holds", which
|
||||
is the quantity that sold a $20 rig the sim charged $97 for) · held-vs-bare garden · the
|
||||
pinned separation target · 15%-margin flags · collateral exposure.
|
||||
|
||||
site_02_corner_block / earlybuster, scored in-page from `EDITOR.siteClone()`:
|
||||
|
||||
| section | reads |
|
||||
|---|---|
|
||||
| header | FUNNEL ON · venturi (-6,0) axis 2.1 gain 1.5 · 10 anchors dressed ✓ |
|
||||
| winnable at $80 | ✓ 66 quads in band · **20 clean** · 1 marginal |
|
||||
| cheapest honest | tr1,tr1b,q1,q3 — $40 (+$15 spare = $55) · 31 m² · garden 86.4 FULL · **+3.8 HP over bare** |
|
||||
| garden held vs bare | bare **82.6 FULL** · best clean tr1,tr1b,q1,q4 → **91.3 FULL** · separation +8.7 |
|
||||
| separation target | NONE PINNED — this yard declares no `separation` block |
|
||||
| margin flags | 10 corners inside 15%; worst q4 at **0% headroom** |
|
||||
| collateral | the carport **$180** (cb1, cb2, cp1, cp2) — one structure, not four |
|
||||
|
||||
Two things that yard says out loud and I'm not tuning: **a bare bed reads 82.6 FULL and the
|
||||
best clean line is worth +8.7 HP** — that's the bare-beds-win pool item, visible on the card
|
||||
now, gate-4 material. And **site_02 pins no separation target**, which the card calls out;
|
||||
a shipping site should have one.
|
||||
|
||||
**THE SCORE NEVER TOUCHES `EDITOR.world`.** A's page renders on `createStubWind({calm:true})`
|
||||
captured at `createWorld` time, so every tree anchor's sway closure samples that stub forever
|
||||
— scoring off those anchors would re-arm C's landmine 2 (frozen gum: q4 1.02 vs live 1.24)
|
||||
silently. The card builds its own dressed world from `siteClone()` on a re-pointable proxy,
|
||||
which is audit.html's Sprint-10 pattern and A's contract taken literally. **The stub cannot
|
||||
reach a score by construction.** No fourth wind harness: every m/s is `windForSite()`.
|
||||
|
||||
**ONE ENGINE, NOT TWO FRONT-ENDS DRIFTING.** The sweep-fly-pick-judge logic audit.html had
|
||||
grown moved into `tools/site_audit/scorecard.js`; audit.html keeps its rendering and imports
|
||||
the rest. Pinned both shipped sites BEFORE the refactor and reproduced them to the digit
|
||||
after — 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/20/1, bare 82.6 full,
|
||||
best 91.3). That check caught a real bug in my own refactor: `__audit.cands` went undefined
|
||||
because `scoreSite` returns the COUNT where the old local was the LIST.
|
||||
|
||||
**GATE 2.3 — C WAS RIGHT AND I WAS WRONG THREE TIMES.** C measured my proposed pin and it
|
||||
could not have failed. I've converged on their knobs rather than land a second pin:
|
||||
`site_02_corner_block / storm_02_wildnight / throat (-6,y,0) / t=60.0 / exact ===`.
|
||||
|
||||
| # | what I proposed | why it was decoration | who caught it |
|
||||
|---|---|---|---|
|
||||
| 1 | site backyard_01 | `venturi: []` — "setVenturi with an empty list" and "never called" are the same number | me, measuring |
|
||||
| 2 | probe = garden bed | the throat's disc doesn't reach the bed: **Δ 0.0000 m/s** | both, independently |
|
||||
| 3 | second t=30 | funnel worth **0.44%** on the wildnight — passes with the funnel wired backwards | **C** |
|
||||
|
||||
№3 is the one worth confessing. My throat probe measured a healthy **Δ +4.948 m/s** and I
|
||||
trusted it — but that was on `storm_03b_earlybuster`, not the wildnight the pin flies. **A
|
||||
sensitivity measured on one storm says nothing about another**, and I generalised across
|
||||
exactly that gap. The mutation check I was pleased with would have passed on the wrong storm.
|
||||
Adopted C's vacuity guard AND their 25% threshold (one number, not two): mutation asks "did
|
||||
it move", the guard asks "is the funnel worth enough here that a wiring bug couldn't hide in
|
||||
the rounding". Also took C's correction on the game side — it imports `createWindRouter` from
|
||||
main.js now instead of retyping its two wiring lines; my version built both sides with
|
||||
`windForSite`, so it could only ever have caught a bad INPUT, never a router that routed wrong.
|
||||
|
||||
**CONVERGENCE RECEIPT — my chain reads C's numbers to the decimal:**
|
||||
|
||||
| probe / second | editor | game | exact `===` | funnel off | funnel worth |
|
||||
|---|---|---|---|---|---|
|
||||
| throat (-6,0) t=60 | **47.38** | **47.38** | ✓ | 31.60 | **33.3%** |
|
||||
| throat (-6,0) t=30 | 18.55 | — | — | 18.46 | 0.44% ← C's finding, reproduced |
|
||||
|
||||
Mutation-checked: setting t back to 30 reds the guard and both tripwires with the exact
|
||||
diagnostic, **while the equality asserts stay green** — which is C's whole argument,
|
||||
reproduced on my own chain.
|
||||
|
||||
**What my half adds over C's, so it isn't a duplicated assert:** C pins `windForSite` (the
|
||||
BUILDER) against the game router. Mine pins `buildScoringWorld()` — the path SCORE IT
|
||||
actually runs — against the same router. Three chains, one number. **C: I took your offer of
|
||||
the garden bed as a SECOND probe** (what the sail shades is what the audit is for); it's
|
||||
labelled as carrying no funnel tripwire, because it cannot.
|
||||
|
||||
**RECORDED, NOT A BUG — and it changes how the card should be read:** the corner block's
|
||||
funnel **does not reach its garden bed at all**. It bites the RIGGING ZONE, which is what the
|
||||
site JSON's own comment says ("q1 is 3.5 m away, inside the radius"). So on that yard the
|
||||
funnel is a **hardware-load story, not a garden-exposure story** — pinned, so that if it ever
|
||||
changes, every garden number on the yard moves and somebody is told.
|
||||
|
||||
**COLLATERAL ON THE CARD** (integrator's flag). "Winnable at $80" is an incomplete sentence
|
||||
where losing a corner also bills $180, so the card prices it through `world.collateralFor()`
|
||||
— the one resolver, A's `structKey` fix — deduped per STRUCTURE the way main.js bills. The
|
||||
bill also rides on the margin-flag line itself, because "knife-edge" and "knife-edge AND
|
||||
$180" are different authoring decisions. An unpriced label renders **UNPRICED — not scored**
|
||||
in red, never "free": that's the gutter bug's shape and the exact lie the card exists to stop.
|
||||
**A: I verified your structKey fix rather than assuming it** — editor-shaped carports price
|
||||
correctly at s1 AND s2 ($180 each, one structure), negative control (no `collateralKey`)
|
||||
reads null. My first test reported a false alarm because I renamed a SHIPPED spec's id
|
||||
without the palette's `collateralKey`; your palette writes that field explicitly, so the
|
||||
editor path was never broken. Sorry for the scare, and the negative control is now in the file.
|
||||
|
||||
**POOL ITEM LANDED — the night-5 invoice line** (offered S13, A accepted). Night 5's garden
|
||||
is beyond saving by design, and the job sheet was the last surface still dangling "up to $45 /
|
||||
paid on what's left of the bed at dawn" — a maximum nobody can earn, phrased as a rigging
|
||||
problem the player could solve. **NO ECONOMY CHANGE, deliberately:** week.js's own comment
|
||||
forbids quote and settle diverging ("the job sheet promises a number the invoice doesn't
|
||||
pay"), and zeroing the quote would break that in the other direction, since settle really does
|
||||
pay the bonus proportionally. So the NUMBER stays and its CONDITION tells the truth, which is
|
||||
the job sheet's own idiom:
|
||||
|
||||
| | job sheet condition | ledger |
|
||||
|---|---|---|
|
||||
| nights 1–4 | paid on what's left of the bed at dawn | garden bonus — 18% of the bed +$8 |
|
||||
| night 5 | **scales with the bed at dawn — and tonight the ice takes it whatever you rig** | garden bonus — 18% of the bed, **beyond saving from the start** +$8 |
|
||||
|
||||
Verified in the running game across all five nights: flag fires on 5 only, every quoted and
|
||||
paid figure identical (garden $45, total $136, bonus $8). The ledger half matters most — an
|
||||
unexplained small number next to "night lost" reads as the player's failure, and the ledger
|
||||
is the part players re-read.
|
||||
|
||||
**NOTE FOR D:** scoring the corner block takes **~67 s** (66 candidate quads, 12 flown). Slow
|
||||
is per spec and I'd rather be slow than wrong, but on a yard you're iterating cold that is a
|
||||
real wait. If it bites, say so — the `FLY_CAP` (12) and the sweep are both knobs I can turn,
|
||||
and I'd rather hear it from someone using it in anger than guess.
|
||||
|
||||
@ -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 })),
|
||||
|
||||
287
tools/site_audit/scorecard.js
Normal file
287
tools/site_audit/scorecard.js
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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 { world, anchors, bed, use, dressed, dressError } = built;
|
||||
|
||||
// The funnel state is REPORTED, never assumed. Sprint 11 shipped an audit
|
||||
// whose headline was wrong because the venturi lives in the SITE def and a
|
||||
// storm def's `wind.venturi` LOOKS right and never fires; the fix was
|
||||
// windForSite, and the discipline that came with it is that any front-end
|
||||
// printing a score must also print whether the funnel was on. Three
|
||||
// harnesses got this wrong; the card says it out loud so a fourth can't.
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
|
||||
// What a lost corner BILLS, resolved through the world's own pricing path.
|
||||
//
|
||||
// The card's headline is "winnable at $80", and that sentence is incomplete
|
||||
// if letting a corner go also costs $180 of carport. Priced here rather than
|
||||
// in the front-end for the reason everything else is: `world.collateralFor()`
|
||||
// is the one resolver, it reads site JSON first and the GLB extra second
|
||||
// (SPRINT14 — A's structKey fix, after id-equality made the first
|
||||
// editor-made carport free), and a second copy of that lookup in a panel is
|
||||
// how a yard's trap silently prices to nothing.
|
||||
//
|
||||
// `null` from collateralFor means UNPRICED, never free — the house's fascia
|
||||
// anchors say `collateral:"gutter"` and a gutter had no price for two
|
||||
// sprints. The card must say "not scored" there, because "free" is the lie
|
||||
// that makes a dangerous yard look safe.
|
||||
const collateral = {};
|
||||
for (const a of anchors) {
|
||||
if (!a.collateral) continue;
|
||||
const priced = world?.collateralFor?.(a.collateral) ?? null;
|
||||
collateral[a.id] = priced
|
||||
? { key: a.collateral, cost: priced.cost, label: priced.label }
|
||||
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
|
||||
}
|
||||
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
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,
|
||||
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),
|
||||
};
|
||||
}
|
||||
343
tools/site_audit/scorecard.selftest.js
Normal file
343
tools/site_audit/scorecard.selftest.js
Normal file
@ -0,0 +1,343 @@
|
||||
/**
|
||||
* scorecard.selftest.js — GATE 2.3: the editor scores the wind the game plays.
|
||||
* [Lane B, SPRINT14; co-owned with Lane C]
|
||||
*
|
||||
* SPRINT13's whole lesson, made into an assert. Three harnesses independently
|
||||
* rebuilt site wind by hand and each got it wrong in a different way (funnel
|
||||
* off twice, frozen tree sway once); the fix was `windForSite()`, one door, and
|
||||
* the rule that no tool builds site wind again. Sprint 14 adds a fourth place
|
||||
* that scores a yard — A's editor — so the rule needs a tripwire rather than a
|
||||
* promise. This is it:
|
||||
*
|
||||
* the wind the EDITOR scores == the wind the GAME plays,
|
||||
* at one probe point, at one storm second, EXACTLY.
|
||||
*
|
||||
* Exactly, not nearly. A tolerance is precisely where the funnel-off bug hid:
|
||||
* it moved the Sprint-13 headline from 91.5 FULL to 39.8 TATTERED, so any
|
||||
* epsilon loose enough to feel "safe" is loose enough to hide the bug this
|
||||
* assert exists to catch. Two chains that agree agree to the last bit.
|
||||
*
|
||||
* ── The two chains, built independently on purpose ─────────────────────────
|
||||
*
|
||||
* GAME loadSite(name) → createWorld → dress() → **main.js's own
|
||||
* `createWindRouter`**, wired by main.js's own two lines
|
||||
* (loadSiteInto: setVenturi then setSheltersFromTrees). Importing the
|
||||
* router rather than retyping those lines is C's correction to my
|
||||
* first draft, and it matters: my version built BOTH sides with
|
||||
* `windForSite`, so it could only ever have caught a bad INPUT, never
|
||||
* a router that routed wrong.
|
||||
*
|
||||
* EDITOR siteClone-shaped object → buildScoringWorld() → dress() →
|
||||
* windForSite(storm, clone, scoringWorld.anchors). The editor's yard
|
||||
* is an object that was never a file, and its scoring world is built
|
||||
* fresh (A's page renders on a calm stub whose sway closures must
|
||||
* never reach a score — C's landmine 2 wearing a new hat). This is
|
||||
* the path SCORE IT actually runs, which is what my half adds over
|
||||
* C's: C pins the BUILDER against the game, this pins the thing the
|
||||
* BUTTON runs against the game. Three chains, one number.
|
||||
*
|
||||
* The clone goes through a JSON round-trip because that is what `siteClone()`
|
||||
* and the export actually produce: if canonicalisation ever dropped or
|
||||
* reshaped something wind-relevant, the editor would score a yard whose
|
||||
* weather nobody could play. This file does not import `editor.js` — the pin
|
||||
* is about the two WIND CHAINS, and importing A's page would make lane/b's
|
||||
* selftest unable to run on lane/b.
|
||||
*
|
||||
* ── WHERE the probe goes: C's knobs, and the three wrong answers before them ──
|
||||
*
|
||||
* **This pin uses Lane C's knobs, not the ones I proposed.** C co-owns gate
|
||||
* 2.3, measured my proposal, and moved all three; converging beats landing a
|
||||
* second pin that disagrees, so the numbers below are theirs.
|
||||
*
|
||||
* site site_02_corner_block the only yard with a shipped venturi
|
||||
* storm storm_02_wildnight (mine, kept)
|
||||
* probe (-6, y, 0) the authored throat centre — site geometry,
|
||||
* not a magic number; speedAt ignores y
|
||||
* second t = 60.0 the alignment plateau, where the funnel is
|
||||
* worth about a third of the answer
|
||||
* match exact `===`
|
||||
*
|
||||
* I proposed backyard_01 / bed corner / t=30. All three were wrong, and it is
|
||||
* worth writing down because they are the SAME failure three times:
|
||||
*
|
||||
* 1. backyard_01 declares `venturi: []`. There, "setVenturi called with an
|
||||
* empty list" and "setVenturi never called" are the same number — the
|
||||
* regression the pin exists to catch is invisible. (Found while measuring;
|
||||
* it is why the pin moved to site_02 at all.)
|
||||
* 2. site_02's BED cannot see the funnel either — the throat's disc doesn't
|
||||
* reach it. Measured Δ 0.0000 m/s, independently by both of us.
|
||||
* 3. t = 30 is a second where the wildnight's direction does not line up with
|
||||
* the gap: C measured the funnel worth 0.4% at the throat there. **This one
|
||||
* I missed.** My throat measurement read a healthy Δ +4.948 m/s and I
|
||||
* trusted it — but that was on `storm_03b_earlybuster`, not on the
|
||||
* wildnight this pin flies. A sensitivity measured on one storm says
|
||||
* nothing about another, and I generalised across exactly that gap. The
|
||||
* mutation check I was proud of would have passed on the wrong storm.
|
||||
*
|
||||
* A pin at 0.4% passes with the funnel wired backwards. Hence the vacuity
|
||||
* guard below: it re-measures what the funnel is WORTH at the pin and fails
|
||||
* under 25% — the check that would have caught my proposal on its own.
|
||||
*
|
||||
* ── The bed is kept, as a SECOND probe, on C's offer ───────────────────────
|
||||
*
|
||||
* C offered to carry the garden bed as a second probe rather than the one, and
|
||||
* I took it: what the sail SHADES is what the audit is ultimately about, and
|
||||
* equality there is a real claim even though the funnel is not what decides it.
|
||||
* It is labelled honestly and carries no funnel tripwire, because it cannot. A
|
||||
* second probe that measures a different thing is worth having; a first probe
|
||||
* that measures nothing is not.
|
||||
*
|
||||
* A finding worth more than the pin, which C reached independently: the corner
|
||||
* block's funnel does not reach its garden bed at all. It bites the RIGGING
|
||||
* ZONE — what the site JSON's own comment says ("q1 is 3.5 m away, inside the
|
||||
* radius"). The funnel on that yard is a HARDWARE-LOAD story, not a
|
||||
* garden-exposure story, and the score card should be read that way.
|
||||
*
|
||||
* ── Vacuous-pass guard ─────────────────────────────────────────────────────
|
||||
*
|
||||
* `a === b` also passes when both are 0, or both are garbage from a chain that
|
||||
* silently did nothing. Every equality assert here is paired with a liveness
|
||||
* assert (finite, non-trivial) and every claim of sensitivity is paired with a
|
||||
* mutation assert. An assert that cannot fail is documentation, and
|
||||
* documentation cannot fail.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.js';
|
||||
// main.js's OWN router, not a retyped copy of its two wiring lines — C's
|
||||
// correction, and the right call: a pin that retypes the thing it pins agrees
|
||||
// with itself by construction.
|
||||
import { createWindRouter } from '../../web/world/js/main.js';
|
||||
import { buildScoringWorld } from './scorecard.js';
|
||||
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
/**
|
||||
* GATE 2.3's pinned knobs — Lane C's, adopted. One copy, both lanes, no drift.
|
||||
* If either half moves these, it moves them here and says so in THREADS.
|
||||
*/
|
||||
export const PIN = {
|
||||
site: 'site_02_corner_block',
|
||||
storm: 'storm_02_wildnight',
|
||||
t: 60.0,
|
||||
probeY: 0,
|
||||
/** The funnel must be worth at least this much AT THE PIN or the pin is
|
||||
* decoration. C's threshold, shared rather than re-chosen. */
|
||||
MIN_FUNNEL_SHARE: 0.25,
|
||||
};
|
||||
|
||||
/** Probe sets per yard. Each probe says what it is ALLOWED to claim. */
|
||||
const PROBES = {
|
||||
site_02_corner_block: {
|
||||
// THE pin: the authored throat centre, where the funnel decides the answer
|
||||
throat: { x: -6, z: 0 },
|
||||
// C's offered SECOND probe — what the sail shades. Funnel-blind by
|
||||
// geometry (Δ 0.0000), so it carries equality only, and says so.
|
||||
bed: 'gardenBed',
|
||||
tree: { x: 7, z: -1 }, // tr1 — the shelter half
|
||||
},
|
||||
backyard_01: {
|
||||
bed: 'gardenBed',
|
||||
tree: { x: -9, z: 2 }, // t1, the gum whose sway is dynamic load
|
||||
},
|
||||
};
|
||||
|
||||
const vec = (p) => new THREE.Vector3(p.x, PIN.probeY, p.z);
|
||||
const resolveProbes = (name, world) => Object.fromEntries(
|
||||
Object.entries(PROBES[name]).map(([k, v]) =>
|
||||
[k, vec(v === 'gardenBed' ? { x: world.gardenBed.x, z: world.gardenBed.z } : v)]));
|
||||
|
||||
/**
|
||||
* The GAME chain: main.js's OWN router, wired by main.js's own two lines
|
||||
* (loadSiteInto — `setVenturi(siteDef.wind.venturi)` then
|
||||
* `setSheltersFromTrees(anchors.filter(type === 'tree'))`).
|
||||
*/
|
||||
async function gameChain(siteName, stormDef) {
|
||||
const site = await loadSite(siteName);
|
||||
const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site });
|
||||
await world.dress();
|
||||
const inner = createWind(stormDef);
|
||||
const router = createWindRouter([inner]);
|
||||
router.use(inner);
|
||||
router.setVenturi(site.wind?.venturi ?? []);
|
||||
router.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
return { site, world, wind: router };
|
||||
}
|
||||
|
||||
/** The EDITOR chain: a siteClone-shaped object through the scoring world. */
|
||||
async function editorChain(siteName, stormDef) {
|
||||
// What EDITOR.siteClone() hands over: a deep clone that has been through the
|
||||
// canonical export shape. JSON round-trip stands in for the key ordering —
|
||||
// the pin is that this survives as the SAME WEATHER.
|
||||
const clone = JSON.parse(JSON.stringify(await loadSite(siteName)));
|
||||
const built = await buildScoringWorld(clone);
|
||||
assert(built.dressed, `gate 2.3: scoring world for ${siteName} did not dress (${built.dressError}) — `
|
||||
+ 'an undressed yard has no fascia hints and is a different game');
|
||||
return { site: clone, built, wind: windForSite(stormDef, clone, built.anchors) };
|
||||
}
|
||||
|
||||
function stubProxy() {
|
||||
return {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, setVenturi() {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildScorecardTests() {
|
||||
const tests = [];
|
||||
|
||||
// ── the two shipped yards ────────────────────────────────────────────────
|
||||
// The pinned case first (C's knobs). backyard_01 rides along because
|
||||
// equality on a funnel-less yard is still a real claim about the clone and
|
||||
// the dress — it just carries no funnel tripwire, and says so.
|
||||
const cases = [
|
||||
{ site: PIN.site, storm: PIN.storm },
|
||||
{ site: 'backyard_01', storm: 'storm_02_wildnight' },
|
||||
];
|
||||
|
||||
const measured = {};
|
||||
for (const c of cases) {
|
||||
const stormDef = await loadStorm(c.storm);
|
||||
const game = await gameChain(c.site, stormDef);
|
||||
const editor = await editorChain(c.site, stormDef);
|
||||
|
||||
const gProbes = resolveProbes(c.site, game.world);
|
||||
const eProbes = resolveProbes(c.site, editor.built.world);
|
||||
measured[c.site] = { stormDef, editor, probes: eProbes };
|
||||
|
||||
tests.push([`gate 2.3: ${c.site} — both chains agree where the yard's probes ARE`, () => {
|
||||
for (const k of Object.keys(gProbes)) {
|
||||
assert(gProbes[k].equals(eProbes[k]),
|
||||
`gate 2.3: the chains disagree about the '${k}' probe on ${c.site} — `
|
||||
+ `game (${gProbes[k].x},${gProbes[k].z}) vs editor (${eProbes[k].x},${eProbes[k].z}). `
|
||||
+ 'A pin on two different points is not a pin.');
|
||||
}
|
||||
}]);
|
||||
|
||||
for (const k of Object.keys(gProbes)) {
|
||||
const gSpeed = game.wind.speedAt(gProbes[k], PIN.t);
|
||||
const eSpeed = editor.wind.speedAt(eProbes[k], PIN.t);
|
||||
const gVec = game.wind.sample(gProbes[k], PIN.t, new THREE.Vector3());
|
||||
const eVec = editor.wind.sample(eProbes[k], PIN.t, new THREE.Vector3());
|
||||
|
||||
tests.push([`gate 2.3: ${c.site} @${k} — editor-scored wind === game-played wind (t=${PIN.t})`, () => {
|
||||
// liveness first: `a === b` on two zeroes is not agreement, it is silence
|
||||
assert(Number.isFinite(gSpeed) && gSpeed > 1,
|
||||
`gate 2.3: the GAME chain read ${gSpeed} m/s at '${k}' — that is not a storm, it is a `
|
||||
+ 'broken chain, and an equality assert against it would pass vacuously');
|
||||
assert(gSpeed === eSpeed,
|
||||
`gate 2.3 FAILED on ${c.site} @${k}: the editor would score a wind the game never plays.\n`
|
||||
+ ` game ${gSpeed}\n editor ${eSpeed}\n delta ${eSpeed - gSpeed}\n`
|
||||
+ 'Both sides go through windForSite(); if they differ, the INPUTS differ — check the '
|
||||
+ 'venturi list survived the clone, and that the scoring world dressed.');
|
||||
assert(gVec.x === eVec.x && gVec.y === eVec.y && gVec.z === eVec.z,
|
||||
`gate 2.3: speeds matched but DIRECTIONS did not on ${c.site} @${k} — `
|
||||
+ `game (${gVec.x},${gVec.y},${gVec.z}) vs editor (${eVec.x},${eVec.y},${eVec.z}). `
|
||||
+ 'A sail cares which way the wind blows, so scalar agreement alone is not the pin.');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
// backyard_01 carries no funnel, and the pin must SAY so rather than let a
|
||||
// future reader assume the funnel-off tripwire covers both yards.
|
||||
tests.push(['gate 2.3: backyard_01 declares no venturi (so site_02 carries the funnel tripwire)', () => {
|
||||
const v = measured['backyard_01'].editor.site.wind?.venturi ?? [];
|
||||
assert(v.length === 0,
|
||||
`backyard_01 now declares ${v.length} venturi. That is not a failure — but the funnel `
|
||||
+ 'mutation check below lives on site_02 BECAUSE this yard had none, and if this yard grew '
|
||||
+ 'weather the pin should cover it too. Update the probe table.');
|
||||
}]);
|
||||
|
||||
// ── the mutation checks, on the yard that HAS weather ────────────────────
|
||||
// These are what stop the pin above from being decoration. Each one breaks an
|
||||
// input that a real harness has actually broken, and demands the number move.
|
||||
const fun = measured['site_02_corner_block'];
|
||||
const baseWind = () => windForSite(fun.stormDef, fun.editor.site, fun.editor.built.anchors);
|
||||
|
||||
/** Minimum move a mutation must produce to count as "this probe can feel it".
|
||||
* Not `!== 0`: a floating-point hair would satisfy that while the probe sat
|
||||
* effectively blind. Measured deltas here are ~4.9 m/s, so 1.0 is a floor
|
||||
* with two orders of headroom, not a threshold anyone tuned to pass. */
|
||||
const MIN_MOVE = 1.0;
|
||||
|
||||
// ── C's vacuity guard, adopted — threshold and all ───────────────────────
|
||||
// The strongest of the three checks, and the one that would have caught my
|
||||
// proposal on its own. A mutation check asks "did the number MOVE"; this asks
|
||||
// "is the funnel worth ENOUGH here that a wiring bug couldn't hide in the
|
||||
// rounding". At t=30 on this storm the answer was 0.4%.
|
||||
tests.push([`GATE 2.3 guard: the funnel is worth ≥${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% at the pinned probe/second`, () => {
|
||||
const probe = fun.probes.throat;
|
||||
const on = baseWind().speedAt(probe, PIN.t);
|
||||
const off = windForSite(fun.stormDef, { ...fun.editor.site, wind: { venturi: [] } },
|
||||
fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
const share = (on - off) / on;
|
||||
assert(Number.isFinite(share) && share >= PIN.MIN_FUNNEL_SHARE,
|
||||
`gate 2.3 GUARD FAILED: at (${probe.x},${probe.z}) t=${PIN.t} on ${PIN.storm} the funnel is `
|
||||
+ `worth ${(share * 100).toFixed(2)}% (${off.toFixed(2)} → ${on.toFixed(2)} m/s), under the `
|
||||
+ `${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% floor. The equality pin is now decoration — it `
|
||||
+ 'would pass with setVenturi deleted. Do NOT lower this floor: re-measure and move the '
|
||||
+ 'second, the way C moved it off t=30 (0.4%) onto the alignment plateau.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 mutation: dropping the venturi MOVES the throat (funnel-off tripwire)', () => {
|
||||
const probe = fun.probes.throat;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
||||
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
assert(Math.abs(before - after) >= MIN_MOVE,
|
||||
`gate 2.3 mutation FAILED: starving site_02 of its venturi moved the THROAT probe by `
|
||||
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}). This probe cannot SEE the `
|
||||
+ 'funnel, so the equality pin above would stay green through exactly the funnel-off bug it '
|
||||
+ 'exists to catch — the one that moved the S13 headline 91.5 → 39.8. This already happened '
|
||||
+ 'THREE times on this pin (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a '
|
||||
+ 'radius-5 throat; t=30 on the wildnight is worth 0.4%). Re-measure and move the probe; '
|
||||
+ 'do not relax the assert.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 mutation: hiding the trees MOVES the tree probe (shelter tripwire)', () => {
|
||||
// setSheltersFromTrees filters on type; anchors that no longer say "tree"
|
||||
// register no shelter. The frozen-tree landmine's cousin — the wind arrives
|
||||
// unsheltered and the yard is easier than the one that ships.
|
||||
const probe = fun.probes.tree;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const noTrees = fun.editor.built.anchors.map((a) => ({ ...a, type: a.type === 'tree' ? 'post' : a.type }));
|
||||
const after = windForSite(fun.stormDef, fun.editor.site, noTrees).speedAt(probe, PIN.t);
|
||||
assert(Math.abs(before - after) >= MIN_MOVE,
|
||||
`gate 2.3 mutation FAILED: hiding every tree moved the tree probe by `
|
||||
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}) — the ANCHOR half of `
|
||||
+ 'windForSite is not reaching this probe, so a harness that forgot tree shelters would '
|
||||
+ 'pin green.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 (2nd probe): the corner block\'s funnel does NOT reach its garden bed — recorded, not a bug', () => {
|
||||
// Not a defect — it is what the site JSON's own comment describes ("q1 is
|
||||
// 3.5 m away, inside the radius"): the funnel is aimed at the RIGGING
|
||||
// ZONE, so it is a hardware-load story rather than a garden-exposure one.
|
||||
// Pinned because it is load-bearing for anyone reading the score card: if
|
||||
// this ever changes, the bed probe silently becomes funnel-sensitive and
|
||||
// every garden number on that yard moves with it.
|
||||
const probe = fun.probes.bed;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
||||
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
assert(before === after,
|
||||
`The funnel now reaches site_02's garden bed: ${before} → ${after} with the venturi removed. `
|
||||
+ 'That is a real design change (the bed sat 6.08 m from a radius-5 throat and felt nothing). '
|
||||
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
|
||||
}]);
|
||||
|
||||
// ── the clone is the same weather as the file ────────────────────────────
|
||||
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
|
||||
const v = fun.editor.site.wind?.venturi ?? [];
|
||||
assert(v.length === 1,
|
||||
`gate 2.3: the cloned site_02 has ${v.length} venturi, not 1 — the editor would score, and `
|
||||
+ 'export, a corner block with no weather. The funnel lives in the SITE def; if the clone '
|
||||
+ 'loses it the whole yard silently becomes an easier one.');
|
||||
assert(v[0].gain === 1.5 && v[0].axis === 2.1,
|
||||
`gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
|
||||
}]);
|
||||
|
||||
return tests;
|
||||
}
|
||||
355
web/world/js/editor_score.js
Normal file
355
web/world/js/editor_score.js
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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, collateralExposure } 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) {
|
||||
// the bill rides on the same line as the flag: "knife-edge" and
|
||||
// "knife-edge AND $180 of carport" are different authoring decisions
|
||||
const bill = f.collateral
|
||||
? (f.collateral.unpriced
|
||||
? ` · wrecks ${f.collateral.label} (UNPRICED)`
|
||||
: ` · wrecks ${f.collateral.label} ${money(f.collateral.cost)}`)
|
||||
: '';
|
||||
const row = kv(cm, `${f.id}${f.hint !== 1 ? ` ×${f.hint}` : ''}`,
|
||||
`${pct(f.headroom)} headroom — ${(f.peak / 1000).toFixed(1)} kN on ${f.line}${bill}`, '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.'));
|
||||
|
||||
// ── 6. collateral exposure ───────────────────────────────────────────────
|
||||
// "Winnable at $80" is an incomplete sentence on a yard where letting a
|
||||
// corner go bills $180. Priced through world.collateralFor() — the one
|
||||
// resolver, site JSON first, GLB extra second.
|
||||
const exp = collateralExposure(s);
|
||||
const ce = card(out, 'COLLATERAL EXPOSURE', exp.total > 0 ? 'ed-warn' : null);
|
||||
if (!exp.priced.length && !exp.unpriced.length) {
|
||||
kv(ce, 'at risk', 'nothing priced in this yard', 'ed-ok');
|
||||
} else {
|
||||
for (const e of exp.priced) kv(ce, e.label, `${money(e.cost)} (${e.anchors.join(', ')})`, 'ed-warn');
|
||||
kv(ce, 'total if all lost', money(exp.total), exp.total > START_BUDGET ? 'ed-err' : 'ed-warn');
|
||||
for (const e of exp.unpriced) {
|
||||
kv(ce, e.label, `UNPRICED — not scored (${e.anchors.join(', ')})`, 'ed-err');
|
||||
}
|
||||
if (exp.unpriced.length) {
|
||||
ce.append(el('div', 'ed-note ed-err',
|
||||
'An unpriced label reads "not scored", never "free" — that distinction is the whole '
|
||||
+ 'point. A yard whose trap prices to nothing scores as SAFER than it is, which is '
|
||||
+ 'exactly the lie this card exists to prevent.'));
|
||||
}
|
||||
ce.append(el('div', 'ed-note',
|
||||
'Billed per STRUCTURE, not per corner — two beams letting go is one carport gone, not two. '
|
||||
+ `Against a ${money(START_BUDGET)} budget, this is what the yard can take off the player `
|
||||
+ 'on top of the hardware.'));
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@ -779,7 +779,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
|
||||
<div class="cond">the night, rigged and stood up</div>
|
||||
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
|
||||
<div class="cond">paid on what's left of the bed at dawn</div>
|
||||
<div class="cond">${q.beyondSaving
|
||||
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
|
||||
// cannot reach the win line at any price (week.js's flag, A's S13
|
||||
// ruling), so "paid on what's left of the bed at dawn" quotes a
|
||||
// maximum nobody can earn and reads as a rigging problem the
|
||||
// player could solve. The AMOUNT stays honest — settle() really
|
||||
// does pay this proportionally, and zeroing it here would make the
|
||||
// sheet lie in the other direction — but the CONDITION now says
|
||||
// what kind of night this is, up front, where the brief already
|
||||
// levels with you. The steel is still yours to save.
|
||||
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
|
||||
: "paid on what's left of the bed at dawn"}</div>
|
||||
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
|
||||
<div class="cond">nothing of theirs broken</div>
|
||||
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
|
||||
@ -939,7 +950,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
const money = w ? `
|
||||
<div class="ledger">
|
||||
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
|
||||
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
||||
// night this row is always small, and next to "callout fee — night
|
||||
// lost" it reads as the player's failure. It isn't: the bed was
|
||||
// gone at the forecast (week.js gardenBeyondSaving). Naming that
|
||||
// HERE, in the ledger, matters more than in the verdict prose —
|
||||
// the ledger is the part players re-read, and an unexplained small
|
||||
// number is the most persuasive lie on the card.
|
||||
w.gardenBeyondSaving ? ', beyond saving from the start' : ''
|
||||
}</span><b>+$${w.bonus}</b></div>
|
||||
${cleanRow}
|
||||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${collateralRows}
|
||||
|
||||
@ -28,6 +28,7 @@ import { SAIL_TESTS } from '../sail.selftest.js';
|
||||
import { RIGGING_TESTS } from '../rigging.selftest.js';
|
||||
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
|
||||
import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js';
|
||||
import { buildScorecardTests } from '../../../../tools/site_audit/scorecard.selftest.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
@ -42,4 +43,10 @@ export default async function run(t) {
|
||||
// precedent). run() is async now — runAll awaits it.
|
||||
const gardenflyTests = await buildGardenflyTests();
|
||||
for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn);
|
||||
// SPRINT14 gate 2.3 (co-owned with C): the editor's scored wind and the
|
||||
// game's played wind are the same number, exactly, at one probe and one
|
||||
// second. Same async-prelude shape — the two chains are built here and the
|
||||
// asserts read the captured measurements.
|
||||
const scorecardTests = await buildScorecardTests();
|
||||
for (const [name, fn] of scorecardTests) t.test(name, fn);
|
||||
}
|
||||
|
||||
@ -303,6 +303,23 @@ export function createWeek(opts = {}) {
|
||||
base: PAY.feeFor(gustPeakOf(def)),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
/**
|
||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||
* to say when the garden bonus is money the night cannot pay.
|
||||
*
|
||||
* Deliberately a FLAG and not a zero. The comment in settle() below is
|
||||
* the rule — quote and settle must read the same source or the sheet
|
||||
* promises a number the invoice doesn't pay — and zeroing this would
|
||||
* break it in the opposite direction: settle still pays the bonus
|
||||
* proportionally (hp is low on a beyond-saving night, not zero), so a
|
||||
* quoted $0 would be its own lie on paper. The NUMBER is honest. What
|
||||
* was missing is its CONDITION, which on this night is "and the ice
|
||||
* takes the bed whatever you rig". The job sheet's own idiom is that
|
||||
* every amount carries its condition underneath; this is a night where
|
||||
* the condition is the entire story and the schedule was the last place
|
||||
* still not telling it.
|
||||
*/
|
||||
beyondSaving: j.gardenBeyondSaving ?? false,
|
||||
get total() { return this.base + this.garden + this.clean; },
|
||||
};
|
||||
},
|
||||
@ -371,6 +388,11 @@ export function createWeek(opts = {}) {
|
||||
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
|
||||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
|
||||
// B's pool item: the invoice needs this to explain a small garden bonus
|
||||
// as the night's design rather than the player's failure. Carried on the
|
||||
// settlement so the HUD never has to reach back into week state.
|
||||
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
||||
gardenMax,
|
||||
client: job.client, addr: job.addr, site: job.site };
|
||||
log.push(s);
|
||||
return s;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user