From 482803a480cc30debe6f647aca079d05e3f49017 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 12:44:54 +1000 Subject: [PATCH] Sprint 13 gate 1 RULINGS: p5 the clothesline post; the separation target, amended and PINNED - backyard_01 gains p5 (5.5,-2) h2.6, honest steel NE of the bed: before it the yard could not beat tattered on the wild night at any price (covering quads need a fascia corner capped at 2.27 kN; the holding line ceilings at 61.3). Swept against the $80 wallet; position/height table in the site JSON. - Separation target adopted AMENDED (ruled on D's funnel-corrected picture): wild night, best buyable line FULL (>66) and WIN; bare bed LOSES (<50). Pinned as site data (backyard_01.separation names the $75 recipe p5=rated + p1/p2/p3=shackle) and FLOWN by a.test through the real chain: shop at START_BUDGET -> commit -> attach -> skyfx -> garden.js. Measured 68.3 FULL / 0 lost vs bare 35.7 (sep 32.6). - Full cover stays unbuyable (covering quads still pull 6.4-6.9 kN), the >45 m2 floor holds, and the ice night still refuses $80 (needs $105 - p1/p2 miss their shackles by 0.05 kN) - night 5 stays bank-funded canon. - New asserts: M reaches the real bus / is only advertised when real; anchor census now 13 (5 posts). Selftest 341/0/0. --- web/world/data/sites/backyard_01.json | 64 +++++++++++- web/world/js/tests/a.test.js | 145 +++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 7 deletions(-) diff --git a/web/world/data/sites/backyard_01.json b/web/world/data/sites/backyard_01.json index 60b8e41..87c7fe5 100644 --- a/web/world/data/sites/backyard_01.json +++ b/web/world/data/sites/backyard_01.json @@ -97,8 +97,68 @@ "than the 23-38 m2 rigs that survive unaided. a.test.js asserts the >45 m2 floor, so", "moving this inward goes red — that assert is the tension's only guard." ] - } - ], + }, + { + "id": "p5", "x": 5.5, "z": -2, "h": 2.6, "type": "post", "work": "cloth", + "_why": [ + "SPRINT13 gate 1 — A's backyard ruling, the anchor C's diagnosis routed here. The old", + "clothesline post: galvanised steel sunk in concrete by the back path, NE of the bed,", + "shorter than the sail posts because it was never a sail post. Honest steel (ratingHint 1),", + "8 m from the fascia, so the house trap keeps its whole lesson.", + "", + "WHY IT EXISTS, measured (probeA on C's garden_bench chain, wildnight, funnel-free yard):", + "before it, the backyard could not beat TATTERED on the wild night at any price - every", + "vertical-covering quad needs a house corner (fascia caps the best steel at 2.27 kN while", + "those quads pull 3-7 kN -> tear in the settle), and the best HOLDING line p1..p4 ceilings", + "at hp 61.3 through 25% cover. D played both halves through the real UI and confirmed to", + "the decimal. Placement, not budget, not weather.", + "", + "WHY (5.5, -2) at h 2.6 and not somewhere kinder: the LOW top is the point - it twists the", + "p1+p2+p3+p5 quad so it spills the gusts (small+twisted survives, DESIGN.md), and the", + "position is swept against the $80 wallet:", + " (4.5,-2) h2.6 -> p1 3.35 kN, needs RATED, line costs $90 DO NOT", + " (5,-2.5) h2.8 -> p5 6.20, p1 3.75 -> $90 DO NOT", + " (5.5,-2) h2.8 -> p2 3.08, p1 3.06 (4% margin on shackle) - legal but knife-edged", + " (5.5,-2) h2.6 -> p5 4.88, p2 3.14, p1 2.91, p3 1.82 -> $75, hp 68.3 FULL <- here", + " (6,-2) h2.6 -> shadow drops, hp 65.8 tattered DO NOT", + "The separation block below pins the recipe; a.test flies it. Full cover STAYS unbuyable:", + "the covering quads (p1+p2+p4+p5 and up) still pull 6.4-6.9 kN and still tear - the", + "smallest full-cover quad stays >45 m2 (a.test's floor holds), so the tradeoff decision 2", + "protects is intact. And the ICE night still refuses the $80 wallet: same line needs $105", + "there (p1 3.25, p2 3.22 vs a shackle's 3.20 - the same few-hundredths miss as C's q3),", + "so night 5 stays the night the bank pays for, exactly as ruled in SPRINT9." + ] + } ], + + "separation": { + "_why": [ + "SPRINT13 gate 1.4 — A's ruling on C's proposed target, AMENDED to the numbers the real game", + "measures (ruled on D's funnel-corrected picture, not C's original headline). C proposed:", + "'on a wild night the best buyable line reads FULL (>66) and a bare bed reads DEAD (<33)'.", + "Adopted in spirit, amended in both halves:", + " - The bare half becomes 'a bare bed LOSES the night (hp < 50, the win line)'. Nowhere in", + " the shipped game does a bare bed read DEAD on the wild night (35.7, tattered) — D's", + " check — and tuning storm_02's damage budget until a slogan lands is exactly the fudge", + " gate 1 exists to kill. The felt boundary is the WIN line, and 35.7 fails it honestly.", + " - The FULL half binds THIS yard, because the shipped week's only wild night is here", + " (week.js night 4). C's passing example was site_02, whose wild night the corner block", + " never plays — and with its venturi live the example dies anyway (D's landmine).", + "This block is the pin: the recipe below is the measured best $80 line, a.test flies it", + "through the real chain (session -> commit -> attach -> skyfx) every run, and B's audit can", + "read the same recipe instead of inventing one. A target nothing checks is decoration —", + "the repo's oldest lesson, and the reason this is data and not a THREADS sentence." + ], + "stormKey": "storm_02_wildnight", + "tension": 1.0, + "line": [ + { "anchor": "p5", "hw": "rated shackle" }, + { "anchor": "p1", "hw": "shackle" }, + { "anchor": "p2", "hw": "shackle" }, + { "anchor": "p3", "hw": "shackle" } + ], + "heldMustExceed": 66, + "bareMustLoseBelow": 50 + }, "shed": { "model": "shed_01_v1", "x": 11.8, "z": 6.2, "rotYDeg": -90 }, "shedTable": { "model": "shed_table_v1", "x": 9, "z": 6, "rotYDeg": -90, "pickupNode": "pickup_anchor" }, diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 097c864..c123f20 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -4,15 +4,21 @@ */ import * as THREE from '../../vendor/three.module.js'; -import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; +import { + ANCHOR_TYPE, FIXED_DT, HARDWARE, PHASES, START_BUDGET, STORM_LEN, YARD, + checkContract, createStubWind, +} from '../contracts.js'; import { createWindField } from '../weather.core.js'; import { createWorld, heightAt, loadSite, validateSite } from '../world.js'; import { createCameraRig, spawnYawFor } from '../camera.js'; import { - CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits, + CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits, stormsToPreload, verdictFor, } from '../main.js'; -import { orderRing } from '../sail.js'; +import { createGarden } from '../garden.js'; +import { RiggingSession } from '../rigging.js'; +import { createSkyFx } from '../skyfx.js'; +import { SailRig, orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js'; import { createHud } from '../hud.js'; @@ -131,6 +137,36 @@ export default async function run(t) { assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in'); }); + t.test('M reaches the real bus when there is one, and is not advertised when there is not', () => { + // SPRINT13 gate 3.3, closed by C's sky.setMute (lane/c 8a3dc32). The wiring + // bug this pins is the one D named on rigging.setWorld: `sky.setMute?.(on)` + // on a tree without the tap is a no-op that looks like a call, so a key the + // splash advertises can silently do nothing on a public URL. applyMute is + // the application as a value; the HUD half asserts the advertisement tracks + // the same feature-detect in BOTH directions. + const calls = []; + const bus = { setMute: (v) => calls.push(v) }; + assertEq(applyMute(bus, true), true, 'a real bus takes the state'); + assertEq(applyMute(bus, false), true, 'both directions'); + assertEq(calls.join(','), 'true,false', 'and receives booleans, in order — this is what M re-applies across makeSky()'); + assertEq(applyMute({}, true), false, 'no tap: says so instead of pretending'); + assertEq(applyMute(null, true), false, 'no sky at all (pre-boot): still honest'); + + if (typeof document === 'undefined') return; + const hud = createHud({ scene: new THREE.Scene() }); + try { + assertEq(hud.comfortKeysHint(), 'P pause', 'no bus: the help line does not promise M'); + hud.setAudioMuteAvailable(true); + assertEq(hud.comfortKeysHint(), 'P pause · M mute', 'bus landed: M appears by itself'); + hud.showSplash(() => {}); + assert((document.querySelector('#hud-card')?.textContent ?? '').includes('P · M'), + 'and the splash card lists it'); + hud.hideCard(); + hud.setAudioMuteAvailable(false); + assertEq(hud.comfortKeysHint(), 'P pause', 'and it can go dark again — the detect is live, not a latch'); + } finally { hud.dispose(); } + }); + t.test('the spawn frame points at the garden with no pole through the player', () => { // The QA pass: "the boot camera puts a pole dead-centre through the player on // every single first impression." Measured, not eyeballed — these are the @@ -845,11 +881,12 @@ export default async function run(t) { // --- anchors ------------------------------------------------------------- - t.test('yard offers 12 anchors: 3 house, 5 tree, 4 post', () => { + t.test('yard offers 13 anchors: 3 house, 5 tree, 5 post', () => { const by = (type) => world.anchors.filter((a) => a.type === type).length; assertEq(by('house'), 3, 'house anchors'); assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())'); - assertEq(by('post'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)'); + assertEq(by('post'), 5, + 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1), p5 the clothesline post (SPRINT13 gate 1)'); const ids = world.anchors.map((a) => a.id); assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`); }); @@ -977,6 +1014,104 @@ export default async function run(t) { `supposed to cost you a sail the storm can take`); }); + // --- SPRINT13 gate 1.4: THE PINNED SEPARATION TARGET ---------------------- + // + // The ruling (THREADS [A], on D's funnel-corrected picture): on the shipped + // week's wild night — night 4, THIS yard — the best line START_BUDGET can buy + // must read FULL (>66) and WIN, and a bare bed must LOSE (<50). The recipe is + // DATA (site.separation) so B's audit reads the same one, and this flight is + // the pin: real shop at the real budget, real commit()->attach() (D's + // skipped-attach landmine — session.commit(rig) IS the attach path, verified + // by C's probe2 agreement), real skyfx exposure, the real garden model out of + // garden.js. Flown OUTSIDE t.test() because Suite.test cannot await — the + // two async-test ghosts above are the precedent. + // + // Sway is frozen (balance.test's reasoning): the recipe is all posts, whose + // real sway is constant anyway, so frozen here means "identical to the game", + // not "conveniently calm". + const sep = site.separation; + let sepRun = null; + if (sep && typeof document !== 'undefined') { + try { + const frozen = world.anchors.map((a) => { + const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; + return { id: a.id, type: a.type, pos, sway: () => pos, ratingHint: a.ratingHint }; + }); + const trees = frozen.filter((a) => a.type === 'tree'); + const stormDef = await loadStorm(sep.stormKey); + const calmDef = await loadStorm(CALM_STORM); + + const session = new RiggingSession({ anchors: frozen }); // the REAL budget + const shopLog = []; + for (const { anchor } of sep.line) { + const r = session.rig(anchor); + if (!r.ok) shopLog.push(`rig ${anchor}: ${r.reason}`); + } + for (const { anchor, hw } of sep.line) { + const want = HARDWARE.find((h) => h.name === hw); + if (!want) { shopLog.push(`no hardware named "${hw}"`); continue; } + const r = session.setHardware(anchor, want); + if (!r.ok) shopLog.push(`setHardware ${anchor} ${hw}: ${r.reason}`); + } + session.setTension(sep.tension); + const spent = START_BUDGET - session.budget; + const rig = session.commit(new SailRig({ anchors: frozen, gridN: 10 })); + + const calmWind = createWind(calmDef); + calmWind.setSheltersFromTrees(trees); + let prepT = 0; + for (let i = 0; i < Math.round(12 / FIXED_DT); i++) { + rig.step(FIXED_DT, calmWind, prepT % 3); prepT += FIXED_DT; + } + + const flyNight = (sail) => { + const wind = createWind(stormDef); + wind.setSheltersFromTrees(trees); + const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); + camera.position.set(0, 2, 8); + const sky = createSkyFx({ wind, night: true, camera }); + const garden = createGarden({ setPlants() {} }); // the model main.js flies + const bed = world.gardenBed; + const steps = Math.round(stormDef.duration / FIXED_DT); + for (let i = 0; i < steps; i++) { + const t2 = i * FIXED_DT; + if (sail) sail.step(FIXED_DT, wind, t2); + sky.step(FIXED_DT, t2, { sail }); + garden.step(FIXED_DT, sky.gardenHailExposure(bed, t2), sky.gardenExposure(bed, t2)); + } + sky.dispose?.(); + return { hp: garden.hp, state: garden.state }; + }; + + const held = flyNight(rig); + const lost = rig.corners.filter((c) => c.broken).length; + const bare = flyNight(null); + sepRun = { shopLog, spent, held, lost, bare }; + } catch (err) { + sepRun = { err: String(err && err.stack || err) }; + } + } + + t.test('PINNED: the wild night separates — best buyable line FULL and winning, bare bed losing', () => { + if (!sep) throw new Error('backyard_01 lost its separation block — the target is decoration again'); + if (typeof document === 'undefined') return t.skip('needs DOM (skyfx)'); + if (sepRun.err) throw new Error(`separation flight died: ${sepRun.err}`); + assertEq(sepRun.shopLog.join('; '), '', 'the shop sold the whole recipe at the real budget'); + assert(sepRun.spent <= START_BUDGET, `recipe costs $${sepRun.spent} — must be buyable night 1`); + assertEq(sepRun.lost, 0, 'the pinned line HOLDS the wild night — 0/4 lost'); + assert(sepRun.held.hp > sep.heldMustExceed, + `held bed reads ${sepRun.held.hp.toFixed(1)} — the target is FULL (>${sep.heldMustExceed}). ` + + `Measured 68.3 at landing; a red here means a retune moved the wild night and the ` + + `separation ruling needs re-litigating in THREADS, not a quiet threshold bump.`); + assertEq(sepRun.held.state, 'full', 'and the plants SAY full — the state the player sees'); + assertLess(sepRun.bare.hp, sep.bareMustLoseBelow, + `bare bed must LOSE the night (<${sep.bareMustLoseBelow}) — measured 35.7 at landing`); + // The felt spread, stated so a red prints the whole story: + assert(sepRun.held.hp - sepRun.bare.hp > 25, + `separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state, ` + + `not a garden-bonus margin (measured 32.6 at landing)`); + }); + t.test('sway() returns an absolute position, not an offset', () => { // If sway ever regresses to returning an offset, the returned point lands // near the origin instead of near the anchor, and Lane B's cloth corners