Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-18 16:47:17 +10:00
commit e49ad04f99
8 changed files with 1264 additions and 87 deletions

View File

@ -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 14 | 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.

View File

@ -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 })),

View 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 ~515%, 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),
};
}

View 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;
}

View 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 nights 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 shops 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)}`);
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 nights 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 As 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 UIs peaks by 515% 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();

View File

@ -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}

View File

@ -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);
}

View File

@ -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;