From 1bcfcdad672ef7764722310cf6fcdfe2c4debbc9 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:45:49 +1000 Subject: [PATCH] Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/site_audit/gardenfly.js | 6 +- tools/site_audit/scorecard.js | 55 +++++++++- tools/site_audit/scorecard.selftest.js | 138 ++++++++++++++++++++++++- tools/site_audit/sweep.js | 102 +++++++++++++++--- web/world/js/editor_score.js | 57 ++++++++-- web/world/js/rigging.js | 137 +++++++++++++++++++++++- web/world/js/rigging.selftest.js | 102 ++++++++++++++++++ 7 files changed, 562 insertions(+), 35 deletions(-) diff --git a/tools/site_audit/gardenfly.js b/tools/site_audit/gardenfly.js index 33ccbd8..015b9f2 100644 --- a/tools/site_audit/gardenfly.js +++ b/tools/site_audit/gardenfly.js @@ -99,7 +99,7 @@ export function hardwareByName(name) { * you want a different door.] * @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }} */ -export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = 0.30 }) { +export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = AUDIT.POROSITY }) { const wind = windForSite(stormDef, siteDef, anchors); use?.(wind); @@ -107,8 +107,8 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, if (ids) { const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle')); cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0); - // shade cloth (porosity 0.30) unless the caller is measuring the fabric - // bet — same default as sweep.js, so nothing pre-SPRINT16 moves. + // shade cloth (AUDIT.POROSITY — the sweep charter, one knob since + // SPRINT17 gate 0.3) unless the caller is measuring the fabric bet. rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension); // D's skipped-attach trap: a rig without its shadow mesh scores as a bare // bed and the number LOOKS plausible. Refuse to fly it. diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js index 156a938..435e2a6 100644 --- a/tools/site_audit/scorecard.js +++ b/tools/site_audit/scorecard.js @@ -53,9 +53,46 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { createWorld } from '../../web/world/js/world.js'; +import { FABRIC } from '../../web/world/js/rigging.js'; +import { hailBlockFor } from '../../web/world/js/weather.core.js'; import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js'; import { flyGarden, flySeparation } from './gardenfly.js'; +/** + * SPRINT17 gate 0.3 — the score's fabric charter, SAID OUT LOUD. [B, with D's + * soaker finding as the brief and A consulted on the card wording.] + * + * Every audit number is flown on knitted shade cloth (AUDIT.POROSITY — one + * knob, sweep.js and gardenfly.js both read it). Sound charter, but the card + * never NAMED it, and on the one night where fabric is the whole answer the + * silence lied by omission: the soaker's card said "✓ WINNABLE at $80" over a + * garden table where every cloth line reads DEAD, and a reader without the + * charter in their head concludes the night is broken. The fix is the same + * discipline as the funnel state: any front-end printing a score also prints + * what fabric it flew — and on a storm whose stones pass the weave (C's 2 mm + * ruling, the same hailBlockFor the sim charges), it says the F key is not in + * the card. + * + * Pure data from pure inputs, browser-free, so the selftest can pin it against + * real storm defs without a flight. + * + * @param {object|null} stormDef parsed storm def (hail.size in storm units) + * @returns {{ fabric: {id,name,porosity}, leaks: boolean }} + * `fabric` — the charter entry (matched out of rigging's FABRIC by the + * porosity actually flown, so a charter retune renames the card by itself); + * `leaks` — true when tonight's stones pass this weave, i.e. the un-swept + * membrane would stop stones the swept cloth lets through. + */ +export function fabricCharter(stormDef) { + const f = FABRIC.find((x) => x.porosity === AUDIT.POROSITY) + ?? { id: 'custom', name: `porosity ${AUDIT.POROSITY}`, porosity: AUDIT.POROSITY }; + const hailSize = stormDef?.hail?.size ?? 0; + return { + fabric: { id: f.id, name: f.name, porosity: f.porosity }, + leaks: hailSize > 0 && hailBlockFor(hailSize, AUDIT.POROSITY) < 1, + }; +} + /** How many affordable lines get flown. Flights are seconds each; the card is * on-demand and slow is fine, but an unbounded sweep on a yard with fifteen * anchors is a hang, not a score. */ @@ -129,7 +166,7 @@ export async function buildScoringWorld(site) { * @returns {Promise} pure data — no DOM, no strings-as-verdicts */ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null, - onProgress = null, yieldEvery = 1 }) { + onProgress = null, yieldEvery = 1, constraints = [] }) { const built = prebuilt ?? await buildScoringWorld(site); const { world, anchors, bed, use, dressed, dressError } = built; @@ -164,8 +201,11 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef : { key: a.collateral, cost: null, label: a.collateral, unpriced: true }; } - const { cands, rows, verdict, winners, marginalWinners } = - await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery }); + // gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms — + // bans shrink the candidate list before a single flight, the cap decides + // affordability. A card that recommends a forbidden line is the card lying. + const { cands, rows, verdict, winners, marginalWinners, budget, constrainedOut } = + await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery, constraints }); // Garden flights: the bare-bed control first, then every line the budget can // buy. The flight list is known up front so the progress tick has an honest @@ -232,10 +272,19 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef } } + // The charter the whole card is denominated in (gate 0.3): reported next to + // the funnel state, computed from the storm actually scored — never assumed. + const charter = fabricCharter(stormDef); + return { site: site.id ?? site.name ?? '(unnamed)', storm: stormName, dressed, dressError, venturi, funnelOn: venturi.length > 0, + fabric: charter.fabric, fabricLeaks: charter.leaks, + // gate 2: the terms this card was scored under, echoed so every front-end + // can print them — a constrained score that doesn't SAY so is the soaker + // silence again, one gate over. `budget` is what decided affordable/clean. + constraints, budget, constrainedOut, anchorCount: anchors.length, bed, cands: cands.length, rows, winners, marginalWinners, verdict, flown, skipped, flyCap, diff --git a/tools/site_audit/scorecard.selftest.js b/tools/site_audit/scorecard.selftest.js index f6045e9..56d9aa2 100644 --- a/tools/site_audit/scorecard.selftest.js +++ b/tools/site_audit/scorecard.selftest.js @@ -109,8 +109,10 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j // 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'; -import { auditSweep, auditSweepAsync } from './sweep.js'; +import { buildScoringWorld, fabricCharter } from './scorecard.js'; +import { auditSweep, auditSweepAsync, AUDIT } from './sweep.js'; +import { hailBlockFor } from '../../web/world/js/weather.core.js'; +import { START_BUDGET } from '../../web/world/js/contracts.js'; const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; @@ -408,5 +410,137 @@ export async function buildScorecardTests() { `gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`); }]); + // ── SPRINT17 gate 0.3: the fabric charter is named, and named RIGHT ────── + // D's soaker finding: the card said WINNABLE over an all-DEAD cloth garden + // and nothing named the charter. These pin the disclosure's DATA against + // real storm defs — the soaker (fine stones, the one night the charter is + // the wrong bet) must read leaks:true, the icenight (1.4 fists of ice that + // no weave passes) must read leaks:false, and a dry storm must never warn. + // The wording is the card's; the truth of the flag is pinned here. + { + const soaker = await loadStorm('storm_06_soaker'); + const icenight = await loadStorm('storm_02b_icenight'); + + tests.push(['gate 0.3: the charter names a real fabric, and it is the cloth the sweep flies', () => { + const c = fabricCharter(soaker); + assert(c.fabric.id === 'cloth' && c.fabric.porosity === AUDIT.POROSITY, + `gate 0.3: the charter resolved to '${c.fabric.id}' porosity ${c.fabric.porosity} — the card ` + + `would name a fabric the sweep does not fly (AUDIT.POROSITY ${AUDIT.POROSITY}). If the ` + + 'charter knob moved, rigging.js FABRIC must carry an entry at the new porosity or the ' + + 'card degrades to a raw number.'); + assert(hailBlockFor(soaker.hail.size, AUDIT.POROSITY) < 1, + 'gate 0.3 vacuity guard: the soaker\'s stones no longer pass the charter weave — the ' + + 'leaks pin below would be testing a night that cannot leak. Re-pick the fixture storm.'); + }]); + + tests.push(['gate 0.3: soaker leaks (fine stones pass the weave), icenight does not, dry night silent', () => { + assert(fabricCharter(soaker).leaks === true, + `gate 0.3: the soaker (hail.size ${soaker.hail.size}) reads leaks:false — the one card ` + + 'the disclosure exists for would not carry it. hailBlockFor and the charter porosity ' + + 'no longer agree with C\'s weave ruling.'); + assert(fabricCharter(icenight).leaks === false, + `gate 0.3: the icenight (hail.size ${icenight.hail.size}) reads leaks:true — stones an ` + + 'order of magnitude over the weave aperture do not pass it, and a warning on every ' + + 'card teaches the player to ignore the one that matters.'); + assert(fabricCharter({ hail: null }).leaks === false && fabricCharter(null).leaks === false, + 'gate 0.3: a storm with no hail (or no storm at all) warns about a leak — the flag must ' + + 'mean "tonight\'s stones pass the weave", never "the weave has holes in principle".'); + }]); + } + + // ── SPRINT17 gate 2: the audit honours the night's constraints ─────────── + // A card that recommends a forbidden line is the card lying — so the sweep + // itself refuses banned steel and prices against the client's cap, and these + // pin it on a fixture where both bites are provably non-vacuous. Shapes are + // A's seam contract verbatim; enforcement asserts for the SESSION live in + // rigging.selftest.js — this is the SCORE IT half of the same gate. + { + // YARD5 with the fifth anchor as HOUSE steel — same geometry, one family + // change, so every delta below is the constraint and nothing else. + const YARD5H = [ + { id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } }, + { id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } }, + { id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } }, + { id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } }, + { id: 'a5', type: 'house', pos: { x: 0, y: 3.9, z: 4 } }, + ].map((a) => ({ ...a, sway: () => a.pos })); + const HOUSE_BAN = { kind: 'noAnchorFamily', family: 'house', + label: 'nothing attached to the house', + says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.' }; + const sweepArgs = { anchors: YARD5H, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] }; + + const baseH = auditSweep({ ...sweepArgs }); + const emptyCon = auditSweep({ ...sweepArgs, constraints: [] }); + const bannedH = auditSweep({ ...sweepArgs, constraints: [HOUSE_BAN] }); + + tests.push(['gate 2: constraints:[] is byte-identical to no constraints at all', () => { + assert(sweepJSON(emptyCon) === sweepJSON(baseH), + 'gate 2: an empty constraint list changed the sweep — the unconstrained path is not ' + + 'the default path, and every historical number is quietly a different claim.'); + assert(emptyCon.budget === START_BUDGET && emptyCon.constrainedOut === 0, + 'gate 2: an unconstrained sweep reports a cap or a drop that did not happen'); + }]); + + tests.push(['gate 2: the house ban removes every line touching house steel — and provably removed something', () => { + assert(baseH.cands.some((c) => c.ids.includes('a5')), + 'gate 2 vacuity guard: no unconstrained candidate touches a5 — the ban below would be ' + + 'measuring nothing. Fix the fixture, not the assert.'); + assert(bannedH.constrainedOut > 0, 'gate 2: the ban dropped nothing on a yard with house candidates'); + assert(bannedH.cands.length + bannedH.constrainedOut === baseH.cands.length, + 'gate 2: dropped + kept != total — the filter is inventing or eating candidates'); + for (const c of bannedH.cands) { + assert(!c.ids.includes('a5'), + `gate 2: candidate ${c.ids.join(',')} survived the house ban with a5 in it — ` + + 'the card would recommend a line the session refuses to rig, i.e. the card lying'); + } + for (const r of [...bannedH.winners, ...bannedH.marginalWinners]) { + assert(!r.ids.includes('a5'), `gate 2: recommended line ${r.ids.join(',')} touches banned steel`); + } + }]); + + tests.push(['gate 2: the cap re-prices affordability without touching a single load', () => { + // The cap is derived from the fixture's own price list so the test + // tracks the physics instead of hardcoding tonight's dollars — and so + // it can never go vacuous: when the yard's clean lines differ in price + // the cap sits at the cheapest (keeping it, excluding the rest); when + // they all cost the same (this symmetric fixture's actual shape, the + // first run of this test proved it) the cap sits $1 under, and must + // exclude every clean line. + assert(baseH.winners.length >= 1, 'gate 2 vacuity guard: no clean line to cap — re-fixture'); + const prices = [...new Set(baseH.rows.filter((r) => r.cleanHw != null).map((r) => r.cleanHw))] + .sort((a, b) => a - b); + const capVal = prices.length >= 2 ? prices[0] : prices[0] - 1; + const over = baseH.rows.filter((r) => r.cleanHw != null && r.cleanHw > capVal); + assert(over.length >= 1, 'gate 2 vacuity guard: the derived cap excludes nothing — unreachable by construction, so the derivation regressed'); + const capped = auditSweep({ ...sweepArgs, constraints: [{ kind: 'budgetCap', cap: capVal, + label: `client caps the rig at $${capVal}`, says: 'Cheapest option that does the job.' }] }); + assert(capped.budget === capVal, `gate 2: cap $${capVal} not echoed (got ${capped.budget})`); + assert(capped.winners.length === baseH.winners.length - over.filter((o) => baseH.winners.some((w) => w.ids.join(',') === o.ids.join(','))).length, + `gate 2: ${capped.winners.length} winner(s) under the cap — the cap must remove exactly the clean lines it cannot buy`); + for (const r of capped.winners) { + assert(r.cleanHw <= capVal, `gate 2: winner ${r.ids.join(',')} at $${r.cleanHw} sold over a $${capVal} cap`); + } + for (const o of over) { + const twin = capped.rows.find((r) => r.ids.join(',') === o.ids.join(',')); + assert(twin && !twin.clean, + `gate 2: ${o.ids.join(',')} ($${o.cleanHw} clean) still reads clean under a $${capVal} cap`); + assert(twin.cleanHw === o.cleanHw && twin.hw === o.hw, + `gate 2: the cap moved a PRICE on ${o.ids.join(',')} — the client caps the invoice, ` + + 'never the physics; loads and tiers must be byte-identical'); + } + return `cap $${capVal} (${prices.length >= 2 ? 'cheapest of ' + prices.length + ' prices' : 'all clean lines priced $' + prices[0] + ', capped under'}) — ${over.length} line(s) priced out, loads untouched`; + }]); + + tests.push(['gate 2: a ban that empties the yard says no-cover honestly, with the drop counted', () => { + const allBanned = auditSweep({ ...sweepArgs, constraints: [{ kind: 'noAnchorFamily', family: 'post', + label: 'nothing on the posts', says: 'Those posts are heritage-listed, believe it or not.' }] }); + assert(allBanned.cands.length === 0 && allBanned.verdict.code === 'no-cover', + 'gate 2: banning every rideable family did not empty the sweep'); + assert(allBanned.constrainedOut === baseH.cands.length, + `gate 2: emptied yard reports ${allBanned.constrainedOut} dropped, expected ${baseH.cands.length} — ` + + 'the card cannot tell "no geometry" from "client forbids all of it" without this number'); + }]); + } + return tests; } diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js index f5c3421..44da425 100644 --- a/tools/site_audit/sweep.js +++ b/tools/site_audit/sweep.js @@ -22,6 +22,9 @@ 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 = { @@ -37,6 +40,17 @@ export const AUDIT = { * 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 @@ -90,11 +104,22 @@ export const tierFor = (peakN, ratingHint = 1) => * @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 - * @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }} + * @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 }) { - const cands = findCandidates({ anchors, bed, siteDef }); - if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } }; +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: @@ -114,8 +139,43 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [ const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors); use?.(wind); - const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind })); - return judgeSweep(cands, rows); + 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 }; } /** @@ -166,9 +226,10 @@ export function findCandidates({ anchors, bed, siteDef = null }) { * 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 }) { - // shade cloth (porosity 0.30): the fabric a competent player takes into a windy night - const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }) +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); @@ -196,10 +257,14 @@ export function priceCandidate(cnd, { anchors, stormDef, wind }) { 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 <= START_BUDGET, - clean: cleanHw != null && cleanHw <= START_BUDGET }; + affordable: !unholdable.length && hw <= budget, + clean: cleanHw != null && cleanHw <= budget }; } /** @@ -250,20 +315,25 @@ export function judgeSweep(cands, rows) { * @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 }) { - const cands = findCandidates({ anchors, bed, siteDef }); - if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: 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 })); + 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); + return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped }; } /** diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js index e649e76..acaba4e 100644 --- a/web/world/js/editor_score.js +++ b/web/world/js/editor_score.js @@ -186,6 +186,13 @@ function boot() { function render(out, s, ms) { out.replaceChildren(); + // gate 2 (SPRINT17): the budget every affordability sentence on this card is + // denominated in — START_BUDGET on an open night, the client's cap on a + // capped one. The engine decided with this number; the card must print with + // the same one or the two tell different stories. + const BUDGET = s.budget ?? START_BUDGET; + const capped = BUDGET < START_BUDGET; + // ── 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 @@ -197,6 +204,20 @@ function render(out, s, ms) { 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'); + // SPRINT17 gate 0.3 [B, A consulted] — the fabric charter, printed on EVERY + // card, not only where somebody already suspects a problem: "a disclosure + // that appears only where somebody already knows there's a problem teaches + // that its absence means no problem" (A's consult, adopted). Same discipline + // as the funnel row above — a score whose fabric you can't see is a rumour. + kv(head, 'fabric', `${s.fabric.name} (sweep charter)`, s.fabricLeaks ? 'ed-warn' : null); + if (s.fabricLeaks) { + // D's soaker finding, worded with A: name what is MISSING from the card, + // not what key to press — the binding can move, the missing line can't. + head.append(el('div', 'ed-note ed-warn', + 'Scored on SHADE CLOTH, and tonight\'s stones pass that weave — on this night the ' + + 'fabric is the bet, and the audit does not place it. The membrane line is not in ' + + 'these numbers: a DEAD garden table below means the cloth loses, not the night.')); + } kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`, s.dressed ? 'ed-ok' : 'ed-err'); if (!s.dressed) { @@ -206,6 +227,17 @@ function render(out, s, ms) { + '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})`); + // gate 2: the client's terms this score ran under, on the header with the + // other things that change what the numbers MEAN (funnel, fabric). Nothing + // prints on an unconstrained score — absence must keep meaning absence. + if (s.constraints?.length) { + for (const c of s.constraints) kv(head, 'client terms', `⚖ ${c.label}`, 'ed-warn'); + if (s.constrainedOut > 0) { + head.append(el('div', 'ed-note ed-warn', + `${s.constrainedOut} line(s) in the audit band touch banned steel and were not scored — ` + + 'they are not answers on this night, whatever they hold.')); + } + } kv(head, 'took', `${(ms / 1000).toFixed(1)} s`); // ── 1. winnability at $80 ──────────────────────────────────────────────── @@ -213,16 +245,22 @@ function render(out, s, ms) { 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); + const cw = card(out, `${vhead} — at ${money(BUDGET)}${capped ? ' (client cap)' : ''}`, 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.)')); + cw.append(el('div', 'ed-note ed-err', s.constrainedOut > 0 + // gate 2: same verdict code, different fact — a yard the BAN emptied is + // not a yard with no geometry, and saying so tells the author which + // knob is wrong (the constraint's, not the yard's). + ? `Every quad that shades the bed touches banned steel (${s.constrainedOut} removed by the ` + + 'client\'s terms). The yard has geometry; this CLIENT forbids all of it — a night the ' + + 'board must not offer, or a constraint that needs re-ruling.' + : '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', @@ -234,7 +272,7 @@ function render(out, s, ms) { 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` + + `${money(BUDGET)}${capped ? ' (client-capped)' : ''} budget` + (b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop’s ceiling at any price.` : '.'))); @@ -249,14 +287,15 @@ function render(out, s, ms) { 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, '+ spare', money(ch.total), ch.total <= BUDGET ? 'ed-ok' : 'ed-warn'); kv(cc, 'area', `${ch.row.area.toFixed(0)} m²`); kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`, ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err'); kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`); - if (ch.total > START_BUDGET) { + if (ch.total > 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 — ` + `Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare` + + `${capped ? ' under the client\'s cap' : ''} — ` + 'a night with no spare is a repair the player cannot make.')); } } else { diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index bdcde17..cc10aed 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -113,18 +113,109 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const OK = { ok: true }; const fail = (reason) => ({ ok: false, reason }); +/** + * SPRINT17 gate 2 [B] — CLIENT CONSTRAINTS: A's data, this file's teeth. + * + * The shapes are A's seam contract (THREADS 2026-07-21, posted before either + * side was load-bearing), verbatim: + * + * { kind:'noAnchorFamily', family:'house', label:'…', says:'…' } + * { kind:'budgetCap', cap:45, label:'…', says:'…' } + * + * `label` is what the papers print (A's half); `says` is the client's own + * voice, and it is what this session's refusals return — the ticker on a + * refused pick is the CLIENT talking, not the software. `family` is an + * ANCHOR_TYPE to refuse; `cap` is a hard ceiling on the night's SPEND (the + * client caps the invoice, not your wallet), enforced where the spending + * happens and asserted again at commit. + * + * The enum is CHECKED on both ends of the seam on purpose: A's + * validateConstraint guards the data source (board.js), this guards the + * enforcement door — a constraint that arrives here malformed is a caller bug + * (setHardware's class, so it THROWS and names the problem), never a player + * mistake to ticker at. + */ +export const CONSTRAINT_KINDS = Object.freeze(['noAnchorFamily', 'budgetCap']); +export function validateNightConstraint(c) { + if (!c || typeof c !== 'object') { + throw new TypeError(`night constraint must be an object, got ${JSON.stringify(c)}`); + } + if (!CONSTRAINT_KINDS.includes(c.kind)) { + throw new TypeError( + `unknown constraint kind "${c.kind}" — known: ${CONSTRAINT_KINDS.join(', ')}. ` + + 'An unenforced enum is decoration (the carport-typed-as-a-post scar); if A shipped a ' + + 'new kind, the session needs its enforcement written before a night can carry it.'); + } + if (typeof c.says !== 'string' || !c.says || typeof c.label !== 'string' || !c.label) { + throw new TypeError( + `constraint "${c.kind}" is missing says/label — a constraint with no client words ` + + 'renders an empty quote block on two papers and an empty refusal ticker here.'); + } + if (c.kind === 'noAnchorFamily' && (typeof c.family !== 'string' || !c.family)) { + throw new TypeError('noAnchorFamily with no family bans nothing — decoration.'); + } + if (c.kind === 'budgetCap' && !(Number.isFinite(c.cap) && c.cap > 0)) { + throw new TypeError(`budgetCap cap must be a positive number, got ${JSON.stringify(c.cap)}`); + } + return c; +} + export class RiggingSession { /** * @param {object} opts * @param {Array} opts.anchors world.anchors — [{id, pos, type, sway?}] * @param {number} opts.budget starting cash + * @param {Array} opts.constraints the night's client constraints (A's + * shapes, validated here) — see setConstraints */ - constructor({ anchors = [], budget = START_BUDGET } = {}) { + constructor({ anchors = [], budget = START_BUDGET, constraints = [] } = {}) { this.anchors = anchors; this._startBudget = budget; + this.setConstraints(constraints); this.reset(); } + /** + * The client's terms for tonight (gate 2). Validated at the door — a + * malformed constraint throws with the kind named, because by the time one + * reaches the session it has already passed A's board validator, so a bad + * one here is a caller bug, not data. + * + * SURVIVES reset() deliberately, like `_startBudget`: "play again" on the + * same night replays the same client. The wiring contract (filed for A, the + * setBudget pattern): main.js calls + * `session.setConstraints(week.job?.constraints ?? [])` at the same night + * boundary that re-banks the shop — the `?? []` is what clears the previous + * client's terms, so an unconstrained night cannot inherit a ban. + */ + setConstraints(list = []) { + for (const c of list) validateNightConstraint(c); + this._constraints = Object.freeze([...list]); + return this; + } + get constraints() { return this._constraints; } + + /** The ban that covers this anchor, or null. (gate 2, noAnchorFamily) */ + _banFor(anchor) { + return this._constraints.find((c) => c.kind === 'noAnchorFamily' && c.family === anchor.type) ?? null; + } + + /** + * The cap refusal for spending `amount` more, or null. (gate 2, budgetCap) + * + * Checked at SPEND time, not only at commit: a cap the player discovers + * four corners deep is a restart wearing a rule's name, and the refusal + * reason is the client's own words so the ticker reads as the client + * querying the invoice, mid-shop. Refunds are always allowed — the way BACK + * under the cap must never be blocked by the cap. + */ + _capRefusal(amount) { + if (!(amount > 0)) return null; + const c = this._constraints.find((x) => x.kind === 'budgetCap'); + if (!c) return null; + return (this.spent + amount > c.cap) ? fail(c.says) : null; + } + /** * Back to an empty prep phase, same anchors and starting budget. Lane A's * "play again" reaches into the state machine to fake a fresh round rather @@ -172,7 +263,11 @@ export class RiggingSession { return this.reset(); } - get spent() { return START_BUDGET - this.budget; } + // Against _startBudget, not the global (gate 2's incidental fix): the two + // are identical on every shipped night, but a re-banked session measuring + // its spend against the DEFAULT bank would misprice the cap check the + // moment those diverge — the same class of quiet lie as the frozen sway. + get spent() { return this._startBudget - this.budget; } get canStart() { return this.picks.length === MAX_CORNERS; } isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); } pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; } @@ -188,8 +283,14 @@ export class RiggingSession { rig(anchorId) { const a = this.anchors.find((x) => x.id === anchorId); if (!a) return fail('no such anchor'); + // gate 2: the house ban — refused in the CLIENT's words. The ticker is the + // client talking ("Nothing goes on the house…"), not the software. + const ban = this._banFor(a); + if (ban) return fail(ban.says); if (this.isRigged(anchorId)) return fail('already rigged'); if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners'); + const capNo = this._capRefusal(HARDWARE[0].cost); + if (capNo) return capNo; if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget'); this.picks.push({ anchorId, hw: HARDWARE[0] }); this._reorder(); @@ -214,6 +315,8 @@ export class RiggingSession { const p = this.pickOf(anchorId); if (!p) return fail('not rigged'); const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length]; + const capNo = this._capRefusal(next.cost - p.hw.cost); + if (capNo) return capNo; if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget'); p.hw = next; return OK; @@ -247,6 +350,8 @@ export class RiggingSession { } const p = this.pickOf(anchorId); if (!p) return fail('not rigged'); + const capNo = this._capRefusal(hw.cost - p.hw.cost); + if (capNo) return capNo; if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget'); p.hw = hw; return OK; @@ -260,6 +365,8 @@ export class RiggingSession { setFabric(f) { const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f; if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric'); + const capNo = this._capRefusal(pick.cost - this.fabric.cost); + if (capNo) return capNo; // both fabrics are $0 today; the cap covers the day one isn't if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget'); this.fabric = pick; return OK; @@ -275,6 +382,12 @@ export class RiggingSession { setSpares(n) { n = Math.max(0, Math.floor(n)); const delta = (n - this.spares) * SPARE_COST; + // gate 2: the cap covers spares too — the client caps the INVOICE, and the + // spare is on it. "$40 of steel + a $15 spare" under a $45 cap is the + // constraint's own squeeze, said out loud here rather than discovered at + // settlement. + const capNo = this._capRefusal(delta); + if (capNo) return capNo; if (!this._spend(delta)) return fail('not enough budget'); this.spares = n; return OK; @@ -327,6 +440,26 @@ export class RiggingSession { /** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */ commit(rig) { if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`); + // gate 2, the promise the sprint doc names ("enforces the budget cap at + // commit"): by now the refusals above have made these states unreachable + // through the session's own API, so reaching one is a caller walking + // around the doors — setHardware's class. THROW, don't ticker: a sail + // attached over the client's terms is a bug on its way to an invoice. + for (const p of this.picks) { + const a = this.anchors.find((x) => x.id === p.anchorId); + const ban = a && this._banFor(a); + if (ban) { + throw new Error( + `commit: ${p.anchorId} is ${a.type} steel and the client's terms say "${ban.label}" — ` + + 'a pick got past the rig() refusal. Whoever built these picks skipped the session.'); + } + } + const cap = this._constraints.find((c) => c.kind === 'budgetCap'); + if (cap && this.spent > cap.cap) { + throw new Error( + `commit: $${this.spent} spent under the client's $${cap.cap} cap — ` + + 'a spend got past the session\'s cap refusals.'); + } // Fabric before attach: porosity scales the wind pressure term and decides // whether the cloth ponds, so the sail has to know what it's made of before // it is built. (It's also half of the wild night's only winnable line — diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index 1f0d041..5e27f07 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -392,6 +392,108 @@ test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence w return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`; }); +// ---------- SPRINT17 gate 2: client constraints — A's shapes, this session's teeth ---------- +// The literals below are A's seam contract verbatim (THREADS 2026-07-21): the +// session must enforce exactly the shape the board ships, not a friendlier +// cousin of it. If A's shapes move, these move in the same commit or go red. + +const HOUSE_BAN = { + kind: 'noAnchorFamily', family: 'house', + label: 'nothing attached to the house', + says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.', +}; +const CAP_45 = { + kind: 'budgetCap', cap: 45, + label: 'client caps the rig at $45', + says: 'Cheapest option that does the job. Anything over forty-five and I\'ll be querying the invoice.', +}; +const FAKE_RIG = { setFabric() {}, attach: () => 'attached' }; + +test('gate 2: the house ban refuses the pick in the CLIENT\'s words, charges nothing, bans a family not a yard', () => { + const s = session().setConstraints([HOUSE_BAN]); + for (const id of ['h1', 'h2', 'h3']) { + const r = s.rig(id); + assert(!r.ok, `${id} rigged under a house ban`); + assert(r.reason === HOUSE_BAN.says, + `the refusal must be the client's own words for the ticker, got "${r.reason}"`); + } + assert(s.budget === START_BUDGET, 'a refused pick was charged'); + assert(s.picks.length === 0, 'a refused pick was kept'); + // the rest of the yard stays open — the ban is a family, not a lockout + for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, `${id} refused without being banned`); + assert(s.canStart, 'four honest corners must still close the quad under a house ban'); + assert(s.commit(FAKE_RIG) === 'attached', 'a legal rig failed to commit under the ban'); + return 'h1/h2/h3 refused with the client talking, t/p corners rigged and committed'; +}); + +test('gate 2: the cap is a wall at spend time — every path, exact at the boundary, refunds always free', () => { + const s = session().setConstraints([CAP_45]); + for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, 'carabiners must fit any sane cap'); + assert(s.setHardware('t1', SHACKLE).ok, 'upgrade inside the cap refused'); // $30 + assert(s.setSpares(1).ok, 'spend TO the cap refused — the cap is a ceiling, not a strict bound'); // $45, exact + assert(s.spent === CAP_45.cap, `expected exactly $${CAP_45.cap} spent, got $${s.spent}`); + // every remaining spend path refuses, in the client's words, charging nothing + for (const [what, r] of [ + ['cycleHardware', s.cycleHardware('t2')], + ['setHardware', s.setHardware('t2', RATED)], + ['setSpares', s.setSpares(2)], + ]) { + assert(!r.ok, `${what} spent past the client's cap`); + assert(r.reason === CAP_45.says, `${what} refusal must be the client's words, got "${r.reason}"`); + } + assert(s.spent === CAP_45.cap && s.pickOf('t2').hw === CARABINER && s.spares === 1, + 'a refused spend changed state anyway'); + // the way BACK under the cap is never blocked — refund, then respend + assert(s.setSpares(0).ok, 'a refund was blocked by the cap'); // $30 + assert(s.setHardware('t2', SHACKLE).ok, 'freed room under the cap refused'); // $40 + assert(s.commit(FAKE_RIG) === 'attached', 'a rig at $40 under a $45 cap failed to commit'); + return `spent to $${CAP_45.cap} exactly, three paths refused past it, refund freed $10 of room`; +}); + +test('gate 2: commit is belt-and-braces — a caller who walked around the doors gets a THROW, not an invoice', () => { + // Both states below are unreachable through the session's own API (the + // refusal tests above prove the doors), so these poke the internals the way + // only a buggy caller could — and commit must refuse to attach the sail. + const s = session().setConstraints([HOUSE_BAN]); + for (const id of ['t1', 't2', 'p1']) s.rig(id); + s.picks.push({ anchorId: 'h1', hw: CARABINER }); // banned steel, no door used + let threw = null; + try { s.commit(FAKE_RIG); } catch (e) { threw = e; } + assert(threw && /h1/.test(threw.message), 'commit attached a sail to banned steel'); + + const s2 = session().setConstraints([CAP_45]); + for (const id of ['t1', 't2', 'p1', 'p2']) s2.rig(id); + s2.picks[0].hw = RATED; s2.picks[1].hw = RATED; // $70 of steel, budget never asked + s2.budget = START_BUDGET - 2 * RATED.cost - 2 * CARABINER.cost; + threw = null; + try { s2.commit(FAKE_RIG); } catch (e) { threw = e; } + assert(threw && new RegExp(`\\$${CAP_45.cap}`).test(threw.message), + 'commit attached a sail spent past the client\'s cap'); + return 'banned pick and cap overspend both throw at commit, naming the term broken'; +}); + +test('gate 2: the constraint door is a checked enum, and the terms survive reset but not setConstraints([])', () => { + const throwsOn = (list) => { + try { session().setConstraints(list); return false; } catch { return true; } + }; + assert(throwsOn([{ kind: 'noSwearing', label: 'x', says: 'y' }]), + 'unknown kind accepted — the enum is decoration (the carport-typed-as-a-post scar)'); + assert(throwsOn([{ kind: 'budgetCap', cap: 0, label: 'x', says: 'y' }]), 'cap $0 accepted — a soft-lock'); + assert(throwsOn([{ kind: 'budgetCap', cap: NaN, label: 'x', says: 'y' }]), 'cap NaN accepted — NaN compares false and caps nothing'); + assert(throwsOn([{ kind: 'noAnchorFamily', family: 'house' }]), 'a constraint with no client words accepted — empty ticker, empty papers'); + assert(throwsOn([{ kind: 'noAnchorFamily', label: 'x', says: 'y' }]), 'a ban with no family accepted — bans nothing'); + // persistence: reset() is "play again" on the SAME night, so the client's + // terms ride through it (the _startBudget pattern); [] is the only clear, + // which is why the wiring contract is setConstraints(job?.constraints ?? []) + const s = session().setConstraints([HOUSE_BAN]); + s.reset(); + const r = s.rig('h1'); + assert(!r.ok && r.reason === HOUSE_BAN.says, 'reset() dropped the client\'s terms — "play again" rigged the house'); + s.setConstraints([]); + assert(s.rig('h1').ok, 'setConstraints([]) did not clear the ban — the next client inherited it'); + return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly'; +}); + export const RIGGING_TESTS = TESTS; export function runRiggingSelftest() {