GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on wording and their every-card rule adopted). The audit always flew shade cloth and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob (sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the fabric and whether tonight's stones pass its weave (same hailBlockFor the sim charges), and the SCORE IT header prints 'fabric · shade cloth (sweep charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the fabric is the bet, and the audit does not place it. GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as posted), this lane's teeth. · rigging.js: validateNightConstraint (checked enum, both ends of the seam), setConstraints (survives reset like _startBudget; [] is the only clear). The house ban refuses the pick with the CLIENT'S WORDS as the ticker reason; the cap refuses at SPEND time on every path (rig / cycle / set / spares / fabric), exact at the boundary, refunds always free — and commit is belt-and-braces: a caller who walked around the doors gets a THROW. The cap covers the whole invoice, spares included — the client queries the invoice, not the hardware line. · sweep.js: applyNightConstraints, ONE function both drivers share — bans shrink the candidate list (a card that recommends a forbidden line is the card lying), the cap re-prices affordable/clean without touching a load. · scoreSite carries constraints/budget/constrainedOut; the card prints the client's terms next to funnel and fabric, and tells 'no geometry' from 'client forbids all of it'. · spent getter now measures against _startBudget (the re-banked session was measuring spend against the default bank). Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4 session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS: session.setConstraints(week.job?.constraints ?? []) at the night boundary.
357 lines
19 KiB
JavaScript
357 lines
19 KiB
JavaScript
/**
|
||
* sweep.js — the winnability sweep, shared by BOTH front-ends. [Lane B, SPRINT10]
|
||
*
|
||
* There is exactly one copy of "is this site winnable" and this is it. audit.mjs
|
||
* (node, fast, but blind to GLB-dressed anchors) and audit.html (browser, reads
|
||
* the fully dressed createWorld(site).anchors) both import auditSweep and only
|
||
* differ in how they GET the anchors and how they PRINT the result. A tool built
|
||
* to catch reimplemented-formula drift must not carry two copies of its own math.
|
||
*
|
||
* Pure given its inputs: hand it anchors (world.anchors themselves, ideally —
|
||
* live sway matters, see below), the bed rect, the storm def and the SITE def,
|
||
* and it flies the same attach→storm chain the game flies and returns ranked
|
||
* rows + a verdict. No I/O, no process, no DOM.
|
||
*
|
||
* SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) — a
|
||
* corner priced inside 15% of its effective rating holds on this bench and
|
||
* "breaks in the game" (C's residual, cause unfound), so every row prices
|
||
* twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines
|
||
* are winners. The garden side of the audit lives in gardenfly.js (browser).
|
||
*/
|
||
|
||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||
import { windForSite } from '../../web/world/js/weather.js';
|
||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
|
||
// session that enforces them — audit and enforcement must not disagree.
|
||
import { validateNightConstraint } from '../../web/world/js/rigging.js';
|
||
|
||
/** Audit knobs, in one place so both front-ends and any future site agree. */
|
||
export const AUDIT = {
|
||
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
|
||
MIN_COVER: 0.25, // A's "shades the bed" bar
|
||
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
|
||
/**
|
||
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
|
||
* the real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool).
|
||
* Until it's found, a corner priced within this fraction of its effective
|
||
* rating "breaks in the game" — the bench held q4 at 1.24-vs-1.2 and read
|
||
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
|
||
* A line with a marginal corner is flagged, never sold as a clean PASS.
|
||
*/
|
||
MARGIN: 0.15,
|
||
/**
|
||
* SPRINT17 gate 0.3 — the sweep's FABRIC CHARTER, in the knobs instead of
|
||
* hardcoded twice (here and gardenfly.js). Every sweep and every garden
|
||
* flight is knitted shade cloth (0.30), the fabric a competent player takes
|
||
* into a windy night; the F key never enters an audit. That was always true
|
||
* and never SAID — D's soaker finding: the card read WINNABLE over an
|
||
* all-DEAD cloth garden and the silence read as "the night is broken".
|
||
* scorecard.js names the charter on every score now (fabricCharter);
|
||
* this constant is what it names.
|
||
*/
|
||
POROSITY: 0.30,
|
||
};
|
||
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
|
||
// settle they parameterised. In the real game the rig does not exist before
|
||
// commit — commit→attach→storm is one keypress (the rig.t fix's own words) —
|
||
// so the attach transient flies under STORM wind and its loads count. The old
|
||
// 12 s calm settle + resetPeaks measured a chain the game never flies, and
|
||
// also skewed every storm sample 12 s off the authored curve (C measured both
|
||
// on the bench and removed its copy the same day).
|
||
|
||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||
export function areaOf(q) {
|
||
const r = orderRing(q);
|
||
let a = 0;
|
||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||
return Math.abs(a / 2);
|
||
}
|
||
|
||
/**
|
||
* Cheapest hardware that holds a corner at `peakN` newtons off an anchor with
|
||
* `ratingHint` hint, or null if the shop can't at any price.
|
||
*
|
||
* SPRINT12: sail.js fails a corner on `load > hw.rating * anchor.ratingHint`
|
||
* (A's ruling — the anchor is a failure point, not just the steel), so the
|
||
* hardware that HOLDS is the cheapest h with `h.rating * hint >= peak`, i.e.
|
||
* `h.rating >= peak / hint`. An audit that keeps pricing on bare hw.rating
|
||
* flies a different game than ships: on the corner block it would sell you a
|
||
* $5 carabiner for a beam that now lets go at 0.22 of it.
|
||
*/
|
||
export const tierFor = (peakN, ratingHint = 1) =>
|
||
HARDWARE.find((h) => h.rating * ratingHint >= peakN) || null;
|
||
|
||
/**
|
||
* Sweep every bed-covering quad and price the cheapest holding line.
|
||
* @param {object} o
|
||
* @param {Array} o.anchors world.anchors THEMSELVES where possible (their
|
||
* sway closures carry the tree's real movement — a
|
||
* static remap froze the gum tree and under-read
|
||
* every tr1 corner, C's landmine 2), or resolved
|
||
* { id, type, pos, sway, ratingHint } for synthetic
|
||
* yards. Omit ratingHint and the anchor is priced
|
||
* at full hardware strength (hint 1), which is a
|
||
* LIE for any GLB-dressed anchor (fascia 0.35,
|
||
* carport beam 0.22). Hand this the dressed truth.
|
||
* @param {object} o.bed garden bed rect { x, z, w, d }
|
||
* @param {object} o.stormDef the storm JSON to fly
|
||
* @param {object} [o.siteDef] the SITE json — its wind.venturi is half the
|
||
* yard's weather (three harnesses learned this the
|
||
* hard way); preferred over `venturi`
|
||
* @param {Array} [o.venturi] bare funnel list, for synthetic yards with no
|
||
* site JSON (sweep.selftest). Ignored if siteDef given.
|
||
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
|
||
* pattern) — called with the sweep's wind so live
|
||
* tree-sway closures sample the storm being flown
|
||
* @param {Array} [o.constraints] the night's client constraints (A's shapes;
|
||
* SPRINT17 gate 2) — bans shrink the candidate
|
||
* list, a cap shrinks the budget, and the result
|
||
* says how much of each. A card that recommends a
|
||
* forbidden line is the card lying.
|
||
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best },
|
||
* budget, constrainedOut }}
|
||
*/
|
||
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, constraints = [] }) {
|
||
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
|
||
const cands = con.cands;
|
||
if (!cands.length) {
|
||
return { cands, rows: [], winners: [], marginalWinners: [],
|
||
verdict: { ok: false, code: 'no-cover', best: null },
|
||
budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
|
||
// is a bug some harness shipped:
|
||
// · windForSite — the one shared wind builder (C's helper): venturi from
|
||
// the SITE def + tree shelters, byte-for-byte main.js's site-load
|
||
// wiring. THREE harnesses independently mis-built site wind before it
|
||
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
|
||
// def.wind.venturi off the STORM def, D's first garden harness) — a
|
||
// fourth copy of the wiring is how there's a fifth bug.
|
||
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
|
||
// closures sample this sweep's storm (frozen sway under-read q4 by
|
||
// 0.22 kN on the $80 line — C's landmine 2).
|
||
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
|
||
// in the real game, so the attach transient flies under storm wind
|
||
// and its loads count (cheap steel genuinely dies "at the settle" —
|
||
// that's the storm's opening seconds, not a separate phase).
|
||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||
use?.(wind);
|
||
|
||
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind, budget: con.budget }));
|
||
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
/**
|
||
* SPRINT17 gate 2 [B] — the night's client constraints, applied to the
|
||
* sweep's inputs. ONE function shared by the sync and async drivers, because
|
||
* their purity contract ("every number equal, to the byte") extends to which
|
||
* candidates exist and what budget prices them.
|
||
*
|
||
* noAnchorFamily drops every candidate that touches a banned-family anchor
|
||
* — including the site's pinned separation line, if it does:
|
||
* the pin is a fact about the YARD, but tonight's card
|
||
* recommends lines for tonight's CLIENT, and a card that
|
||
* recommends a forbidden line is the card lying.
|
||
* budgetCap caps the budget that decides `affordable`/`clean` — the
|
||
* client caps the invoice, not your wallet, so the numbers
|
||
* below the cap don't move; lines above it stop being
|
||
* answers.
|
||
*
|
||
* Shapes validated at the door (rigging.js's validator — the same teeth the
|
||
* session bites with, so the audit and the enforcement can never disagree
|
||
* about what a constraint means).
|
||
*
|
||
* @returns {{ cands, budget, dropped }}
|
||
*/
|
||
export function applyNightConstraints({ cands, anchors, constraints = [] }) {
|
||
for (const c of constraints) validateNightConstraint(c);
|
||
let budget = START_BUDGET, out = cands, dropped = 0;
|
||
const fams = new Set(constraints.filter((c) => c.kind === 'noAnchorFamily').map((c) => c.family));
|
||
if (fams.size) {
|
||
const banned = new Set(anchors.filter((a) => fams.has(a.type)).map((a) => a.id));
|
||
out = cands.filter((c) => !c.ids.some((id) => banned.has(id)));
|
||
dropped = cands.length - out.length;
|
||
}
|
||
for (const c of constraints) if (c.kind === 'budgetCap') budget = Math.min(budget, c.cap);
|
||
return { cands: out, budget, dropped };
|
||
}
|
||
|
||
/**
|
||
* Step 1 of the sweep, alone: every quad in the rigging band that shades the
|
||
* bed, plus the site's pinned separation line. Extracted (SPRINT15 gate 2.1)
|
||
* so an ASYNC driver can enumerate the work before doing it — a progress tick
|
||
* needs a denominator. Same code auditSweep always ran, one copy.
|
||
*/
|
||
export function findCandidates({ anchors, bed, siteDef = null }) {
|
||
// 1. every quad, in the rigging band, that shades the bed
|
||
const cands = [];
|
||
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
|
||
for (let c = b + 1; c < anchors.length; c++) for (let d = c + 1; d < anchors.length; d++) {
|
||
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
|
||
const area = areaOf(q);
|
||
if (area < AUDIT.BAND.lo || area > AUDIT.BAND.hi) continue;
|
||
let rig;
|
||
try {
|
||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||
} catch { continue; } // degenerate ring
|
||
const cover = rig.coverageOver(bed, { x: 0, y: 1, z: 0 });
|
||
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||
}
|
||
|
||
// The site's PINNED separation line sweeps regardless of the band: A's
|
||
// gate-1.4 recipe (p1+p2+p3+p5) is 45.9 m² against the band's 45 — the band
|
||
// is an audit heuristic calibrated before p5 existed, and an audit that
|
||
// cannot see the site's own ruled-best line is auditing a different yard.
|
||
// Flagged `pinned` so front-ends can say why it's there; band-vs-pin
|
||
// reconciliation is A's (posted in THREADS).
|
||
const pinIds = siteDef?.separation?.line?.map((l) => l.anchor);
|
||
if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) {
|
||
const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean);
|
||
if (q.length === 4) {
|
||
try {
|
||
const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0);
|
||
cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true });
|
||
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
|
||
}
|
||
}
|
||
return cands;
|
||
}
|
||
|
||
/**
|
||
* Fly ONE candidate and price its corners. Extracted (SPRINT15 gate 2.1) as
|
||
* the unit of chunked work: the async driver yields between calls to this so
|
||
* the page can paint, and because each call builds its own rig and flies the
|
||
* same wind at the same seconds, chunking cannot change a number — the
|
||
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
|
||
*/
|
||
export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BUDGET }) {
|
||
// shade cloth (AUDIT.POROSITY): the fabric a competent player takes into a
|
||
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
|
||
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
|
||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||
|
||
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
|
||
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
|
||
//
|
||
// TWO prices per corner (C's margin rule):
|
||
// `tier` cheapest hardware that HOLDS the measured peak — what the
|
||
// old audit sold, and what a player gambling the knife edge
|
||
// actually buys;
|
||
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
|
||
// the price the margin rule trusts (demand ÷ (1 − MARGIN)).
|
||
// A corner whose `tier` sits inside the margin band is `marginal`: it
|
||
// holds on this bench and "breaks in the game" (the residual's working
|
||
// rule). The row's clean price is what closing that gap costs.
|
||
const tiers = rig.corners.map((c) => {
|
||
const hint = c.anchor.ratingHint ?? 1;
|
||
const tier = tierFor(c.peakLoad, hint);
|
||
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
|
||
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
|
||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||
});
|
||
const unholdable = tiers.filter((c) => !c.tier);
|
||
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
|
||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||
const cleanHw = tiers.every((c) => c.cleanTier)
|
||
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
|
||
// `budget` is START_BUDGET on an unconstrained night and the client's cap on
|
||
// a capped one (gate 2): the loads and tiers above never move — the client
|
||
// caps the invoice, not the physics — but a line the cap can't buy stops
|
||
// being an answer.
|
||
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
|
||
total: hw + AUDIT.SPARE_COST,
|
||
affordable: !unholdable.length && hw <= budget,
|
||
clean: cleanHw != null && cleanHw <= budget };
|
||
}
|
||
|
||
/**
|
||
* Sort the priced rows and hand down the verdict — the tail of auditSweep,
|
||
* shared with the async driver. Sorts `rows` in place, exactly as auditSweep
|
||
* always has (Array.prototype.sort is stable, so chunked and sync drivers
|
||
* order ties identically).
|
||
*/
|
||
export function judgeSweep(cands, rows) {
|
||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||
|
||
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
|
||
// every corner); a line only affordable on knife-edge steel is the wild-night
|
||
// 91.9-FULL illusion and gets its own bucket + verdict code so no front-end
|
||
// can print PASS over it by accident.
|
||
const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw);
|
||
const marginalWinners = rows.filter((r) => r.affordable && !r.clean);
|
||
return {
|
||
cands, rows, winners, marginalWinners,
|
||
verdict: winners.length
|
||
? { ok: true, code: 'pass', best: winners[0] }
|
||
: marginalWinners.length
|
||
? { ok: false, code: 'marginal-only', best: marginalWinners[0] }
|
||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* auditSweep with a heartbeat — same candidates, same flights, same verdict,
|
||
* but the candidate loop yields to the event loop every `yieldEvery` flights
|
||
* so a page driving it repaints instead of freezing. [SPRINT15 gate 2.1]
|
||
*
|
||
* D's verdict on the 75–82 s blocking score: "made the editor batch, not
|
||
* iterative — I scored once and shipped that run rather than tuning", and the
|
||
* worst part "is not the wait, it's not knowing whether it died". The fix is
|
||
* NOT a faster sim (the numbers must not move) — it is telling the truth about
|
||
* progress while the same work happens.
|
||
*
|
||
* PURITY IS THE CONTRACT: every number this returns must equal auditSweep's
|
||
* to the byte, for any yieldEvery ≥ 1. That holds by construction — each
|
||
* priceCandidate builds its own rig and flies a wind that is a pure function
|
||
* of (p, t) — and by assert (scorecard.selftest.js runs sync vs yieldEvery 1
|
||
* vs yieldEvery 3 on the same yard and demands identical JSON). If you add
|
||
* state that survives across candidates, that assert is the tripwire.
|
||
*
|
||
* @param {function} [o.onProgress] called ({ phase:'sweep', done, total })
|
||
* after every flight — cheap, DOM-free, caller renders it
|
||
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
|
||
*/
|
||
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
|
||
onProgress = null, yieldEvery = 1, constraints = [] }) {
|
||
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
|
||
const cands = con.cands;
|
||
if (!cands.length) {
|
||
return { cands, rows: [], winners: [], marginalWinners: [],
|
||
verdict: { ok: false, code: 'no-cover', best: null },
|
||
budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||
use?.(wind);
|
||
|
||
const rows = [];
|
||
for (let i = 0; i < cands.length; i++) {
|
||
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind, budget: con.budget }));
|
||
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
|
||
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
|
||
}
|
||
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
/**
|
||
* One macrotask boundary — long enough for the browser to paint, nothing else.
|
||
*
|
||
* MessageChannel, NOT setTimeout(0), and it is load-bearing: Chrome clamps
|
||
* chained timers in hidden/occluded tabs (1 s, then 1/minute under intensive
|
||
* throttling), which turned a 72 s score into one flight per MINUTE the first
|
||
* time this ran in an occluded pane — measured, 12→13 of 66 across 60 s.
|
||
* Message tasks are not timer-throttled, and the selftest header's own rule
|
||
* ("stays honest in a background tab") applies to the score as much as the
|
||
* suite. Falls back to setTimeout where MessageChannel is missing (node).
|
||
*/
|
||
export const yieldToEventLoop = typeof MessageChannel === 'undefined'
|
||
? () => new Promise((r) => setTimeout(r, 0))
|
||
: () => new Promise((r) => {
|
||
const ch = new MessageChannel();
|
||
ch.port1.onmessage = () => { ch.port1.close(); r(); };
|
||
ch.port2.postMessage(0);
|
||
});
|