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:
parent
b38311021c
commit
4aee055f2d
@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user