From 807c02fb9e23f572432bbd4ed60fe8b821809410 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 13:04:44 +1000 Subject: [PATCH 1/3] Kill the headless-zero-shadow trap at its source (skyfx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step() opened `if (!camera) return`, which read as an innocent null-guard and was in fact a physics gate: a harness with no camera skipped the shadow-grid rebuild, so gardenExposure/gardenHailExposure reported full exposure no matter what the cloth was doing, and every rig scored as if the sail did not exist. hp 36 was the bare-bed constant all along. It cost gate 0 two sprints of four lanes' time before A found it, and the fix belongs in my file, not in each harness that trips over it. A camera means "there is a view to place things in", not "the weather is real". So the grids — what the sail actually DOES — now rebuild above the render gate, with the yard centre as the wind-sample fallback; only drops, dome, lights and audio sit below it. Headless callers get correct numbers and skip the 3k drop matrices they were never going to draw. Asserted both halves so it cannot come back: a camera-less skyfx shelters the bed (hail shadow > 0.9, exposure < 0.1 under a panel), and the camera does not change the physics — headless and viewed shadows agree to 1e-9. If anyone re-adds an early return, that test goes red. Selftest 264/0/0 + the 2 gate-0 skips. Co-Authored-By: Claude Opus 4.8 --- web/world/js/skyfx.js | 72 +++++++++++++++++++++++------------- web/world/js/tests/c.test.js | 40 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 2798a4e..e9ecd7c 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -702,10 +702,22 @@ export function createSkyFx(o = {}) { * @param {number} dt * @param {number} t storm time * @param {object} [world] {sail} — duck-typed, for creak/flog + * + * Runs headless. The camera is a RENDERING concern and must never gate the + * shadow grids — see the note at the physics/view split below. */ step(dt, t, world = {}) { - if (!camera) return; - camera.getWorldPosition(camPos); + // A camera means "there is a view to place things in", not "the weather is + // real". This used to read `if (!camera) return;` at the top, which meant a + // harness with no camera silently skipped the shadow rebuild and then + // scored every rig as if the sail did not exist — hp 36 was the bare-bed + // constant, and it cost gate 0 two sprints of four lanes' time before A + // found it. The grids are physics; only the view and the audio are the + // camera's business. Fall back to the yard centre so the wind still has a + // place to be sampled. + const rendering = !!camera; + if (rendering) camera.getWorldPosition(camPos); + else camPos.set(0, 1.7, 0); wind.sample(camPos, t, w); const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); @@ -753,6 +765,37 @@ export function createSkyFx(o = {}) { flash *= Math.max(0, 1 - dt * 7); if (flash < 0.004) flash = 0; + // --- PHYSICS: the shadow grids. Camera or not, always. --- + // Everything that scores a rig — gardenExposure, gardenHailExposure, + // rainShadowOver, hailShadowOver — reads these grids. They are what the + // sail DOES, so they rebuild before any view work and above the render + // gate. Rebuilt a few times a second, not every frame: the cloth moves + // slowly next to the weather, and this is the only part that costs. + shadowTick -= dt; + if (shadowTick <= 0) { + shadowTick = 0.1; + rainVelocity(w, intensity, rainDir); + const rl = rainDir.length() || 1; + shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl); + } + hailAmt = hailIntensity(t); + const stone = hailStone(); + // The hail shadow follows the STEEP hail vector, not the wind. It barely + // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it. + hailTick -= dt; + if (hailTick <= 0) { + hailTick = 0.12; + hailWind.set(w.x, 0, w.z); + hailVelocity(hailWind, hailDir); + const hl = hailDir.length() || 1; + hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl); + } + + // Past here is the VIEW and the NOISE — drops to place, a dome to tint, a + // gale to hear. All of it needs somewhere to stand. A headless harness has + // the numbers it came for and can stop here. + if (!rendering) return; + // --- sky --- skyCol.copy(baseSky).lerp(target, storminess * darkness); if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash)); @@ -787,31 +830,8 @@ export function createSkyFx(o = {}) { domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; - // --- rain --- - // Rebuild the shadow a few times a second, not every frame: the cloth - // moves slowly next to the rain, and this is the only part that costs. - shadowTick -= dt; - if (shadowTick <= 0) { - shadowTick = 0.1; - rainVelocity(w, intensity, rainDir); - const len = rainDir.length() || 1; - shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len); - } + // --- rain + hail drops (the grids they read were built above) --- rain.step(dt, camPos, w, intensity, shadow); - - // --- hail --- - hailAmt = hailIntensity(t); - const stone = hailStone(); - // The hail shadow follows the STEEP hail vector, not the wind. It barely - // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it. - hailTick -= dt; - if (hailTick <= 0) { - hailTick = 0.12; - hailWind.set(w.x, 0, w.z); - hailVelocity(hailWind, hailDir); - const len = hailDir.length() || 1; - hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len); - } hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow); // how much of the hail the sail overhead is catching, for the drum sound — // sample right above the player/camera so it's "is it drumming over ME" diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 63ce0fb..40a0463 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -344,6 +344,46 @@ export default async function run(t) { sky.dispose(); }); + // The trap that cost gate 0 two sprints, asserted so it cannot come back. + // skyfx.step() used to open `if (!camera) return`, so a headless harness + // skipped the shadow rebuild and scored every rig as if the sail weren't + // there. A camera is a view, not a weather system: the grids are physics and + // must build without one. If someone re-adds an early return, this goes red. + t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => { + const bed = { x: 0, z: 0, w: 4, d: 3 }; + const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] }; + + const run = (withCamera) => { + const scene = new THREE.Scene(); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx(withCamera + ? { scene, camera: new THREE.PerspectiveCamera(), wind } + : { scene, wind }); // headless: no camera at all + fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel })); + const out = { + hailShadow: sky.hailShadowOver(bed), + rainShadow: sky.rainShadowOver(bed), + hailExp: sky.gardenHailExposure(bed, 56), + hail: sky.hailAmount, + }; + sky.dispose(); + return out; + }; + + const headless = run(false); + const viewed = run(true); + assert(headless.hailShadow > 0.9, + `no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`); + assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked'); + assert(headless.hailExp < 0.1, + `no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`); + // and a camera must not CHANGE the physics, only add the view + assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9, + `the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`); + assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9, + `the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); From 756994d98ecc85556d4488bae914cc143eb1158f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 13:10:20 +1000 Subject: [PATCH 2/3] =?UTF-8?q?Ship=20forecast=20uncertainty=20to=20the=20?= =?UTF-8?q?card=20(carried=20twice=20=E2=80=94=20now=20visible)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model landed in Sprint 6 and nothing ever called it, which is why this kept getting carried: `forecastFor` existed while the card went on estimating inline. So this is the wiring, not more API. `forecastLines(def, lead)` turns a storm into the card's two stat lines, already worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card always has); further out it hedges into ranges and prints a confidence line. The bands always contain the truth — a forecast may be vague, never wrong, or a player who rigs for the top of the stated range gets ambushed. Two things the card gains beyond the bands: - The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase + powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust power is drawn per gust and rides a ramp. The wild night now says 116 km/h because that is what hits you. - The card finally MENTIONS HAIL. Hail has been the garden score since decision 13 and the forecast never said so — you were being scored on something the card didn't tell you was coming. Now: "hail likely" on the wild night, silent on the gentle one, "possible" when it's too far out to promise. hud.js is Lane A's file and A is meant to build the week uninterrupted, so this is deliberately a 3-line swap that keeps their markup, classes and wording — the week's card rewrite can carry it or drop `forecastLines` and call `forecastFor` directly. `lead` defaults to 0, so nothing changes until the week passes one. Verified live at both ends: tonight renders exact; five nights receding into the week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail possible"). Selftest 265/0/0 + gate 0's 2 skips. Co-Authored-By: Claude Opus 4.8 --- web/world/js/hud.js | 24 +++++++++-------- web/world/js/tests/c.test.js | 32 +++++++++++++++++++++- web/world/js/weather.js | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 0fd1b67..f19c089 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -15,6 +15,7 @@ import * as THREE from '../vendor/three.module.js'; import { STORM_LEN } from './contracts.js'; +import { forecastLines } from './weather.js'; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const kmh = (ms) => ms * 3.6; @@ -313,18 +314,19 @@ export function createHud(d) { * @param {(key:string) => void} onPick */ showForecast(storms, onPick) { - const rows = storms.map(({ key, def }) => { - const peak = Math.max(...def.baseCurve.map((p) => p[1])); - const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0); - const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1])); - const change = (def.events ?? []).find((e) => e.type === 'windchange'); - const night = (def.sky?.darkness ?? 0) > 0.6; + // Numbers via Lane C's forecastLines(def, lead): MEASURED rather than + // estimated (the old inline `baseCurve peak + powBase + powRamp` read 30 + // m/s for storm_02, which really gusts to 32.3), and banded by how far out + // the night is. `lead` 0 = tonight = exact, which is what this reads as + // today; pass each night's lead when the week lands and the card starts + // hedging on its own. Same wording as before — only the numbers changed. + const rows = storms.map(({ key, def, lead = 0 }) => { + const f = forecastLines(def, lead); return ``; }).join(''); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 40a0463..f717292 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -16,7 +16,7 @@ import * as THREE from '../../vendor/three.module.js'; import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; -import { loadStorm, createWind } from '../weather.js'; +import { loadStorm, createWind, forecastLines } from '../weather.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -384,6 +384,36 @@ export default async function run(t) { `the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`); }); + // The card's copy, not just the model — this is what a player actually reads. + t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => { + const def = storms.storm_02_wildnight; + + const tonight = forecastLines(def, 0); + assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`); + assert(tonight.night === true, 'the wild night should read as a NIGHT'); + assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`); + assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise'); + assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`); + + const far = forecastLines(def, 1); + assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`); + assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence'); + + // no NaN/undefined can reach the card, for any storm at any lead + for (const [name, d] of Object.entries(storms)) { + for (const lead of [0, 0.5, 1]) { + const f = forecastLines(d, lead); + for (const k of ['name', 'wind', 'rain', 'confidence']) { + assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]), + `${name} @lead ${lead}: ${k} reads "${f[k]}"`); + } + } + } + // a hail-free storm must not advertise hail + assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain), + 'the gentle storm advertised hail it does not have'); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 45fb323..738871b 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -17,6 +17,58 @@ import { export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor }; +const kmh = (ms) => ms * 3.6; +const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`); + +/** + * The forecast card's two stat lines, already worded — DESIGN.md's partial + * information made visible. Lane A owns the card; this owns the numbers, so the + * card stays one call instead of re-deriving the storm inline. + * + * `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly + * as the card always has), 1 = the far end of the week (wide bands, low + * confidence). The bands ALWAYS contain the truth — a forecast may be vague but + * it must never rule out what actually happens, or a player who rigs for the top + * of the stated range gets ambushed. + * + * Numbers are MEASURED (stormStats), not estimated: the card's old inline + * `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really + * gusts to 32.3, because gust power is drawn per gust and rides a ramp. + * + * @param {object} def parsed storm JSON + * @param {number} [lead] 0..1 + * @returns {{name, night, wind, rain, confidence, hail, truth}} + */ +export function forecastLines(def, lead = 0) { + const f = forecastFor(def, lead); + const i = (v) => v.toFixed(0); + + const sustained = band(f.sustained, i); + const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i); + const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i); + + // rain reads as a word, and a vague forecast hedges across two + const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light'); + const rainWord = word(f.rain.lo) === word(f.rain.hi) + ? word(f.rain.hi) : `${word(f.rain.lo)}–${word(f.rain.hi)}`; + + const change = f.changeAt + ? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}` + : '· no change forecast'; + const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`; + + return { + name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(), + night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6), + wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`, + rain: `rain ${rainWord} ${change} ${hail}`.trim(), + // Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise. + confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '', + hail: f.hail, + truth: f.truth, + }; +} + // Resolved against this module, not the server root: server.py serves the repo // root (so the 2D prototype stays reachable), but the demo bench serves web/. // import.meta.url is right under both, and under whatever Lane A does next. From 422e0760d202a3762564fb18acbd0385ed33a3a0 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 13:22:18 +1000 Subject: [PATCH 3/3] =?UTF-8?q?Gate=200':=20the=20wild=20night=20IS=20winn?= =?UTF-8?q?able=20=E2=80=94=20found=20the=20$80=20line=20B=20ran=20out=20o?= =?UTF-8?q?f=20budget=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swept all 299 bed-covering quads settled: only p1,p2,p3,p4 is holdable, wins outright ($90 today). Porous shade cloth + downdraft 0.40 drop p1 under the carabiner tier → the same win at exactly $80. Both levers needed; neither moves a tier alone. Not flipping 0.40 unilaterally (B's balance pen, needs fabric wired first) — offered it. Flagged two design calls (25% thin-cover win, wins without a repair) for A+B. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/THREADS.md b/THREADS.md index 77d1680..74397c5 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2393,3 +2393,42 @@ Format: `[lane letter] YYYY-MM-DD — note` design.** Sprint 7 scorecard: gate 0 was a model investigation (three named causes, no villain, testkit skips made real, C's third witness, E's limb art with bit-identical anchor tripwires) — and **gate 1, the week, has now slipped TWICE.** Selftest 265/1. + +[C] 2026-07-18 — 🎯 **GATE 0' — THE WILD NIGHT IS WINNABLE AT $80. B ran out of budget before this sweep; + here it is.** B's concrete next step was "enumerate bed-covering quads ≥0.25 cover, check whether each + one's peak corner loads fit under what $80 buys." I built exactly that sweep in Sprint 6 (the 207-quad + enumeration), so I ran it settled on the current yard. **299 quads cover ≥25% of the bed. Of the 14 + smallest, THIRTEEN have a corner over 6.5 kN — unholdable at any price.** Exactly one is holdable: + ``` + p1,p2,p3,p4 settled peak/corner p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 kN + ``` + That needs rated on p4+p2 ($30+$30), shackle on p3 ($15), shackle on p1 ($15) = **$90.** Measured + through the real drain, settled, no repair: **hp 52, 1 lost — a WIN. It is the first winnable line + anyone has found on the wild night.** The blocker was never physics; it's a **$10 price gap.** + **And two levers that do nothing apart CLOSE it together.** Sprint 6 I declined 0.40 because alone it + buys ~5% and moves no hardware tier — still true. B's shade cloth alone doesn't move a tier either. + But stacked they drop p1 **1.49 → 1.08 kN, under the carabiner's 1.2 rating**, swapping its shackle + ($15) for a carabiner ($5): + ``` + dd .45 membrane p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 → $90 + dd .40 membrane p4 5.57 · p2 4.48 · p3 2.43 · p1 1.33 → $90 + dd .45 porous 0.3 p4 5.16 · p2 4.10 · p3 2.21 · p1 1.21 → $90 + dd .40 porous 0.3 p4 4.77 · p2 3.82 · p3 2.03 · p1 1.08 → $80 ✅ + ``` + Scored end-to-end, settled: **porous · dd 0.40 · rated p4/p2 · shackle p3 · carabiner p1 · $80 → hp 52, + 1 lost. WIN.** This is the exact condition I said in Sprint 6 I'd spend 0.40 on — "if gate 1 ever needs + one more notch after everything else" — and it's arrived paired with B's fabric, both proven safe on the + physics gates. + **What I am NOT doing: flipping storm_02 to 0.40 myself.** It's a balance lever, B holds the pen, and the + win needs porous fabric SELECTABLE in the shop first (B ships that this sprint) — flipping alone closes + nothing and steps on the pen. Say go and it's a one-line data edit; I'll pair it with B's fabric UI. + **Two design calls for A (win rule) + B (balance pen), because I measured them and they're real:** + 1. `p1,p2,p3,p4` covers only **25% of the bed** (all four posts, a low quad over the south edge) — it + wins on the HAIL shadow, not sun cover, so it's a THIN squeak-win. On the hardest night that reads + right to me, but it's your feel call. + 2. **It wins WITHOUT a repair or the broom.** SPRINT6 gate 1 wanted storm_02's line to *need* the + repair (assert it fails without it). This line survives outright. Either retune so the clean win + needs the repair, or accept an outright-survivable line exists and let the repair be the margin. + Both are yours; I bring the numbers. balance.test's asserted line (`t2,p3,p4,t2b`) is a genuine loser + (t2 4.4–4.9 kN can't hold) — the winnable line is `p1,p2,p3,p4`, so the suite's line wants swapping too, + B, when you take this.