From 4aee055f2dbb7bff128cbda3ab5727c8fe38d53f Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:20:11 +1000 Subject: [PATCH] =?UTF-8?q?Sprint=2011=20gate=201:=20three=20rulings=20?= =?UTF-8?q?=E2=80=94=20enum=20widened,=20carport=20priced,=20venturi=20rec?= =?UTF-8?q?onciled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corner block's seams, all of them A's to close. ENUM (D's flag). Anchor.type was a closed JSDoc enum 'house'|'tree'|'post', so site_02 typed its carport 'post' to fit — quietly enrolling it in the sail-post family that C's venturi and B's audit both read. Widened to E's own asset strings (carport, carport_post) and, more to the point, CHECKED: it was documentation for ten sprints, and documentation cannot fail. validateSite now rejects an unknown type by name. The ladder is untouched either way — D keyed it on `work`, the mechanism, which is exactly why that was right. CARPORT (E's proposal, adopted). $180, with E's reasoning intact: gnome 25, a night's budget 80, so 180 is 2.25 nights — a good week turned broke and felt for the rest of the run, without ending a strong one outright. Under ~120 it's a shrug, over ~250 a silent game over that teaches nothing. The number lives in site JSON, not the GLB: sites are data, and what a carport COSTS is this site's economy, not a property of the mesh. E's baked value stays as the fallback and the proposal. And it BILLS now. E shipped the trap and said it plainly — the anchors said collateral "carport" and nothing said what one cost, so nothing scored it. You could lose the worst steel in the game and pay for a $15 shackle. One broken corner is enough (the gnome needs two: it needs the sail to land on it; the carport doesn't need the sail at all), priced per structure so two beams is one carport gone, not $360. The wreck swaps on the same event — E built it to the same origin for exactly this. VENTURI (C's question). There was no disagreement to settle. weather.core aligns on |dot(wind, axis)| because a gap funnels either way through it, so an axis is a LINE: -1.08 + PI = 2.0616, and my shipped 2.1 was the same gap read from the other end. Took C's number because C MEASURED it off storm_03b and I eyeballed mine at authoring time; the 2.2 deg of my eyeballing was worth 0.44 m/s. gain left at C's 1.35 — that's a balance lever, theirs with B's audit. Also wired validateSiteWind at site load, C's ask from Sprint 10. selftest 300/0/0 (was 296). --- .../data/sites/site_02_corner_block.json | 61 +++++++++--- web/world/js/contracts.js | 24 ++++- web/world/js/main.js | 28 ++++++ web/world/js/tests/a.test.js | 95 ++++++++++++++++++- web/world/js/world.js | 86 ++++++++++++++++- 5 files changed, 280 insertions(+), 14 deletions(-) diff --git a/web/world/data/sites/site_02_corner_block.json b/web/world/data/sites/site_02_corner_block.json index 3cd7ae7..02bd10c 100644 --- a/web/world/data/sites/site_02_corner_block.json +++ b/web/world/data/sites/site_02_corner_block.json @@ -27,13 +27,34 @@ "structures": [ { "id": "carport", "model": "carport_01_v1", + "wreckedModel": "carport_01_wrecked_v1", "x": -7, "z": -3, "rotYDeg": 0, "solid": true, + + "collateralValue": 180, + "collateralLabel": "the carport", + "_collateral": [ + "SPRINT11, Lane A's ruling. E proposed 180 in the asset and asked to be argued with rather", + "than adopted; the reasoning holds, so it's adopted unchanged. Gnome 25, a night's shop", + "budget 80: at 180 the carport is 2.25 nights, which turns a good week broke and is felt for", + "the rest of the run without ending a strong one outright. Under ~120 the trap is a shrug;", + "over ~250 it's a silent game over, which teaches nothing because you never get to act on it.", + "It lives HERE and not in the GLB for the same reason the gnome's 25 does: sites are data,", + "and what a carport COSTS is this site's economy, not a property of the mesh. E's baked", + "collateral_value stays as the proposal and the fallback; world.js prefers this number." + ], + + "_types": [ + "SPRINT11: these were all 'post' to fit the old closed enum, which is exactly the lie D", + "flagged — a carport silently joining the sail-post family that C's venturi and B's audit", + "read. Now typed with E's own asset strings. The ladder is unaffected either way: it reads", + "`work` (the mechanism), which is why D keyed it there." + ], "anchors": [ - { "id": "cb1", "node": "beam_anchor_01", "type": "post", "work": "bracket" }, - { "id": "cb2", "node": "beam_anchor_02", "type": "post", "work": "bracket" }, - { "id": "cp1", "node": "post_anchor_01", "type": "post", "work": "cloth" }, - { "id": "cp2", "node": "post_anchor_02", "type": "post", "work": "cloth" } + { "id": "cb1", "node": "beam_anchor_01", "type": "carport", "work": "bracket" }, + { "id": "cb2", "node": "beam_anchor_02", "type": "carport", "work": "bracket" }, + { "id": "cp1", "node": "post_anchor_01", "type": "carport_post", "work": "cloth" }, + { "id": "cp2", "node": "post_anchor_02", "type": "carport_post", "work": "cloth" } ] } ], @@ -66,15 +87,33 @@ "wind": { "_venturi": [ - "Lane C: the corner block's whole weather personality is the venturi — a street on two sides,", - "so a gap that SCREAMS when the southerly swings to run along it and is calm otherwise. The gap", - "runs NE-SW between the house line and the carport, i.e. along the yard's west edge. Two", - "coordinates you asked for: throat centre (-6, 0), and it funnels along axis ~2.1 rad (the", - "southerly's direction after the change). Numbers are yours to tune; I've put a starting shape", - "below in your schema. gain/radius/sharp are your call." + "The corner block's whole weather personality: a street on two sides, so a gap that SCREAMS", + "when the southerly swings to run along it and is calm otherwise. The gap runs NE-SW between", + "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.", + "", + "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.", + "", + "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." ], "venturi": [ - { "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 } + { "x": -6, "z": 0, "axis": -1.08, "gain": 1.35, "radius": 5, "sharp": 3 } ] } } diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index 1139edc..bd2db25 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -62,6 +62,23 @@ export const HARDWARE = [ /** Game phases, in loop order. */ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; +/** + * Every anchor type a site may declare (SPRINT11, D's flag). + * + * These are the strings Lane E bakes as `anchor_type` in the GLBs — kept + * identical on purpose, so an asset and a site say the same word for the same + * steel. The list is OPEN to new sites: adding a type here and in the asset is + * the intended way to grow, which is the whole reason the carport didn't fit. + * + * The only behaviour keyed on a type string is the tree wind-shadow filter + * (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's + * `rating_hint`. Keep it that way — a rule keyed on a type a future site + * doesn't fit is the exact shape of the bug this widening fixed. + */ +export const ANCHOR_TYPE = Object.freeze([ + 'house', 'tree', 'post', 'carport', 'carport_post', +]); + // --------------------------------------------------------------------------- // Determinism helpers // --------------------------------------------------------------------------- @@ -156,7 +173,12 @@ export class Emitter { * @typedef {object} Anchor * @property {string} id Stable, e.g. 'h1', 't1', 'p2'. * @property {THREE.Vector3} pos REST position. Does not move. - * @property {'house'|'tree'|'post'} type + * @property {ANCHOR_TYPE[number]} type + * What the anchor IS. Widened in SPRINT11 on D's flag: site_02's carport was + * typed 'post' to fit the old closed enum, which quietly enrolled it in the + * sail-post family that C's venturi and B's audit both read. The types now + * match the strings Lane E bakes into the GLB, so `type` can be believed. + * NOT the ladder's field — that's `work`, the mechanism (D, SPRINT10). * @property {(t:number) => THREE.Vector3} sway * ABSOLUTE world position at time t, including wind sway. NOT an offset — * this is the value you pin a cloth corner to: diff --git a/web/world/js/main.js b/web/world/js/main.js index 67f5faf..74b1eb5 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -611,6 +611,34 @@ export async function boot(opts = {}) { // The gnome is collateral if the sail came down over it. DESIGN.md: the // worst debris in any storm is your own failed work. if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue }); + + /** + * SPRINT11 — the carport trap finally BILLS (§gate 1). + * + * E shipped the trap in Sprint 10 and said it plainly: the anchors say + * `collateral:"carport"` but nothing said what a carport COSTS, so nothing + * scored it. Until now you could tie 25 m2 to the worst steel in the game, + * lose it, and pay for a $15 shackle. The temptation was real and the + * consequence was decoration — the site's whole thesis, missing its verb. + * + * ONE broken corner is enough, unlike the gnome's two. The gnome needs the + * sail to come down over it; the carport doesn't need the sail at all. That + * beam is rated 0.22 and holds a roof — let go of it under load and the roof + * is what leaves. Requiring a second failure would say the first one was + * free, which is the lie this ruling exists to delete. + * + * Priced per STRUCTURE, not per corner: two beams letting go is one carport + * gone, not $360. It bills once and the wreck swaps once. + */ + const taken = new Set(); + for (const c of lost) { + const key = world.anchor(c.anchorId)?.collateral; + const priced = key ? world.collateralFor(key) : null; + if (!priced || taken.has(key)) continue; + taken.add(key); + collateral.push({ what: priced.label, cost: priced.cost }); + world.wreckStructure(key); // false offline/graybox — the bill still lands + } const s = rigging.summary; const hp = garden.hp; /** diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 6bf7494..8bc33c7 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -4,7 +4,8 @@ */ import * as THREE from '../../vendor/three.module.js'; -import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; +import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; +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'; @@ -132,6 +133,10 @@ export default async function run(t) { // Gate 2 acceptance: both sites load from data, and the corner block is not // the backyard with the furniture moved. Built up front (Suite.test() can't // await — the guard I added last sprint enforces it, and just caught me). + // storm_03b is the corner block's night: the early buster IS the southerly the + // funnel exists to teach, so the venturi assert measures the real pairing. + const earlyBusterDef = await loadStorm('storm_03b_earlybuster'); + let site2World = null; try { const s2 = validateSite(await loadSite('site_02_corner_block')); @@ -163,6 +168,94 @@ export default async function run(t) { assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder'); }); + // --- SPRINT11 gate 1: the three rulings, each pinned ---------------------- + + t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => { + if (!site2World) return 'SKIPPED — no server for site_02'; + const by = (id) => site2World.anchors.find((a) => a.id === id); + // D's flag. Typed 'post' these four quietly joined the sail-post family that + // C's venturi and B's audit both read off `type`. The lie is what's pinned + // here: if someone re-types them 'post' to dodge the enum, this reddens. + assertEq(by('cb1').type, 'carport', 'beam anchors are carports'); + assertEq(by('cb2').type, 'carport', 'beam anchors are carports'); + assertEq(by('cp1').type, 'carport_post', 'the posts are carport posts'); + assertEq(by('cp2').type, 'carport_post', 'the posts are carport posts'); + // The one behaviour keyed on a type string, and the reason the lie mattered: + // a carport must never be mistaken for a tree and cast a wind shadow. + const trees = site2World.anchors.filter((a) => a.type === 'tree'); + assert(trees.length > 0, 'the gum tree is still a tree'); + assert(!trees.some((a) => a.id.startsWith('c')), 'no carport anchor answers the tree filter'); + }); + + t.test('ruling: the widened enum is CHECKED, so a bad type cannot ship', () => { + // The enum was JSDoc for ten sprints — documentation cannot fail, which is + // exactly how a carport got typed 'post'. This is the assert that makes the + // contract real, so it must actually reject something. + const bogus = { + yard: { width: 10, depth: 10 }, gardenBed: { x: 0, z: 0, w: 2, d: 2 }, + sun: { elevationDeg: 45, azimuthDeg: 0 }, + posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'trampoline', work: 'cloth' }], + }; + let threw = null; + try { validateSite(bogus, 'bogus'); } catch (err) { threw = err.message; } + assert(threw, 'a site with an unknown anchor type must fail loud'); + assert(/trampoline/.test(threw), 'and must NAME the type it rejected'); + // ...and must not reject the types that are real, including the new ones. + for (const type of ANCHOR_TYPE) { + const ok = { ...bogus, posts: [{ id: 'x1', x: 0, z: 0, h: 3, type, work: 'cloth' }] }; + validateSite(ok, `ok-${type}`); // throws = red + } + }); + + 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. + const at = (axis) => { + const f = createWindField(earlyBusterDef); + f.setVenturi([{ x: -6, z: 0, axis, gain: 1.35, radius: 5, sharp: 3 }]); + return f; + }; + const c = at(-1.08), flipped = at(-1.08 + 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))); + } + // NOT assertEq(worst, 0) — and the reason is worth the comment. I first + // "measured" this at exactly 0.000000 in a node probe and wrote that into + // the site JSON. It was a toFixed(6) printing 1e-14 as zero: the probe + // rounded a number I then reported as measured. cos(x+PI) is not bit-exactly + // -cos(x), so flipping the axis carries ~1e-16 of float noise into |dot|. + // 1e-14 m/s is not physics — a gust is 21 m/s — but "exactly" was my word, + // not the harness's. The claim is: identical to float precision. + assertLess(worst, 1e-9, 'axis and axis+PI are the same funnel, to float precision'); + // And the funnel must actually DO something, or the corner block's whole + // weather personality is a no-op that no test would have noticed. + const off = createWindField(earlyBusterDef); + const peakT = 48.75; // measured: storm_03b's worst + assert(c.speedAt(-6, 0, peakT) > off.speedAt(-6, 0, peakT) * 1.2, + 'the throat is meaningfully faster than the open yard'); + }); + + 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 + // "carport" and nothing said what a carport COST, so nothing scored it. + // You could lose the worst steel in the game and pay for a $15 shackle. + const priced = site2World.collateralFor('carport'); + assert(priced, 'the carport has a price at all'); + assertEq(priced.cost, 180, "A's ruling: 180 — 2.25 nights' budget"); + assertEq(priced.label, 'the carport', 'and reads as English on the invoice'); + // The price is the SITE's, not the mesh's. Sites are data. + assertEq(site2World.anchors.find((a) => a.id === 'cb1').collateral, 'carport', + 'the beam anchor carries the key that reaches the price'); + // An unpriced label must read as "not scored", never as free: the house's + // fascia anchors carry collateral "gutter" and nobody has priced a gutter. + assertEq(site2World.collateralFor('gutter'), null, 'unpriced is null, not 0'); + assertEq(site2World.collateralFor(undefined), null, 'and no key is not a bill'); + }); + // --- the wind router ----------------------------------------------------- // Loaded HERE, not inside t.test(). These two were written `async` and // Suite.test() cannot await — so they were recorded as passes while asserting diff --git a/web/world/js/world.js b/web/world/js/world.js index 63cc618..f7b6c7d 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -12,7 +12,8 @@ */ import * as THREE from '../vendor/three.module.js'; -import { createStubWind } from './contracts.js'; +import { ANCHOR_TYPE, createStubWind } from './contracts.js'; +import { validateSiteWind } from './weather.core.js'; /** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */ const rad = (deg) => ((deg ?? 0) * Math.PI) / 180; @@ -101,7 +102,20 @@ export function validateSite(site, name = site?.id ?? '?') { if (a.work !== 'cloth' && a.work !== 'bracket') { bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`); } + // SPRINT11 — the enum is CHECKED now, not just documented. It was a JSDoc + // comment before, which is why site_02 could type a carport 'post' and + // nothing said a word (D's flag). An unenforced enum is decoration; this + // one fails loud, so the next site that doesn't fit argues with the list + // in contracts.js instead of quietly joining the wrong family. + if (!ANCHOR_TYPE.includes(a.type)) { + bad.push(`anchor ${a.id}: type ${JSON.stringify(a.type)} is not one of ${ANCHOR_TYPE.join('|')}`); + } } + // C's validator, wired at site load as they asked in SPRINT10: a site's wind + // block is hand-authored data, so it fails loud like a storm's rather than + // being silently clamped. A site with no wind block passes (backyard_01 has + // no funnel) — that's C's contract, not an accident. + bad.push(...validateSiteWind(site.wind, name).errors); } if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`); return site; @@ -693,6 +707,25 @@ export function createWorld(scene, opts = {}) { if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); }); glb.updateWorldMatrix(true, true); for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id); + entry.glb = glb; + + // SPRINT11 — the wreck, loaded NOW and parked invisible. E built it to the + // same origin and footprint so it swaps mesh-for-mesh (their e.test pins + // that, and that it carries the same price and names its intact twin). + // Loaded during dress rather than at the moment it's needed because the + // swap happens as the storm ends: an async load there would drop the + // payoff frames after the aftermath card, or not at all offline. + if (st.wreckedModel) { + const wreck = await load(st.wreckedModel); + if (wreck) { + wreck.name = `${st.id}_wrecked`; + wreck.position.copy(glb.position); + wreck.rotation.y = glb.rotation.y; + wreck.visible = false; + root.add(wreck); + entry.wreck = wreck; + } + } } return { shed, table }; @@ -728,6 +761,57 @@ export function createWorld(scene, opts = {}) { return anchors.find((a) => a.id === id) ?? null; }, + /** + * What the client's property costs you when your rig takes it with it. + * + * The key is the string E bakes as `collateral` on the anchor (site_02's + * carport anchors all say "carport"), so the chain is: tie to the beam → + * the beam's anchor carries `collateral:"carport"` → this prices it. The + * PRICE lives in site JSON, not the GLB: sites are data, and what a carport + * costs is this site's economy, not a property of the mesh. E's baked + * `collateral_value` is the fallback and their proposal (SPRINT11: 180, + * adopted with their reasoning). + * + * Returns null for an unpriced label, and that is deliberate rather than a + * zero: the house's fascia anchors carry `collateral:"gutter"` and NOBODY + * has priced a gutter yet. A missing price must read as "not scored", never + * as "free" — see THREADS, it's an open seam. + * + * @param {string} key + * @returns {{cost:number, label:string}|null} + */ + collateralFor(key) { + if (!key) return null; + for (const { spec, glb } of structures.values()) { + if (spec.id !== key) continue; + const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null; + if (!Number.isFinite(cost)) return null; + return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id }; + } + return null; + }, + + /** + * Swap a structure for its wreck. The payoff for the trap: you don't break a + * shackle, you take the carport, and the aftermath has to SHOW that. + * No-op (returns false) when the site declares no wreck or the GLB is + * missing — the graybox and headless paths still score the bill, they just + * can't show it. Idempotent. + * @param {string} id + */ + wreckStructure(id) { + const entry = structures.get(id); + if (!entry?.wreck || !entry.glb) return false; + entry.glb.visible = false; + entry.wreck.visible = true; + return true; + }, + + /** Is this structure standing? Lane D asked for a poke-able truth. */ + isWrecked(id) { + return structures.get(id)?.wreck?.visible === true; + }, + /** * Take the whole yard out of the scene and free its GPU memory. SPRINT10: * the week rebuilds the world when the site changes between nights, and