From a74075cda5c24197c8097c631e1b2c3b3971d4ea Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:51:30 +1000 Subject: [PATCH] Sprint 11 shepherd: C's venturi correction taken, D's two landmines defused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C AND D BOTH LANDED WHILE I WAS IN GATE 2, AND BOTH CORRECT ME. VENTURI — reverted to axis 2.1 / gain 1.5. C reached the same physics I did, independently (|dot| means an axis is a line, mod PI), and then went one step further and caught my reasoning: -1.08 was never a proposed AXIS. It's dirAt(40), the southerly's HEADING once the change settles. 2.1 is the GAP'S ORIENTATION — site geometry, which doesn't move when the wind does. They're different quantities that sit a hair over PI apart only because the gap was BUILT along the southerly. So "measured beats authored" was the wrong rule for this question: I'd have swapped a geometry fact for a weather fact because they nearly coincide, and the site's axis would silently mean the wrong thing the day someone authors a storm from another quarter. "A number gathered from the wrong harness" caught both of us on the same question from opposite sides in one sprint. Gain reverted too, and that one I simply jumped: it's a balance lever, B's audit calls it, and C's standing offer is to drop to 1.35 only if no $80 line survives. B hasn't run. D then measured the site winnable off honest anchors at 98% — so the funnel isn't killing the line and the gain stays. D's LANDMINES, both mine, both in gate 2's path: · CALM_STORM loaded only because night one HAPPENED to be storm_01_gentle. Gate 2 rewrote every night entry. Drop gentle and windTime() throws every non-storm frame on "Cannot read properties of undefined (reading 'duration')" — naming neither storms, nor calm, nor the week. Now explicit in the preload, and the assert calls stormsToPreload() with a gentle-free ladder, because asserting it against the shipped NIGHTS passes either way. That's how it hid. · boot({site}) was a documented lie that failed INVERSELY — markers on the requested yard, world on the week's. Honoured through the week now (seek to that site's night), because a site without its night is half a game: no client, no brief, no storm, no bank. Plus opts.bank, so D can boot the corner block at the $237 they measured instead of night one's $80 and OFF THE JOB. RATINGHINT — D is right that nothing reads it, and the naive fix is worse than the bug: honest posts are built from site JSON, never touch adoptAnchor, so their ratingHint was undefined — and `load > hw.rating * undefined` is `load > NaN`, always false. The strongest anchors would have become UNBREAKABLE while the carport became the only thing that can fail: the trap would look like it works, for the wrong reason, and read as a successful playtest. Every anchor carries a real number now (DEFAULT_RATING_HINT), pinned, so the wire is safe before it's made. The wire itself is sail.js — B's — and the ruling is in THREADS. selftest 306/0/0 (was 304). --- .../data/sites/site_02_corner_block.json | 39 ++++++----- web/world/js/hud.js | 23 +++++-- web/world/js/main.js | 65 +++++++++++++++++-- web/world/js/tests/a.test.js | 54 +++++++++++++-- web/world/js/world.js | 25 ++++++- 5 files changed, 169 insertions(+), 37 deletions(-) diff --git a/web/world/data/sites/site_02_corner_block.json b/web/world/data/sites/site_02_corner_block.json index 02bd10c..615112b 100644 --- a/web/world/data/sites/site_02_corner_block.json +++ b/web/world/data/sites/site_02_corner_block.json @@ -92,28 +92,31 @@ "the house line and the carport, i.e. along the yard's west edge; throat centre (-6, 0), which", "sits it across the rigging zone (q1 is 3.5 m away, inside the radius) as C asked.", "", - "SPRINT11 — A/C RECONCILED, and there was never a disagreement to settle. This shipped axis", - "2.1 and C measured storm_03b's southerly at -1.08, which LOOKS like two answers. It is one:", - "-1.08 + PI = 2.0616. An axis is a LINE, not a heading — weather.core.js aligns with", - "Math.abs(dot(wind, axis)) precisely because 'a gap funnels either way through it' — so 2.1", - "and -1.08 were the same gap described from opposite ends. Measured: axis -1.08 and axis", - "-1.08+PI differ by ~1e-14 m/s at the throat across the whole storm, i.e. float noise and not", - "physics (a.test pins it under 1e-9). Careful: my first probe printed that as 'exactly", - "0.000000' because it was a toFixed(6) — cos(x+PI) is not bit-exactly -cos(x). Same lesson as", - "the rest of this repo's scar tissue: the harness rounded, and I nearly reported the rounding.", + "SPRINT11 — A/C RECONCILED, AND THE ANSWER IS: DON'T TOUCH THE AXIS. Both lanes measured this", + "independently and landed on the same physics. An axis is a LINE, not an arrow — weather.core", + "aligns with Math.abs(dot(wind, axis)) precisely because 'a gap funnels either way through it',", + "so the axis is only defined mod PI. 2.1 and -1.08 are 2.2 deg apart, i.e. the same gap read", + "from opposite ends: 33.5061 vs 33.5329 m/s peak at the throat, a 0.08% difference on the same", + "gust in the same second. Any edit here would be a no-op.", "", - "So the edit is 2.1 -> -1.08 and it is NOT a correction of direction; it's provenance. C's", - "number is measured off storm_03b's actual post-change heading, mine was eyeballed at", - "authoring time ('~2.1', 'numbers are yours to tune'). The residual 2.2 deg of my eyeballing", - "was worth 0.44 m/s at peak. Take the measured one. If you ever need the other end of the", - "line, add PI — do not 'fix' this number back.", + "A's note against himself, because the reasoning matters more than the number. I nearly wrote", + "-1.08 in here on 'measured beats authored' grounds. C caught what that misses: -1.08 was never", + "a proposed axis. It's dirAt(40) — the SOUTHERLY'S HEADING once the change settles. 2.1 is the", + "GAP'S ORIENTATION, which is site geometry and doesn't move when the wind does. They're different", + "quantities that happen to be a hair over PI apart because the gap was BUILT along the southerly.", + "Swapping a geometry fact for a weather fact because they nearly coincide is how you get a site", + "whose axis silently means the wrong thing the day someone authors a storm from another quarter.", + "'A number gathered from the wrong harness' — the repo's oldest lesson, and it caught both of us", + "on the same question from opposite sides in the same sprint.", "", - "gain is NOT A's call and is left where C proposed it: 1.35, tuned to B's audit (SPRINT11", - "gate 1). For scale, measured at the throat at peak: 22.36 m/s unfunnelled, 33.51 at gain 1.5,", - "so the funnel is doing real work and the gain is the lever that decides MEAN vs impossible." + "gain stays 1.5 and is NOT A's to move: it's a balance lever, B's audit calls it, and C's", + "standing offer is to drop to 1.35 the moment B says no $80 line survives the funnel. Measured", + "at the throat: 22.36 m/s unfunnelled, 30.16 at 1.35, 33.51 at 1.5. Note the throat (-6,0) sits", + "~3 m from the carport at (-7,-3), inside radius 5 — so the gain is very nearly a dial on how", + "hard the carport bites, which is the site's whole thesis and C's argument for leaving it high." ], "venturi": [ - { "x": -6, "z": 0, "axis": -1.08, "gain": 1.35, "radius": 5, "sharp": 3 } + { "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 } ] } } diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 33cd52d..ff7fc0e 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -431,13 +431,22 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { // `forecastLines(def, lead)` has carried the lead param for two sprints // with nothing ever calling it above 0 — this is the call site. // - // ⚠️ C: THE SLOT IS MINE, THE NUMBER IS YOURS. `lead` is a 0..1 haze dial - // (confidence = 1 − lead), NOT "nights out" — I read it as nights first, - // passed 1, and the card cheerfully advertised "forecast confidence 0%". - // 0.6 because your own hail rule calls L > 0.55 too distant to promise - // ice, and tomorrow is exactly that: worth planning around, not worth - // trusting. It reads "confidence 40%" and the band resolves to an exact - // number when tomorrow becomes tonight at lead 0. Tune it. + // `lead` is a 0..1 haze dial (confidence = 1 − lead), NOT "nights out" — I + // read it as nights first, passed 1, and the card advertised "forecast + // confidence 0%": a forecast admitting it knows nothing, printed as news. + // + // ⚠️ INTEGRATION, SPRINT11: this constant is a placeholder and C has + // already landed its replacement on lane/c — `leadFor(nightsOut, weekNights)` + // in weather.js, which maps the week onto forecastFor's 0..1 domain and + // gives 0.25 for tomorrow of five (75% confidence), not my 0.6. Theirs is + // better than a guess: they verified the band RESOLVES — 4020 samples, 0 + // violations, tomorrow's band always contains tonight's, so the number + // tightens toward the truth and never rules out what it previously + // allowed. That's the property that makes it safe to print on a card. + // At merge this becomes C's own call site, verbatim from their THREADS: + // forecastLines(tomorrowDef, leadFor(1, wk.nights)) + // Not imported here yet only because leadFor is on lane/c and this branch + // would go red reaching for it. const TOMORROW_LEAD = 0.6; const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null; const tomorrow = tf ? ` diff --git a/web/world/js/main.js b/web/world/js/main.js index 5a63bd2..c3dce55 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -28,14 +28,36 @@ import { createHud } from './hud.js'; import { createWeek, NIGHTS, nightAt } from './week.js'; /** The calm day the forecast and prep phases run under. */ -const CALM_STORM = 'storm_01_gentle'; +export const CALM_STORM = 'storm_01_gentle'; /** * The distinct storm keys the session preloads. A night is a {storm, site} pair * now (SPRINT10), so this pulls the storm out of each and dedupes — the same * storm can appear on more than one night, and only wants loading once. + * + * CALM_STORM is in the list EXPLICITLY (SPRINT11, Lane D's landmine) because + * prep and forecast run under it whether or not any night does. It used to load + * only because night one happened to be `storm_01_gentle` — an invariant nothing + * stated and nothing checked. Take gentle out of NIGHTS and `calmWind` is + * undefined, `windTime()` throws every non-storm frame, and the game dies at boot + * on "Cannot read properties of undefined (reading 'duration')" — a message that + * mentions neither storms, nor calm, nor the week. D lost ten minutes to it + * rewriting NIGHTS[0] for a harness, and gate 2 is the sprint that rewrote every + * night entry. Same species as the enum: an invariant nothing checks. + * + * A function, and exported, so the invariant IS checkable: the test feeds it a + * ladder with no gentle storm anywhere and asserts the calm day survives. Assert + * it against the shipped NIGHTS instead and it passes whether or not the fix is + * there — night one is gentle today, so the bug hides. That assert would be + * decoration, which is the exact failure mode this repo keeps naming. + * + * @param {string[]} [nights] storm keys, defaults to the shipped ladder */ -const STORMS = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))]; +export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) { + return [...new Set([CALM_STORM, ...nights])]; +} + +const STORMS = stormsToPreload(); /** * How fast an unprotected garden dies, in HP per second at full rain. @@ -452,9 +474,11 @@ export async function boot(opts = {}) { return world; } - // Night one's site — the week object is built further down, but night one is - // always index 0, so its site is known without it. (opts.site lets a debug - // boot jump straight to a site.) + // The opening yard. `opts.site` is honoured by seeking the WEEK to that site's + // night (see below, where the week exists) — showTonight() then loads it as + // that night's site, which is why this can just load it directly here: the two + // agree by construction now instead of racing. Before SPRINT11 they didn't, + // and the markers ended up on a different yard than the world. await loadSiteInto(opts.site ?? nightAt(0).site); // --- 3. sail ------------------------------------------------------------ @@ -699,9 +723,38 @@ export async function boot(opts = {}) { // Five nights, one wallet. The forecast stops being a difficulty picker — the // ladder decides what's coming and the only question left is what you rig // against it with the money you have. - const week = createWeek(); + // `opts.bank` — a debug boot's wallet (SPRINT11, for Lane D). Seeking to a + // night doesn't EARN the nights before it, so `boot({site:'site_02...'})` alone + // puts you on night three with night one's $80 and the shop reads OFF THE JOB — + // which is precisely the harness artefact D had to explain away in their + // playtest ("the $0 above is MY harness's fault, not your balance"). They + // measured the real night-three bank at $237. `boot({site, bank:237})` is that + // run, honestly, without rewriting NIGHTS to get it. + const week = createWeek({ bank: opts.bank }); let spentThisNight = 0; + /** + * `boot({site})` — honoured through the WEEK, which is the only way it can be + * true (SPRINT11, Lane D's bug 2). + * + * It was a documented lie: :468 loaded opts.site, then showTonight() re-ran + * `loadSiteInto(week.site)` and overwrote it — but the markers had been built + * in between, off the requested site. So it failed INVERSELY, markers on one + * yard and world on another, from the first frame. Worse than not working: the + * hook anyone debugging a site reaches for first handed them a hybrid. + * + * Seeking the week is the fix rather than forcing the site, because a site + * without its night is half a game — no client, no brief, no storm, no bank. + * Night 3 debugged at night 1's $80 reads "OFF THE JOB" and tells you nothing + * about the corner block; at its own night it's the $237 bank D measured. D + * had to rewrite NIGHTS[0] to get a coherent cold boot; this is that, honestly. + */ + if (opts.site) { + const i = NIGHTS.findIndex((_, n) => nightAt(n).site === opts.site); + if (i < 0) throw new Error(`main: boot({site:'${opts.site}'}) — no night in NIGHTS uses that site`); + for (let n = 0; n < i; n++) week.advance(); + } + /** * Tonight's card. Reads the bank, not START_BUDGET. * diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 9753ec5..2f5de5a 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -8,7 +8,7 @@ import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } import { createWindField } from '../weather.core.js'; import { createWorld, heightAt, loadSite, validateSite } from '../world.js'; import { createCameraRig } from '../camera.js'; -import { createGame, createWindRouter, verdictFor } from '../main.js'; +import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js'; import { orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js'; @@ -98,6 +98,26 @@ export default async function run(t) { assertEq(w.bank, s.bankAfter, 'the bank IS the next shop'); }); + t.test("the calm day loads even if no night is the gentle storm (D's landmine)", () => { + // main.js preloads STORMS and reads winds[CALM_STORM] for every forecast and + // prep frame. That only worked because night one HAPPENED to be the gentle + // storm — an invariant nothing stated. Drop gentle from the ladder and the + // game died at boot on "Cannot read properties of undefined (reading + // 'duration')", naming neither storms nor calm nor the week. Gate 2 rewrote + // every night entry, which is exactly when that bites. + // + // Asserts the RULE against main.js's own function, and asserts it on a + // ladder WITHOUT the gentle storm — the case that actually broke. Checked + // against the shipped NIGHTS it would pass either way (night one is gentle + // today), which is how the bug hid in the first place. + assert(stormsToPreload().includes(CALM_STORM), + 'the calm day is preloaded on the shipped ladder'); + assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM), + "...and on a ladder that doesn't contain it anywhere — the case that broke"); + assertEq(stormsToPreload(['storm_01_gentle']).length, 1, + 'and it is still deduped, not loaded twice'); + }); + // --- SPRINT11 gate 2: the job sheet ------------------------------------- t.test('every night is a JOB — a client, a brief, and a yard', () => { @@ -286,14 +306,16 @@ export default async function run(t) { t.test('ruling: the venturi axis is a LINE — C and A never disagreed', () => { // The reconciliation SPRINT11 asked for, pinned so it is never "fixed" back. // weather.core aligns on |dot(wind, axis)| because a gap funnels either way - // through it, so axis and axis+PI are the same gap. A shipped 2.1, C - // measured -1.08, and -1.08 + PI = 2.0616: one line, two ends. + // through it, so axis and axis+PI are the same gap. A shipped 2.1, C read the + // southerly's heading at -1.08, and those are 2.2 deg apart: one line, two + // ends. Pinned against the SHIPPED axis — the site's geometry is the fact + // under test, not the storm heading it happens to run along. const at = (axis) => { const f = createWindField(earlyBusterDef); - f.setVenturi([{ x: -6, z: 0, axis, gain: 1.35, radius: 5, sharp: 3 }]); + f.setVenturi([{ x: -6, z: 0, axis, gain: 1.5, radius: 5, sharp: 3 }]); return f; }; - const c = at(-1.08), flipped = at(-1.08 + Math.PI); + const c = at(2.1), flipped = at(2.1 + Math.PI); let worst = 0; for (let tt = 0; tt <= 90; tt += 0.5) { worst = Math.max(worst, Math.abs(c.speedAt(-6, 0, tt) - flipped.speedAt(-6, 0, tt))); @@ -314,6 +336,28 @@ export default async function run(t) { 'the throat is meaningfully faster than the open yard'); }); + t.test("every anchor carries a NUMBER for ratingHint (D's landmine)", () => { + if (!site2World) return 'SKIPPED — no server for site_02'; + // Nothing reads ratingHint yet (that's the open ruling — see THREADS), and + // this assert exists BECAUSE of that: it makes the wire safe before it's + // made. The honest posts are built from site JSON and never pass through + // adoptAnchor, so they had no ratingHint at all — and `load > hw.rating * + // undefined` is `load > NaN`, which is always false. Wire it naively and the + // strongest anchors become immortal while the trap looks like it works. + for (const a of site2World.anchors) { + assert(Number.isFinite(a.ratingHint), `${a.id} has a finite ratingHint, not undefined`); + assert(a.ratingHint > 0, `${a.id}'s ratingHint is positive — a 0 would be unbreakable-by-zero`); + } + for (const a of world.anchors) { + assert(Number.isFinite(a.ratingHint), `backyard ${a.id} has a finite ratingHint too`); + } + // And the honest steel must still out-rate the trap, or the site has no thesis. + const q1 = site2World.anchors.find((a) => a.id === 'q1'); + const cb1 = site2World.anchors.find((a) => a.id === 'cb1'); + if (q1 && cb1 && cb1.ratingHint < 1) assertLess(cb1.ratingHint, q1.ratingHint, + 'the carport beam is worse steel than an honest post'); + }); + t.test('ruling: the carport BILLS — 180, once, and takes the roof with it', () => { if (!site2World) return 'SKIPPED — no server for site_02'; // E shipped the trap and said it plainly: the anchors said collateral diff --git a/web/world/js/world.js b/web/world/js/world.js index f7b6c7d..0ad2032 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -15,6 +15,28 @@ import * as THREE from '../vendor/three.module.js'; import { ANCHOR_TYPE, createStubWind } from './contracts.js'; import { validateSiteWind } from './weather.core.js'; +/** + * Full strength: this anchor takes whatever its hardware is rated for. + * + * SPRINT11 — Lane D's landmine, defused before anyone can step on it. Every + * anchor now carries a NUMBER here, always. It used to be set only by + * `adoptAnchor`, off Lane E's baked `rating_hint` — so GLB-backed anchors had it + * and the ones built straight from site JSON (site_02's honest posts q1..q4) did + * not. Their `ratingHint` was `undefined`. + * + * That matters the moment anyone wires the ratings into the failure threshold, + * which is the open question D put to me. The naive wire is + * `load > hw.rating * anchor.ratingHint`, and with `undefined` that is + * `load > NaN` — **always false**. The honest posts wouldn't read weak, they'd + * be UNBREAKABLE, and the carport would become the only anchor in the yard that + * can fail. The trap would appear to work, for entirely the wrong reason, and it + * would have looked like a successful playtest. + * + * "Default before you multiply" — D's words. This is the default, at the source, + * so no consumer has to remember. 1 = honest steel; E's traps ship lower. + */ +const DEFAULT_RATING_HINT = 1; + /** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */ const rad = (deg) => ((deg ?? 0) * Math.PI) / 180; @@ -847,7 +869,7 @@ const _worldPos = new THREE.Vector3(); /** @returns {import('./contracts.js').Anchor} */ function makeStaticAnchor(id, type, pos) { const p = pos.clone(); - return { id, type, pos: p, sway: () => p }; + return { id, type, pos: p, sway: () => p, ratingHint: DEFAULT_RATING_HINT }; } /** @@ -864,6 +886,7 @@ function makeSwayAnchor(id, pos, phase, wind) { id, type: 'tree', pos: p, + ratingHint: DEFAULT_RATING_HINT, sway(t) { const speed = wind.sample(p, t).length(); const amp = Math.min(0.35, speed * 0.012);