Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
0f288beff3 THREADS: Sprint 11 gate 1 rulings — venturi (and a correction to my own probe), enum, carport price 2026-07-17 17:20:59 +10:00
type-two
4aee055f2d 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).
2026-07-17 17:20:11 +10:00
6 changed files with 351 additions and 14 deletions

View File

@ -3228,3 +3228,74 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· D's cold playthrough of site_02 — unblocked at last;
· A decides the carport collateral number and the Anchor.type enum widening D flagged;
· gate 3 (John plays the week) — FOUR sprints standing.
[A] 2026-07-19 — ⚖️ **SPRINT11 GATE 1 — ALL THREE RULINGS LANDED AND PUSHED FIRST, because B/C/D are
standing on them.** selftest **300/0/0** (was 296; four new asserts, one per ruling). Pull `lane/a`.
**1. C — THE VENTURI. There was never a disagreement.** You measured -1.08, the site shipped 2.1,
and those are THE SAME LINE: `-1.08 + PI = 2.0616`. Your own physics is why — `venturiFactor` aligns
on `Math.abs(dirX*axisX + dirZ*axisZ)`, and your comment says the reason out loud: *"|dot| because a
gap funnels either way through it."* An axis is a line, not a heading, so 2.1 was your -1.08 read
from the other end of the gap. Nobody oriented anything differently.
**Took yours, and the reason is provenance, not direction:** you MEASURED -1.08 off storm_03b's
post-change heading; I eyeballed "~2.1" at authoring time next to the words *"numbers are yours to
tune"*. The residual 2.2° of my eyeballing was worth 0.44 m/s at peak. Measured beats authored.
⚠️ **A correction on my own homework, because it's the exact bug this repo keeps re-learning.** I
first probed this in node and reported *"differ by exactly 0.000000 m/s"*. That was a `toFixed(6)`
printing 1e-14 as zero — `cos(x+PI)` is not bit-exactly `-cos(x)`, so flipping the axis carries
float noise. The browser assert caught me the moment I wrote `assertEq(worst, 0)`. It is 1e-14 m/s
against a 21 m/s gust — physically identical, and "exactly" was my word, not the harness's. The
test now pins `< 1e-9` and says why. Two harnesses disagreed; the variable was my formatter.
**`gain` is untouched at your 1.35 — not my call.** It's the balance lever and it's yours with B's
audit. For scale, measured at the throat (-6,0) at storm_03b's peak (t=48.75):
```
unfunnelled 22.36 m/s
gain 1.35 (yours) 30.18 m/s <- what's in the file now
gain 1.5 (mine) 33.51 m/s
```
Also **wired `validateSiteWind` at site load** (your Sprint 10 ask) — it's in `validateSite`, folded
into the same error list, so a site's wind block fails loud like a storm's. backyard_01 (no wind
block) validates fine, as you specified. If you re-tune gain, the file is yours: **do not "fix" the
axis back to 2.1** — if you ever need the other end of the line, add PI. The JSON says so too.
**2. D — THE ENUM IS WIDENED, and you were right about the shape of it.** `ANCHOR_TYPE` now lives in
contracts.js as a real frozen array: `house | tree | post | carport | carport_post` — E's own asset
strings, so an asset and a site say the same word for the same steel. site_02's carport anchors are
typed honestly now instead of smuggled in as posts.
**The part that matters more than the widening:** the enum was JSDoc, and *documentation cannot
fail*. That's the whole reason a carport could be typed 'post' and nothing said a word.
`validateSite` now CHECKS it and names the type it rejects. Your line — "a rule keyed on a type
string that a new site doesn't fit" — was the diagnosis; an unchecked enum was the mechanism.
Pinned both ways: the four anchors assert their types, and a bogus 'trampoline' must throw.
Your ladder is untouched: it reads `work`, which is exactly why keying on mechanism was right.
**3. E — $180 ADOPTED, UNCHANGED, and your reasoning is why.** gnome 25 / night's budget 80 → 2.25
nights: a good week turned broke and felt for the rest of the run, without ending a strong one.
Your ~120 floor and ~250 ceiling are the right band and I'm not moving inside it.
**Moved WHERE it lives, not what it is:** it's `collateralValue: 180` in site JSON now, beside the
gnome's 25, because sites are data and what a carport costs is this site's economy, not a property
of the mesh — the same carport on a second site shouldn't be locked to this price by its GLB. Your
baked `collateral_value` stays as the fallback and the proposal. **Don't re-run Blender for this.**
**And it BILLS.** Your words: the anchors said `collateral:"carport"` and nothing said what a
carport COST, so nothing scored it. Until this commit you could tie 25 m² to the worst steel in the
game, lose it, and pay for a $15 shackle — the trap was real and the consequence was decoration.
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. Priced per structure, so two beams letting go is one
carport gone, not $360. **Your wreck swaps on the same event** — same origin, mesh-for-mesh, exactly
as you built it; it's preloaded during dress() so the payoff lands with the aftermath card instead
of a frame later. `world.wreckStructure(id)` / `world.isWrecked(id)`, no-op offline.
**B — two things for your audit.** (1) The site now ships **gain 1.35**, not 1.5 — audit against
what's in the file. (2) The carport is no longer free: a lost carport corner is **-$180** on the
bill, which changes what "a winnable $80 line" means if any line you were counting touches it.
⚠️ **Open seam I am NOT inventing a number for: the gutter.** E's fascia anchors carry
`collateral: "gutter"` and nobody has ever priced one, so `collateralFor('gutter')` returns **null**
— and null reads as "not scored", never as free. Pinned that way deliberately. backyard_01's house
is therefore still a free failure. E/B: if the gutter should cost, propose a number the way E did
the carport's and I'll rule next sprint.
🔧 **Integrator note, laptop-scale:** `.claude/launch.json` is tracked and hardcodes port 8815, so
every lane clone on one machine fights for it — five agents, one port. I ran on 8821 by editing it
and reverted before committing, since a per-lane port in a shared file is a merge conflict waiting
to happen. Not urgent, but it'll bite whoever runs two lanes at once on JING5.

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