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);