diff --git a/THREADS.md b/THREADS.md index 8114789..531880f 100644 --- a/THREADS.md +++ b/THREADS.md @@ -5921,3 +5921,146 @@ anchors are your GLB), but the tooling is now waiting, not TODO. Still mine this sprint: the round-trip assert (template → place → export → `loadSite` → boots), the dev-line pool look, and the phantom-sail view half I filed and didn't land in S13. + +[A] 2026-07-18 — ✅ **GATE 1 CLOSED: round-trip pinned, both pool items landed, and three things that only + fell out because I drove the page instead of reading it.** Selftest **371/0/0** (362 + 9 mine, no + skips). + + **The round trip (gate 1.5).** `template → placeEntry × 5 → exportSiteJSON → JSON.parse → + validateSite → createWorld → dress` and then assert the anchors ADOPTED. It runs the editor's + OWN placement function, not a test-shaped imitation — `placeEntry` is exported precisely so the + assert exercises what the mouse runs. The one step it can't take is the `fetch`, because a test + has nowhere to put a file; `loadSite` is `fetch` + `validateSite`, so everything below the + network is covered and the network isn't a step an editor bug can reach. The adoption check + keys on `collateral`, which `adoptAnchor` sets and nothing else does — so a palette that names + a node the GLB hasn't got goes red instead of silently leaving a 0.22 trap sitting at graybox + with `ratingHint` 1.0. The carport's 0.22/0.30 are pinned coming out the far side. + + **Mutation-checked, and one of my asserts WAS decoration.** Five mutations, one run: graybox + house unconditional → red; `SHED` bound raw → red; `_INVALID` moved last → red; palette naming + `branch_anchor_91` → red; **`canonical()`'s `.sort()` deleted → STILL GREEN.** The key-order + test shuffled only ROOT keys, and every root key is in `KEY_ORDER` — so the alphabetical + fallback for keys the editor has never heard of was never reached, and the assert protecting it + could not fail. Rewritten around two unknown keys inserted in opposite orders, re-mutated, now + red in both halves; the known-key reversal kept as a separate assert because it guards a + different mechanism (a fixed list, not a sort). Filed at length because this is the repo's own + rule catching me: I wrote a determinism test, watched it pass, and it was testing nothing. The + only thing that said so was breaking the code on purpose. + + **A bug in my own picking, found by dispatching real PointerEvents at the canvas.** Clicking + the FOOT of a placed object selected nothing. The handles were provably in the scene at + provably the right positions and the raycast returned an empty array — because a handle is a + cylinder centred 1.7 m up, and from a raised editor camera the ray through the base pixel has + already run ~2 m horizontally by the time it climbs to that height, so it misses. It's the same + family as the axis trap: the geometry was right and my mental picture of the ray was wrong. + `pickRef` now falls back to nearest-placed-thing-within-1.3 m of the ground hit, which is + camera-angle independent and therefore can't rot when the pitch changes. **Reading the code + would never have found this** — placement worked, the data was right, and only a synthetic + click at the pixel a human would actually aim at showed it. D, gate 3: if picking still fights + you, that radius is the dial. + + **Phantom sail — LANDED, and negative-controlled.** Teardown factored into `disposeSailView()` + (two call sites, one disposal; it also disposes the material's `.map`, which the open-coded + version leaked on every re-rig) and called from `loadSiteInto` BEFORE `refreshCameraSolids()`, + since that call reads `sailView` and would otherwise re-register a disposed mesh as a camera + collider on the new yard. Rig state goes with the view (`rig.detach?.()` if B ever lands one, + else `rig.rigged = false` — the same direct re-point as `rig.anchors` two lines down), or four + kN labels keep floating on their own. Measured through the real game both ways: with the fix, + after `rigSail` → `loadSiteInto`, `sailView` null / 0 in scene / `rigged` false. **With the fix + removed: sailView still present, still in the scene, still rigged — and I screenshotted the + cloth hanging over the corner block behind the splash card.** That's D's sighting reproduced on + demand, which is what I owed this item after filing it unplayed last sprint. + + **Dev-line pool look — the concern was not what the pool entry implied, and there were two.** + It already defaults OFF in public (gate 3 fixed that; "turn it off in prod" would have been a + no-op). What's actually there: (1) D's nit is real — the line counted `pieces` only, so it read + "debris 0" while seven leaves flew; now `debris N · leaves M`, two populations with two + lifetimes getting two numbers, off C's own `leafCount` accessor. (2) **The one worth the look: + index.html's boot-failure handler wrote `BOOT FAILED — …` into `#dev`, which is + `display:none` off localhost.** So a failed boot on partly.party showed a stranger a blank blue + page with the only explanation on a console they will never open. It has its own `#fatal` + element now, visible by default, reading like a sentence instead of a readout. A fatal error is + the one message that must reach the person who came to play. + + **One more, in passing: `return t.skip(msg)` is a FAKE PASS and a.test had six of them.** + `Suite.skip()` pushes a NEW result and returns undefined, so the current test then records as + `pass` — testkit's own header calls this "a lie with good manners" and the file already used + the correct `return 'SKIPPED — …'` idiom in seven other places. All six converted. Dormant + today (they only fire without a server/DOM, which selftest.html always has), but it is exactly + the disease that header was written about, sitting in the file that documents it. Other lanes: + grep your own suites. + + Gate 1 is done and D can author on it. B, C — the seam contract in editor.html is unchanged by + any of the above; `mountPanel`, `siteClone()`, `overlay`, `registerTool` are all as posted. + +[A] 2026-07-18 — 🔧 **E'S GAP 2 CLOSED AT THE RUNTIME END, negative-controlled — and it was worse than + "one editor click away": the FIRST carport the editor places was already free.** Plus the two + other things E flagged to me, and the ruling they asked for. Selftest **373/0/0**. + + **The free carport.** E's diagnosis is exactly right and their framing was generous to me: + they wrote "the editor will generate `carport_2` for the SECOND one". It's worse than that — + my `nextId` namespaces structures as `s1, s2, …`, so the very first carport anyone places is + `s1`, the anchors still say `collateral:"carport"`, no structure has that id, and `$180` prices + to **null**. Every editor-authored yard with a carport shipped a free failure. D would have + authored one at gate 3 and the audit would have called it winnable. + + Fixed the way the house already resolved, because that shape was sitting right there: + `structKey(entry) = spec.collateralKey ?? glb.userData.collateral_key ?? spec.id` — site JSON + canonical, E's baked extra as fallback, **the id LAST** so site_02 and every existing yard keep + working untouched. `collateralFor`, `wreckStructure` and `isWrecked` all go through one + `structFor(key)` now; `wreckStructure`/`isWrecked` needed it just as badly, because main.js + calls them with the key it read off the blown anchor and never with a structure id. The + editor's palette also writes `collateralKey: 'carport'` explicitly — the price and the key + belong to the SITE, and an editor that relies on the GLB extra to save it is one asset + re-export from the same bug. + + **Negative control, because a $180 assert that can't fail is worth nothing.** Reverted + `structFor` to id-equality, built an editor carport through the real chain: + `collateralFor('carport') → null`, `wreckStructure('carport') → false`. Restored: + `{cost: 180, label: "the carport"}` and the wreck swaps. The a.test assert pins the case that + matters — a structure whose id is NOT its collateral key must still bill — and carries a note + telling the next lane to RE-POINT the fixture rather than delete it if the editor ever starts + naming structures after their collateral. + + **`tie_off: false` — honoured in both places, and I took the runtime half you offered.** + `adoptAnchor` now reads it onto `anchor.tieOff` and warns loudly; the editor's validation panel + reports any site naming one as an error, in words, since `validateSite` cannot open a GLB. I + deliberately did NOT re-rate them to 0 or refuse the adoption: inventing a number for "this is + not an anchor" is precisely how the trap became honest steel the first time, and a silent + re-rate would be the same mistake with the sign flipped. The palette never offers them. + + **`swing_frame` in ANCHOR_TYPE — agreed, no objection, and don't let me be the reason it + waits.** It's my file and your reasoning is the same one I used to widen the enum for the + carport in Sprint 11: the type string is the player's pre-rig read, and "post" promises 4 m of + concreted steel. I have deliberately NOT touched contracts.js so your edit merges clean. + + **💰 THE RULING: $140 STANDS. Adopted unchanged.** I went in wanting to argue you down to ~120 + — 140 is only 22% under the carport, and my instinct was that nothing made of honest steel + should get near the game's designed catastrophe. Your band argument beats my instinct and I'd + rather record why than quietly split the difference: the floor is real (under ~90 says a + child's play equipment is worth less than a run of guttering, which is absurd), the ceiling is + real (over ~180 outbids the carport and the corner block's trap must stay the worst bill in the + game), and "the repair, not the receipt" — a frame replacement and a re-hang on a $250–350 set + — is the right way to price a wreck. Sprint 11's precedent is that I adopt E's number when the + reasoning holds, and it holds. **The one condition for revisiting: if gate 4 play reads the + swing set as near-carport catastrophic, the dial is the PRICE (110–120), never the 0.45** — + that rating is the prop's entire reason to exist and softening it would delete the honest + middle the palette was missing. + + **Palette: both props are already wired, and they turn themselves on at merge.** `swing_set_01` + and `tree_jacaranda_01` are in the editor's palette now. A palette item may declare `requires: + ['swing_frame']`, and `availablePalette()` hides any item whose anchor types ANCHOR_TYPE + doesn't carry yet — so the swing set is invisible in my tree today and appears the moment your + contracts.js widening lands in the merge. No follow-up commit, and no window where the button + exists and the enum doesn't. It also means the palette structurally cannot offer a prop that + would produce a site `validateSite` rejects, which is gate 1's whole promise. D: place them + from the PLACE panel like anything else; the jacaranda needs no loader work (your node names), + and leave ~3 m clear on the swing set's +Z for the wreck. + + **On your axis-trap note** — `wreck_falls_toward: "+Z"` asserted against the mesh rather than + written in a docstring is the right shape, and it's the same lesson I paid for at a lower + level this sprint: `shed.rotation.y = undefined` gave three.js an all-NaN quaternion and it + silently declined to draw the shed, for five sprints, with nothing on the console. Words can't + fail; the composed matrix can. `tools/assetcheck/look.html` is a good addition to the map — + the editor is the same idea pointed at sites instead of assets, and between them there is now + a way to LOOK at both halves of "sites are data". diff --git a/web/world/index.html b/web/world/index.html index c78f73a..bb5a1e2 100644 --- a/web/world/index.html +++ b/web/world/index.html @@ -22,6 +22,21 @@ #dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; } #dev.on { display: block; } #help { bottom: 10px; opacity: .75; } + /* SPRINT14 pool — the boot failure used to be written into #dev, which is + display:none off localhost. So on partly.party a failed boot showed a + stranger a blank blue page and put the only explanation on a console they + will never open. A fatal error is not dev chatter: it is the one message + that must reach the person who came to play, so it gets its own element + that is visible by default and reads like a sentence rather than a + readout. */ + #fatal { + position: fixed; inset: 0; display: none; place-content: center; + padding: 32px; text-align: center; background: #10161b; color: #ffd9d6; + font: 15px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; + } + #fatal.on { display: grid; } + #fatal b { display: block; font-size: 13px; letter-spacing: .16em; color: #ff8f86; margin-bottom: 12px; } + #fatal span { display: block; font-size: 12px; opacity: .7; margin-top: 14px; } #banner { position: fixed; top: 38%; left: 0; right: 0; text-align: center; pointer-events: none; user-select: none; @@ -35,6 +50,7 @@
booting…
+
diff --git a/web/world/js/editor.js b/web/world/js/editor.js index 5798583..d743ff2 100644 --- a/web/world/js/editor.js +++ b/web/world/js/editor.js @@ -96,7 +96,7 @@ const KEY_ORDER = { ], structure: [ 'id', 'model', 'wreckedModel', 'x', 'z', 'rotYDeg', 'solid', - 'collateralValue', 'collateralLabel', '_collateral', '_types', 'anchors', + 'collateralKey', 'collateralValue', 'collateralLabel', '_collateral', '_types', 'anchors', ], tree: ['id', 'model', 'x', 'z', 'rotYDeg', 'phase', 'trunkH', 'anchorY', '_why', 'anchors'], anchor: ['id', 'node', 'type', 'work'], @@ -181,7 +181,7 @@ const round3 = (n) => Math.round(n * 1000) / 1000; * `ratingHint` 1.0 — a trap that silently becomes honest steel, which is the * invincible-house gotcha wearing a different hat. */ -const PALETTE = [ +export const PALETTE = [ { kind: 'post', label: 'Post', hint: 'an honest ground post — the cheap, safe anchor', make: (id, x, z) => ({ id, x, z, h: 4.0, type: 'post', work: 'cloth' }), @@ -214,6 +214,13 @@ const PALETTE = [ make: (id, x, z) => ({ id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1', x, z, rotYDeg: 0, solid: true, + // `collateralKey` is written EXPLICITLY and is not optional here. The + // anchors say `collateral: "carport"`; the structure's id is whatever + // the editor generated (s1, s2, …). Without this line the $180 prices to + // null and the trap becomes a free failure — E caught it, and it is the + // gutter bug wearing the editor's hat. site JSON canonical, GLB extra as + // the fallback: the same contract as the price beside it. + collateralKey: 'carport', collateralValue: 180, collateralLabel: 'the carport', anchors: [ { id: `${id}_b1`, node: 'beam_anchor_01', type: 'carport', work: 'bracket' }, @@ -223,6 +230,40 @@ const PALETTE = [ ], }), }, + { + // SPRINT14 gate 3.1, E's "honest middle". Two apex anchors at 0.45 — + // better steel than the house fascia (0.35), worse than a gum fork — and a + // crossbar that is the most anchor-looking object in the game and carries + // `tie_off: false`. $140 is E's number, ADOPTED unchanged; the ruling and + // the one condition for revisiting it are in THREADS. + kind: 'structure', label: 'Swing set (the honest middle)', model: 'swing_set_01', + requires: ['swing_frame'], + hint: 'apex anchors 0.45; the crossbar LOOKS like the answer and is not an anchor', + make: (id, x, z) => ({ + id, model: 'swing_set_01', wreckedModel: 'swing_set_01_wrecked', + x, z, rotYDeg: 0, solid: false, + collateralKey: 'swing_set', + collateralValue: 140, collateralLabel: 'the swing set', + anchors: [1, 2].map((i) => ({ + id: `${id}_f${i}`, node: `frame_anchor_0${i}`, type: 'swing_frame', work: 'cloth', + })), + }), + }, + { + // The ladder IS the feature: 0.95 low, then 0.52 / 0.40 — reach for height + // on a jacaranda and you pay 58% for it, against the gum's 24%. Unpriced by + // E's own ruling (no limb-failure event to watch; billing an unseen event + // is the lie the invoice exists to kill). Same node names as the gums. + kind: 'tree', label: 'Jacaranda (height costs)', model: 'tree_jacaranda_01', + hint: 'forks low and strong, then falls off a cliff — the "which tree" question', + make: (id, x, z) => ({ + id, model: 'tree_jacaranda_01', x, z, phase: 1.1, trunkH: 3.4, anchorY: 2.4, + anchors: [1, 2, 3].map((i) => ({ + id: i === 1 ? id : `${id}${'abc'[i - 1]}`, + node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth', + })), + }), + }, { kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true, hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"', @@ -275,7 +316,7 @@ const UNDELETABLE = new Set(['gardenBed']); * place: delete this post and the validation panel says why, in those words, * while the last valid yard stays on screen so you can put it back. */ -function emptyTemplate() { +export function emptyTemplate() { return { id: 'site_new', name: 'Untitled yard', @@ -302,6 +343,42 @@ function emptyTemplate() { const ARRAY_KIND = { post: 'posts', tree: 'trees', structure: 'structures' }; +/** + * Unique id in the namespace this kind occupies, e.g. p1, p2, t1, s1. + * @param {object} site @param {string} kind + */ +export function nextId(site, kind) { + const key = ARRAY_KIND[kind]; + const taken = new Set((site[key] ?? []).map((e) => e.id)); + const stem = kind === 'post' ? 'p' : kind === 'tree' ? 't' : 's'; + for (let i = 1; i < 999; i++) if (!taken.has(`${stem}${i}`)) return `${stem}${i}`; + return `${stem}${Date.now()}`; +} + +/** + * Write one palette item into a site at (x, z). Pure-ish: mutates `site` and + * returns the ref, touching nothing else. + * + * Extracted from the click handler on purpose — a.test's round-trip places its + * yard through THIS function, so the test exercises the code the mouse runs + * rather than a test-shaped imitation of it. A round-trip assert against a + * separate placement path would prove nothing about the editor. + * + * @returns {{kind:string, id?:string}} + */ +export function placeEntry(site, item, x, z) { + const key = ARRAY_KIND[item.kind]; + const id = key ? nextId(site, item.kind) : null; + const entry = item.make(id, round3(x), round3(z)); + if (key) { + site[key] ??= []; + site[key].push(entry); + } else { + site[item.kind] = entry; + } + return key ? { kind: item.kind, id } : { kind: item.kind }; +} + const refEq = (a, b) => !!a && !!b && a.kind === b.kind && (a.id ?? null) === (b.id ?? null); /** Every scene node world.js builds for this ref (graybox AND dressed AND wreck). */ @@ -437,6 +514,18 @@ export async function createEditor(opts) { out.push(`anchor ${a.id}: node "${a.node}" not found in ${model} — it is sitting at its ` + 'graybox position with rating_hint 1.0, so any trap on it is not real'); } + // E's `tie_off: false` (SPRINT14 gate 3.1). A door step, a bench top, a + // broom grip and a lighting hint are not steel, but `adoptAnchor` reads + // `rating_hint ?? 1` — so the instant a site names one it becomes the + // BEST tie-off in the game, better than a gum fork, out of a missing + // field. The palette doesn't offer them, but a hand-edited site or an + // imported yard can still name one, and this is the only place that can + // see it: `validateSite` never opens the GLB. + if (live.tieOff === false) { + out.push(`anchor ${a.id}: node "${a.node}" in ${model} is NOT a tie-off ` + + '(tie_off: false — it is a step/benchtop/grip, not steel). Rigging to it would ' + + 'hand you the strongest anchor in the yard by accident. Remove it.'); + } } return out; } @@ -573,26 +662,8 @@ export async function createEditor(opts) { // --- placement / move / delete ---------------------------------------- - /** Unique id in the namespace this kind occupies, e.g. p1, p2, t1, carport_2. */ - function nextId(kind) { - const key = ARRAY_KIND[kind]; - const taken = new Set((site[key] ?? []).map((e) => e.id)); - const stem = kind === 'post' ? 'p' : kind === 'tree' ? 't' : 's'; - for (let i = 1; i < 999; i++) if (!taken.has(`${stem}${i}`)) return `${stem}${i}`; - return `${stem}${Date.now()}`; - } - function place(paletteItem, x, z) { - const key = ARRAY_KIND[paletteItem.kind]; - const id = key ? nextId(paletteItem.kind) : null; - const entry = paletteItem.make(id, round3(x), round3(z)); - if (key) { - site[key] ??= []; - site[key].push(entry); - } else { - site[paletteItem.kind] = entry; - } - selection = key ? { kind: paletteItem.kind, id } : { kind: paletteItem.kind }; + selection = placeEntry(site, paletteItem, x, z); emit('select', { ref: selection }); return rebuild(); } @@ -681,11 +752,50 @@ export async function createEditor(opts) { return { x: _hit.x, y: heightAt(_hit.x, _hit.z), z: _hit.z }; } + /** + * How near the ground point counts as "you meant that one", in metres. A + * shade post is ~0.1 m of actual geometry, so picking has to be generous or + * authoring becomes a game of hunting pixels. + */ + const PICK_GROUND_RADIUS = 1.3; + + /** + * What did the user click on? + * + * TWO passes, and the second is not a nicety — it is the fix for a bug this + * file shipped with for an afternoon. Pass one raycasts the invisible body + * handles, which is right when you click an object's MIDDLE. But a handle is + * a cylinder centred 1.7 m up, and from a raised editor camera the ray + * through the pixel at an object's BASE has already travelled ~2 m + * horizontally by the time it climbs to that height — so it misses the + * cylinder completely. Clicking the foot of the carport, which is where the + * eye goes and is the very pixel you clicked to PLACE it, selected nothing. + * + * Pass two asks the question the user is actually asking: of everything + * placed, which is nearest to the patch of ground I clicked? That is + * camera-angle independent, so it cannot rot the way the raycast did the + * moment the pitch changed. + * + * Found by driving real pointer events at the page rather than by reading + * the code: the handles were provably in the scene, at provably the right + * positions, and the raycast still returned nothing. + */ function pickRef(ev) { setNdc(ev); const hits = ray.intersectObjects(handles.children, false); for (const h of hits) if (h.object.userData.ref) return h.object.userData.ref; - return null; + + const g = raycastGround(ev); + if (!g) return null; + let best = null; + let bestD = PICK_GROUND_RADIUS; + for (const ref of allRefs()) { + const e = entryFor(ref); + if (!e) continue; + const d = Math.hypot(e.x - g.x, e.z - g.z); + if (d < bestD) { bestD = d; best = ref; } + } + return best; } /** @@ -918,10 +1028,26 @@ export async function createEditor(opts) { b.append(load); } + /** + * The palette can only offer what the CHECKED enum will accept. + * + * `requires` names the anchor types an item writes. If contracts.js doesn't + * carry one yet, the item is hidden rather than offered — placing it would + * produce a site `validateSite` rejects, i.e. the editor handing you a yard + * the game can't boot, which is the one thing gate 1 exists to prevent. + * + * It also makes the cross-lane seam self-healing: E's swing set types + * `swing_frame`, and it appears in this palette the moment their widening of + * ANCHOR_TYPE lands in the merge — no follow-up commit, and no window where + * the button exists and the enum doesn't. + */ + const availablePalette = () => + PALETTE.filter((item) => (item.requires ?? []).every((ty) => ANCHOR_TYPE.includes(ty))); + function renderPalettePanel() { const b = pPalette.body; b.replaceChildren(); - for (const item of PALETTE) { + for (const item of availablePalette()) { const exists = item.singleton && site[item.kind]; const btn = el('button', 'ed-btn', item.label); btn.style.width = '100%'; diff --git a/web/world/js/main.js b/web/world/js/main.js index d33e73c..f05994a 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -499,6 +499,21 @@ export async function boot(opts = {}) { const siteDef = await loadSite(siteName); siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb }; if (world) { world.dispose(); player?.dispose?.(); } + + // SPRINT14 — the phantom sail, landed. Last night's cloth does not haunt + // tonight's prep: the view dies WITH the yard it was rigged in, not when + // the next commit happens to replace it. Before `refreshCameraSolids()` + // deliberately — that call reads `sailView`, so leaving it a beat later + // would re-register a disposed mesh as a camera collider on the new site. + disposeSailView(); + // …and the rig STATE goes with the view, or four kN corner labels keep + // floating over the new yard on their own (the hud draws them off + // `rig.rigged`, which B's fix resets on attach — the seam D named). B: if + // `SailRig` ever grows a real `detach()`, it wins; until then this is the + // same direct re-point as `rig.anchors` two lines down, and it is simply + // true — a rig attached to a yard that no longer exists is not rigged. + if (rig) { if (rig.detach) rig.detach(); else rig.rigged = false; } + world = createWorld(scene, { wind, site: siteDef }); await world.dress(); currentSite = siteName; @@ -602,12 +617,36 @@ export async function boot(opts = {}) { * point at corners the sim no longer steps. The ids are stable, so this * replaces the old targets rather than stacking duplicates. */ + /** + * Take the cloth off the glass and free it. SPRINT14 — the phantom sail. + * + * This teardown used to be open-coded inside `rigSail` and NOWHERE else, + * which meant the only thing that could ever remove a sail from the scene was + * rigging the next one. So night 3's committed rig — cloth, and its kN corner + * labels, with `rig.t` still at 90.8 — hung in mid-air over the Hendersons' + * backyard through night 4's forecast and prep until the new commit + * re-attached (D's sighting, Sprint 13; I ruled the view half mine and filed + * it rather than landing a UI-lifecycle change I hadn't watched in play). + * + * Two call sites now, one disposal: a re-rig replaces the cloth, and a SITE + * CHANGE ends it. Also disposes the material's texture, which the open-coded + * version missed — `traverse` disposes geometry and material but a material's + * `.map` is a separate GPU object, and the weave was leaking one per re-rig. + */ + function disposeSailView() { + if (!sailView) return; + scene.remove(sailView); + sailView.traverse((o) => { + o.geometry?.dispose(); + o.material?.map?.dispose(); + o.material?.dispose(); + }); + sailView = null; + } + async function rigSail(anchorIds, hwChoices, tension = 1.0) { rig.attach(anchorIds, hwChoices, tension); - if (sailView) { - scene.remove(sailView); - sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); - } + disposeSailView(); sailView = await createSailView(rig); scene.add(sailView); refreshCameraSolids(); // the new cloth; the one it replaced is disposed @@ -1152,7 +1191,18 @@ export async function boot(opts = {}) { frames++; fpsT += raw; if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; } - if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`; + // SPRINT14 pool (D's nit, Sprint 13): this counted `pieces` only, so it read + // "debris 0" while seven leaves streamed through frame — the line was + // telling a playtester the storm was empty at the exact moment C's ambient + // leaves were the best "this is a gale" tell on the glass. They are two + // populations with two lifetimes (events vs. a recycled ambient pool), so + // they get two numbers rather than one merged count that could never be + // reconciled against either. `leafCount` is C's own accessor, built for + // this. + if (dev) { + dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s` + + ` · t ${simT.toFixed(0)}s · debris ${debris.pieces.length} · leaves ${debris.leafCount}`; + } requestAnimationFrame(frame); } requestAnimationFrame(frame); diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 78ea650..6d569ca 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -22,7 +22,8 @@ import { SailRig, orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js'; import { createHud } from '../hud.js'; -import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; +import { PALETTE, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; +import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ export default async function run(t) { @@ -929,7 +930,7 @@ export default async function run(t) { }); t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => { - if (!dressed) return t.skip('needs dress()'); + if (!dressed) return 'SKIPPED — needs dress()'; const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint; // DESIGN.md: "The fascia board is a lie: holds until the first real gust." // Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the @@ -945,7 +946,7 @@ export default async function run(t) { // --- decision 2: the yard has to offer a real choice ---------------------- t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => { - if (!dressed) return t.skip('needs dress() — anchors are only final after it'); + if (!dressed) return 'SKIPPED — needs dress() — anchors are only final after it'; // SPRINT3 decision 2. Before the rework every quad covering the bed was // 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the @@ -1002,7 +1003,7 @@ export default async function run(t) { }); t.test('full shade over the bed stays expensive — the tradeoff is the game', () => { - if (!dressed) return t.skip('needs dress()'); + if (!dressed) return 'SKIPPED — needs dress()'; // The other half of decision 2, and the half that is easy to "fix" by // accident. DESIGN.md's core tension is that big+flat+low buys great shade // and dies in a storm, while small+twisted survives and shades patchily. If @@ -1140,7 +1141,7 @@ export default async function run(t) { t.test('PINNED: the wild night separates — best buyable line holds and wins, bare bed loses', () => { if (!sep) throw new Error('backyard_01 lost its separation block — the target is decoration again'); - if (typeof document === 'undefined') return t.skip('needs DOM (skyfx)'); + if (typeof document === 'undefined') return 'SKIPPED — needs DOM (skyfx)'; if (sepRun.err) throw new Error(`separation flight died: ${sepRun.err}`); assertEq(sepRun.shopLog.join('; '), '', 'the shop sold the whole recipe at the real budget'); assert(sepRun.spent <= START_BUDGET, `recipe costs $${sepRun.spent} — must be buyable night 1`); @@ -1271,4 +1272,212 @@ export default async function run(t) { try { game.setPhase('apocalypse'); } catch { threw = true; } assert(threw, 'setPhase accepted a phase that does not exist'); }); + + // ========================================================================= + // SPRINT14 gate 1 — THE YARD EDITOR + // ========================================================================= + + /** + * The round trip: template → place a rig-able yard → export → load it back → + * build → dress → anchors adopt. Gate 1.5's requirement is that editor bugs + * must not be able to produce a yard the game can't boot, so this exercises + * the editor's OWN placement function — `placeEntry`, the one the mouse + * calls — rather than a test-shaped imitation of it. + * + * What it cannot do is the fetch: `loadSite(name, dir)` builds a URL and a + * test has nowhere to put a file. Worth being precise rather than vague + * about that — `loadSite` IS `fetch` then `validateSite`, so every step + * below the network is exercised here, and the network is not a step an + * editor bug can reach. + */ + const edScene = new THREE.Scene(); + const built = emptyTemplate(); + const P = (pred) => { + const item = PALETTE.find(pred); + assert(item, 'palette item missing — the round-trip fixture is built on it'); + return item; + }; + const postItem = P((p) => p.kind === 'post'); + // A quad wants four corners: the template ships one post, so place three + // more — plus the two GLB-backed things whose anchors have to be ADOPTED for + // this to mean anything (a yard of bare posts never touches dress()). + placeEntry(built, postItem, 4.5, -3); + placeEntry(built, postItem, 4.5, 4.5); + placeEntry(built, postItem, -4, 4.5); + placeEntry(built, P((p) => p.model === 'tree_gum_01_v1'), -8, 0); + placeEntry(built, P((p) => p.model === 'carport_01_v1'), 7, -4); + built.id = 'roundtrip_yard'; + + const exported = exportSiteJSON(built, { ok: true, errors: [] }); + const reloaded = validateSite(JSON.parse(exported), 'roundtrip_yard'); + const rtWorld = createWorld(edScene, { wind: createStubWind({ calm: true }), site: reloaded }); + let rtDressed = false; + try { await rtWorld.dress(); rtDressed = true; } catch (err) { + console.warn('[a.test] round-trip dress() unavailable:', err.message); + } + + t.test('editor: a placed yard survives export → load → build', () => { + assertEq(reloaded.id, 'roundtrip_yard'); + // 4 posts (1 template + 3 placed) + 3 tree branches + 4 carport = 11 + assertEq(rtWorld.anchors.length, 11, 'the rebuilt world has the wrong anchor count'); + assertEq(checkContract('world', rtWorld).join('; '), '', 'round-tripped world breaks the contract'); + }); + + t.test('editor: every placed anchor types inside the checked enum', () => { + const types = [ + ...(reloaded.trees ?? []).flatMap((x) => x.anchors ?? []), + ...(reloaded.structures ?? []).flatMap((x) => x.anchors ?? []), + ...(reloaded.posts ?? []), + ].map((a) => a.type); + assertEq(types.length, 11, 'the fixture stopped placing what it claims to place'); + for (const ty of types) { + assert(ANCHOR_TYPE.includes(ty), `the palette wrote type '${ty}', which is not in ANCHOR_TYPE`); + } + }); + + t.test('editor: GLB-backed anchors ADOPT, and the carport is still a trap', () => { + if (!rtDressed) return 'SKIPPED — no server — dress() unavailable'; + // `collateral` is set by adoptAnchor and by nothing else, so its presence + // is the honest tell that the named node was found in the asset. A palette + // naming a node the GLB doesn't have would leave the anchor at its graybox + // position with ratingHint 1.0 — a trap silently made into honest steel. + for (const a of [...(reloaded.trees ?? []), ...(reloaded.structures ?? [])] + .flatMap((x) => x.anchors ?? [])) { + const live = rtWorld.anchors.find((x) => x.id === a.id); + assert(live, `anchor ${a.id} was never built`); + assert(Object.hasOwn(live, 'collateral'), + `anchor ${a.id}: node '${a.node}' was not adopted from the GLB`); + } + // The numbers E baked, arriving through the editor's palette unchanged. + const beam = rtWorld.anchors.find((a) => a.id === 's1_b1'); + const cpost = rtWorld.anchors.find((a) => a.id === 's1_p1'); + assertClose(beam.ratingHint, 0.22, 1e-6, 'carport beam lost its rating_hint'); + assertClose(cpost.ratingHint, 0.30, 1e-6, 'carport post lost its rating_hint'); + assertEq(beam.collateral, 'carport', 'carport beam lost its collateral key'); + }); + + t.test('editor export is byte-identical across a round trip', () => { + const again = exportSiteJSON(JSON.parse(exported), { ok: true, errors: [] }); + assertEq(again, exported, 'export → parse → export changed the bytes'); + }); + + t.test('editor export ignores key INSERTION order', () => { + // The determinism that actually matters. Two objects that ARE the same + // yard but were assembled in a different order must serialise identically, + // or every site diff carries noise that hides the one number that changed. + // + // The fixture carries two keys `canonical()` has NEVER HEARD OF, and that + // is the entire point of it. Written first, this test shuffled only root + // keys — every one of which is in KEY_ORDER, so the alphabetical fallback + // for unknown keys was never reached, and deleting the `.sort()` outright + // did not turn it red. It was decoration, and only mutating the code it + // claims to protect said so. An unknown key is also the realistic case: + // it's what a hand-added field, or a prop the palette doesn't know yet, + // looks like on its way through the editor. + const ok = { ok: true, errors: [] }; + const base = JSON.parse(exported); + // The two unknown keys, inserted in OPPOSITE orders. Nothing normalises + // them on the way in, so these two objects differ only in insertion order. + const withZA = { ...base, zzUnknown: 1, aaUnknown: 2 }; + const withAZ = { ...base, aaUnknown: 2, zzUnknown: 1 }; + assertEq(exportSiteJSON(withZA, ok), exportSiteJSON(withAZ, ok), + 'two identical yards assembled in different key orders exported different bytes'); + const keys = Object.keys(JSON.parse(exportSiteJSON(withZA, ok))); + assertLess(keys.indexOf('aaUnknown'), keys.indexOf('zzUnknown'), + 'keys canonical() does not know must sort alphabetically, not follow insertion order'); + // The KNOWN-key path is a separate mechanism (a fixed list, not a sort), so + // it gets its own reversal: this is what goes red if `known` ever starts + // following the object instead of KEY_ORDER. + const reversed = {}; + for (const k of Object.keys(base).reverse()) reversed[k] = base[k]; + assertEq(exportSiteJSON(reversed, ok), exported, + 'reversing the known keys changed the export'); + }); + + t.test('editor: an INVALID yard exports, but exports LOUD', () => { + const broken = emptyTemplate(); + delete broken.posts; // now nothing to rig to + let v; + try { + validateSite(structuredClone(broken), 'broken'); + v = { ok: true, errors: [] }; + } catch (err) { + v = { ok: false, errors: String(err.message).split('\n').slice(1).map((s) => s.trim()) }; + } + assert(!v.ok, 'an anchor-less yard validated — this fixture is not testing anything'); + const json = exportSiteJSON(broken, v); + assertEq(Object.keys(JSON.parse(json))[0], '_INVALID', + '_INVALID must be the FIRST key or a human scanning a diff will miss it'); + assert(json.includes('no anchors at all'), 'the _INVALID banner must carry the actual reasons'); + assert(!exported.includes('_INVALID'), 'a VALID yard exported the invalid banner'); + }); + + // --- the two shipped bugs the editor found (SPRINT14) -------------------- + + t.test('world: the graybox house is built ONLY when the site declares one', () => { + // Both directions, because only the pair can fail for the right reason: + // backyard_01 declares a house and must have one; a site that declares + // none must NOT get a 16 x 3 x 6 m grey slab across its north horizon, + // which is what site_02_corner_block shipped with until this sprint. + assert(world.root.getObjectByName('house_yardside'), + 'backyard_01 declares a house and lost it'); + assert(!reloaded.house, 'fixture drift: the template should declare no house'); + assertEq(rtWorld.root.getObjectByName('house_yardside') ?? null, null, + 'a site with no house still got the graybox house'); + }); + + t.test('world: the shed composes a finite matrix (rotY, not undefined)', () => { + if (!dressed) return 'SKIPPED — no server — dress() unavailable'; + const shed = world.root.getObjectByName('shed_01'); + assert(shed, 'backyard_01 declares a shed and it is not in the yard'); + // The bug was `rotation.y = undefined` → an all-NaN quaternion → three.js + // silently declining to draw it. Nothing threw and nothing logged, so the + // only assert that could ever have caught it is one that looks at the + // COMPOSED matrix. Dimensions and positions cannot see a NaN rotation — + // this is the same lesson as MANUAL.md's axis trap, one level lower. + assert(Number.isFinite(shed.rotation.y), `shed rotation.y is ${shed.rotation.y}`); + shed.updateMatrixWorld(true); + assert(!shed.matrixWorld.elements.some((n) => !Number.isFinite(n)), + 'the shed composes a non-finite world matrix — it will not render'); + assertClose(shed.rotation.y, -Math.PI / 2, 1e-9, 'shed lost the rotation the site asked for'); + }); + + t.test('collateral resolves by KEY, not by structure id (the free-carport bug)', () => { + // SPRINT14, E's find. This resolved by structure ID for five sprints and + // was correct only because site_02 happens to id its structure "carport", + // matching the string its anchors carry. The editor MUST generate unique + // ids, so the very first carport anyone places is `s1` — at which point + // the anchors still say "carport", no structure has that id, the $180 + // prices to null, and the trap becomes a free failure. The gutter bug. + // + // The fixture is the real thing: a carport the editor named itself. + const st = reloaded.structures[0]; + assertEq(st.id, 's1', 'fixture drift — the editor should have generated s1'); + assertEq(st.collateralKey, 'carport'); + assert(st.id !== st.collateralKey, + 'this assert only means something while the id and the key DIFFER — if the editor ever ' + + 'starts naming structures after their collateral, re-point the fixture, do not delete it'); + const priced = rtWorld.collateralFor('carport'); + assert(priced, 'a carport the editor named "s1" priced to NULL — that is a free failure'); + assertEq(priced.cost, 180, 'the carport must still cost $180 under a generated id'); + assertEq(priced.label, 'the carport'); + }); + + t.test('the wreck swaps by collateral key too', () => { + if (!rtDressed) return 'SKIPPED — no server, no wreck GLB'; + // Same seam, the other half: main.js calls wreckStructure() with the key + // it read off the blown anchor, never with the structure's id. + assertEq(rtWorld.isWrecked('carport'), false, 'the carport starts standing'); + assert(rtWorld.wreckStructure('carport'), + 'wreckStructure could not find the carport by its collateral key'); + assertEq(rtWorld.isWrecked('carport'), true, 'the carport never swapped to its wreck'); + }); + + t.test('world: a site with no shedTable builds instead of throwing', () => { + // Unreachable for both shipped sites; reached by the editor's template on + // its first frame. Every consumer already guards `world.shedTable`. + assert(!reloaded.shedTable, 'fixture drift: the template should declare no shed table'); + assertEq(rtWorld.shedTable, null, 'a table-less site should publish shedTable = null'); + assert(world.shedTable?.pos, 'backyard_01 declares a table and lost its pickup point'); + }); } diff --git a/web/world/js/world.js b/web/world/js/world.js index 458b821..0d813fe 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -491,6 +491,34 @@ export function createWorld(scene, opts = {}) { const houseEntry = { spec: HOUSE, glb: null, wreck: null }; const houseKey = () => HOUSE ? (HOUSE.collateralKey ?? houseEntry.glb?.userData?.collateral_key ?? null) : null; + + /** + * Which collateral string does this structure's price answer? SPRINT14 — the + * carport was one editor click from being FREE, and E's new assert found it. + * + * `collateralFor`/`wreckStructure`/`isWrecked` used to match a key against a + * structure's site-JSON **id**. site_02 happens to id its structure + * `"carport"` and its anchors say `collateral:"carport"`, so it resolved — + * by coincidence of naming, on a sample size of one yard, for five sprints. + * The editor generates unique ids (`s1`, `s2`, …), so the very first carport + * anyone places writes an id that is not `"carport"`; the anchors still say + * `"carport"`, no structure has that id, and the $180 trap silently prices + * to null. That is the gutter bug — a failure that reads as free — recurring + * in the sprint meant to bury it. + * + * So structures resolve exactly the way the house already did: site JSON + * canonical, GLB extra as fallback, and **the id last** so every existing + * site keeps working unchanged. Same three-step, one shape, no special case. + */ + const structKey = (entry) => + entry.spec.collateralKey ?? entry.glb?.userData?.collateral_key ?? entry.spec.id; + + /** Find a structure by collateral key, then by literal id. */ + const structFor = (key) => { + if (!key) return null; + for (const entry of structures.values()) if (structKey(entry) === key) return entry; + return structures.get(key) ?? null; + }; for (const st of site.structures ?? []) { const marker = new THREE.Group(); marker.name = st.id; @@ -650,6 +678,19 @@ export function createWorld(scene, opts = {}) { anchor.pos.setFromMatrixPosition(node.matrixWorld); anchor.ratingHint = node.userData?.rating_hint ?? 1; anchor.collateral = node.userData?.collateral ?? null; + // SPRINT14, E's gap 1: some baked anchor nodes are NOT tie-offs — a door + // step, a bench top, a broom grip, a lighting hint. They carry no + // `rating_hint`, and `?? 1` therefore rated them PERFECT: name one in a + // site and it silently becomes the best steel in the yard. Carried onto + // the anchor so the thing that can see it can say so (the editor's + // validation panel); left LOUD rather than silently re-rated, because + // inventing a number for "this is not an anchor" is how the trap became + // honest steel in the first place. + anchor.tieOff = node.userData?.tie_off !== false; + if (anchor.tieOff === false) { + console.warn(`[world] anchor ${anchorId} names '${nodeName}', which is baked tie_off:false ` + + '— that node is not steel and must not carry a sail.'); + } return true; }; @@ -894,8 +935,9 @@ export function createWorld(scene, opts = {}) { */ collateralFor(key) { if (!key) return null; - for (const { spec, glb } of structures.values()) { - if (spec.id !== key) continue; + const entry = structFor(key); + if (entry) { + const { spec, glb } = entry; 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 }; @@ -919,10 +961,12 @@ export function createWorld(scene, opts = {}) { * 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. + * Takes a COLLATERAL KEY (what main.js reads off the blown anchor), or a + * literal structure id — `structFor` resolves both. See `structKey`. * @param {string} id */ wreckStructure(id) { - const entry = structures.get(id); + const entry = structFor(id); if (entry?.wreck && entry.glb) { entry.glb.visible = false; entry.wreck.visible = true; @@ -947,7 +991,7 @@ export function createWorld(scene, opts = {}) { /** Is this structure standing? Lane D asked for a poke-able truth. */ isWrecked(id) { if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true; - return structures.get(id)?.wreck?.visible === true; + return structFor(id)?.wreck?.visible === true; }, /**