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

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-21 03:06:16 +10:00
commit 4cfb227935
10 changed files with 730 additions and 35 deletions

View File

@ -8713,3 +8713,106 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the
optional slot went to crossing all three yards instead of one, filed here with its reason: optional slot went to crossing all three yards instead of one, filed here with its reason:
a complete cross is worth more than a prettier panel the week the board ships. a complete cross is worth more than a prettier panel the week the board ships.
[B] 2026-07-21 — 🔧 **GATES 0.2 + 0.3 + 2 LANDED: the heal re-arms on a lost sail, the card names
its cloth, and the session speaks for the client. Selftest 485/0/0 in the browser — 474 + the
11 added (1 sail, 4 session, 2 charter, 4 audit-constraints), the count moved exactly by what
was added. Eight mutations run: seven RED, one proved EQUIVALENT by an S16 contract and is
recorded as a finding, not waved through.**
**GATE 0.2 — THE SECOND WIRE (D's poison poke, my S16 filing, verbatim brief).** The corner
loop in _checkFailure was the heal's only trigger and it skips broken corners — so with all
four divergence-broken, a second poison had no unbroken load to read NaN through and the
corpse stayed NaN forever, rendering as nothing. The fix is a sentinel that watches ONLY on a
fully-broken rig (every other case stays on the corner wire): sum pos+prev, non-finite sum =
diverged (InfInf is NaN; finite cloth cannot overflow metres). Branch-free, no mutation, so
a FINITE lost sail is untouched byte-for-byte. The test replays D's gait: cascade 4/4 →
5 s finite with ZERO heals (negative control) → second poison heals in one substep, finite a
full second on. A lost sail now LOOKS lost — cloth on the yard — instead of vanishing.
**GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding; A consulted, and A's counsel
adopted on both counts).** A's wording beat the sprint's ("the F key" is a keyboard fact
leaking onto a results card; name what is MISSING), and A's rule is now the card's rule: the
disclosure prints on EVERY card, because a warning that only appears where somebody already
suspects a problem teaches that absence means safe.
· The charter number is ONE knob now: AUDIT.POROSITY (sweep.js), read by priceCandidate and
flyGarden both — it was hardcoded 0.30 in two files, which is how a charter drifts.
· scorecard.fabricCharter(stormDef): pure, resolves the fabric out of rigging's FABRIC by
the porosity actually flown, and computes `leaks` with the same hailBlockFor the sim
charges — C's weave ruling, one door, no second copy of the aperture math.
· **Verified on the glass, my own eyes, the exact scenario D filed** (site_02 × soaker,
scored on :8825): the header card now reads `fabric · shade cloth (sweep charter)` and, on
this storm whose stones pass the weave: "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." Directly above ✓ WINNABLE at $80 over bare 17.5 DEAD — the silence D
flagged is gone. Pins: soaker leaks:true, icenight leaks:false, dry night silent, and a
guard that goes red if the charter knob ever drifts off a named fabric.
**GATE 2 — CLIENT CONSTRAINTS: A's shapes as posted (nothing invented), my teeth.** Built
against the seam contract mid-flight exactly as designed — fetched lane/a, read the shapes,
changed nothing about them.
· **The session** (rigging.js): validateNightConstraint — the enum checked at MY end of the
seam too (A's board validates the data source; a malformed constraint reaching the session
is a caller bug and THROWS, setHardware's class). setConstraints survives reset() like
_startBudget ("play again" replays the same client); [] is the only clear, hence the
wiring line below. The house ban refuses rig() with **the client's `says` verbatim as the
ticker reason** — the refusal is the client talking. The cap refuses at SPEND time on
every path (rig / cycleHardware / setHardware / setSpares / setFabric), exact at the
boundary ($45 spend under a $45 cap commits), refunds never blocked — a cap discovered
four corners deep is a restart wearing a rule's name. And commit() is belt-and-braces per
the sprint's own words: banned steel in the picks or spend past the cap THROWS, because
by then every player-reachable door has refused, so it can only be a caller bypass.
· **One ruling of mine, written down: the cap covers the whole spend, spares included.**
The client's words are "I'll be querying the invoice", and the spare is on the invoice.
$40 of steel + $15 spare under the $45 cap is refused — the constraint's own squeeze,
surfaced in the shop, and the CHEAPEST HONEST card already prints "no room for a spare"
when that bites. Contest at integration if the papers disagree.
· **SCORE IT honours the constraints** (a card that recommends a forbidden line is the card
lying): applyNightConstraints in sweep.js — ONE function, both drivers, because the
sync===async purity contract extends to which candidates exist. Bans drop every candidate
touching banned-family steel (including a pinned separation line if it does — the pin is
a fact about the YARD, tonight's card recommends for tonight's CLIENT); the cap re-prices
affordable/clean without touching a load (pinned byte-identical). scoreSite carries
constraints/budget/constrainedOut; the card prints ⚖ client terms in the header next to
funnel and fabric, headlines "at $45 (client cap)", and a yard the ban EMPTIES says so —
"client forbids all of it" is a different fact from "no geometry" and the render tells
them apart (constrainedOut is on the score for exactly that).
· Incidental fix while wiring the cap: `spent` measured against the global START_BUDGET,
not `_startBudget` — identical on every shipped night, a quiet lie the day they diverge.
**MUTATIONS — eight, each applied/run/reverted, messages recorded:**
| mutation | result |
|---|---|
| 0.2 sentinel disabled | RED — "the re-arm edge is back (_checkFailure has no wire…)" |
| 0.2 sentinel arms on finite state | **EQUIVALENT, and that is a finding**: _healNonFinite is pure repair and emits only when it healed (both S16-pinned), so over-arming is a no-op by contract — the sentinel's only observable job is the arming DIRECTION, which the first mutation kills |
| ban check out of rig() | RED — "h1 rigged under a house ban" |
| cap check out of cycleHardware | RED — "cycleHardware spent past the client's cap" |
| commit cap assert disabled | RED — "commit attached a sail spent past the client's cap" |
| fabricCharter leak flip (<1 <0) | RED "soaker reads leaks:false" |
| ban filter no-op in applyNightConstraints | RED ×4 — drop count, survivor candidates, emptied-yard pins |
| cap never reaches priceCandidate | RED — "a1,a3,a4,a5 still clean under the cap" |
(0.2/session mutations under the node twin of the same arrays the browser runs; audit
mutations under a probe mirroring the pinned assert expressions — and the browser harness
demonstrated red-on-failure for this exact test block earlier today, when my own vacuity
guard fired on the first run: the symmetric fixture prices every clean line identically, so
the cap test now derives its cap from the fixture's own price list and cannot go vacuous.)
**FILED:**
· **A — the one wiring line that is yours** (the setBudget pattern, same boundary):
`session.setConstraints(week.job?.constraints ?? [])` wherever main.js re-banks the shop
for the night. The `?? []` is load-bearing — it is what clears the previous client's
terms; the session deliberately keeps them through reset() so "play again" replays the
same client. Everything else on my side is live the moment that line lands.
· **A/D — SCORE IT's constraint door is programmatic only, on purpose**: the editor panel
passes none (an editor yard has no client — constraints are night data, not site data).
Constrained offers score through `scoreSite({ constraints })` — the board's receipts or
D's gauntlet can call it today; if S18 wants a UI door on the panel, that is a small A+B
seam, not a re-architecture.
· **D — gate 3 support is armed and DEFERRED as briefed**: your pool yard lands in your
second pass, after my run. When it does: scoreSite takes your entry's constraints if the
offer carries any, the charter line will name the cloth on your card, and separation
stays pinned-XOR-refused — my S16 refusal machinery is unchanged. Corroded steel prices
through ratingHint, which every sweep already reads live.
Pushed lane/b: aa0b36a (gate 0.2), 1bcfcda (gates 0.3+2), this entry. Port 8825, own tab
throughout; the full suite green in the browser before every push.

View File

@ -99,7 +99,7 @@ export function hardwareByName(name) {
* you want a different door.] * you want a different door.]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }} * @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); const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind); use?.(wind);
@ -107,8 +107,8 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null,
if (ids) { if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle')); const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0); cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30) unless the caller is measuring the fabric // shade cloth (AUDIT.POROSITY — the sweep charter, one knob since
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves. // SPRINT17 gate 0.3) unless the caller is measuring the fabric bet.
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension); 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 // 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. // bed and the number LOOKS plausible. Refuse to fly it.

View File

@ -53,9 +53,46 @@
import * as THREE from '../../web/world/vendor/three.module.js'; import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.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 { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.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 /** 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 * on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
* anchors is a hang, not a score. */ * 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 * @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, 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 built = prebuilt ?? await buildScoringWorld(site);
const { world, anchors, bed, use, dressed, dressError } = built; 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 }; : { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
} }
const { cands, rows, verdict, winners, marginalWinners } = // gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms —
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery }); // 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 // 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 // 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 { return {
site: site.id ?? site.name ?? '(unnamed)', site: site.id ?? site.name ?? '(unnamed)',
storm: stormName, dressed, dressError, storm: stormName, dressed, dressError,
venturi, funnelOn: venturi.length > 0, 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, anchorCount: anchors.length, bed,
cands: cands.length, rows, winners, marginalWinners, verdict, cands: cands.length, rows, winners, marginalWinners, verdict,
flown, skipped, flyCap, flown, skipped, flyCap,

View File

@ -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 // correction, and the right call: a pin that retypes the thing it pins agrees
// with itself by construction. // with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js'; import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js'; import { buildScoringWorld, fabricCharter } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.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); }; 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.`); `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; return tests;
} }

View File

@ -22,6 +22,9 @@
import { SailRig, orderRing } from '../../web/world/js/sail.js'; import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js'; import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.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. */ /** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = { export const AUDIT = {
@ -37,6 +40,17 @@ export const AUDIT = {
* A line with a marginal corner is flagged, never sold as a clean PASS. * A line with a marginal corner is flagged, never sold as a clean PASS.
*/ */
MARGIN: 0.15, 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 // 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 // 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 * @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) called with the sweep's wind so live * pattern) called with the sweep's wind so live
* tree-sway closures sample the storm being flown * 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 }) { export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, constraints = [] }) {
const cands = findCandidates({ anchors, bed, siteDef }); const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } }; 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 // 2. peak corner loads, flown the way the GAME flies them. Every clause here
// is a bug some harness shipped: // 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); const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind); use?.(wind);
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind })); const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind, budget: con.budget }));
return judgeSweep(cands, rows); 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 * same wind at the same seconds, chunking cannot change a number the
* scorecard selftest asserts exactly that (chunked === sync, to the byte). * scorecard selftest asserts exactly that (chunked === sync, to the byte).
*/ */
export function priceCandidate(cnd, { anchors, stormDef, wind }) { export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BUDGET }) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night // shade cloth (AUDIT.POROSITY): the fabric a competent player takes into a
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }) // 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); .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); 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 hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
const cleanHw = tiers.every((c) => c.cleanTier) const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null; ? 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, return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST, total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET, affordable: !unholdable.length && hw <= budget,
clean: cleanHw != null && cleanHw <= START_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 * @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
*/ */
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
onProgress = null, yieldEvery = 1 }) { onProgress = null, yieldEvery = 1, constraints = [] }) {
const cands = findCandidates({ anchors, bed, siteDef }); const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } }; 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); const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind); use?.(wind);
const rows = []; const rows = [];
for (let i = 0; i < cands.length; i++) { 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 }); onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop(); if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
} }
return judgeSweep(cands, rows); return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
} }
/** /**

View File

@ -186,6 +186,13 @@ function boot() {
function render(out, s, ms) { function render(out, s, ms) {
out.replaceChildren(); 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 ──────── // ── the header card: WHAT WAS SCORED, and the funnel state, loudly ────────
// Sprint 11 shipped a headline built with the funnel off, and the number // Sprint 11 shipped a headline built with the funnel off, and the number
// looked entirely reasonable. Three harnesses made that mistake because // looked entirely reasonable. Three harnesses made that mistake because
@ -197,6 +204,20 @@ function render(out, s, ms) {
kv(head, 'venturi', s.funnelOn kv(head, 'venturi', s.funnelOn
? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ') ? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
: 'none declared in site.wind'); : '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'}`, kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`,
s.dressed ? 'ed-ok' : 'ed-err'); s.dressed ? 'ed-ok' : 'ed-err');
if (!s.dressed) { if (!s.dressed) {
@ -206,6 +227,17 @@ function render(out, s, ms) {
+ 'are not what ships. Fix the page before believing this card.')); + 'are not what ships. Fix the page before believing this card.'));
} }
kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`); kv(head, '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`); kv(head, 'took', `${(ms / 1000).toFixed(1)} s`);
// ── 1. winnability at $80 ──────────────────────────────────────────────── // ── 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 vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err';
const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY', const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY',
unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code; 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, '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, '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); kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null);
if (V.code === 'no-cover') { if (V.code === 'no-cover') {
cw.append(el('div', 'ed-note ed-err', cw.append(el('div', 'ed-note ed-err', s.constrainedOut > 0
'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an ' // gate 2: same verdict code, different fact — a yard the BAN emptied is
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — ' // not a yard with no geometry, and saying so tells the author which
+ 'a yard failing here is failing on geometry, not on the heuristic.)')); // 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') { } else if (V.code === 'marginal-only') {
const b = V.best; const b = V.best;
cw.append(el('div', 'ed-note ed-warn', cw.append(el('div', 'ed-note ed-warn',
@ -234,7 +272,7 @@ function render(out, s, ms) {
const b = V.best; const b = V.best;
cw.append(el('div', 'ed-note ed-err', cw.append(el('div', 'ed-note ed-err',
`No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a ` `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 + (b.unholdable.length
? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shops ceiling at any price.` ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shops ceiling at any price.`
: '.'))); : '.')));
@ -249,14 +287,15 @@ function render(out, s, ms) {
if (ch) { if (ch) {
kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' 📌 pinned' : '')); 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, '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)}`); kv(cc, 'area', `${ch.row.area.toFixed(0)}`);
kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`, kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`,
ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err'); 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`); 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', 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.')); + 'a night with no spare is a repair the player cannot make.'));
} }
} else { } else {

View File

@ -113,18 +113,109 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true }; const OK = { ok: true };
const fail = (reason) => ({ ok: false, reason }); 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 { export class RiggingSession {
/** /**
* @param {object} opts * @param {object} opts
* @param {Array} opts.anchors world.anchors [{id, pos, type, sway?}] * @param {Array} opts.anchors world.anchors [{id, pos, type, sway?}]
* @param {number} opts.budget starting cash * @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.anchors = anchors;
this._startBudget = budget; this._startBudget = budget;
this.setConstraints(constraints);
this.reset(); 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 * 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 * "play again" reaches into the state machine to fake a fresh round rather
@ -172,7 +263,11 @@ export class RiggingSession {
return this.reset(); 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; } get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); } isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; } pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
@ -188,8 +283,14 @@ export class RiggingSession {
rig(anchorId) { rig(anchorId) {
const a = this.anchors.find((x) => x.id === anchorId); const a = this.anchors.find((x) => x.id === anchorId);
if (!a) return fail('no such anchor'); 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.isRigged(anchorId)) return fail('already rigged');
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners'); 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'); if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
this.picks.push({ anchorId, hw: HARDWARE[0] }); this.picks.push({ anchorId, hw: HARDWARE[0] });
this._reorder(); this._reorder();
@ -214,6 +315,8 @@ export class RiggingSession {
const p = this.pickOf(anchorId); const p = this.pickOf(anchorId);
if (!p) return fail('not rigged'); if (!p) return fail('not rigged');
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length]; 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'); if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
p.hw = next; p.hw = next;
return OK; return OK;
@ -247,6 +350,8 @@ export class RiggingSession {
} }
const p = this.pickOf(anchorId); const p = this.pickOf(anchorId);
if (!p) return fail('not rigged'); 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'); if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw; p.hw = hw;
return OK; return OK;
@ -260,6 +365,8 @@ export class RiggingSession {
setFabric(f) { setFabric(f) {
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f; const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric'); 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'); if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
this.fabric = pick; this.fabric = pick;
return OK; return OK;
@ -275,6 +382,12 @@ export class RiggingSession {
setSpares(n) { setSpares(n) {
n = Math.max(0, Math.floor(n)); n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST; 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'); if (!this._spend(delta)) return fail('not enough budget');
this.spares = n; this.spares = n;
return OK; return OK;
@ -327,6 +440,26 @@ export class RiggingSession {
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */ /** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) { commit(rig) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`); 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 // 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 // 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 — // it is built. (It's also half of the wild night's only winnable line —

View File

@ -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`; 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 const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() { export function runRiggingSelftest() {

View File

@ -970,6 +970,26 @@ export class SailRig {
// the spring network, and the cloth a player sees after the bang is a // the spring network, and the cloth a player sees after the bang is a
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break — // shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
// an honest overload break has finite state and must not be touched. // an honest overload break has finite state and must not be touched.
//
// SPRINT17 gate 0.2 — THE SECOND WIRE (D's poison poke, S16 filing). The
// loop above is the heal's only trigger and it SKIPS broken corners, so
// once all four are gone there is no corner load left to read NaN through:
// a fully-lost sail that diverged again stayed NaN forever and rendered as
// nothing. A lost sail is still cloth in the yard — it must LOOK lost, not
// vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire
// covers every other case): sum the state — any NaN/±Inf makes the sum
// non-finite (InfInf is NaN), and finite cloth cannot overflow it
// (positions are metres, prev within a step of pos). Branch-free O(n)
// adds, no mutation, so a finite lost sail is untouched byte-for-byte —
// the negative control in sail.selftest.js pins that.
if (!diverged
&& this.corners[0].broken && this.corners[1].broken
&& this.corners[2].broken && this.corners[3].broken) {
const pos = this.pos, prev = this.prev;
let s = 0;
for (let i = 0; i < pos.length; i++) s += pos[i] + prev[i];
if (!Number.isFinite(s)) diverged = true;
}
if (diverged) this._healNonFinite(); if (diverged) this._healNonFinite();
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; } if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
} }

View File

@ -327,6 +327,51 @@ test('divergence heal: negative control — honest overload breaks never trigger
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`; return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
}); });
test('divergence heal: re-arms on a fully-lost sail (D\'s second poison) — and stays silent on a finite one', () => {
// SPRINT17 gate 0.2, D's S16 filing verbatim: "with all four corners already
// divergence-broken, a SECOND poison has no unbroken corner left to arm the
// heal (_checkFailure skips broken corners), so a fully-lost sail that
// diverges again stays NaN and renders as nothing." The fix is the sentinel
// in _checkFailure — it watches ONLY when every corner is broken. This test
// is D's gait replayed: cascade, then poke the corpse.
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const heals = [];
r.events.on('heal', (e) => heals.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
// First poison: the cascade D observed — all four corners let go by
// divergence inside the run, the S16 heal answers it.
r.pos[(r.N + 1) * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(r.corners.every((c) => c.broken),
`first poison broke only ${r.corners.filter((c) => c.broken).length}/4 corners — D's cascade did not happen and this test is not testing the edge`);
assert(heals.length > 0, 'the first divergence never healed — S16 gate 2.1 regressed, this test cannot reach the edge');
const healsAfterCascade = heals.length;
// NEGATIVE CONTROL first: a fully-broken sail with FINITE state rides on
// with zero heals — the sentinel must never mistake "lost" for "sick".
runStorm(r, w, 5);
assert(heals.length === healsAfterCascade,
`${heals.length - healsAfterCascade} heal event(s) on a finite lost sail — the sentinel is touching healthy state`);
// The second poison: before the sentinel, this NaN had no wire to trip —
// no unbroken corner reads a load — and the corpse stayed poisoned forever.
r.pos[(r.N + 1) * 3 + 1] = NaN;
let steps = 0;
const cap = Math.round(1 / SIM_DT);
while (heals.length === healsAfterCascade && steps < cap) {
r.step(SIM_DT, w, 9 + steps * SIM_DT); steps++;
}
assert(heals.length > healsAfterCascade,
'second poison on a fully-broken sail never healed — the re-arm edge is back (_checkFailure has no wire when all four corners are broken)');
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite after the re-armed heal');
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite after the re-armed heal');
// ...and it HOLDS: a full second of sim later the corpse is still cloth.
runStorm(r, w, 1);
for (const v of r.pos) assert(Number.isFinite(v), 'lost sail went non-finite again inside 1 s — the re-armed heal did not hold');
for (const v of r.water) assert(Number.isFinite(v), 'water non-finite after the re-armed heal');
return `cascade 4/4 → 5 s finite, no heal (control) → second poison healed in ${steps} substep(s), finite 1 s on`;
});
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => { test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill // The rain-add clamp only ran WHILE raining; once rain stopped, downhill
// flow could pile a basin node arbitrarily high and nothing trimmed it. // flow could pile a basin node arbitrarily high and nothing trimmed it.