Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client
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.
This commit is contained in:
parent
aa0b36a54c
commit
1bcfcdad67
@ -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.
|
||||
|
||||
@ -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<object>} pure data — no DOM, no strings-as-verdicts
|
||||
*/
|
||||
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
|
||||
onProgress = null, yieldEvery = 1 }) {
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,14 +245,20 @@ 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 '
|
||||
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') {
|
||||
@ -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 {
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user