Sprint 14 gate 1 close: round-trip pinned, pool items landed, free-carport fixed

Gate 1.5 -- the round trip. a.test drives the editor's OWN placement function
(placeEntry, exported for exactly this) through
template -> place -> export -> parse -> validateSite -> createWorld -> dress,
then asserts the GLB-backed anchors ADOPTED and the carport's 0.22/0.30 came
out the far side. Mutation-checked with five deliberate breakages; four went
red and ONE DID NOT -- the key-order test shuffled only root keys, every one
of which is in KEY_ORDER, so the alphabetical fallback it claimed to protect
was never reached. Rewritten around unknown keys, re-mutated, red in both
halves. A determinism test that passed while testing nothing.

Picking bug, found by dispatching real PointerEvents rather than reading the
code: clicking an object's FOOT selected nothing, because the pick handle is a
cylinder centred 1.7 m up and from a raised camera the ray has already run
~2 m horizontally by the time it climbs there. pickRef now falls back to
nearest-placed-thing within 1.3 m of the ground hit -- camera-angle
independent, so it can't rot when the pitch changes.

Phantom sail (pool, filed unlanded in S13) -- LANDED and negative-controlled.
Teardown factored into disposeSailView() and called from loadSiteInto before
refreshCameraSolids (which reads sailView and would re-register a disposed
mesh as a camera collider). Rig state goes with the view. Verified through the
real game both ways: with the fix removed the cloth still hangs over the
corner block, screenshotted.

Dev-line (pool) -- two findings, both landed. The line counted `pieces` only,
so it read "debris 0" while seven leaves flew; now reports both populations
off C's leafCount. And index.html's boot-failure handler wrote into #dev,
which is display:none off localhost -- so a failed boot on partly.party showed
a stranger a blank page. It has its own visible #fatal element now.

E's gap 2, runtime half -- and it was worse than "one click away": nextId
namespaces structures s1, s2..., so the FIRST carport the editor places was
already free. collateralFor/wreckStructure/isWrecked now resolve through
structKey (site collateralKey, then GLB collateral_key, then id last so every
existing site is untouched). Negative-controlled: id-equality prices an
editor carport to null. E's tie_off:false honoured at both ends. E's swing set
and jacaranda are in the palette behind a `requires` gate, so they switch
themselves on when the ANCHOR_TYPE widening merges.

Also: `return t.skip(msg)` is a fake pass -- Suite.skip pushes a new result and
lets the current test record as pass. a.test had six; all converted to the
documented 'SKIPPED --' idiom.

Selftest 373/0/0, no skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 16:14:34 +10:00
parent 16dee119d6
commit f3bf038515
6 changed files with 637 additions and 39 deletions

View File

@ -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 $250350 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 (110120), 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".

View File

@ -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 @@
<canvas id="c"></canvas>
<div id="banner"></div>
<div id="dev">booting…</div>
<div id="fatal"></div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",
@ -47,7 +63,17 @@
// letting it die as an unhandled rejection behind a blue screen.
boot().catch((err) => {
console.error(err);
document.getElementById('dev').textContent = `BOOT FAILED — ${err.message} (see console)`;
// Into #fatal, NOT #dev: #dev is display:none off localhost, so this
// message has been invisible to every stranger it was written for.
const box = document.getElementById('fatal');
box.innerHTML = '<div><b>HARD YARDS COULDN\'T START</b></div>';
box.firstChild.append(
Object.assign(document.createElement('div'), { textContent: err.message }),
Object.assign(document.createElement('span'), {
textContent: 'A reload usually fixes it. If it doesn\'t, the details are in the browser console.',
}),
);
box.classList.add('on');
});
</script>
</body>

View File

@ -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%';

View File

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

View File

@ -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');
});
}

View File

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