Sprint 11 gate 1: three rulings — enum widened, carport priced, venturi reconciled

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).
This commit is contained in:
type-two 2026-07-17 17:20:11 +10:00
parent b38311021c
commit 4aee055f2d
5 changed files with 280 additions and 14 deletions

View File

@ -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 }
]
}
}

View File

@ -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:

View File

@ -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;
/**

View File

@ -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

View File

@ -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