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:
parent
16dee119d6
commit
f3bf038515
143
THREADS.md
143
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` →
|
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.
|
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".
|
||||||
|
|||||||
@ -22,6 +22,21 @@
|
|||||||
#dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; }
|
#dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; }
|
||||||
#dev.on { display: block; }
|
#dev.on { display: block; }
|
||||||
#help { bottom: 10px; opacity: .75; }
|
#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 {
|
#banner {
|
||||||
position: fixed; top: 38%; left: 0; right: 0;
|
position: fixed; top: 38%; left: 0; right: 0;
|
||||||
text-align: center; pointer-events: none; user-select: none;
|
text-align: center; pointer-events: none; user-select: none;
|
||||||
@ -35,6 +50,7 @@
|
|||||||
<canvas id="c"></canvas>
|
<canvas id="c"></canvas>
|
||||||
<div id="banner"></div>
|
<div id="banner"></div>
|
||||||
<div id="dev">booting…</div>
|
<div id="dev">booting…</div>
|
||||||
|
<div id="fatal"></div>
|
||||||
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{ "imports": { "three": "./vendor/three.module.js",
|
{ "imports": { "three": "./vendor/three.module.js",
|
||||||
@ -47,7 +63,17 @@
|
|||||||
// letting it die as an unhandled rejection behind a blue screen.
|
// letting it die as an unhandled rejection behind a blue screen.
|
||||||
boot().catch((err) => {
|
boot().catch((err) => {
|
||||||
console.error(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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ const KEY_ORDER = {
|
|||||||
],
|
],
|
||||||
structure: [
|
structure: [
|
||||||
'id', 'model', 'wreckedModel', 'x', 'z', 'rotYDeg', 'solid',
|
'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'],
|
tree: ['id', 'model', 'x', 'z', 'rotYDeg', 'phase', 'trunkH', 'anchorY', '_why', 'anchors'],
|
||||||
anchor: ['id', 'node', 'type', 'work'],
|
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
|
* `ratingHint` 1.0 — a trap that silently becomes honest steel, which is the
|
||||||
* invincible-house gotcha wearing a different hat.
|
* 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',
|
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' }),
|
make: (id, x, z) => ({ id, x, z, h: 4.0, type: 'post', work: 'cloth' }),
|
||||||
@ -214,6 +214,13 @@ const PALETTE = [
|
|||||||
make: (id, x, z) => ({
|
make: (id, x, z) => ({
|
||||||
id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1',
|
id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1',
|
||||||
x, z, rotYDeg: 0, solid: true,
|
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',
|
collateralValue: 180, collateralLabel: 'the carport',
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: `${id}_b1`, node: 'beam_anchor_01', type: 'carport', work: 'bracket' },
|
{ 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,
|
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"',
|
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,
|
* 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.
|
* while the last valid yard stays on screen so you can put it back.
|
||||||
*/
|
*/
|
||||||
function emptyTemplate() {
|
export function emptyTemplate() {
|
||||||
return {
|
return {
|
||||||
id: 'site_new',
|
id: 'site_new',
|
||||||
name: 'Untitled yard',
|
name: 'Untitled yard',
|
||||||
@ -302,6 +343,42 @@ function emptyTemplate() {
|
|||||||
|
|
||||||
const ARRAY_KIND = { post: 'posts', tree: 'trees', structure: 'structures' };
|
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);
|
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). */
|
/** 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 `
|
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');
|
+ '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;
|
return out;
|
||||||
}
|
}
|
||||||
@ -573,26 +662,8 @@ export async function createEditor(opts) {
|
|||||||
|
|
||||||
// --- placement / move / delete ----------------------------------------
|
// --- 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) {
|
function place(paletteItem, x, z) {
|
||||||
const key = ARRAY_KIND[paletteItem.kind];
|
selection = placeEntry(site, paletteItem, x, z);
|
||||||
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 };
|
|
||||||
emit('select', { ref: selection });
|
emit('select', { ref: selection });
|
||||||
return rebuild();
|
return rebuild();
|
||||||
}
|
}
|
||||||
@ -681,11 +752,50 @@ export async function createEditor(opts) {
|
|||||||
return { x: _hit.x, y: heightAt(_hit.x, _hit.z), z: _hit.z };
|
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) {
|
function pickRef(ev) {
|
||||||
setNdc(ev);
|
setNdc(ev);
|
||||||
const hits = ray.intersectObjects(handles.children, false);
|
const hits = ray.intersectObjects(handles.children, false);
|
||||||
for (const h of hits) if (h.object.userData.ref) return h.object.userData.ref;
|
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);
|
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() {
|
function renderPalettePanel() {
|
||||||
const b = pPalette.body;
|
const b = pPalette.body;
|
||||||
b.replaceChildren();
|
b.replaceChildren();
|
||||||
for (const item of PALETTE) {
|
for (const item of availablePalette()) {
|
||||||
const exists = item.singleton && site[item.kind];
|
const exists = item.singleton && site[item.kind];
|
||||||
const btn = el('button', 'ed-btn', item.label);
|
const btn = el('button', 'ed-btn', item.label);
|
||||||
btn.style.width = '100%';
|
btn.style.width = '100%';
|
||||||
|
|||||||
@ -499,6 +499,21 @@ export async function boot(opts = {}) {
|
|||||||
const siteDef = await loadSite(siteName);
|
const siteDef = await loadSite(siteName);
|
||||||
siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb };
|
siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb };
|
||||||
if (world) { world.dispose(); player?.dispose?.(); }
|
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 });
|
world = createWorld(scene, { wind, site: siteDef });
|
||||||
await world.dress();
|
await world.dress();
|
||||||
currentSite = siteName;
|
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
|
* point at corners the sim no longer steps. The ids are stable, so this
|
||||||
* replaces the old targets rather than stacking duplicates.
|
* 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) {
|
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
|
||||||
rig.attach(anchorIds, hwChoices, tension);
|
rig.attach(anchorIds, hwChoices, tension);
|
||||||
if (sailView) {
|
disposeSailView();
|
||||||
scene.remove(sailView);
|
|
||||||
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
|
||||||
}
|
|
||||||
sailView = await createSailView(rig);
|
sailView = await createSailView(rig);
|
||||||
scene.add(sailView);
|
scene.add(sailView);
|
||||||
refreshCameraSolids(); // the new cloth; the one it replaced is disposed
|
refreshCameraSolids(); // the new cloth; the one it replaced is disposed
|
||||||
@ -1152,7 +1191,18 @@ export async function boot(opts = {}) {
|
|||||||
|
|
||||||
frames++; fpsT += raw;
|
frames++; fpsT += raw;
|
||||||
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
|
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);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import { SailRig, orderRing } from '../sail.js';
|
|||||||
import { loadStorm, createWind } from '../weather.js';
|
import { loadStorm, createWind } from '../weather.js';
|
||||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||||
import { createHud } from '../hud.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 */
|
/** @param {import('../testkit.js').Suite} t */
|
||||||
export default async function run(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', () => {
|
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;
|
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."
|
// 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
|
// 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 ----------------------
|
// --- 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', () => {
|
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
|
// 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
|
// 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', () => {
|
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
|
// 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
|
// 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
|
// 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', () => {
|
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 (!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}`);
|
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');
|
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`);
|
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; }
|
try { game.setPhase('apocalypse'); } catch { threw = true; }
|
||||||
assert(threw, 'setPhase accepted a phase that does not exist');
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -491,6 +491,34 @@ export function createWorld(scene, opts = {}) {
|
|||||||
const houseEntry = { spec: HOUSE, glb: null, wreck: null };
|
const houseEntry = { spec: HOUSE, glb: null, wreck: null };
|
||||||
const houseKey = () =>
|
const houseKey = () =>
|
||||||
HOUSE ? (HOUSE.collateralKey ?? houseEntry.glb?.userData?.collateral_key ?? null) : null;
|
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 ?? []) {
|
for (const st of site.structures ?? []) {
|
||||||
const marker = new THREE.Group();
|
const marker = new THREE.Group();
|
||||||
marker.name = st.id;
|
marker.name = st.id;
|
||||||
@ -650,6 +678,19 @@ export function createWorld(scene, opts = {}) {
|
|||||||
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
||||||
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
||||||
anchor.collateral = node.userData?.collateral ?? null;
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -894,8 +935,9 @@ export function createWorld(scene, opts = {}) {
|
|||||||
*/
|
*/
|
||||||
collateralFor(key) {
|
collateralFor(key) {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
for (const { spec, glb } of structures.values()) {
|
const entry = structFor(key);
|
||||||
if (spec.id !== key) continue;
|
if (entry) {
|
||||||
|
const { spec, glb } = entry;
|
||||||
const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null;
|
const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null;
|
||||||
if (!Number.isFinite(cost)) return null;
|
if (!Number.isFinite(cost)) return null;
|
||||||
return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id };
|
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
|
* 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
|
* missing — the graybox and headless paths still score the bill, they just
|
||||||
* can't show it. Idempotent.
|
* 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
|
* @param {string} id
|
||||||
*/
|
*/
|
||||||
wreckStructure(id) {
|
wreckStructure(id) {
|
||||||
const entry = structures.get(id);
|
const entry = structFor(id);
|
||||||
if (entry?.wreck && entry.glb) {
|
if (entry?.wreck && entry.glb) {
|
||||||
entry.glb.visible = false;
|
entry.glb.visible = false;
|
||||||
entry.wreck.visible = true;
|
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. */
|
/** Is this structure standing? Lane D asked for a poke-able truth. */
|
||||||
isWrecked(id) {
|
isWrecked(id) {
|
||||||
if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true;
|
if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true;
|
||||||
return structures.get(id)?.wreck?.visible === true;
|
return structFor(id)?.wreck?.visible === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user