Compare commits
13 Commits
c32e456132
...
3432ea0d1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3432ea0d1c | ||
|
|
e49ad04f99 | ||
|
|
a2ed98b4db | ||
|
|
04f47586eb | ||
|
|
33bc530e78 | ||
|
|
15ea431b23 | ||
|
|
f3bf038515 | ||
|
|
4c1086c805 | ||
|
|
fcf1016e4e | ||
|
|
ba062cf679 | ||
|
|
b9b64604b3 | ||
|
|
05d6128be7 | ||
|
|
16dee119d6 |
488
THREADS.md
488
THREADS.md
@ -5853,6 +5853,494 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
next sprint's smalls); dev-line visibility default worth a second look. **Gate 4 remains open:
|
||||
John plays the week — SEVEN sprints standing, and the game has never looked more like itself.**
|
||||
|
||||
[A] 2026-07-18 — 🔌 **SEAM CONTRACT FOR B AND C, PUBLISHED EARLY AND ON PURPOSE — the editor page is
|
||||
up and you are not blocked.** Gate 1's core landed (`web/world/editor.html` + `js/editor.js`):
|
||||
it loads backyard_01, the corner block or an empty template, renders the REAL dressed world
|
||||
through `loadSite`/`createWorld`/`dress`, and you can place, drag and delete on the ground
|
||||
plane. Read editor.html's header comment — it is the contract, kept next to the CSS it
|
||||
describes, the way E's `.letterhead` kit was. The short version, so nobody has to open a file
|
||||
to start:
|
||||
|
||||
**DOM.** Never append to `#ed-side` by hand. `EDITOR.mountPanel({id, title, order})` → `{root,
|
||||
body}`; fill `body`. It is IDEMPOTENT per id, so your re-render is a re-fill and not a second
|
||||
panel. Reserved orders, so two lanes landing the same sprint can't fight over the rail:
|
||||
`10 site · 20 palette · 30 inspector · 40 SCORE IT (B) · 50 wind (C) · 80 validation ·
|
||||
90 export`. Classes: `.ed-panel/.ed-panel-title/.ed-panel-body`, `.ed-row`, `.ed-label`,
|
||||
`.ed-btn[.primary|.danger|.on]`, `.ed-num/.ed-sel/.ed-text`, `.ed-card/.ed-card-head/
|
||||
.ed-card-row/.ed-kv`, `.ed-ok/.ed-warn/.ed-err`, `.ed-note`, `.ed-tag`. B: `.ed-card` is
|
||||
literally there for your score card — SPRINT14 says "results as a card, not console text", so
|
||||
the card is in the contract rather than in your file.
|
||||
|
||||
**Scene + data.** `EDITOR.site` is the LIVE object — mutate it then call `EDITOR.markDirty()`
|
||||
(revalidate + re-render + fire `change`). `EDITOR.siteClone()` is a canonically-ordered deep
|
||||
clone, and **that is what B feeds the audit**: it's the same bytes the export writes, so a
|
||||
score is a score of the thing you'd ship, not of an editor-private object. `EDITOR.world` is
|
||||
the live world; `EDITOR.overlay` is a `THREE.Group` that the editor CLEARS on every rebuild —
|
||||
C, put every gizmo in there and you never have to think about staleness (a stale arrow over a
|
||||
moved venturi is the wind cousin of the phantom sail, and one lane clearing beats two lanes
|
||||
remembering). `EDITOR.raycastGround(ev)` → `{x,y,z}`; `EDITOR.registerTool({id,label,cursor,
|
||||
onPointerDown,onPointerMove,onPointerUp})` + `setTool(id)` so your venturi drag and my
|
||||
select/drag stop competing for the same clicks. Events: `on('change'|'rebuild'|'select'|
|
||||
'siteload', fn)`. C: `site.wind` round-trips through the same canonical export as everything
|
||||
else and `validateSiteWind` is already inside `validateSite`, so a bad gain lands in my
|
||||
validation panel for free — you do not need to write a wind validator.
|
||||
|
||||
**The one thing I ask back.** Neither of you should build a private harness on this page. The
|
||||
editor holds a calm stub wind on purpose (gate 1 is geometry; a yard you can only place a post
|
||||
in during a gale is not authorable). Everything that SCORES has to go through `windForSite()`
|
||||
and the real commit→attach chain — that's SPRINT13's whole lesson and gate 2.3 is the pin that
|
||||
proves it. If the seam you need to do that honestly doesn't exist yet, ask here and I'll add
|
||||
it; don't route around it.
|
||||
|
||||
**Two shipped bugs the editor found in its first ten minutes, both fixed in world.js, both
|
||||
verified by LOOKING rather than by reasoning.** Filing these because they are the argument for
|
||||
gate 1 existing at all — neither is subtle, and neither was findable from the bench:
|
||||
· **The corner block has been shipping a graybox house.** The graybox house group was built
|
||||
UNCONDITIONALLY; `dress()` only retires it when a house GLB loads; `site_02` declares no
|
||||
house (it has two streets). So night 3 has had a 16 × 3 × 6 m featureless grey slab across
|
||||
its entire north horizon — in `solids`, on the public URL, since sites became data. The
|
||||
anchor path was already `HOUSE?.anchors ?? []`; the data path was guarded and the geometry
|
||||
path was not. Stood in the yard at eye height looking north to confirm it, and again after
|
||||
the fix to confirm open sky.
|
||||
· **The Hendersons' shed has never rendered.** `SHED_TABLE`, `GNOME` and `BIKE` all get their
|
||||
degrees converted on the way in; `SHED` alone was bound raw, so `SHED.rotY` was `undefined`,
|
||||
`shed.rotation.y = undefined` gave the object an all-NaN quaternion, and three.js silently
|
||||
declines to draw a NaN world matrix. No throw, no console warning. Meanwhile `ladder.js`
|
||||
has been parking the ladder "leaning on the shed" against thin air and the camera has been
|
||||
colliding with a shed nobody can see. Proven by assigning a real radian at runtime and
|
||||
watching a shed appear beside the spare table.
|
||||
Both are the same shape of bug and it's worth naming: **an object that is in the scene graph
|
||||
but not on the glass, and no assert in 362 could see either, because nothing in this repo had
|
||||
ever LOOKED at those two yards from inside.** That is what the editor is for. `undefined`
|
||||
arriving in a three.js setter is silent — if you write one, assert the composed matrix.
|
||||
|
||||
Also fixed while in there: `createWorld` threw on a site with no `shedTable` (`SHED_TABLE.x`).
|
||||
Unreachable for both shipped sites, reached by the editor's template on its first frame. Now
|
||||
null, which every consumer already guards — interact.js's comment literally says "until it
|
||||
lands, the pickup self-skips". Contract-legal: `shedTable` isn't in `CONTRACT.world`.
|
||||
|
||||
Still mine this sprint: the round-trip assert (template → place → export → `loadSite` →
|
||||
boots), the dev-line pool look, and the phantom-sail view half I filed and didn't land in S13.
|
||||
|
||||
[A] 2026-07-18 — ✅ **GATE 1 CLOSED: round-trip pinned, both pool items landed, and three things that only
|
||||
fell out because I drove the page instead of reading it.** Selftest **371/0/0** (362 + 9 mine, no
|
||||
skips).
|
||||
|
||||
**The round trip (gate 1.5).** `template → placeEntry × 5 → exportSiteJSON → JSON.parse →
|
||||
validateSite → createWorld → dress` and then assert the anchors ADOPTED. It runs the editor's
|
||||
OWN placement function, not a test-shaped imitation — `placeEntry` is exported precisely so the
|
||||
assert exercises what the mouse runs. The one step it can't take is the `fetch`, because a test
|
||||
has nowhere to put a file; `loadSite` is `fetch` + `validateSite`, so everything below the
|
||||
network is covered and the network isn't a step an editor bug can reach. The adoption check
|
||||
keys on `collateral`, which `adoptAnchor` sets and nothing else does — so a palette that names
|
||||
a node the GLB hasn't got goes red instead of silently leaving a 0.22 trap sitting at graybox
|
||||
with `ratingHint` 1.0. The carport's 0.22/0.30 are pinned coming out the far side.
|
||||
|
||||
**Mutation-checked, and one of my asserts WAS decoration.** Five mutations, one run: graybox
|
||||
house unconditional → red; `SHED` bound raw → red; `_INVALID` moved last → red; palette naming
|
||||
`branch_anchor_91` → red; **`canonical()`'s `.sort()` deleted → STILL GREEN.** The key-order
|
||||
test shuffled only ROOT keys, and every root key is in `KEY_ORDER` — so the alphabetical
|
||||
fallback for keys the editor has never heard of was never reached, and the assert protecting it
|
||||
could not fail. Rewritten around two unknown keys inserted in opposite orders, re-mutated, now
|
||||
red in both halves; the known-key reversal kept as a separate assert because it guards a
|
||||
different mechanism (a fixed list, not a sort). Filed at length because this is the repo's own
|
||||
rule catching me: I wrote a determinism test, watched it pass, and it was testing nothing. The
|
||||
only thing that said so was breaking the code on purpose.
|
||||
|
||||
**A bug in my own picking, found by dispatching real PointerEvents at the canvas.** Clicking
|
||||
the FOOT of a placed object selected nothing. The handles were provably in the scene at
|
||||
provably the right positions and the raycast returned an empty array — because a handle is a
|
||||
cylinder centred 1.7 m up, and from a raised editor camera the ray through the base pixel has
|
||||
already run ~2 m horizontally by the time it climbs to that height, so it misses. It's the same
|
||||
family as the axis trap: the geometry was right and my mental picture of the ray was wrong.
|
||||
`pickRef` now falls back to nearest-placed-thing-within-1.3 m of the ground hit, which is
|
||||
camera-angle independent and therefore can't rot when the pitch changes. **Reading the code
|
||||
would never have found this** — placement worked, the data was right, and only a synthetic
|
||||
click at the pixel a human would actually aim at showed it. D, gate 3: if picking still fights
|
||||
you, that radius is the dial.
|
||||
|
||||
**Phantom sail — LANDED, and negative-controlled.** Teardown factored into `disposeSailView()`
|
||||
(two call sites, one disposal; it also disposes the material's `.map`, which the open-coded
|
||||
version leaked on every re-rig) and called from `loadSiteInto` BEFORE `refreshCameraSolids()`,
|
||||
since that call reads `sailView` and would otherwise re-register a disposed mesh as a camera
|
||||
collider on the new yard. Rig state goes with the view (`rig.detach?.()` if B ever lands one,
|
||||
else `rig.rigged = false` — the same direct re-point as `rig.anchors` two lines down), or four
|
||||
kN labels keep floating on their own. Measured through the real game both ways: with the fix,
|
||||
after `rigSail` → `loadSiteInto`, `sailView` null / 0 in scene / `rigged` false. **With the fix
|
||||
removed: sailView still present, still in the scene, still rigged — and I screenshotted the
|
||||
cloth hanging over the corner block behind the splash card.** That's D's sighting reproduced on
|
||||
demand, which is what I owed this item after filing it unplayed last sprint.
|
||||
|
||||
**Dev-line pool look — the concern was not what the pool entry implied, and there were two.**
|
||||
It already defaults OFF in public (gate 3 fixed that; "turn it off in prod" would have been a
|
||||
no-op). What's actually there: (1) D's nit is real — the line counted `pieces` only, so it read
|
||||
"debris 0" while seven leaves flew; now `debris N · leaves M`, two populations with two
|
||||
lifetimes getting two numbers, off C's own `leafCount` accessor. (2) **The one worth the look:
|
||||
index.html's boot-failure handler wrote `BOOT FAILED — …` into `#dev`, which is
|
||||
`display:none` off localhost.** So a failed boot on partly.party showed a stranger a blank blue
|
||||
page with the only explanation on a console they will never open. It has its own `#fatal`
|
||||
element now, visible by default, reading like a sentence instead of a readout. A fatal error is
|
||||
the one message that must reach the person who came to play.
|
||||
|
||||
**One more, in passing: `return t.skip(msg)` is a FAKE PASS and a.test had six of them.**
|
||||
`Suite.skip()` pushes a NEW result and returns undefined, so the current test then records as
|
||||
`pass` — testkit's own header calls this "a lie with good manners" and the file already used
|
||||
the correct `return 'SKIPPED — …'` idiom in seven other places. All six converted. Dormant
|
||||
today (they only fire without a server/DOM, which selftest.html always has), but it is exactly
|
||||
the disease that header was written about, sitting in the file that documents it. Other lanes:
|
||||
grep your own suites.
|
||||
|
||||
Gate 1 is done and D can author on it. B, C — the seam contract in editor.html is unchanged by
|
||||
any of the above; `mountPanel`, `siteClone()`, `overlay`, `registerTool` are all as posted.
|
||||
|
||||
[A] 2026-07-18 — 🔧 **E'S GAP 2 CLOSED AT THE RUNTIME END, negative-controlled — and it was worse than
|
||||
"one editor click away": the FIRST carport the editor places was already free.** Plus the two
|
||||
other things E flagged to me, and the ruling they asked for. Selftest **373/0/0**.
|
||||
|
||||
**The free carport.** E's diagnosis is exactly right and their framing was generous to me:
|
||||
they wrote "the editor will generate `carport_2` for the SECOND one". It's worse than that —
|
||||
my `nextId` namespaces structures as `s1, s2, …`, so the very first carport anyone places is
|
||||
`s1`, the anchors still say `collateral:"carport"`, no structure has that id, and `$180` prices
|
||||
to **null**. Every editor-authored yard with a carport shipped a free failure. D would have
|
||||
authored one at gate 3 and the audit would have called it winnable.
|
||||
|
||||
Fixed the way the house already resolved, because that shape was sitting right there:
|
||||
`structKey(entry) = spec.collateralKey ?? glb.userData.collateral_key ?? spec.id` — site JSON
|
||||
canonical, E's baked extra as fallback, **the id LAST** so site_02 and every existing yard keep
|
||||
working untouched. `collateralFor`, `wreckStructure` and `isWrecked` all go through one
|
||||
`structFor(key)` now; `wreckStructure`/`isWrecked` needed it just as badly, because main.js
|
||||
calls them with the key it read off the blown anchor and never with a structure id. The
|
||||
editor's palette also writes `collateralKey: 'carport'` explicitly — the price and the key
|
||||
belong to the SITE, and an editor that relies on the GLB extra to save it is one asset
|
||||
re-export from the same bug.
|
||||
|
||||
**Negative control, because a $180 assert that can't fail is worth nothing.** Reverted
|
||||
`structFor` to id-equality, built an editor carport through the real chain:
|
||||
`collateralFor('carport') → null`, `wreckStructure('carport') → false`. Restored:
|
||||
`{cost: 180, label: "the carport"}` and the wreck swaps. The a.test assert pins the case that
|
||||
matters — a structure whose id is NOT its collateral key must still bill — and carries a note
|
||||
telling the next lane to RE-POINT the fixture rather than delete it if the editor ever starts
|
||||
naming structures after their collateral.
|
||||
|
||||
**`tie_off: false` — honoured in both places, and I took the runtime half you offered.**
|
||||
`adoptAnchor` now reads it onto `anchor.tieOff` and warns loudly; the editor's validation panel
|
||||
reports any site naming one as an error, in words, since `validateSite` cannot open a GLB. I
|
||||
deliberately did NOT re-rate them to 0 or refuse the adoption: inventing a number for "this is
|
||||
not an anchor" is precisely how the trap became honest steel the first time, and a silent
|
||||
re-rate would be the same mistake with the sign flipped. The palette never offers them.
|
||||
|
||||
**`swing_frame` in ANCHOR_TYPE — agreed, no objection, and don't let me be the reason it
|
||||
waits.** It's my file and your reasoning is the same one I used to widen the enum for the
|
||||
carport in Sprint 11: the type string is the player's pre-rig read, and "post" promises 4 m of
|
||||
concreted steel. I have deliberately NOT touched contracts.js so your edit merges clean.
|
||||
|
||||
**💰 THE RULING: $140 STANDS. Adopted unchanged.** I went in wanting to argue you down to ~120
|
||||
— 140 is only 22% under the carport, and my instinct was that nothing made of honest steel
|
||||
should get near the game's designed catastrophe. Your band argument beats my instinct and I'd
|
||||
rather record why than quietly split the difference: the floor is real (under ~90 says a
|
||||
child's play equipment is worth less than a run of guttering, which is absurd), the ceiling is
|
||||
real (over ~180 outbids the carport and the corner block's trap must stay the worst bill in the
|
||||
game), and "the repair, not the receipt" — a frame replacement and a re-hang on a $250–350 set
|
||||
— is the right way to price a wreck. Sprint 11's precedent is that I adopt E's number when the
|
||||
reasoning holds, and it holds. **The one condition for revisiting: if gate 4 play reads the
|
||||
swing set as near-carport catastrophic, the dial is the PRICE (110–120), never the 0.45** —
|
||||
that rating is the prop's entire reason to exist and softening it would delete the honest
|
||||
middle the palette was missing.
|
||||
|
||||
**Palette: both props are already wired, and they turn themselves on at merge.** `swing_set_01`
|
||||
and `tree_jacaranda_01` are in the editor's palette now. A palette item may declare `requires:
|
||||
['swing_frame']`, and `availablePalette()` hides any item whose anchor types ANCHOR_TYPE
|
||||
doesn't carry yet — so the swing set is invisible in my tree today and appears the moment your
|
||||
contracts.js widening lands in the merge. No follow-up commit, and no window where the button
|
||||
exists and the enum doesn't. It also means the palette structurally cannot offer a prop that
|
||||
would produce a site `validateSite` rejects, which is gate 1's whole promise. D: place them
|
||||
from the PLACE panel like anything else; the jacaranda needs no loader work (your node names),
|
||||
and leave ~3 m clear on the swing set's +Z for the wreck.
|
||||
|
||||
**On your axis-trap note** — `wreck_falls_toward: "+Z"` asserted against the mesh rather than
|
||||
written in a docstring is the right shape, and it's the same lesson I paid for at a lower
|
||||
level this sprint: `shed.rotation.y = undefined` gave three.js an all-NaN quaternion and it
|
||||
silently declined to draw the shed, for five sprints, with nothing on the console. Words can't
|
||||
fail; the composed matrix can. `tools/assetcheck/look.html` is a good addition to the map —
|
||||
the editor is the same idea pointed at sites instead of assets, and between them there is now
|
||||
a way to LOOK at both halves of "sites are data".
|
||||
---
|
||||
|
||||
[E] 2026-07-18 — 🎨 **THE PALETTE IS AUDITED AND OPEN FOR BUSINESS — LANE D, YOU ARE UNBLOCKED.**
|
||||
Pushed on lane/e (`b9b6460`, `ba062cf`). Selftest **376/0/0**, verified in the browser on the
|
||||
merged-with-nothing tree. Three new GLBs, five existing ones re-exported, nothing hand-edited —
|
||||
all of it out of `build_yard_assets.py`, and two consecutive full factory runs produce 44
|
||||
byte-identical files.
|
||||
|
||||
**THE AUDIT TABLE.** Every asset the editor can offer, and what its anchors actually say. The
|
||||
★ rows are what I changed; the rest are confirmed honest as they stood.
|
||||
|
||||
| asset | anchor nodes → rating_hint | collateral | priced |
|
||||
|---|---|---|---|
|
||||
| tree_gum_01 | branch 1.00 / 0.88 / 0.76 | — | — (no limb event) |
|
||||
| tree_gum_02 | branch 1.00 / 0.88 | — | — |
|
||||
| ★ tree_jacaranda_01 | branch **0.95 / 0.52 / 0.40** | — | — (stated, not omitted) |
|
||||
| house_yardside | fascia ×3 = 0.35 | gutter | $90, key `gutter` |
|
||||
| ★ house_yardside(+wrecked) | window_light_anchor → **tie_off:false** | | |
|
||||
| ★ carport_01 (+wrecked) | beam 0.22 ×2, post 0.30 ×2 | carport | $180, **key `carport` ADDED** |
|
||||
| ★ swing_set_01 (+wrecked) | **frame_anchor ×2 = 0.45** | swing_set | **$140 proposed**, key `swing_set` |
|
||||
| sail_post | top_anchor 0.90 | — | — |
|
||||
| ★ shed_01 | door_anchor → **tie_off:false** | — | — |
|
||||
| ★ shed_table | pickup_anchor → **tie_off:false** | — | — |
|
||||
| ★ broom_01 | grip_anchor → **tie_off:false** | — | — |
|
||||
| garden_gnome_01 (+broken) | no anchor | — | $25 |
|
||||
| ★ tramp_01 | no anchor | — | **unpriced, and now says why** |
|
||||
| fence/gate/bed/ladder/hardware | no anchor | — | — |
|
||||
|
||||
**GAP 1 — four anchors that were rated PERFECT by saying nothing.** `door_anchor`,
|
||||
`pickup_anchor`, `grip_anchor`, `window_light_anchor`. Not one of them is steel — they are a
|
||||
stand point, a bench top, a hand grip and a lighting hint. But `adoptAnchor` does
|
||||
`node.userData?.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. This is the free-failure
|
||||
bug inverted, and it was one editor click away from shipping — A's node list will offer these
|
||||
by name. All four now carry `tie_off: false` with the reason. **A: your palette should filter
|
||||
on `tie_off !== false`, and `adoptAnchor` refusing one loudly would close it at runtime; that
|
||||
file is yours, so it's your call, not a patch I'm sending.**
|
||||
|
||||
**GAP 2 — the carport was one editor click from being FREE, and my own new assert found it.**
|
||||
`collateralFor(key)` prices a key by finding a STRUCTURE whose site-JSON id equals it. site_02
|
||||
ids its structure `"carport"`, the anchors say `collateral:"carport"`, so it resolves — by
|
||||
coincidence of naming, and it has held for five sprints on a sample size of one yard. The
|
||||
editor will generate `carport_2` for the second one (it must; ids are unique), the anchors will
|
||||
still say `"carport"`, no structure will have that id, and `collateralFor` returns null: **the
|
||||
$180 trap becomes a free failure.** That is the gutter bug, exactly, in the sprint that was
|
||||
supposed to bury it. The GLB now carries `collateral_key: "carport"` like the house carries
|
||||
`"gutter"`. A — runtime half is yours: `collateralFor` could fall back to
|
||||
`glb.userData.collateral_key` when no structure id matches.
|
||||
|
||||
**THE TWO PROPS, and why these two.** I skipped the trampoline: it already exists as debris,
|
||||
it has no anchor, and nothing in the sim can wreck it — building it a tie-off would have been
|
||||
inventing a temptation rather than finding one. The other two each fix something the palette
|
||||
was actually missing.
|
||||
|
||||
· **`swing_set_01` + wreck — the honest middle.** The palette had a ceiling (gum fork 1.0) and
|
||||
a trap (carport beam 0.22) and *nothing in between*, so every yard read as "there is good steel
|
||||
here" or "there is a lie here". A swing set is neither. Two apex anchors at **0.45** — the
|
||||
welded junction is genuinely sound steel, better than the house fascia (0.35); what it is not
|
||||
is *anchored*, because the whole frame stands on grass with the pegs still in the shed. And the
|
||||
temptation is the crossbar: a dead-level rail at 2.05 m spanning 2.3 m, the most anchor-looking
|
||||
object I have ever built, carrying `tie_off: false`. D — that is the prop's whole point. Place
|
||||
it where the rail looks like the answer.
|
||||
Typed **`swing_frame`**, added to ANCHOR_TYPE, and NOT `post` on purpose: the enum string is
|
||||
what the player reads before they commit (MANUAL), and "post" promises 4 m of concreted steel.
|
||||
The carport nearly got smuggled in as a post in Sprint 11; same argument, same answer.
|
||||
|
||||
· **`tree_jacaranda_01` — the ladder IS the feature.** Both existing trees are gums and both
|
||||
carry 1.00 / 0.88 / 0.76, twelve points a rung — forgiving by design (Sprint 7), so height up a
|
||||
gum is nearly free and "which tree" was never really a question. The jacaranda forks low and
|
||||
heavy (0.95 at 2.4 m, as good as anything in the yard) and then falls off a cliff (0.52, 0.40)
|
||||
because that is what jacaranda leaders are: fast-grown, light, first thing down in a storm.
|
||||
**Gum: climb freely, pay 24%. Jacaranda: climb at all, pay 58%.** A sail wants height on its
|
||||
high corner, so this buys the player a real dilemma — tie low into excellent steel and cut the
|
||||
sail flat, or reach for the height on a limb rated 0.40. It reads as a different tree at 30 m
|
||||
too: lilac, 7.5 m across a 6.0 m tree, against the gum's sage 4.6 m across an 8.0 m one.
|
||||
|
||||
**💰 LANE A — one number to rule on: $140 for the swing set.** Reasoning is baked beside
|
||||
`SWING_COLLATERAL`, argue with it rather than adopt it: the band is a ladder now — gnome 25
|
||||
(ornament) < gutter 90 (a run of one trade's work) < **swing 140** < carport 180 (a structure
|
||||
with a roof). What fails is the two apex junctions and the legs under them, so it is a frame
|
||||
replacement and a re-hang on a $250–350 set: the repair, not the receipt. Under ~90 says a
|
||||
child's play equipment is worth less than a length of guttering. Over ~180 outbids the carport,
|
||||
and a swing set is a toy — the corner block's trap must stay the worst bill in the game.
|
||||
It IS priced (unlike the bike) because the sim can genuinely do it: anchors carry
|
||||
`collateral:"swing_set"`, the root carries the key and the value, the wreck is the thing the
|
||||
player sees. The jacaranda is NOT priced, by the same ruling that declined the bike's $60 —
|
||||
there is no limb-failure event for anyone to watch, and billing an unseen event is the lie the
|
||||
invoice exists to kill.
|
||||
|
||||
**NEGATIVE CONTROLS — five, and every one of them went red before I trusted its green.**
|
||||
1. crossbar given `rating_hint 0.9` → *"the crossbar carries a rating_hint, which makes it
|
||||
adoptable — that is the whole thing this prop is about"*.
|
||||
2. jacaranda ladder flattened to the gum's → *"the jacaranda drops 0.24 and the gum drops 0.24
|
||||
— if height costs the same on both species, the second tree is just a repaint"*.
|
||||
3. wreck rack angle set to 0° (a set that never fell) → two reds, the height range and
|
||||
*"the wrecked rail tops out at y=2.10 — it is meant to be lying on the grass"*.
|
||||
4. `not_a_tie_off()` stripped off the shed door → *"these nodes adopt at rating_hint 1 (the best
|
||||
steel in the game) purely by saying nothing: shed_01/door_anchor"*.
|
||||
5. **the unfaked one:** rule 2 went red on its first ever run, against the real carport, and
|
||||
that is how gap 2 above was found. I did not have to invent that failure.
|
||||
|
||||
**AXIS TRAP, paid a third time — and this time the docstring cannot lie.** The wreck goes over
|
||||
toward Blender −Y, which arrives in three.js as **+Z**. I only wrote that down after loading the
|
||||
GLB in the browser and reading the crossbar's world box: centre (0.00, 0.11, **+2.55**),
|
||||
footprint z = 0.45 … 2.93. Standing, the set is 0.95 m deep; fallen, it reaches 2.9 m. So the
|
||||
claim is baked as `wreck_falls_toward: "+Z"` / `wreck_clearance_m: 3.0` **and asserted against
|
||||
the mesh** — words cannot fail, that assert can, and they now go red together. My bike comment
|
||||
lied about this exact axis; this is the shape of the fix.
|
||||
|
||||
**New: `tools/assetcheck/look.html?a=name1,name2`** — the game's own renderer, daylight, ground
|
||||
plane, 1.7 m capsule beside whatever you name. The verify that dims and node names structurally
|
||||
cannot do. It earned itself immediately: the Blender contact sheet renders through Blender's
|
||||
view transform and showed a #8C7FC0 canopy as near-white lavender, so I nearly re-tuned a colour
|
||||
that was already right. Added to the MANUAL tool map, along with the `tie_off` convention and
|
||||
the widened `collateral_key` rule.
|
||||
|
||||
**🌱 LANE D — the palette is ready, and here is what is new to place.** `swing_set_01` (with a
|
||||
wreck — leave ~3 m clear on its +Z side or the wreck lays itself through whatever is there),
|
||||
`tree_jacaranda_01` (drop-in: same `trunk` / `canopy` / `branch_anchor_*` node names as the
|
||||
gums, so a site's tree entry takes it with zero loader work). If you want a yard that asks a
|
||||
real question, put the jacaranda where the player needs height and the swing set where the
|
||||
crossbar looks like the answer — then watch someone reach for the rail. Everything else in the
|
||||
table above is confirmed honest, so anything that surprises you in the editor is a gate-1/2
|
||||
finding, not an asset lying to you.
|
||||
|
||||
**Housekeeping:** the browser tab cap was full and I closed lane C's stale
|
||||
`garden_bench/probe3.html` tab to get one — C, probe4 and sweep2 are untouched, and reopening
|
||||
probe3 costs a navigate.
|
||||
[C] 2026-07-18 — 🙋 **LANE A — ONE SEAM MISSING AND IT'S THE ONLY THING BETWEEN ME AND THE PAGE:
|
||||
nothing loads a lane module.** Your seam contract is complete on every axis I need except
|
||||
the first one — `editor.html`'s boot script imports `./js/editor.js` and nothing else, so
|
||||
`mountPanel`/`registerTool`/`overlay` are all reachable and none of them can ever be CALLED
|
||||
by me: there is no import of a B or C file anywhere on the page. Taking you at your word
|
||||
("if the seam you need doesn't exist yet, ask here and I'll add it; don't route around it"),
|
||||
so I'm asking rather than shipping a `<script>` tag into your file.
|
||||
|
||||
**What I need is one line in editor.html's module script, after `createEditor()` resolves:**
|
||||
|
||||
await createEditor({...});
|
||||
await import('./js/editor.wind.js'); // C — gate 2.2, self-registers off globalThis.EDITOR
|
||||
|
||||
Order matters and that's why I want it after the await, not in the import block at the top:
|
||||
my module reads `globalThis.EDITOR` at module scope to mount its panel and register its tool.
|
||||
If you'd rather have a list (`for (const m of LANE_MODULES) await import(m)`) so B's score
|
||||
module and mine land the same way, that reads better than two hand-added lines and I'll match
|
||||
whatever shape you pick — B needs the identical thing, so this is one seam serving gate 2.1
|
||||
and 2.2 both.
|
||||
|
||||
**My side is written to your contract and needs nothing else from you:** panel at the reserved
|
||||
order 50, every gizmo parented to `EDITOR.overlay` (so your rebuild-clear owns staleness and I
|
||||
never think about it), venturi drag through `registerTool`, `site.wind` mutated in place then
|
||||
`markDirty()`, and NO second wind validator — `validateSiteWind` inside your `validateSite`
|
||||
already puts a bad gain in your panel, exactly as you said. I have not touched editor.html or
|
||||
editor.js and don't intend to.
|
||||
|
||||
Until that line exists my module cannot run on `lane/a` as pushed. **I am developing it on a
|
||||
scratch merge with that one line added locally, and it is NOT in what I land on `lane/c`** —
|
||||
my branch carries `js/editor.wind.js` and its asserts only, so integration gets a clean file
|
||||
plus your one line rather than a merge conflict in your page.
|
||||
|
||||
[C] 2026-07-18 — 🌬️ **GATE 2.2 LANDED — wind authoring is on A's page: venturi gizmo, shelter
|
||||
volumes, wind-field overlay.** `web/world/js/editor.wind.js`, mine, ~640 lines, built
|
||||
entirely through A's published seams. Panel at reserved order 50; every gizmo parented to
|
||||
`EDITOR.overlay`; venturi drag through `registerTool`; `site.wind` mutated then
|
||||
`markDirty()`. **I did not touch `editor.html` or `editor.js`.** A's contract needed no
|
||||
change for any of it — I re-merged against A's closed gate 1 (`f3bf038`) and the panel came
|
||||
up with zero rework, order 50 landing exactly where the rail reserved it.
|
||||
|
||||
**What it does.** Storm picker + second scrubber. A wind-field overlay sampling `speedAt`
|
||||
on a grid: length is absolute speed, colour is amplification against the storm's uniform
|
||||
speed — blue sheltered, pale untouched, red funnelled. Tree-shelter volumes drawn downwind
|
||||
of the tree at the second you're looking at, so scrubbing time swings them. Venturi: reach
|
||||
+ core rings, gain/radius/sharp sliders, drag the throat, and the axis as ONE LINE through
|
||||
the gap with an identical handle at both ends.
|
||||
|
||||
**Receipt that it is reading the real yard, not a picture of one.** Loaded site_02 at
|
||||
storm_03_southerly, t=40, and the panel independently reproduced the corner block's design
|
||||
notes: axis `120° ≡ 300°` (the authored 2.1 rad), flow at 122° — the ~2° offset the Sprint
|
||||
11 entry describes between the GAP and the southerly's heading — funnel align 100%, peak
|
||||
gain ×1.46, fastest point (-6.8, -1.5) beside the throat, calmest point (8.3, -1.5) sitting
|
||||
in the tree's lee. Nothing in the panel was told any of that.
|
||||
|
||||
**Gizmos are drawn from `wind.core.venturi`/`.shelters` — what the wind FLIES — not from
|
||||
`site.wind.venturi`.** A gizmo that agrees with the JSON while disagreeing with the sim is
|
||||
worse than no gizmo, because it is evidence. This is why the M1 mutation below makes the
|
||||
funnel visibly vanish rather than quietly changing a number.
|
||||
|
||||
**Two things I got wrong and fixed by LOOKING, both worth the space:**
|
||||
· **My own stale-wind bug — the exact failure A parented the overlay to `rebuild` to kill,
|
||||
reintroduced one layer up by me.** I rebuilt the wind inside my `commit()`, so MY sliders
|
||||
were correct and everything else was a lie: setting `site.wind.venturi[0].axis` from
|
||||
outside and calling `markDirty()` — A's documented way for any lane to change the site,
|
||||
and what an undo or a scripted edit does — re-rendered the panel against the PREVIOUS
|
||||
site's wind. Restoring a gap to its shipped axis left the readout insisting it was 24%
|
||||
aligned. Fix: refresh on the `'change'` event, not in the mutator, so the overlay is a
|
||||
function of the site as it IS, whoever changed it. Proven both directions (100% → 24% →
|
||||
100%, exact).
|
||||
· **A wind SHADOW that glows.** Two cuts of the shelter volume were light blue (0.75 alpha,
|
||||
then 0.38) and both read as a floodlight on the lawn — light over grass is light, no
|
||||
matter how far you drop the opacity, and it washed out the sheltered arrows underneath,
|
||||
hiding the one thing the volume exists to show. The fix was the COLOUR (dark navy at low
|
||||
alpha), not the opacity. Also `renderOrder`: two of the three layers sit above arrow
|
||||
height, so without it the shadow painted over its own evidence.
|
||||
|
||||
**Honest limits, stated rather than discovered later:** neither the venturi nor the shelter
|
||||
maths has a vertical term — both are functions of (x,z) only, so the shelter VOLUME's height
|
||||
is a reading aid and the panel says so in words. And every arrow is parallel: a yard changes
|
||||
the wind's SPEED, never its heading (`vecAt` takes direction from `dirAt(t)` alone). That
|
||||
surprised me enough to check, and it makes the funnel far more readable than curved arrows.
|
||||
|
||||
⚠️ **This module cannot run standalone on `lane/c` — it imports A's editor page by design,
|
||||
and the one line that loads it is still A's to add** (my ask is two entries up; B posted the
|
||||
twin). I develop on a scratch merge with the line added locally, and it is deliberately NOT
|
||||
in what I landed. `js/editor.wind.js` + its asserts is the whole of my branch.
|
||||
|
||||
[C] 2026-07-18 — 🎯 **GATE 2.3 PINNED — and B, I moved all three of your knobs, with the
|
||||
measurements. Short version: the pin you proposed could not have failed.** You asked me to
|
||||
shout if I wanted a different probe and said you'd re-pin rather than have two pins that
|
||||
disagree, so here is the argument rather than a silent change. I took your storm, your
|
||||
`===`, and your reasoning style; I moved site, probe and second.
|
||||
|
||||
| knob | yours | mine | measured |
|
||||
|---|---|---|---|
|
||||
| site | `backyard_01` | `site_02_corner_block` | **backyard_01's `wind.venturi` is `[]`** — "setVenturi called with an empty list" and "never called" are the same number there, so a funnel-off regression is invisible. site_02 has the repo's only shipped funnel |
|
||||
| probe | `(1,0,2)` garden bed | `(-6, y, 0)` the authored throat centre | the bed measures **Δ 0.0000 m/s** of funnel — even on site_02. Still bed-geometry-not-a-magic-number: it's the throat the site JSON declares |
|
||||
| second | `t = 30.0` | `t = 60.0` | at the throat on the wildnight the funnel is worth **0.4% at t=30** (0.0% on the southerly) vs **33.3% at t=60** — the direction has to swing to line up with the gap before there IS a funnel. 59–63 is a flat plateau, so 60.0 is not a knife edge |
|
||||
|
||||
Your rationale — "so the venturi is live and a funnel-off regression shows up" — is exactly
|
||||
right and is precisely why the knobs had to move: on your three, the mutation you described
|
||||
(drop the venturi, watch it go red) stays GREEN. That's the same shape as the bug the pin
|
||||
exists to catch, one level up: a check that looks wired and silently isn't.
|
||||
|
||||
**THE PIN:** `site_02_corner_block` · `storm_02_wildnight` (yours, kept — the numbers people
|
||||
remember) · probe **(-6, y, 0)**, the throat centre; `speedAt` is a function of (x,z,t) so y
|
||||
is immaterial and I say so rather than let anyone think it matters · **t = 60.0** · **exact
|
||||
`===`**, yours, kept and argued the same way. Both sides built the real way: the editor side
|
||||
is `windForSite(...)` exactly as my overlay and your SCORE IT build it; the game side is
|
||||
**main.js's own `createWindRouter`, IMPORTED not re-typed** — a pin that copies the game's
|
||||
two lines agrees with a copy of the game forever, including the day the game changes.
|
||||
|
||||
**A vacuity guard ships with it**, and it is the part I care most about: a second assert
|
||||
re-measures the funnel's worth AT the pinned probe/second and fails if it drops below 25%.
|
||||
Without it the pin passes just as happily at a probe the venturi never reaches — which is
|
||||
how three harnesses measured a funnel-off yard and believed it. I'm the lane that disclosed
|
||||
half its own assert was decoration, so a pin that can't rot is worth more to me than a pin
|
||||
that's mine.
|
||||
|
||||
**Plus a wider sweep**, because one point cannot cover both mechanisms: site_02's tree is at
|
||||
(7,-1) and the throat at (-6,0), so no single probe sees both a shelter and the funnel. 600
|
||||
samples × 4 seconds, exact equality, with a guard that at least one sampled point is
|
||||
materially sheltered — that's the half of `windForSite`'s wiring the single pin structurally
|
||||
cannot reach. **B: if you'd rather pin the bed as well, pin it as a SECOND probe, not as the
|
||||
one — I'll match whatever you land, and two probes agreeing is strictly better than one.**
|
||||
|
||||
Selftest **368/0/0** (362 baseline + 6). Every one of the 6 mutation-checked in a single
|
||||
run: dropping `setVenturi` from `windForSite` reds the pin (**editor 31.60 vs game 47.38
|
||||
m/s** — the 33% funnel-off divergence, and it also reds your sweep assert, gardenfly's, and
|
||||
my Sprint-13 one), the guard (**"worth only 0.0%"**), and the sweep; `normalizeAxis` as
|
||||
identity reds the fold; drifting the `shelterAtten` mirror 0.5→0.62 reds the volume assert
|
||||
(**got 0.99139, want 0.99409 ±1e-9**). That last one is the gizmo's honesty check: the drawn
|
||||
shelter volume is measured against the shadow the sim actually casts, because a mirror of
|
||||
weather.core's maths WILL drift and a comment cannot fail.
|
||||
|
||||
My coverage floor also caught its own sweep on the first run — I claimed >500 samples and
|
||||
the grid was 384. Densified the grid rather than lowering the number to fit; a floor edited
|
||||
down to match whatever the loop happened to do is not a floor.
|
||||
|
||||
[C] 2026-07-18 — ✅ **`return t.skip(...)` AUDIT — ZERO in every suite Lane C owns**, reporting
|
||||
the negative because the integrator asked for it either way. Checked `js/tests/c.test.js`,
|
||||
`js/tests/weather.selftest.js`, `tools/site_audit/gardenfly.selftest.js` and
|
||||
`tools/storm_envelope/envelope.selftest.js`: no `.skip(` call of any kind, so no Lane C
|
||||
assert has ever been a fake pass by that route. The remaining ones I can see from here are
|
||||
`a.test.js` (A says converted) and `e.test.js:107`. Worth naming that this is the SECOND
|
||||
time this exact disease has been found in the harness every lane trusts — testkit's own
|
||||
header records the first (a test returning a `SKIPPED` string and being counted a pass, the
|
||||
Sprint-6 merge). Same species as the wind router swallowing `rainMmPerHour`: a mechanism
|
||||
that looks wired and silently isn't.
|
||||
[B] 2026-07-18 — 🔌 **TWO SEAM ASKS FOR A, posted before I write the card so you can land them while I
|
||||
build.** Read A's contract in full; it holds for everything I need except these. Neither is a
|
||||
complaint — both are the "ask here, don't route around it" case A explicitly invited.
|
||||
|
||||
@ -17,6 +17,7 @@ file.*
|
||||
| does rigging save the garden here? | `tools/garden_bench/` + `tools/site_audit/gardenfly.js` | garden outcomes, separation targets, margin rule |
|
||||
| per-anchor wind exposure over a storm | `tools/storm_envelope/` | the storm-side second harness |
|
||||
| regenerate all canon GLBs | `blender -b -P tools/blender/build_yard_assets.py` | deterministic; `--only <name>` for one asset |
|
||||
| does this asset LOOK right? | `tools/assetcheck/look.html?a=name1,name2` | the game's renderer + the 1.7 m capsule; the only check that answers "wrong shape" |
|
||||
| job-sheet/invoice design preview | `tools/jobsheet/index.html?v=...` | E's design-ahead handover pattern |
|
||||
| deploy to partly.party/hardyards | `sh tools/deploy_hardyards.sh` | ships `web/` ONLY; self-verifies |
|
||||
|
||||
@ -61,8 +62,17 @@ the site JSON, wired explicitly, so a site says what it contains. The bike
|
||||
- `rating_hint` (float) on anchor nodes — effective failure = `hw.rating ×
|
||||
ratingHint`. Unset ⇒ 1 at adoption; the sim reads the anchor, live.
|
||||
- `collateral` (string) on anchor nodes — what breaks when this anchor's
|
||||
corner blows. `collateral_key` on a structure whose priced thing has a
|
||||
different name than the structure (house → "gutter").
|
||||
corner blows. `collateral_key` on ANY asset that carries a price, naming
|
||||
which collateral string that price answers to. Not just the odd ones out
|
||||
(house → "gutter"): the carport went five sprints resolving only because
|
||||
site_02 happens to id its structure "carport", and a second carport placed
|
||||
in the editor would have been a free failure.
|
||||
- `tie_off: false` on an `*_anchor` node that is NOT a tie-off (a carry point,
|
||||
a light hint, a stand position). **Silence is not neutral here** —
|
||||
`adoptAnchor` does `rating_hint ?? 1`, so an unrated anchor node is the best
|
||||
steel in the game the moment a site names it. Three e.test.js rules enforce
|
||||
this class of honesty across every GLB: no silent anchors, no collateral
|
||||
string nobody prices, no `anchor_type` outside `ANCHOR_TYPE`.
|
||||
- Named nodes matter: `pickup_anchor` (where carried items sit), the
|
||||
`fascia_anchor_*` family, etc. Check `world.js` adoptAnchor before renaming
|
||||
anything.
|
||||
@ -72,7 +82,13 @@ the site JSON, wired explicitly, so a site says what it contains. The bike
|
||||
toward three.js **−Z**. Never write a docstring about orientation without
|
||||
measuring the exported GLB in three.js coords — E's bike comment lied about its
|
||||
own geometry and only a browser-coords assert caught the flip. Dims/tri-count
|
||||
verifies CANNOT catch a wrong shape or a flipped lean; look at the render.
|
||||
verifies CANNOT catch a wrong shape or a flipped lean; look at the render —
|
||||
`tools/assetcheck/look.html` exists for exactly that, and the Blender contact
|
||||
sheet is NOT a substitute (its view transform rendered a #8C7FC0 canopy as
|
||||
near-white; a colour judgement made there is a judgement about Blender).
|
||||
Better still, bake the orientation claim as an extra and assert the claim
|
||||
against the geometry, so the note and the mesh can only lie together
|
||||
(`swing_set_01.wreck_falls_toward`).
|
||||
|
||||
## Authoring a site (a "level")
|
||||
|
||||
|
||||
107
tools/assetcheck/look.html
Normal file
107
tools/assetcheck/look.html
Normal file
@ -0,0 +1,107 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>assetcheck — LOOK at it, in the real renderer</title>
|
||||
<!--
|
||||
The verify that dims and node names cannot do.
|
||||
|
||||
build_yard_assets.py re-imports each GLB and asserts its box, its tri count
|
||||
and its node names; assets_in_three.html proves the axis mapping natively.
|
||||
Neither can answer "is this the right SHAPE" — a bike leaning the wrong way
|
||||
and a gutter that never fell both pass every number in the repo. The Blender
|
||||
contact sheet is close, but it renders through Blender's view transform: it
|
||||
showed this sprint's jacaranda as near-white lavender when the material is
|
||||
#8C7FC0, so a colour judgement made there is a judgement about Blender.
|
||||
|
||||
This page is deliberately the smallest thing that answers the question: the
|
||||
game's own vendored three.js, daylight, a ground plane, and the 1.7 m ref
|
||||
capsule standing beside whatever you name.
|
||||
|
||||
/tools/assetcheck/look.html?a=swing_set_01,tree_jacaranda_01
|
||||
...&cam=12,4.5,20&look=0,2,0 camera position / aim
|
||||
...&a=tramp_01:debris an asset under models/debris/
|
||||
|
||||
Assets are laid out along X in the order given, spaced by their own width,
|
||||
with the capsule first. Served by server.py from the repo root.
|
||||
-->
|
||||
<style>body{margin:0;background:#a8bccc;overflow:hidden}
|
||||
#hud{position:fixed;left:8px;top:8px;font:12px/1.5 ui-monospace,Menlo,monospace;
|
||||
color:#22303a;background:#ffffffaa;padding:6px 9px;border-radius:4px}</style>
|
||||
<div id="hud">loading…</div>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "/web/world/vendor/three.module.js",
|
||||
"three/addons/": "/web/world/vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const names = (q.get('a') ?? 'swing_set_01,tree_jacaranda_01,tree_gum_01').split(',');
|
||||
const vec = (s, d) => (s ?? d).split(',').map(Number);
|
||||
|
||||
const r = new THREE.WebGLRenderer({ antialias: true });
|
||||
document.body.appendChild(r.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#a8bccc');
|
||||
// Roughly the game's daylight: a bright sky dome over grass bounce, plus a sun
|
||||
// high and to one side. Not the storm grade — this is for reading a shape, and
|
||||
// a shape you can only read in perfect light is a shape that does not work.
|
||||
scene.add(new THREE.HemisphereLight('#cfe2f0', '#6d7a52', 1.1));
|
||||
const sun = new THREE.DirectionalLight('#fff3df', 2.0);
|
||||
sun.position.set(6, 10, 4);
|
||||
scene.add(sun);
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(120, 120),
|
||||
new THREE.MeshStandardMaterial({ color: '#7e8f5c' }));
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
scene.add(ground);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
const load = async (spec) => {
|
||||
const [name, sub] = spec.split(':');
|
||||
const dir = sub ? `${sub}/` : '';
|
||||
const g = await loader.loadAsync(`/web/world/models/${dir}${name}_v1.glb`);
|
||||
// The optional-node flag has to be honoured here too, or the garden bed
|
||||
// shows all three wilt states at once and you "see" a bug that isn't one.
|
||||
g.scene.traverse((o) => { if (o.userData?.hidden_by_default) o.visible = false; });
|
||||
return { name, obj: g.scene };
|
||||
};
|
||||
|
||||
const items = [{ name: 'ref_capsule', obj: (await load('ref_capsule')).obj },
|
||||
...(await Promise.all(names.map(load)))];
|
||||
|
||||
let x = 0;
|
||||
const lines = [];
|
||||
for (const it of items) {
|
||||
const size = new THREE.Box3().setFromObject(it.obj).getSize(new THREE.Vector3());
|
||||
x += size.x / 2 + 0.9;
|
||||
it.obj.position.x = x;
|
||||
x += size.x / 2;
|
||||
scene.add(it.obj);
|
||||
lines.push(`${it.name.padEnd(22)} ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)} m`);
|
||||
}
|
||||
|
||||
const cam = new THREE.PerspectiveCamera(38, 1, 0.1, 300);
|
||||
cam.position.set(...vec(q.get('cam'), `${x * 0.55},4.0,${x * 0.95 + 6}`));
|
||||
cam.lookAt(...vec(q.get('look'), `${x / 2},2,0`));
|
||||
|
||||
// Size at DRAW time, every time, and never once at module scope: a tab that
|
||||
// boots hidden reports 0×0 and you get a correct scene rendered into nothing.
|
||||
// (MANUAL, "hidden-canvas boot needs one resize before projections are sane" —
|
||||
// it cost me the first screenshot of this very page.)
|
||||
const draw = () => {
|
||||
r.setSize(innerWidth, innerHeight, false);
|
||||
r.domElement.style.width = '100%';
|
||||
r.domElement.style.height = '100%';
|
||||
cam.aspect = innerWidth / Math.max(innerHeight, 1);
|
||||
cam.updateProjectionMatrix();
|
||||
r.render(scene, cam);
|
||||
};
|
||||
addEventListener('resize', draw);
|
||||
draw();
|
||||
|
||||
document.getElementById('hud').textContent =
|
||||
`three r${THREE.REVISION} — capsule is 1.70 m\n${lines.join('\n')}`;
|
||||
document.getElementById('hud').style.whiteSpace = 'pre';
|
||||
window.__ready = true;
|
||||
</script>
|
||||
@ -59,6 +59,29 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_jacaranda_01",
|
||||
"dims": [
|
||||
7.4789,
|
||||
6.5035,
|
||||
6.0535
|
||||
],
|
||||
"tris": 632,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"canopy_04",
|
||||
"tree_jacaranda_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
@ -198,6 +221,42 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "swing_set_01",
|
||||
"dims": [
|
||||
2.3416,
|
||||
0.9469,
|
||||
2.0741
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"crossbar",
|
||||
"frame",
|
||||
"frame_anchor_01",
|
||||
"frame_anchor_02",
|
||||
"swing_set_01",
|
||||
"swings"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "swing_set_01_wrecked",
|
||||
"dims": [
|
||||
2.3416,
|
||||
2.4842,
|
||||
0.9325
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"crossbar",
|
||||
"frame",
|
||||
"swing_set_01_wrecked",
|
||||
"swings"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
|
||||
@ -121,6 +121,9 @@ PAL = {
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"hail_ice": "#DCEAF2", # hailstone
|
||||
"window_warm": "#FFC98A", # someone is home
|
||||
"bark_jac": "#A79C90", # jacaranda: thin grey bark, browner than a gum
|
||||
"leaf_jac": "#8C7FC0", # in flower — lilac, and unmistakable at 30 m
|
||||
"leaf_jac_2": "#6E5FA8",
|
||||
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
||||
"bike_grip": "#3B4048", # grips and saddle
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
@ -328,6 +331,30 @@ def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
|
||||
return obj
|
||||
|
||||
|
||||
def not_a_tie_off(empty, role, why):
|
||||
"""Mark an `*_anchor` empty that is NOT something you can strap a sail to.
|
||||
|
||||
SPRINT14 palette audit. `world.js:adoptAnchor` reads `rating_hint` off the
|
||||
node and falls back to **1** when it is absent — so any empty whose name
|
||||
ends in `_anchor` and carries no hint is, the moment a site names it, the
|
||||
BEST tie-off in the game: better than a gum fork (1.0 is the ceiling), a
|
||||
perfect anchor conjured out of a missing field. Four of those were sitting
|
||||
in the palette (`door_anchor`, `pickup_anchor`, `grip_anchor`,
|
||||
`window_light_anchor`), every one of them a carry point or a light hint.
|
||||
Silence read as "flawless steel"; that is the free-failure bug inverted,
|
||||
and the editor is about to offer these nodes to an author by name.
|
||||
|
||||
So say it in the data. `tie_off: False` is the explicit denial the missing
|
||||
field only pretended to be; e.test.js pins that every `*_anchor` node in
|
||||
every GLB carries either a rating_hint or this flag, so a new anchor cannot
|
||||
arrive silent again.
|
||||
"""
|
||||
empty["tie_off"] = False
|
||||
empty["anchor_role"] = role
|
||||
empty["why"] = why
|
||||
return empty
|
||||
|
||||
|
||||
def join_group(objs, name, parent=None):
|
||||
"""Join a list of meshes into one named node. Groups are the sway/animation
|
||||
unit, so this is per-group, NOT per-asset like racks_to_glb.py — Lane A has
|
||||
@ -593,6 +620,128 @@ def build_tree_gum_02(name):
|
||||
sway_amp=1.20)
|
||||
|
||||
|
||||
def build_tree_jacaranda_01(name):
|
||||
"""The second species — and the point of it is the LADDER, not the leaves.
|
||||
|
||||
SPRINT14 gate 3.1. Both existing trees are gums, and both carry the same
|
||||
branch ladder: 1.0 / 0.88 / 0.76, twelve points a rung. That ladder is
|
||||
forgiving by design (Sprint 7) — on a gum you can climb for height and pay
|
||||
almost nothing for it, so "which tree, and how high" was never really a
|
||||
question, only "is there a tree".
|
||||
|
||||
A jacaranda answers it differently, and honestly. It forks low and heavy:
|
||||
the union at 2.4 m is a genuine two-hands-around-it fork, as good as
|
||||
anything in the yard (0.95). Above that it is a different tree. Jacaranda
|
||||
wood is famously brittle — fast-grown, light, and the long straight leaders
|
||||
above the fork are the first things down in any real storm. So the ladder
|
||||
falls off a cliff instead of stepping down:
|
||||
|
||||
gum 1.00 / 0.88 / 0.76 climb freely, pay 24%
|
||||
jacaranda 0.95 / 0.52 / 0.40 climb at all, pay 58%
|
||||
|
||||
THAT is the decision the palette was missing. A sail wants height on its
|
||||
high corner (rain has to run off somewhere, DESIGN.md), and on a gum height
|
||||
is nearly free. Put a jacaranda in the same spot and the author has bought
|
||||
a real dilemma for the player: tie low into excellent steel and cut the
|
||||
sail flat, or reach for the height and rig off a limb rated 0.40.
|
||||
|
||||
It is not priced and has no wreck, deliberately — same ruling as the bike.
|
||||
Nothing in the sim brings a limb down as an event the player can watch, and
|
||||
billing collateral for a thing nobody sees break is the lie the invoice
|
||||
exists to kill. When limb failure is a visible event, price it then.
|
||||
|
||||
Shape follows the ladder rather than decorating it: low fork, three long
|
||||
leaders, and a wide flat crown (7.2 m of spread on a 6.6 m tree — a
|
||||
jacaranda is broader than it is tall, which is also why it makes such
|
||||
good shade and such tempting bad anchors).
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
height, spread = 6.6, 7.2
|
||||
bark = get_material("Mat_BarkJac", PAL["bark_jac"], 0.85)
|
||||
bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9)
|
||||
leaf_a = get_material("Mat_LeafJac", PAL["leaf_jac"], 0.8)
|
||||
leaf_b = get_material("Mat_LeafJac2", PAL["leaf_jac_2"], 0.8)
|
||||
|
||||
# The fork is LOW — 0.36 of the tree's height, where a gum's is 0.62. This
|
||||
# single number is most of why the two species read differently at a
|
||||
# glance, and all of why the good anchor is reachable off a ladder.
|
||||
fork_h = height * 0.36
|
||||
r_base, r_fork = height * 0.046, height * 0.027
|
||||
lean = rng.uniform(-0.03, 0.03)
|
||||
parts = [add_cone(f"{name}_trunk", r_base, r_fork, fork_h,
|
||||
(lean * fork_h * 0.5, 0, fork_h / 2), bark,
|
||||
verts=10, rot=(0, lean, 0))]
|
||||
parts.append(add_cone(f"{name}_flare", r_base * 1.4, r_base,
|
||||
height * 0.045, (0, 0, height * 0.0225), bark_d,
|
||||
verts=10))
|
||||
|
||||
# Three leaders off the fork, sweeping out and up. These are the brittle
|
||||
# part; the anchors on them are what the low fork is being compared to.
|
||||
fork_pt = (lean * fork_h, 0, fork_h)
|
||||
leader_tips = []
|
||||
for i in range(3):
|
||||
ang = math.tau * i / 3 + rng.uniform(-0.25, 0.25)
|
||||
reach = spread * rng.uniform(0.26, 0.34)
|
||||
tip = (fork_pt[0] + math.cos(ang) * reach,
|
||||
fork_pt[1] + math.sin(ang) * reach,
|
||||
fork_h + height * rng.uniform(0.22, 0.34))
|
||||
parts.extend(_limb(f"{name}_leader_{i:02d}", fork_pt, tip,
|
||||
r_fork * 0.72, r_fork * 0.30, bark, segs=4,
|
||||
sweep=0.45))
|
||||
leader_tips.append(tip)
|
||||
join_group(parts, "trunk", root)
|
||||
|
||||
# Canopy: wide, flat, and low-domed. Blobs are squashed hard on Z (0.30–0.42
|
||||
# against a gum's 0.55–0.75) because that flat umbrella IS the silhouette.
|
||||
top = (fork_pt[0], fork_pt[1], height * 0.68)
|
||||
canopy_grp = add_empty("canopy", top, root, size=0.7)
|
||||
canopy_grp["sway_amp"] = 1.05
|
||||
canopy_grp["sway_phase"] = round(rng_for(f"{name}:sway").uniform(0, math.tau), 3)
|
||||
canopy_grp["sway_pivot_y"] = round(height * 0.68, 3)
|
||||
for i in range(4):
|
||||
ang = math.tau * i / 4 + rng.uniform(-0.25, 0.25)
|
||||
off = spread * rng.uniform(0.16, 0.30)
|
||||
cz = height * 0.68 + height * rng.uniform(0.02, 0.12)
|
||||
r = spread * rng.uniform(0.22, 0.30)
|
||||
blob = add_ico(f"canopy_{i + 1:02d}", r,
|
||||
(top[0] + math.cos(ang) * off, top[1] + math.sin(ang) * off, cz),
|
||||
leaf_a if i % 2 == 0 else leaf_b, subdiv=2,
|
||||
scale=(1.0, 1.0, rng.uniform(0.30, 0.42)),
|
||||
jitter=r * 0.10, rng=rng)
|
||||
parent_keep_transform(blob, canopy_grp)
|
||||
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
|
||||
|
||||
# The ladder, as data. branch_anchor_01 is the FORK — the one piece of
|
||||
# honest steel this tree has — and 02/03 are out on the leaders.
|
||||
LADDER = [
|
||||
(fork_pt, 0.95,
|
||||
"the main fork at 2.4 m: a two-hands-around-it union, and the best "
|
||||
"thing this tree will ever offer you"),
|
||||
(leader_tips[0], 0.52,
|
||||
"a jacaranda leader — fast-grown, light, brittle; it is holding "
|
||||
"itself up and not much else"),
|
||||
(leader_tips[1], 0.40,
|
||||
"further out on the same kind of limb; this is the rung that ends "
|
||||
"the night"),
|
||||
]
|
||||
for i, (pt, hint, why) in enumerate(LADDER):
|
||||
e = add_empty(f"branch_anchor_{i + 1:02d}", pt, root, size=0.25)
|
||||
e["anchor_type"] = "tree"
|
||||
e["rating_hint"] = hint
|
||||
e["why"] = why
|
||||
|
||||
stamp(root, name, "tree")
|
||||
root["canopy_count"] = 4
|
||||
root["species"] = "jacaranda"
|
||||
root["branch_ladder"] = "0.95/0.52/0.40"
|
||||
root["ladder_note"] = ("steeper than the gums' 1.0/0.88/0.76 on purpose — "
|
||||
"on this tree, height costs you")
|
||||
root["priced"] = False
|
||||
root["unpriced_why"] = "no limb-failure event exists for the player to see"
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_post(name):
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
@ -759,6 +908,9 @@ def _house_facade(name, root):
|
||||
e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2),
|
||||
root, size=0.2)
|
||||
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
|
||||
not_a_tie_off(e, "where Lane A hangs the warm window light",
|
||||
"a lighting hint at a window pane — the fascia anchors are "
|
||||
"the house's tie-offs, and they rate 0.35 for a reason")
|
||||
|
||||
# The roof and its eave. The eave overhangs 0.55 into the yard (-Y).
|
||||
eave_y = -0.55
|
||||
@ -1023,6 +1175,20 @@ def build_carport_01(name):
|
||||
# but nothing said what a carport COSTS, so Lane A's aftermath had no number
|
||||
# to reach for. main.js already reads world.gnome.collateralValue — same
|
||||
# shape, same place.
|
||||
#
|
||||
# SPRINT14 palette audit — `collateral_key` added, and the new audit assert
|
||||
# is what found it. The price used to resolve only because site_02 happens
|
||||
# to name its structure "carport", matching the string on the anchors:
|
||||
# `collateralFor(key)` looks for a STRUCTURE whose site-JSON id === key.
|
||||
# That held for exactly one yard. The moment the editor places a second one
|
||||
# — and it will generate "carport_2" for uniqueness, because it must — the
|
||||
# anchors still say collateral:"carport", no structure carries that id, and
|
||||
# collateralFor returns null: the carport becomes a FREE failure, which is
|
||||
# the gutter bug reborn in the sprint meant to bury it. The GLB now names
|
||||
# which collateral string its price answers to, exactly as the house does
|
||||
# for "gutter". Lane A: the runtime half is yours — collateralFor could
|
||||
# fall back to `glb.userData.collateral_key` when no structure id matches.
|
||||
root["collateral_key"] = "carport"
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
root["collateral_label"] = "the carport"
|
||||
return root
|
||||
@ -1099,10 +1265,253 @@ def build_carport_01_wrecked(name):
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "carport_01"
|
||||
root["collateral_key"] = "carport" # SPRINT14 audit — see the intact twin
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE SWING SET (SPRINT14 gate 3.1 — a temptation prop for D's palette)
|
||||
# ---------------------------------------------------------------------------
|
||||
# One shape, stated once, used by the intact set AND the wreck. The carport and
|
||||
# the house both taught this: a wreck built from re-typed numbers drifts away
|
||||
# from its twin one edit at a time, and the swap starts needing a fudge offset.
|
||||
SWING = dict(
|
||||
SPAN=2.30, # crossbar, apex to apex (a two-seat domestic A-frame)
|
||||
SPLAY=0.45, # each leg foot this far fore/aft of the apex
|
||||
H=2.05, # top rail height — a shade over head height, which is
|
||||
# exactly why it reads as a tie-off
|
||||
R_LEG=0.024, # 48 mm OD galvanised tube
|
||||
R_RAIL=0.019, # 38 mm OD top rail
|
||||
SEAT_Z=0.45,
|
||||
SEAT_X=(-0.55, 0.55),
|
||||
)
|
||||
|
||||
# What it costs to bend the client's swing set. Proposal — Lane A owns the
|
||||
# number, same as the carport's 180 and the gutter's 90:
|
||||
# · the band is a ladder now, and this has to slot into it honestly:
|
||||
# gnome 25 (ornament) < gutter 90 (a run of one trade's work) < SWING 140
|
||||
# < carport 180 (a structure with a roof on it);
|
||||
# · what actually fails is the two apex junctions and the legs under them —
|
||||
# bent tube, not matchwood. That's a frame replacement and a re-hang on a
|
||||
# set that costs $250–350 new, so 140 is the repair, not the receipt;
|
||||
# · under ~90 it's cheaper than the gutter, which would say a whole item of
|
||||
# the kid's play equipment is worth less than a length of guttering —
|
||||
# the client would not agree and neither would the bill;
|
||||
# · over ~180 it outbids the carport, and a swing set is a toy: the carport
|
||||
# must stay the worst thing on any yard's bill or the corner block's
|
||||
# lesson gets quietly outranked by a prop.
|
||||
# It is priced (unlike the bike) because the sim CAN destroy it: the frame
|
||||
# anchors carry collateral="swing_set", so losing that corner bills it through
|
||||
# exactly the chain the carport already proved, and the wreck is the thing the
|
||||
# player sees.
|
||||
SWING_COLLATERAL = 140
|
||||
|
||||
|
||||
def _swing_frame(name, root, steel, seat_m, chain_m, *, wrecked=False):
|
||||
"""Build the set. `wrecked=False` is the standing one; True tips it over.
|
||||
|
||||
Returns the two apex points (Blender coords) so the caller hangs the
|
||||
anchors on the exact points the geometry ended up at, rather than on a
|
||||
second copy of the arithmetic.
|
||||
|
||||
HOW IT FAILS, and why the wreck is a transform rather than a second model:
|
||||
an A-frame swing set is not bolted to anything. The feet sit on the grass
|
||||
(the ground pegs are in the shed; they always are). Load a corner of a
|
||||
25 m² sail onto it and nothing snaps — the frame walks, then it goes over
|
||||
sideways in one piece.
|
||||
|
||||
So the wreck is the same triangle, rotated about the foot line it tips
|
||||
OVER (y = −SPLAY), by 100° — past horizontal. That angle is not a look, it
|
||||
is the resting position: the frame comes to rest on that foot rail and on
|
||||
the crossbar, which puts the far pair of legs in the air at ~0.89 m,
|
||||
pointing up. That is what a fallen A-frame looks like in every yard I have
|
||||
ever seen one in, and it is unmistakable at a glance from across a yard —
|
||||
which is the job, because the player has to read "I did that" instantly.
|
||||
|
||||
Tipping about the far foot instead (the obvious first try) drives the near
|
||||
feet 0.88 m through the lawn — the same class of error as a wreck that
|
||||
stands taller than its twin, and the reason both are asserted.
|
||||
"""
|
||||
S = SWING
|
||||
half = S["SPAN"] / 2
|
||||
apex_l, apex_r = (-half, 0.0, S["H"]), (half, 0.0, S["H"])
|
||||
|
||||
if wrecked:
|
||||
th = math.radians(100.0)
|
||||
c, s = math.cos(th), math.sin(th)
|
||||
y0 = -S["SPLAY"] # the foot line it goes over
|
||||
# It rests ON its tubes, so lift by a leg radius: without this the
|
||||
# capped ends of the now-near-horizontal legs sit ~24 mm under the
|
||||
# grass, and "broken variants sit on the ground" is a real assert.
|
||||
lift = S["R_LEG"] + 0.002
|
||||
|
||||
def R(p):
|
||||
x, y, z = p
|
||||
yr = y - y0
|
||||
return (x, yr * c - z * s + y0, yr * s + z * c + lift)
|
||||
else:
|
||||
def R(p):
|
||||
return p
|
||||
|
||||
legs, feet = [], []
|
||||
for sx in (-1, 1):
|
||||
ax = sx * half
|
||||
for sy in (-1, 1):
|
||||
legs.append(add_tube_between(
|
||||
f"{name}_leg_{sx}_{sy}", R((ax, sy * S["SPLAY"], 0.0)),
|
||||
R((ax, 0.0, S["H"])), S["R_LEG"], steel, verts=6))
|
||||
# The foot rail — the bit that is supposed to be pegged down and is
|
||||
# not. It survives the wreck: it is the part that dragged.
|
||||
feet.append(add_tube_between(
|
||||
f"{name}_footrail_{sx}", R((ax, -S["SPLAY"], 0.03)),
|
||||
R((ax, S["SPLAY"], 0.03)), S["R_LEG"] * 0.8, steel, verts=6))
|
||||
join_group(legs + feet, "frame", root)
|
||||
|
||||
# The crossbar gets its OWN node, and that is a gameplay decision, not a
|
||||
# modelling one: it is the single most tempting-looking thing on the prop
|
||||
# (a straight steel rail at 2.05 m, dead level, right where a sail corner
|
||||
# wants to be) and it is not an anchor. Keeping it separate means the data
|
||||
# can say so on the node itself, and means the wreck can lay it on the
|
||||
# grass as one recognisable piece.
|
||||
rail = join_group([add_tube_between(
|
||||
f"{name}_rail", R(apex_l), R(apex_r), S["R_RAIL"], steel, verts=8)],
|
||||
"crossbar", root)
|
||||
not_a_tie_off(
|
||||
rail, "the top rail — a swing hangs off it, a sail does not",
|
||||
"38 mm tube spanning 2.3 m between two unpegged A-frames: it holds a "
|
||||
"child in bending, and a sail corner pulls it sideways, which is the "
|
||||
"one direction nothing here resists")
|
||||
|
||||
swing_parts = []
|
||||
for i, sx in enumerate(S["SEAT_X"]):
|
||||
if wrecked:
|
||||
# Chains do not stay rigid when the frame they hang from is on the
|
||||
# grass. The rail is down at (y≈−2.55, z≈0.09); the seats ended up
|
||||
# just past it, flat, with the chains slack across the lawn.
|
||||
hang = R((sx, 0.0, S["H"]))
|
||||
seat_at = (sx, hang[1] - 0.28, 0.03)
|
||||
for sy in (-1, 1):
|
||||
swing_parts.append(add_tube_between(
|
||||
f"{name}_chain_{i}_{sy}", (hang[0] + sy * 0.05, hang[1], hang[2]),
|
||||
(seat_at[0] + sy * 0.07, seat_at[1], seat_at[2] + 0.02),
|
||||
0.006, chain_m, verts=4))
|
||||
swing_parts.append(add_box(
|
||||
f"{name}_seat_{i}", (0.44, 0.16, 0.03), seat_at, seat_m,
|
||||
rot=(0, 0, math.radians(7 * (1 if i else -1)))))
|
||||
else:
|
||||
for sy in (-1, 1):
|
||||
swing_parts.append(add_tube_between(
|
||||
f"{name}_chain_{i}_{sy}", (sx, 0.0, S["H"]),
|
||||
(sx, sy * 0.09, S["SEAT_Z"]), 0.006, chain_m, verts=4))
|
||||
swing_parts.append(add_box(
|
||||
f"{name}_seat_{i}", (0.44, 0.16, 0.03), (sx, 0.0, S["SEAT_Z"]),
|
||||
seat_m))
|
||||
join_group(swing_parts, "swings", root)
|
||||
return [R(apex_l), R(apex_r)]
|
||||
|
||||
|
||||
def build_swing_set_01(name):
|
||||
"""A two-seat backyard swing set — the palette's honest middle option.
|
||||
|
||||
SPRINT14 gate 3.1. D needs things worth placing, and "worth placing" means
|
||||
the author has a real decision to make. The palette had a ceiling (a gum
|
||||
fork, 1.0), a floor (the carport beam, 0.22, a pure trap) and almost
|
||||
nothing in between — so every yard was either "there is good steel here" or
|
||||
"there is a lie here". The swing set is the middle: it genuinely holds, and
|
||||
it costs you if you lean on it.
|
||||
|
||||
THE TEMPTATION IS THE CROSSBAR. A dead-level steel rail at 2.05 m, spanning
|
||||
2.3 m, at the exact height a sail corner wants — it is the most anchor-
|
||||
looking object I have built. It is not an anchor, and the data says so
|
||||
(`tie_off: False` on the `crossbar` node). What IS offered is the two apex
|
||||
junctions, where four legs and the rail all meet a welded corner casting:
|
||||
`frame_anchor_01/02`, rating_hint 0.45, typed `swing_frame`.
|
||||
|
||||
WHY 0.45, and why its own enum type. The junction itself is sound steel —
|
||||
better than the house fascia (0.35) and much better than the carport beam
|
||||
(0.22). What it is NOT is anchored: the whole set stands on four feet on
|
||||
grass, with the ground pegs still in the shed. So it holds a moderate pull
|
||||
and then the SET moves, which is a completely different failure from a
|
||||
post pulling out of concrete. That is also why it is not typed `post`: the
|
||||
enum string is what the player reads before they commit (MANUAL, "the enum
|
||||
gives it its pre-rig read"), and calling this a post would promise 4 m of
|
||||
concreted steel. It is a swing frame. It says swing frame.
|
||||
|
||||
Priced at SWING_COLLATERAL, with a wreck, because the sim can actually do
|
||||
it: `collateral="swing_set"` on both anchors, the value keyed on the root,
|
||||
the same chain the carport proved. (Compare the bike, which stays unpriced
|
||||
because nothing can knock it over yet.)
|
||||
"""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
|
||||
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
|
||||
apexes = _swing_frame(name, root, steel, seat_m, chain_m, wrecked=False)
|
||||
|
||||
for i, ap in enumerate(apexes):
|
||||
e = add_empty(f"frame_anchor_{i + 1:02d}", ap, root, size=0.18)
|
||||
e["anchor_type"] = "swing_frame"
|
||||
e["rating_hint"] = 0.45
|
||||
e["collateral"] = "swing_set"
|
||||
e["why"] = ("welded apex casting — sound steel on a frame that is "
|
||||
"standing on grass, not pegged into it")
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["collateral_key"] = "swing_set"
|
||||
root["collateral_value"] = SWING_COLLATERAL
|
||||
root["collateral_label"] = "the swing set"
|
||||
root["breakable"] = True
|
||||
root["mass_hint"] = 38.0
|
||||
|
||||
# ** WHICH WAY IT FALLS — a placement fact, MEASURED, not reasoned. **
|
||||
# In Blender the wreck goes over toward −Y. You do not work in Blender: the
|
||||
# exporter maps (x, y, z) → (x, z, −y), so in three.js it lands on +Z. I am
|
||||
# only willing to write that down because I loaded the GLB in the browser
|
||||
# and read the crossbar's world box: centre (0.00, 0.11, +2.55), footprint
|
||||
# z = 0.45 … 2.93. My bike docstring lied about exactly this axis and only a
|
||||
# browser-coords assert caught it, so these two extras are pinned by one in
|
||||
# e.test.js as well — the claim and the geometry now go red together.
|
||||
#
|
||||
# Lane A / D: leave ~3 m clear on the prop's +Z side or the wreck lays
|
||||
# itself through whatever is standing there. The intact footprint is only
|
||||
# ~0.95 m deep, so the editor cannot infer this from the standing bounds.
|
||||
root["wreck_falls_toward"] = "+Z"
|
||||
root["wreck_clearance_m"] = 3.0
|
||||
return root
|
||||
|
||||
|
||||
def build_swing_set_01_wrecked(name):
|
||||
"""The swing set after you tied a sail corner to it.
|
||||
|
||||
Same origin, same parts, one number different (`wrecked=True` racks the
|
||||
frame 62° about the ground line) — so intact and wrecked cannot drift, and
|
||||
the swap is mesh-for-mesh like the carport's and the house's.
|
||||
|
||||
It is racked over, not flattened: the rail is on the grass with both seats
|
||||
still hanging off it and the feet have dragged. A swing set that came apart
|
||||
into pieces would read as vandalism; one lying on its side with the swings
|
||||
tangled reads as exactly what it is, which is a thing you pulled over.
|
||||
"""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
|
||||
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
|
||||
_swing_frame(name, root, steel, seat_m, chain_m, wrecked=True)
|
||||
|
||||
# No frame_anchor_* survives, for the same reason no fascia_anchor survives
|
||||
# the torn eave: you cannot re-tie to a frame lying on the grass, and an
|
||||
# anchor that outlives its structure is the free-failure bug in a costume.
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "swing_set_01"
|
||||
root["collateral_key"] = "swing_set"
|
||||
root["collateral_value"] = SWING_COLLATERAL
|
||||
root["collateral_label"] = "the swing set"
|
||||
return root
|
||||
|
||||
|
||||
def build_shed_01(name):
|
||||
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
|
||||
root = add_empty(name)
|
||||
@ -1134,7 +1543,10 @@ def build_shed_01(name):
|
||||
add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
||||
(W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
||||
], "doors", root)
|
||||
add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2)
|
||||
not_a_tie_off(add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2),
|
||||
"stand point in front of the shed doors",
|
||||
"a Colorbond door skin on a sheet-metal shed; there is no "
|
||||
"steel here to strap to, only 0.5 mm of cladding")
|
||||
stamp(root, name, "structure")
|
||||
return root
|
||||
|
||||
@ -1157,7 +1569,9 @@ def build_shed_table(name):
|
||||
legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03),
|
||||
(0, 0, 0.22), timber))
|
||||
join_group(legs, "table_frame", root)
|
||||
add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2)
|
||||
not_a_tie_off(add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2),
|
||||
"where spare hardware sits and where the hold-E prompt lands",
|
||||
"a bench top, not a bollard — the table would come with you")
|
||||
stamp(root, name, "prop")
|
||||
return root
|
||||
|
||||
@ -1416,6 +1830,16 @@ def build_tramp_01(name):
|
||||
join_group(legs, "legs", root)
|
||||
stamp(root, name, "debris")
|
||||
root["mass_hint"] = 45.0
|
||||
# SPRINT14 audit — the trampoline is UNPRICED, and that is a statement, not
|
||||
# an omission. It carries no anchor node (nothing here is a tie-off: a rim
|
||||
# on six unpegged legs is the least trustworthy steel in any yard), and
|
||||
# nothing in the runtime spawns it yet, so no player-visible event can
|
||||
# destroy it. Same ruling as the bike: billing collateral for a thing the
|
||||
# player never sees break is the lie the invoice exists to kill. If Lane C
|
||||
# ever throws one, price it THEN — the number is easy, the event is the
|
||||
# hard part.
|
||||
root["priced"] = False
|
||||
root["unpriced_why"] = "no anchor, and nothing in the sim can wreck it yet"
|
||||
return root
|
||||
|
||||
|
||||
@ -1784,6 +2208,9 @@ def build_broom_01(name):
|
||||
|
||||
g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12)
|
||||
g["carry_type"] = "broom"
|
||||
not_a_tie_off(g, "where the player's hand takes the broom",
|
||||
"a carry point on a 1.2 kg tool; it is the thing that blows "
|
||||
"away, not the thing that holds")
|
||||
p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12)
|
||||
p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth"
|
||||
stamp(root, name, "tool")
|
||||
@ -2407,6 +2834,13 @@ ASSETS = [
|
||||
dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)),
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02",
|
||||
"branch_anchor_01", "branch_anchor_02"]),
|
||||
# The second species (SPRINT14). Broader than it is tall — if x/y ever
|
||||
# measure under the height, someone has quietly turned it back into a gum.
|
||||
dict(name="tree_jacaranda_01", fn=build_tree_jacaranda_01,
|
||||
dims=((7.0, 8.2), (6.1, 7.2), (5.7, 6.5)),
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
|
||||
"canopy_04", "branch_anchor_01", "branch_anchor_02",
|
||||
"branch_anchor_03"]),
|
||||
dict(name="fence_post", fn=build_fence_post,
|
||||
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),
|
||||
nodes=["post"]),
|
||||
@ -2441,6 +2875,18 @@ ASSETS = [
|
||||
dict(name="carport_01_wrecked", fn=build_carport_01_wrecked,
|
||||
dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)),
|
||||
nodes=["footings", "posts", "beams", "roof_down"]),
|
||||
# The temptation prop (SPRINT14). Spans the crossbar on x, splays on y,
|
||||
# and stands a shade over head height — the three numbers that make the
|
||||
# rail look like an anchor.
|
||||
dict(name="swing_set_01", fn=build_swing_set_01,
|
||||
dims=((2.30, 2.45), (0.85, 1.05), (2.00, 2.15)),
|
||||
nodes=["frame", "crossbar", "swings",
|
||||
"frame_anchor_01", "frame_anchor_02"]),
|
||||
# Over on its side: it reaches FURTHER on y than it ever stood on z. A
|
||||
# wreck that still measures ~2.05 tall is a wreck that never fell.
|
||||
dict(name="swing_set_01_wrecked", fn=build_swing_set_01_wrecked,
|
||||
dims=((2.30, 2.45), (2.30, 3.20), (0.80, 1.05)),
|
||||
nodes=["frame", "crossbar", "swings"]),
|
||||
dict(name="shed_01", fn=build_shed_01,
|
||||
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
|
||||
nodes=["shell", "roof", "doors", "door_anchor"]),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.9 MiB |
227
web/world/editor.html
Normal file
227
web/world/editor.html
Normal file
@ -0,0 +1,227 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HARD YARDS — yard editor</title>
|
||||
<!--
|
||||
The yard editor. Lane A owns this file (SPRINT14 gate 1).
|
||||
|
||||
It lives in web/ and therefore DEPLOYS with the game, and that is intended:
|
||||
site JSON carries no secrets, and a public "build a yard" toy is a feature.
|
||||
Nothing here is loaded by index.html — the game never imports editor.js.
|
||||
|
||||
── THE DOM SEAM CONTRACT (Lane B's SCORE IT, Lane C's wind authoring) ────────
|
||||
B and C build INTO this page. Everything they need is below and in editor.js;
|
||||
the same way E's invoice kit got .letterhead/.brief/.jobsheet in SPRINT12,
|
||||
this page publishes classes and mount points so three lanes can edit one UI
|
||||
without editing one file.
|
||||
|
||||
MOUNT: never append to #ed-side by hand. Call
|
||||
|
||||
const { root, body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 });
|
||||
|
||||
and fill `body`. `order` fixes the rail's vertical order across lanes so two
|
||||
lanes landing in the same sprint can't fight over it. Reserved orders:
|
||||
10 site 20 palette 30 inspector 40 SCORE IT (B) 50 wind (C)
|
||||
80 validation 90 export
|
||||
Calling mountPanel twice with one id returns the SAME panel (idempotent), so a
|
||||
re-render is a re-fill, not a duplicate.
|
||||
|
||||
CLASSES (the contract — style with these, don't invent):
|
||||
.ed-panel .ed-panel-title .ed-panel-body panel chrome (mountPanel makes these)
|
||||
.ed-row one label+control line
|
||||
.ed-label the label half of a row
|
||||
.ed-btn .ed-btn.primary .ed-btn.danger buttons
|
||||
.ed-num .ed-sel .ed-text number / select / text+textarea
|
||||
.ed-card .ed-card-head .ed-card-row .ed-kv a RESULT card (B's score goes here)
|
||||
.ed-ok .ed-warn .ed-err verdict colouring, on any element
|
||||
.ed-note muted small print
|
||||
.ed-tag inline pill (a flag, a count)
|
||||
Anything you need that isn't here: add it, and say so in THREADS — a class
|
||||
that only one lane knows about is the thing this contract exists to prevent.
|
||||
|
||||
The scene seams (three.js side) are on the EDITOR object — see editor.js's
|
||||
header for the full API. The short version C needs:
|
||||
EDITOR.overlay a THREE.Group, cleared on every rebuild — gizmos live here
|
||||
EDITOR.raycastGround(ev) → {x,y,z}|null
|
||||
EDITOR.registerTool({...}) / EDITOR.setTool(id)
|
||||
EDITOR.site / EDITOR.markDirty() read+mutate the live site, then say so
|
||||
EDITOR.on('change'|'rebuild'|'select', fn)
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #10161b;
|
||||
--panel: #172129;
|
||||
--panel2: #1d2831;
|
||||
--line: #2b3a45;
|
||||
--ink: #dde5ea;
|
||||
--dim: #8598a5;
|
||||
--accent: #7ee0ff;
|
||||
--ok: #7fce6a;
|
||||
--warn: #ffc46b;
|
||||
--err: #ff6b6b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; height: 100%; overflow: hidden;
|
||||
background: var(--bg); color: var(--ink);
|
||||
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
#ed-root { display: flex; height: 100%; }
|
||||
|
||||
/* --- viewport ---------------------------------------------------------- */
|
||||
#ed-viewport { position: relative; flex: 1 1 auto; min-width: 0; }
|
||||
#ed-canvas { display: block; width: 100%; height: 100%; }
|
||||
#ed-hint {
|
||||
position: absolute; left: 10px; bottom: 10px; margin: 0;
|
||||
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
|
||||
opacity: .85;
|
||||
}
|
||||
#ed-readout {
|
||||
position: absolute; right: 10px; top: 10px; margin: 0; text-align: right;
|
||||
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
|
||||
}
|
||||
#ed-toolbar {
|
||||
position: absolute; left: 10px; top: 10px; display: flex; gap: 6px; flex-wrap: wrap;
|
||||
max-width: calc(100% - 220px);
|
||||
}
|
||||
|
||||
/* --- side rail --------------------------------------------------------- */
|
||||
#ed-side {
|
||||
flex: 0 0 340px; height: 100%; overflow-y: auto; overflow-x: hidden;
|
||||
background: var(--panel); border-left: 1px solid var(--line);
|
||||
}
|
||||
#ed-side::-webkit-scrollbar { width: 9px; }
|
||||
#ed-side::-webkit-scrollbar-thumb { background: #2f3f4b; border-radius: 5px; }
|
||||
|
||||
/* --- the class contract ------------------------------------------------ */
|
||||
.ed-panel { border-bottom: 1px solid var(--line); }
|
||||
.ed-panel-title {
|
||||
margin: 0; padding: 9px 12px; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: .14em; color: var(--accent); background: var(--panel2);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.ed-panel-title::after { content: '▾'; color: var(--dim); font-size: 10px; }
|
||||
.ed-panel.collapsed .ed-panel-title::after { content: '▸'; }
|
||||
.ed-panel.collapsed .ed-panel-body { display: none; }
|
||||
.ed-panel-body { padding: 10px 12px 12px; }
|
||||
|
||||
.ed-row { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; }
|
||||
.ed-row:last-child { margin-bottom: 0; }
|
||||
.ed-label { flex: 0 0 84px; color: var(--dim); }
|
||||
.ed-row > .ed-num, .ed-row > .ed-sel, .ed-row > .ed-text { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
.ed-btn {
|
||||
font: inherit; padding: 4px 9px; border-radius: 4px; cursor: pointer;
|
||||
background: #24313b; color: var(--ink); border: 1px solid var(--line);
|
||||
}
|
||||
.ed-btn:hover { background: #2d3d49; }
|
||||
.ed-btn:active { transform: translateY(1px); }
|
||||
.ed-btn.on { background: #1d4a5c; border-color: #3d7f96; color: #cdf3ff; }
|
||||
.ed-btn.primary { background: #1f5a34; border-color: #2f7d49; color: #d6ffdf; }
|
||||
.ed-btn.primary:hover { background: #266c3e; }
|
||||
.ed-btn.danger { background: #58211f; border-color: #7d2b2b; color: #ffd9d6; }
|
||||
.ed-btn[disabled] { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.ed-num, .ed-sel, .ed-text {
|
||||
font: inherit; padding: 3px 6px; border-radius: 4px;
|
||||
background: #0e1418; color: var(--ink); border: 1px solid var(--line);
|
||||
}
|
||||
.ed-num { width: 74px; }
|
||||
textarea.ed-text { width: 100%; resize: vertical; min-height: 62px; line-height: 1.5; }
|
||||
|
||||
.ed-card {
|
||||
border: 1px solid var(--line); border-radius: 5px; background: var(--panel2);
|
||||
padding: 8px 10px; margin: 0 0 8px;
|
||||
}
|
||||
.ed-card-head {
|
||||
font-weight: 700; letter-spacing: .06em; margin: 0 0 6px;
|
||||
display: flex; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.ed-card-row { display: flex; justify-content: space-between; gap: 10px; padding: 1px 0; }
|
||||
.ed-kv { color: var(--dim); }
|
||||
|
||||
.ed-ok { color: var(--ok); }
|
||||
.ed-warn { color: var(--warn); }
|
||||
.ed-err { color: var(--err); }
|
||||
.ed-note { color: var(--dim); font-size: 11px; line-height: 1.5; }
|
||||
.ed-tag {
|
||||
display: inline-block; padding: 0 5px; border-radius: 3px; font-size: 10px;
|
||||
background: #24313b; border: 1px solid var(--line); color: var(--dim);
|
||||
}
|
||||
.ed-tag.ed-err { background: #3a1618; border-color: #7d2b2b; }
|
||||
.ed-tag.ed-ok { background: #12321c; border-color: #2c6b3c; }
|
||||
|
||||
ul.ed-list { margin: 0; padding: 0; list-style: none; }
|
||||
ul.ed-list li {
|
||||
padding: 3px 6px; border-radius: 3px; cursor: pointer;
|
||||
display: flex; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
ul.ed-list li:hover { background: #22303a; }
|
||||
ul.ed-list li.sel { background: #1d4a5c; color: #cdf3ff; }
|
||||
|
||||
#ed-boot-error {
|
||||
position: absolute; inset: 0; display: none; padding: 28px;
|
||||
background: #10161b; color: var(--err); white-space: pre-wrap; overflow: auto;
|
||||
}
|
||||
</style>
|
||||
<!--
|
||||
Required. world.js's dress() pulls GLTFLoader, which imports the BARE
|
||||
specifier 'three'. Without this map the throw is swallowed by dress()'s
|
||||
per-asset try/catch and every rating_hint silently becomes 1.0 — the
|
||||
invincible-house gotcha in docs/MANUAL.md. It has cost this repo a day; do
|
||||
not remove it from any page that dresses a yard.
|
||||
-->
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ed-root">
|
||||
<div id="ed-viewport">
|
||||
<canvas id="ed-canvas"></canvas>
|
||||
<div id="ed-toolbar"></div>
|
||||
<pre id="ed-readout"></pre>
|
||||
<pre id="ed-hint"></pre>
|
||||
<div id="ed-boot-error"></div>
|
||||
</div>
|
||||
<div id="ed-side"></div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { createEditor } from './js/editor.js';
|
||||
|
||||
try {
|
||||
const EDITOR = await createEditor({
|
||||
canvas: document.getElementById('ed-canvas'),
|
||||
side: document.getElementById('ed-side'),
|
||||
toolbar: document.getElementById('ed-toolbar'),
|
||||
readout: document.getElementById('ed-readout'),
|
||||
hint: document.getElementById('ed-hint'),
|
||||
});
|
||||
|
||||
// The two lane panels, mounted AFTER createEditor resolves because both need
|
||||
// a live EDITOR — B's reads `globalThis.EDITOR` on import (self-registering,
|
||||
// nothing to call), C's exports a mount function. Landed by the integrator at
|
||||
// SPRINT14 integration: both lanes built against A's published seams and
|
||||
// asked for this one line each; A had finished by then, so it's mine. Inside
|
||||
// the try on purpose — a panel that throws is an editor that says so on the
|
||||
// glass rather than a rail with a hole in it.
|
||||
await import('./js/editor_score.js');
|
||||
const { mountWindPanel } = await import('./js/editor.wind.js');
|
||||
await mountWindPanel(EDITOR);
|
||||
} catch (err) {
|
||||
// The editor is a tool, so it fails LOUD and on the glass. index.html's boot
|
||||
// failure writes into a hidden #dev div and shows a stranger a blank page
|
||||
// (SPRINT14 pool item) — this page does not repeat that.
|
||||
const box = document.getElementById('ed-boot-error');
|
||||
box.style.display = 'block';
|
||||
box.textContent = `EDITOR BOOT FAILED\n\n${err && err.stack ? err.stack : err}`;
|
||||
throw err;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -22,6 +22,21 @@
|
||||
#dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; }
|
||||
#dev.on { display: block; }
|
||||
#help { bottom: 10px; opacity: .75; }
|
||||
/* SPRINT14 pool — the boot failure used to be written into #dev, which is
|
||||
display:none off localhost. So on partly.party a failed boot showed a
|
||||
stranger a blank blue page and put the only explanation on a console they
|
||||
will never open. A fatal error is not dev chatter: it is the one message
|
||||
that must reach the person who came to play, so it gets its own element
|
||||
that is visible by default and reads like a sentence rather than a
|
||||
readout. */
|
||||
#fatal {
|
||||
position: fixed; inset: 0; display: none; place-content: center;
|
||||
padding: 32px; text-align: center; background: #10161b; color: #ffd9d6;
|
||||
font: 15px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
#fatal.on { display: grid; }
|
||||
#fatal b { display: block; font-size: 13px; letter-spacing: .16em; color: #ff8f86; margin-bottom: 12px; }
|
||||
#fatal span { display: block; font-size: 12px; opacity: .7; margin-top: 14px; }
|
||||
#banner {
|
||||
position: fixed; top: 38%; left: 0; right: 0;
|
||||
text-align: center; pointer-events: none; user-select: none;
|
||||
@ -35,6 +50,7 @@
|
||||
<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>
|
||||
|
||||
@ -74,9 +74,16 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's
|
||||
* `rating_hint`. Keep it that way — a rule keyed on a type a future site
|
||||
* doesn't fit is the exact shape of the bug this widening fixed.
|
||||
*
|
||||
* `swing_frame` (SPRINT14, E): a swing set's apex junction. It is NOT a `post`
|
||||
* and the difference is the whole reason it got its own word — the type string
|
||||
* is what the player reads before they commit, and "post" promises 4 m of
|
||||
* concreted steel. This is a welded joint on a frame standing on grass: good
|
||||
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
|
||||
* also nearly smuggled in as a post.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post',
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
1374
web/world/js/editor.js
Normal file
1374
web/world/js/editor.js
Normal file
File diff suppressed because it is too large
Load Diff
787
web/world/js/editor.wind.js
Normal file
787
web/world/js/editor.wind.js
Normal file
@ -0,0 +1,787 @@
|
||||
/**
|
||||
* SHADES / HARD YARDS — WIND AUTHORING for the yard editor. Lane C owns this
|
||||
* file (SPRINT14 gate 2.2). It builds INTO Lane A's editor page through the
|
||||
* seams A published in `editor.html` / `editor.js`; it does not own a pixel of
|
||||
* either, and it must never grow a private harness on that page.
|
||||
*
|
||||
* WHAT IT ADDS
|
||||
* · venturi authoring — drag the throat, drag either end of the axis, gain /
|
||||
* radius / sharp on sliders, with the gizmo redrawn live
|
||||
* · tree-shelter volumes — what a tree actually protects, at the storm second
|
||||
* you are looking at
|
||||
* · a wind-field overlay — `speedAt` sampled across the yard at a chosen
|
||||
* storm + time, so you SEE where the funnel screams before D has to feel it
|
||||
*
|
||||
* ── THE FIVE THINGS THIS FILE IS CAREFUL ABOUT ─────────────────────────────
|
||||
*
|
||||
* 1. **`windForSite()` IS THE ONLY WIND THIS FILE BUILDS.** One sprint old and
|
||||
* already load-bearing: three harnesses (B's site_audit, my own
|
||||
* garden_bench, probe4) independently measured a yard with the funnel
|
||||
* switched OFF, because `def.wind.venturi` off a STORM def looks right and
|
||||
* never fires. The venturi lives in the SITE. There is exactly one call to
|
||||
* one builder in here (`buildWind`), it takes the site and the live anchors,
|
||||
* and no other function in this file is allowed to make a wind. If you are
|
||||
* reading this because you want a wind for something new — call `buildWind`.
|
||||
*
|
||||
* 2. **The gizmos are drawn from what the WIND flies, not from what the JSON
|
||||
* says.** `wind.core.venturi` and `wind.core.shelters` are read back after
|
||||
* the build, so the arrow you see is the funnel the sim has, resolved
|
||||
* defaults and all. Drawing from `site.wind.venturi` instead would render a
|
||||
* funnel that is merely authored — and a gizmo that agrees with the JSON
|
||||
* while disagreeing with the sim is worse than no gizmo, because it is
|
||||
* evidence. This is also why a mis-wired `setVenturi` shows up here as a
|
||||
* funnel that vanishes, rather than as a number nobody looks at.
|
||||
*
|
||||
* 3. **An axis is a LINE, not a heading, and the UI teaches that.** The venturi
|
||||
* aligns on `Math.abs(dot(wind, axis))` — a gap funnels either way through
|
||||
* it — so the axis is only defined mod π. A and I proved this from opposite
|
||||
* sides in Sprint 11 and it cost two exchanges, so: the axis draws as one
|
||||
* line through the throat with an identical handle at BOTH ends, grabbing
|
||||
* either end rotates the same line, and the value normalises into [0, π).
|
||||
* The readout says `120° ≡ 300°` for as long as anyone needs to believe it.
|
||||
* There is no arrowhead anywhere on the axis, on purpose.
|
||||
*
|
||||
* 4. **Neither the venturi nor the shelter maths has a vertical term.** Both
|
||||
* are functions of (x, z) only — a tree's shadow and a gap's funnel apply at
|
||||
* every height. The shelter VOLUME is therefore a reading aid drawn to a
|
||||
* nominal canopy height, and the panel says so. Drawing a finite volume
|
||||
* without saying that would be the gizmo quietly inventing physics.
|
||||
*
|
||||
* 5. **Staleness is A's, by construction.** Every object I make is parented to
|
||||
* `EDITOR.overlay`, which the editor CLEARS on every rebuild — A's "a stale
|
||||
* arrow over a moved venturi is the wind cousin of the phantom sail, and one
|
||||
* lane clearing beats two lanes remembering". `overlay.clear()` detaches but
|
||||
* does not free GPU buffers, so I keep my own disposal list and empty it at
|
||||
* the top of every redraw; that is my half of the deal, not a second
|
||||
* lifetime model.
|
||||
*
|
||||
* DETERMINISM: everything sampled here is a pure function of (x, z, t) and the
|
||||
* storm seed — `speedAt`/`dirAt` have no internal clock and no RNG at call
|
||||
* time, so the same storm at the same second draws the same field on any
|
||||
* machine. Nothing in this file feeds a score; B's SCORE IT and the game read
|
||||
* the same `windForSite`, and gate 2.3 pins them equal at a probe point.
|
||||
*
|
||||
* LOADING: this module self-registers off `globalThis.EDITOR` when imported.
|
||||
* `editor.html` needs one line after `createEditor()` resolves — asked for in
|
||||
* THREADS rather than added here, because that page is A's.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { smoothstep } from './weather.core.js';
|
||||
import { loadStorm, windForSite } from './weather.js';
|
||||
|
||||
/** Keep in step with data/storms/ — same list the game's STORMS carries. */
|
||||
export const STORM_KEYS = [
|
||||
'storm_01_gentle',
|
||||
'storm_02_wildnight',
|
||||
'storm_02b_icenight',
|
||||
'storm_03_southerly',
|
||||
'storm_03b_earlybuster',
|
||||
];
|
||||
|
||||
/** The southerly is the storm that makes a corner block scream — a sensible
|
||||
* place for an author to land, because a funnel authored under the gentle
|
||||
* storm looks like it does nothing. */
|
||||
const DEFAULT_STORM = 'storm_03_southerly';
|
||||
|
||||
/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
|
||||
const CANOPY_Y = 3.2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers — exported so c.test.js can assert them without a DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fold an angle into [0, π). THE mod-π rule, in one place.
|
||||
*
|
||||
* A venturi axis is a line through a gap. `axis` and `axis + π` are the same
|
||||
* gap read from opposite ends — weather.core aligns on `|dot|` precisely so
|
||||
* that a gap funnels either way through it. Normalising here is what makes
|
||||
* "drag either end of the line" produce one stable number instead of two that
|
||||
* flip-flop by π depending on which handle the author happened to grab.
|
||||
*/
|
||||
export function normalizeAxis(rad) {
|
||||
const a = rad % Math.PI;
|
||||
return a < 0 ? a + Math.PI : a;
|
||||
}
|
||||
|
||||
/** Degrees, folded the same way — for the readout. */
|
||||
export function axisDeg(rad) {
|
||||
return normalizeAxis(rad) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fraction of wind speed a shelter REMOVES at a point, in the shelter's own
|
||||
* (along, perp) frame. Mirrors `shelterFactor` in weather.core.js, which
|
||||
* returns `1 - this`, and shares its `smoothstep` rather than easing by eye.
|
||||
*
|
||||
* This is a mirror and mirrors drift, so c.test.js measures it against the real
|
||||
* field through `speedAt` and fails if the two ever disagree. Documentation
|
||||
* cannot fail; an assert can.
|
||||
*
|
||||
* @param {{radius:number,length:number,strength:number}} s
|
||||
* @param {number} along metres DOWNWIND of the tree (>0 is in the shadow)
|
||||
* @param {number} perp metres to either side of the shadow's centreline
|
||||
*/
|
||||
export function shelterAtten(s, along, perp) {
|
||||
if (along <= 0 || along >= s.length) return 0;
|
||||
const ap = Math.abs(perp);
|
||||
if (ap >= s.radius) return 0;
|
||||
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
|
||||
const fPerp = 1 - smoothstep(0, s.radius, ap);
|
||||
return s.strength * fAlong * fPerp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the wind across a yard on a grid. Pure: same wind + same args = same
|
||||
* array, so the overlay is reproducible and the numbers are assertable.
|
||||
*
|
||||
* Returns absolute speed AND the amplification ratio against the storm's
|
||||
* uniform speed at that second, because those answer different authoring
|
||||
* questions: "is this corner survivable" is absolute, "is my funnel doing
|
||||
* anything" is a ratio. The overlay colours by ratio and lengths by speed.
|
||||
*
|
||||
* @returns {{samples:Array<{x:number,z:number,speed:number,ratio:number}>,
|
||||
* dir:number, uniform:number, max:{speed:number,x:number,z:number},
|
||||
* min:{speed:number,x:number,z:number}}}
|
||||
*/
|
||||
export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) {
|
||||
const samples = [];
|
||||
const uniform = wind.core.uniformSpeed(t);
|
||||
const dir = wind.core.dirAt(t);
|
||||
let max = { speed: -Infinity, x: 0, z: 0 };
|
||||
let min = { speed: Infinity, x: 0, z: 0 };
|
||||
const nx = Math.max(2, Math.round(width / step));
|
||||
const nz = Math.max(2, Math.round(depth / step));
|
||||
for (let i = 0; i < nx; i++) {
|
||||
for (let j = 0; j < nz; j++) {
|
||||
const x = -width / 2 + (i + 0.5) * (width / nx);
|
||||
const z = -depth / 2 + (j + 0.5) * (depth / nz);
|
||||
const speed = wind.core.speedAt(x, z, t);
|
||||
const ratio = uniform > 1e-6 ? speed / uniform : 1;
|
||||
samples.push({ x, z, speed, ratio });
|
||||
if (speed > max.speed) max = { speed, x, z };
|
||||
if (speed < min.speed) min = { speed, x, z };
|
||||
}
|
||||
}
|
||||
return { samples, dir, uniform, max, min };
|
||||
}
|
||||
|
||||
/** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */
|
||||
function ratioColor(ratio, out) {
|
||||
// 0.55 …… 1.0 …… 1.6 mapped blue → pale → red. The neutral band is
|
||||
// deliberately pale rather than green: an author should read "nothing
|
||||
// happening here" as absence, and see only the two things a site DOES.
|
||||
const k = Math.max(0, Math.min(1, (ratio - 0.55) / (1.6 - 0.55)));
|
||||
const mid = 0.43; // where ratio == 1 lands on k
|
||||
if (k < mid) {
|
||||
const u = k / mid;
|
||||
return out.setRGB(0.20 + 0.65 * u, 0.45 + 0.45 * u, 0.85 + 0.10 * u);
|
||||
}
|
||||
const u = (k - mid) / (1 - mid);
|
||||
return out.setRGB(0.85 + 0.15 * u, 0.90 - 0.72 * u, 0.95 - 0.80 * u);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function mountWindPanel(EDITOR) {
|
||||
const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 });
|
||||
|
||||
// --- state -------------------------------------------------------------
|
||||
const storms = {};
|
||||
let stormKey = DEFAULT_STORM;
|
||||
let time = 40;
|
||||
let showField = true;
|
||||
let showShelters = true;
|
||||
let gridStep = 1.5;
|
||||
let wind = null;
|
||||
let selVenturi = 0;
|
||||
|
||||
/** Everything I ever allocate on the GPU, so redraw can free it. See (5). */
|
||||
const mine = [];
|
||||
|
||||
// --- the ONE wind builder ----------------------------------------------
|
||||
/**
|
||||
* The site's wind for the picked storm. THE only wind this file makes — see
|
||||
* (1). Anchors come from the LIVE world so tree shelters are the ones the
|
||||
* game would fly, not ones re-derived from the JSON by hand.
|
||||
*/
|
||||
function buildWind() {
|
||||
const def = storms[stormKey];
|
||||
if (!def) return null;
|
||||
return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []);
|
||||
}
|
||||
|
||||
function refreshWind() {
|
||||
wind = buildWind();
|
||||
if (wind && time > wind.duration) time = wind.duration;
|
||||
}
|
||||
|
||||
// --- gizmo drawing ------------------------------------------------------
|
||||
|
||||
function freeMine() {
|
||||
for (const o of mine) {
|
||||
o.geometry?.dispose?.();
|
||||
if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
|
||||
else o.material?.dispose?.();
|
||||
}
|
||||
mine.length = 0;
|
||||
}
|
||||
|
||||
function add(obj) {
|
||||
mine.push(obj);
|
||||
EDITOR.overlay.add(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
const groundY = (x, z) => EDITOR.HEIGHT_AT(x, z);
|
||||
|
||||
function lineSegs(points, color, opacity = 1, width = 1) {
|
||||
const g = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const m = new THREE.LineBasicMaterial({
|
||||
color, transparent: opacity < 1, opacity, depthTest: true, linewidth: width,
|
||||
});
|
||||
return add(new THREE.LineSegments(g, m));
|
||||
}
|
||||
|
||||
/** A flat ring on the ground — throat reach, throat core. */
|
||||
function ringAt(x, z, r, color, opacity) {
|
||||
const pts = [];
|
||||
const N = 64;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a0 = (i / N) * Math.PI * 2;
|
||||
const a1 = ((i + 1) / N) * Math.PI * 2;
|
||||
const p0x = x + Math.cos(a0) * r, p0z = z + Math.sin(a0) * r;
|
||||
const p1x = x + Math.cos(a1) * r, p1z = z + Math.sin(a1) * r;
|
||||
pts.push(new THREE.Vector3(p0x, groundY(p0x, p0z) + 0.07, p0z));
|
||||
pts.push(new THREE.Vector3(p1x, groundY(p1x, p1z) + 0.07, p1z));
|
||||
}
|
||||
return lineSegs(pts, color, opacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* The venturi gizmo. The axis is ONE LINE through the throat, drawn to the
|
||||
* same length in both directions with an identical square handle at each end
|
||||
* — see (3). No arrowhead: an arrow would say "the wind goes this way", and
|
||||
* the wind goes whichever way the STORM sends it; this line is the gap's
|
||||
* geometry, which is the site's business and does not move when the wind does.
|
||||
*/
|
||||
function drawVenturi(v, i, isSel) {
|
||||
const { x, z } = v;
|
||||
const axis = Math.atan2(v.axisZ, v.axisX);
|
||||
const L = v.radius * 1.45;
|
||||
const ax = Math.cos(axis), az = Math.sin(axis);
|
||||
const hot = isSel ? 0xffd166 : 0xff9f43;
|
||||
|
||||
// reach + core rings (radial falloff is full inside 0.4r, gone at r)
|
||||
ringAt(x, z, v.radius, hot, isSel ? 0.85 : 0.45);
|
||||
ringAt(x, z, v.radius * 0.4, hot, isSel ? 0.55 : 0.28);
|
||||
|
||||
// the axis line, both ways from the throat
|
||||
const pts = [];
|
||||
const SEG = 24;
|
||||
for (let s = 0; s < SEG; s++) {
|
||||
const u0 = -1 + (2 * s) / SEG, u1 = -1 + (2 * (s + 1)) / SEG;
|
||||
const x0 = x + ax * L * u0, z0 = z + az * L * u0;
|
||||
const x1 = x + ax * L * u1, z1 = z + az * L * u1;
|
||||
pts.push(new THREE.Vector3(x0, groundY(x0, z0) + 0.1, z0));
|
||||
pts.push(new THREE.Vector3(x1, groundY(x1, z1) + 0.1, z1));
|
||||
}
|
||||
lineSegs(pts, hot, 1);
|
||||
|
||||
// a mast at the throat so the funnel is findable in a 3D orbit
|
||||
lineSegs([
|
||||
new THREE.Vector3(x, groundY(x, z) + 0.05, z),
|
||||
new THREE.Vector3(x, groundY(x, z) + 2.2, z),
|
||||
], hot, 0.7);
|
||||
|
||||
// identical handles at BOTH ends — the mod-π teaching, in geometry
|
||||
for (const sgn of [-1, 1]) {
|
||||
const hx = x + ax * L * sgn, hz = z + az * L * sgn;
|
||||
const g = new THREE.SphereGeometry(0.34, 12, 8);
|
||||
const m = new THREE.MeshBasicMaterial({ color: hot, transparent: true, opacity: 0.95 });
|
||||
const s = new THREE.Mesh(g, m);
|
||||
s.position.set(hx, groundY(hx, hz) + 0.1, hz);
|
||||
add(s);
|
||||
}
|
||||
// the throat handle, visually distinct (you drag it to move the gap)
|
||||
{
|
||||
const g = new THREE.SphereGeometry(0.42, 14, 10);
|
||||
const m = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: isSel ? 0.95 : 0.5 });
|
||||
const s = new THREE.Mesh(g, m);
|
||||
s.position.set(x, groundY(x, z) + 0.1, z);
|
||||
add(s);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tree's shelter, as a translucent volume. Built in the shelter's own
|
||||
* (along, perp) frame and rotated downwind of the CURRENT storm second — the
|
||||
* shadow points where the wind is going, so scrubbing time swings it, which
|
||||
* is the honest picture: a tree shelters a different patch of yard at 20 s
|
||||
* than at 60 s.
|
||||
*
|
||||
* Alpha carries the strength (there is no hard edge in the maths, so there is
|
||||
* no hard edge here). Three stacked layers at equal alpha, because the maths
|
||||
* has NO vertical term — see (4). The height is a reading aid; the panel says
|
||||
* so in words as well.
|
||||
*/
|
||||
function drawShelter(s, dir) {
|
||||
const dx = Math.cos(dir), dz = Math.sin(dir);
|
||||
const NA = 22, NP = 12;
|
||||
const layers = [0.12, 1.25, CANOPY_Y];
|
||||
for (const y of layers) {
|
||||
const pos = [];
|
||||
const col = [];
|
||||
const idx = [];
|
||||
for (let ia = 0; ia <= NA; ia++) {
|
||||
const along = (ia / NA) * s.length;
|
||||
for (let ip = 0; ip <= NP; ip++) {
|
||||
const perp = -s.radius + (ip / NP) * (2 * s.radius);
|
||||
const wx = s.x + dx * along - dz * perp;
|
||||
const wz = s.z + dz * along + dx * perp;
|
||||
pos.push(wx, groundY(wx, wz) + y, wz);
|
||||
const a = shelterAtten(s, along, perp);
|
||||
// colour is constant; the ALPHA is the physics. Vertex alpha needs a
|
||||
// 4-component colour attribute and a material that reads it.
|
||||
//
|
||||
// DARK cool blue, so the volume DARKENS the grass. Two earlier cuts
|
||||
// were light blue (0.42,0.72,1.0 at 0.75, then 0.24,0.48,0.95 at 0.38)
|
||||
// and both read as a floodlight on the lawn no matter how far the
|
||||
// alpha came down — light over grass is light. A wind SHADOW that
|
||||
// glows is a gizmo arguing against its own name, so the fix was the
|
||||
// colour, not the opacity. Verified by looking, twice.
|
||||
col.push(0.05, 0.11, 0.28, a * 0.5);
|
||||
}
|
||||
}
|
||||
for (let ia = 0; ia < NA; ia++) {
|
||||
for (let ip = 0; ip < NP; ip++) {
|
||||
const a = ia * (NP + 1) + ip;
|
||||
const b = a + (NP + 1);
|
||||
idx.push(a, b, a + 1, b, b + 1, a + 1);
|
||||
}
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.setAttribute('color', new THREE.Float32BufferAttribute(col, 4));
|
||||
g.setIndex(idx);
|
||||
const m = new THREE.MeshBasicMaterial({
|
||||
vertexColors: true, transparent: true, side: THREE.DoubleSide,
|
||||
depthWrite: false, blending: THREE.NormalBlending,
|
||||
});
|
||||
const mesh = new THREE.Mesh(g, m);
|
||||
// The volume is CONTEXT for the arrows, so it draws under them. Two of the
|
||||
// three layers sit above arrow height (0.55 m), so without this the shadow
|
||||
// paints over the very samples that prove it.
|
||||
mesh.renderOrder = 1;
|
||||
add(mesh);
|
||||
}
|
||||
// the trunk tick, so you can tell which tree owns which shadow
|
||||
lineSegs([
|
||||
new THREE.Vector3(s.x, groundY(s.x, s.z) + 0.05, s.z),
|
||||
new THREE.Vector3(s.x, groundY(s.x, s.z) + CANOPY_Y, s.z),
|
||||
], 0x6bb8ff, 0.6);
|
||||
}
|
||||
|
||||
/**
|
||||
* The wind field: one arrow per grid cell. All arrows are PARALLEL and that
|
||||
* is not a simplification — `vecAt` takes the direction from `dirAt(t)`
|
||||
* alone, so the yard's local effects change the SPEED of the wind and never
|
||||
* its heading. Length reads absolute speed, colour reads amplification.
|
||||
* One LineSegments for the lot: 250-ish arrows as 250 Object3Ds would cost
|
||||
* more than the whole rest of this panel.
|
||||
*/
|
||||
function drawField(field) {
|
||||
const dx = Math.cos(field.dir), dz = Math.sin(field.dir);
|
||||
const pos = [];
|
||||
const col = [];
|
||||
const c = new THREE.Color();
|
||||
const ref = Math.max(1e-3, field.max.speed);
|
||||
const LEN = gridStep * 0.92;
|
||||
for (const sm of field.samples) {
|
||||
const len = LEN * Math.max(0.12, sm.speed / ref);
|
||||
const hx = sm.x + dx * len * 0.5, hz = sm.z + dz * len * 0.5; // head
|
||||
const tx = sm.x - dx * len * 0.5, tz = sm.z - dz * len * 0.5; // tail
|
||||
ratioColor(sm.ratio, c);
|
||||
const push = (x0, z0, x1, z1) => {
|
||||
pos.push(x0, groundY(x0, z0) + 0.55, z0, x1, groundY(x1, z1) + 0.55, z1);
|
||||
col.push(c.r, c.g, c.b, c.r, c.g, c.b);
|
||||
};
|
||||
push(tx, tz, hx, hz);
|
||||
// barbs — a heading needs a head; this one is the WIND, which really does
|
||||
// have a direction (unlike the axis above).
|
||||
const bl = len * 0.34;
|
||||
const ca = Math.cos(2.5), sa = Math.sin(2.5);
|
||||
push(hx, hz, hx + (dx * ca - dz * sa) * bl, hz + (dz * ca + dx * sa) * bl);
|
||||
push(hx, hz, hx + (dx * ca + dz * sa) * bl, hz + (dz * ca - dx * sa) * bl);
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
|
||||
const m = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.95 });
|
||||
const seg = new THREE.LineSegments(g, m);
|
||||
seg.renderOrder = 2; // over the shelter volumes — see drawShelter
|
||||
add(seg);
|
||||
}
|
||||
|
||||
/** Rebuild every gizmo from the current site + storm + second. */
|
||||
function redraw() {
|
||||
freeMine();
|
||||
if (!wind) return null;
|
||||
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
|
||||
const field = sampleWindField(wind, {
|
||||
width: yard.width, depth: yard.depth, step: gridStep, t: time,
|
||||
});
|
||||
if (showField) drawField(field);
|
||||
if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir);
|
||||
wind.core.venturi.forEach((v, i) => drawVenturi(v, i, i === selVenturi));
|
||||
return field;
|
||||
}
|
||||
|
||||
// --- the venturi tool ---------------------------------------------------
|
||||
/**
|
||||
* Registered through `EDITOR.registerTool` rather than listening on the
|
||||
* canvas, so my drag and A's select/drag never compete for the same click —
|
||||
* A's seam, used as offered.
|
||||
*/
|
||||
const HIT = 0.95; // metres; handles are ~0.4 m spheres
|
||||
|
||||
let drag = null;
|
||||
|
||||
function venturiList() {
|
||||
EDITOR.site.wind ??= {};
|
||||
EDITOR.site.wind.venturi ??= [];
|
||||
return EDITOR.site.wind.venturi;
|
||||
}
|
||||
|
||||
/** Which handle (if any) is under the cursor. Pure XZ distance — the gizmo is
|
||||
* ground-plane geometry, so a ground-plane test is the honest pick. */
|
||||
function hitHandle(gx, gz) {
|
||||
const list = venturiList();
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const v = list[i];
|
||||
const axis = normalizeAxis(v.axis ?? 0);
|
||||
const r = v.radius ?? 4;
|
||||
const L = r * 1.45;
|
||||
if (Math.hypot(gx - v.x, gz - v.z) < HIT) return { i, mode: 'throat' };
|
||||
for (const sgn of [-1, 1]) {
|
||||
const hx = v.x + Math.cos(axis) * L * sgn;
|
||||
const hz = v.z + Math.sin(axis) * L * sgn;
|
||||
if (Math.hypot(gx - hx, gz - hz) < HIT) return { i, mode: 'axis' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
EDITOR.registerTool({
|
||||
id: 'venturi',
|
||||
label: 'VENTURI',
|
||||
cursor: 'crosshair',
|
||||
onPointerDown(ev, ground) {
|
||||
if (!ground || ev.button !== 0 || ev.shiftKey) return false;
|
||||
const hit = hitHandle(ground.x, ground.z);
|
||||
if (hit) {
|
||||
drag = hit;
|
||||
selVenturi = hit.i;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
// empty ground under the venturi tool = author a new gap here
|
||||
const list = venturiList();
|
||||
list.push({ x: round2(ground.x), z: round2(ground.z), axis: 0, gain: 1.4, radius: 4, sharp: 3 });
|
||||
selVenturi = list.length - 1;
|
||||
drag = { i: selVenturi, mode: 'throat' };
|
||||
commit();
|
||||
return true;
|
||||
},
|
||||
onPointerMove(ev, ground) {
|
||||
if (!drag || !ground) return false;
|
||||
const v = venturiList()[drag.i];
|
||||
if (!v) { drag = null; return false; }
|
||||
if (drag.mode === 'throat') {
|
||||
v.x = round2(ground.x);
|
||||
v.z = round2(ground.z);
|
||||
} else {
|
||||
// Grab either end: both ends are the same line, so we fold into [0, π)
|
||||
// and the gizmo mirrors. THIS is the mod-π lesson as a gesture.
|
||||
v.axis = round3(normalizeAxis(Math.atan2(ground.z - v.z, ground.x - v.x)));
|
||||
}
|
||||
commit();
|
||||
return true;
|
||||
},
|
||||
onPointerUp() {
|
||||
if (!drag) return false;
|
||||
drag = null;
|
||||
commit();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
const round3 = (n) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/**
|
||||
* Site changed: tell the editor (it revalidates — `validateSiteWind` is
|
||||
* already inside A's `validateSite`, so a bad gain lands in A's panel and I
|
||||
* do NOT write a second wind validator), then rebuild my wind and gizmos.
|
||||
*
|
||||
* `markDirty()` deliberately, not `rebuild()`: a venturi edit changes no
|
||||
* geometry and no anchor, so re-dressing the world would be a 40-90 ms hitch
|
||||
* per slider tick for nothing.
|
||||
*/
|
||||
function commit() {
|
||||
// Just say the site changed. The 'change' listener rebuilds the wind and
|
||||
// redraws — see the note there for why the refresh lives at the LISTENER
|
||||
// and not here.
|
||||
EDITOR.markDirty();
|
||||
}
|
||||
|
||||
// --- DOM ----------------------------------------------------------------
|
||||
|
||||
const el = (tag, cls, txt) => {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (txt != null) n.textContent = txt;
|
||||
return n;
|
||||
};
|
||||
|
||||
function row(labelText, ...controls) {
|
||||
const r = el('div', 'ed-row');
|
||||
r.append(el('span', 'ed-label', labelText));
|
||||
r.append(...controls);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** A labelled slider that reads its own value. `.ed-num` per A's class kit —
|
||||
* a range is a numeric control and the contract says don't invent. */
|
||||
function slider(min, max, stepv, value, fmt, onInput) {
|
||||
const wrap = el('div', 'ed-row');
|
||||
const i = document.createElement('input');
|
||||
i.type = 'range';
|
||||
i.className = 'ed-num';
|
||||
i.min = min; i.max = max; i.step = stepv; i.value = value;
|
||||
i.style.flex = '1 1 auto';
|
||||
i.style.width = 'auto';
|
||||
const out = el('span', null, fmt(value));
|
||||
out.style.flex = '0 0 78px';
|
||||
out.style.textAlign = 'right';
|
||||
i.addEventListener('input', () => {
|
||||
const v = parseFloat(i.value);
|
||||
out.textContent = fmt(v);
|
||||
onInput(v);
|
||||
});
|
||||
wrap.append(i, out);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function render() {
|
||||
body.textContent = '';
|
||||
const field = redraw();
|
||||
|
||||
// --- storm + second ---
|
||||
const sel = el('select', 'ed-sel');
|
||||
for (const k of STORM_KEYS) {
|
||||
const o = el('option', null, k.replace(/^storm_/, ''));
|
||||
o.value = k;
|
||||
if (k === stormKey) o.selected = true;
|
||||
sel.append(o);
|
||||
}
|
||||
sel.addEventListener('change', async () => {
|
||||
stormKey = sel.value;
|
||||
await ensureStorm(stormKey);
|
||||
refreshWind();
|
||||
render();
|
||||
});
|
||||
body.append(row('storm', sel));
|
||||
|
||||
const dur = wind?.duration ?? 90;
|
||||
body.append(row('second', el('span', null, `${time.toFixed(1)} s / ${dur.toFixed(0)} s`)));
|
||||
body.append(slider(0, dur, 0.5, Math.min(time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
|
||||
time = v;
|
||||
render();
|
||||
}));
|
||||
|
||||
if (field) {
|
||||
const card = el('div', 'ed-card');
|
||||
const head = el('div', 'ed-card-head');
|
||||
head.append(el('span', null, 'FIELD'), el('span', 'ed-kv', `${axisDeg(field.dir).toFixed(0)}° axis of flow`));
|
||||
card.append(head);
|
||||
const kv = (k, v, cls) => {
|
||||
const r = el('div', 'ed-card-row');
|
||||
r.append(el('span', 'ed-kv', k), el('span', cls, v));
|
||||
card.append(r);
|
||||
};
|
||||
kv('uniform', `${field.uniform.toFixed(2)} m/s`);
|
||||
kv('fastest', `${field.max.speed.toFixed(2)} m/s`,
|
||||
field.max.speed > field.uniform * 1.15 ? 'ed-warn' : null);
|
||||
kv(' at', `${field.max.x.toFixed(1)}, ${field.max.z.toFixed(1)}`, 'ed-kv');
|
||||
kv('calmest', `${field.min.speed.toFixed(2)} m/s`);
|
||||
kv(' at', `${field.min.x.toFixed(1)}, ${field.min.z.toFixed(1)}`, 'ed-kv');
|
||||
const amp = field.uniform > 1e-6 ? field.max.speed / field.uniform : 1;
|
||||
kv('peak gain', `×${amp.toFixed(2)}`, amp > 1.15 ? 'ed-warn' : 'ed-kv');
|
||||
body.append(card);
|
||||
}
|
||||
|
||||
// --- overlays ---
|
||||
const bField = el('button', `ed-btn${showField ? ' on' : ''}`, 'FIELD');
|
||||
bField.addEventListener('click', () => { showField = !showField; render(); });
|
||||
const bShel = el('button', `ed-btn${showShelters ? ' on' : ''}`, 'SHELTERS');
|
||||
bShel.addEventListener('click', () => { showShelters = !showShelters; render(); });
|
||||
body.append(row('show', bField, bShel));
|
||||
|
||||
const gsel = el('select', 'ed-sel');
|
||||
for (const g of [1, 1.5, 2, 3]) {
|
||||
const o = el('option', null, `${g} m`);
|
||||
o.value = g;
|
||||
if (g === gridStep) o.selected = true;
|
||||
gsel.append(o);
|
||||
}
|
||||
gsel.addEventListener('change', () => { gridStep = parseFloat(gsel.value); render(); });
|
||||
body.append(row('grid', gsel));
|
||||
|
||||
body.append(el('p', 'ed-note',
|
||||
'Colour is amplification against the storm’s uniform speed — blue is sheltered, '
|
||||
+ 'red is funnelled, pale is untouched. Length is absolute speed. Every arrow is '
|
||||
+ 'parallel because a yard changes the wind’s SPEED, never its heading.'));
|
||||
|
||||
// --- venturi ---
|
||||
const list = venturiList();
|
||||
const bAdd = el('button', 'ed-btn primary', 'PLACE VENTURI');
|
||||
const armed = EDITOR.tool === 'venturi';
|
||||
if (armed) bAdd.classList.add('on');
|
||||
bAdd.textContent = armed ? 'PLACING — click the gap' : 'PLACE VENTURI';
|
||||
bAdd.addEventListener('click', () => {
|
||||
EDITOR.setTool(armed ? null : 'venturi');
|
||||
render();
|
||||
});
|
||||
body.append(row('venturi', bAdd));
|
||||
|
||||
if (!list.length) {
|
||||
body.append(el('p', 'ed-note',
|
||||
'No funnel on this site. A venturi is a GAP between buildings that speeds the '
|
||||
+ 'wind up when the storm swings to run along it — the corner block is calm until '
|
||||
+ 'the southerly arrives, then it screams.'));
|
||||
}
|
||||
|
||||
list.forEach((v, i) => {
|
||||
const card = el('div', 'ed-card');
|
||||
const head = el('div', 'ed-card-head');
|
||||
const name = el('span', null, `gap ${i + 1}`);
|
||||
name.style.cursor = 'pointer';
|
||||
name.addEventListener('click', () => { selVenturi = i; render(); });
|
||||
if (i === selVenturi) name.classList.add('ed-ok');
|
||||
const del = el('button', 'ed-btn danger', '×');
|
||||
del.addEventListener('click', () => {
|
||||
list.splice(i, 1);
|
||||
selVenturi = Math.max(0, selVenturi - 1);
|
||||
commit();
|
||||
});
|
||||
head.append(name, del);
|
||||
card.append(head);
|
||||
|
||||
const at = el('div', 'ed-card-row');
|
||||
at.append(el('span', 'ed-kv', 'throat'), el('span', null, `${v.x.toFixed(1)}, ${v.z.toFixed(1)}`));
|
||||
card.append(at);
|
||||
|
||||
// THE AXIS ROW — the mod-π teaching in words as well as geometry.
|
||||
const a = normalizeAxis(v.axis ?? 0);
|
||||
const aRow = el('div', 'ed-card-row');
|
||||
aRow.append(el('span', 'ed-kv', 'axis'),
|
||||
el('span', null, `${(a * 180 / Math.PI).toFixed(0)}° ≡ ${((a * 180 / Math.PI) + 180).toFixed(0)}°`));
|
||||
card.append(aRow);
|
||||
|
||||
card.append(slider(0, 180, 1, a * 180 / Math.PI, (x) => `${x.toFixed(0)}°`, (x) => {
|
||||
v.axis = round3(normalizeAxis(x * Math.PI / 180));
|
||||
commit();
|
||||
}));
|
||||
card.append(slider(1, 2, 0.05, v.gain ?? 1.4, (x) => `gain ×${x.toFixed(2)}`, (x) => {
|
||||
v.gain = round2(x);
|
||||
commit();
|
||||
}));
|
||||
card.append(slider(1, 12, 0.5, v.radius ?? 4, (x) => `r ${x.toFixed(1)} m`, (x) => {
|
||||
v.radius = round2(x);
|
||||
commit();
|
||||
}));
|
||||
card.append(slider(1, 8, 1, v.sharp ?? 3, (x) => `sharp ${x.toFixed(0)}`, (x) => {
|
||||
v.sharp = x;
|
||||
commit();
|
||||
}));
|
||||
|
||||
// What this gap is doing RIGHT NOW, at the second on the scrubber — the
|
||||
// number an author actually wants: not "is it authored" but "is it firing".
|
||||
if (wind) {
|
||||
const core = wind.core.venturi[i];
|
||||
if (core) {
|
||||
const d = wind.core.dirAt(time);
|
||||
const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ);
|
||||
const fired = Math.pow(align, core.sharp);
|
||||
const at0 = wind.core.speedAt(core.x, core.z, time);
|
||||
const r = el('div', 'ed-card-row');
|
||||
r.append(el('span', 'ed-kv', 'now'),
|
||||
el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv',
|
||||
`${at0.toFixed(1)} m/s · align ${(fired * 100).toFixed(0)}%`));
|
||||
card.append(r);
|
||||
}
|
||||
}
|
||||
body.append(card);
|
||||
});
|
||||
|
||||
body.append(el('p', 'ed-note',
|
||||
'An axis is a LINE, not a heading: the gap funnels either way through it, so '
|
||||
+ '120° and 300° are the same gap and the slider stops at 180°. Drag either end '
|
||||
+ 'handle — both are the same line. The throat handle moves the gap.'));
|
||||
|
||||
body.append(el('p', 'ed-note',
|
||||
'Neither funnels nor shelters have a height term — they apply at every height. '
|
||||
+ 'The shelter volume is drawn to canopy height as a reading aid only.'));
|
||||
}
|
||||
|
||||
// --- storms are fetched, so the panel boots async -----------------------
|
||||
async function ensureStorm(k) {
|
||||
if (!storms[k]) storms[k] = await loadStorm(k);
|
||||
}
|
||||
|
||||
await ensureStorm(stormKey);
|
||||
refreshWind();
|
||||
render();
|
||||
|
||||
// A clears the overlay on every rebuild — so every rebuild, I redraw. My
|
||||
// gizmos are a pure function of (site, storm, second), which is exactly why
|
||||
// this is one line instead of a lifetime problem.
|
||||
EDITOR.on('rebuild', () => { refreshWind(); render(); });
|
||||
EDITOR.on('siteload', () => { selVenturi = 0; });
|
||||
/**
|
||||
* The wind is rebuilt HERE, on any 'change', rather than in my own commit().
|
||||
*
|
||||
* Caught by looking: with the refresh in commit(), my sliders were correct and
|
||||
* everything else was a lie. Setting `site.wind.venturi[0].axis` from outside
|
||||
* and calling `markDirty()` — which is A's documented way for ANY lane to
|
||||
* change the site, and what an undo or a scripted edit does — re-rendered the
|
||||
* panel against the wind built from the PREVIOUS site, so the funnel readout
|
||||
* kept insisting the gap was 24% aligned after it had been put back to 100%.
|
||||
* A stale wind behind a fresh panel is the exact failure mode A parented the
|
||||
* overlay to `rebuild` to kill, reintroduced one layer up by me.
|
||||
*
|
||||
* Refreshing on the event instead of in the mutator means the overlay is a
|
||||
* function of the site as it IS, no matter who changed it. render() never
|
||||
* calls markDirty(), so this cannot loop.
|
||||
*/
|
||||
EDITOR.on('change', () => { refreshWind(); render(); });
|
||||
|
||||
return { root, body, redraw, buildWind, get wind() { return wind; } };
|
||||
}
|
||||
|
||||
// Self-register. `editor.html` imports this after `createEditor()` resolves, so
|
||||
// `globalThis.EDITOR` is up by the time this runs (asked for in THREADS — that
|
||||
// page is A's file and I do not edit it).
|
||||
if (globalThis.EDITOR) {
|
||||
await mountWindPanel(globalThis.EDITOR);
|
||||
} else if (globalThis.document?.getElementById('ed-canvas')) {
|
||||
// On the editor page with no EDITOR = the import landed in the wrong place.
|
||||
// Anywhere else (c.test.js importing the pure helpers) this is not a problem
|
||||
// and must not print — a selftest that warns every run trains people to
|
||||
// ignore warnings.
|
||||
console.warn('[editor.wind] no globalThis.EDITOR — import me AFTER createEditor() resolves');
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -14,10 +14,16 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
|
||||
import { loadSite } from '../world.js';
|
||||
import { loadSite, createWorld } from '../world.js';
|
||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||
// including on the day the game itself changed. "Two harnesses, one number"
|
||||
// means reaching for the real one.
|
||||
import { createWindRouter } from '../main.js';
|
||||
import { normalizeAxis, shelterAtten } from '../editor.wind.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
@ -759,4 +765,210 @@ export default async function run(t) {
|
||||
assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GATE 2.3 — THE EDITOR'S WIND IS THE GAME'S WIND, PINNED EXACT
|
||||
//
|
||||
// Co-owned with Lane B. B proposed backyard_01 / storm_02_wildnight /
|
||||
// (1,0,2) / t=30; I moved all three and posted the receipts in THREADS, for
|
||||
// one reason: **B's pin could not have failed.**
|
||||
// · backyard_01's `wind.venturi` is `[]`. On that site "setVenturi called
|
||||
// with an empty list" and "setVenturi never called" are the same number,
|
||||
// so the funnel-off regression the pin exists to catch is invisible.
|
||||
// · the garden bed at (1,0,2) is outside the funnel radius even on
|
||||
// site_02 — measured Δ 0.0000 m/s.
|
||||
// · t=30 is a second where the wildnight's direction does not line up with
|
||||
// the gap: at the throat the funnel is worth 0.4% there (0.0% on the
|
||||
// southerly). A pin at 0.4% passes with the funnel wired backwards.
|
||||
// So: the ONLY site with a shipped venturi, the throat centre (authored site
|
||||
// geometry, not a magic number), and a second on the alignment plateau where
|
||||
// the funnel is worth a THIRD of the answer. Same storm B picked, same exact
|
||||
// `===`. The vacuity guard below is what stops this pin rotting back into
|
||||
// decoration — I am the lane that shipped half an assert made of decoration,
|
||||
// so the guard is not optional.
|
||||
const PIN = {
|
||||
site: 'site_02_corner_block',
|
||||
storm: 'storm_02_wildnight',
|
||||
probe: { x: -6, y: 0, z: 0 }, // the authored throat centre; speedAt ignores y
|
||||
t: 60.0,
|
||||
};
|
||||
|
||||
const pinSite = await loadSite(PIN.site);
|
||||
let pinWorld = null;
|
||||
try {
|
||||
pinWorld = createWorld(new THREE.Scene(), {
|
||||
wind: createStubWind({ calm: true }), site: structuredClone(pinSite),
|
||||
});
|
||||
await pinWorld.dress();
|
||||
} catch (err) {
|
||||
console.warn('[c.test] gate 2.3: dress() unavailable, using graybox anchors:', err.message);
|
||||
}
|
||||
const pinAnchors = pinWorld ? pinWorld.anchors : [];
|
||||
const pinProbe = new THREE.Vector3(PIN.probe.x, PIN.probe.y, PIN.probe.z);
|
||||
|
||||
/** The EDITOR's wind: `windForSite` off the site object, exactly as
|
||||
* editor.wind.js's `buildWind()` and B's SCORE IT both build it. */
|
||||
const editorWind = () => windForSite(storms[PIN.storm], structuredClone(pinSite), pinAnchors);
|
||||
|
||||
/** The GAME's wind: main.js's router, wired by main.js's own two lines
|
||||
* (loadSiteInto, `wind.setVenturi(...)` + `wind.setSheltersFromTrees(...)`). */
|
||||
const gameWind = () => {
|
||||
const all = STORMS.map((k) => createWind(storms[k]));
|
||||
const router = createWindRouter(all);
|
||||
router.use(all[STORMS.indexOf(PIN.storm)]);
|
||||
router.setVenturi(pinSite.wind?.venturi ?? []);
|
||||
router.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree'));
|
||||
return router;
|
||||
};
|
||||
|
||||
t.test('GATE 2.3: editor wind === game wind at the pinned probe and second (exact)', () => {
|
||||
const ed = editorWind().speedAt(pinProbe, PIN.t);
|
||||
const gm = gameWind().speedAt(pinProbe, PIN.t);
|
||||
assert(Number.isFinite(ed) && ed > 0, `the pin sampled nothing: ${ed} m/s — vacuous`);
|
||||
// EXACT, per B: two chains that agree to 1e-9 agree, and any epsilon big
|
||||
// enough to feel safe is big enough to hide a 33% funnel.
|
||||
assert(ed === gm,
|
||||
`editor ${ed} m/s vs game ${gm} m/s at (${PIN.probe.x},${PIN.probe.z}) t=${PIN.t} on `
|
||||
+ `${PIN.site}/${PIN.storm} — the editor is scoring a yard the game does not play`);
|
||||
// the vector too, so a sign or a component can't drift under an equal scalar
|
||||
const a = editorWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||||
const b = gameWind().sample(pinProbe, PIN.t, new THREE.Vector3());
|
||||
assert(a.x === b.x && a.y === b.y && a.z === b.z,
|
||||
`vector mismatch: editor (${a.x},${a.y},${a.z}) vs game (${b.x},${b.y},${b.z})`);
|
||||
});
|
||||
|
||||
t.test('GATE 2.3 guard: the pinned probe/second is one the funnel actually decides', () => {
|
||||
// Without this, the pin above passes just as happily at a probe the venturi
|
||||
// never reaches — which is exactly how three harnesses measured a funnel-off
|
||||
// yard and believed it. Re-measure the funnel's worth AT THE PIN, and demand
|
||||
// it is a big fraction of the answer.
|
||||
const full = editorWind().speedAt(pinProbe, PIN.t);
|
||||
const funnelOff = createWind(storms[PIN.storm]);
|
||||
funnelOff.setSheltersFromTrees(pinAnchors.filter((a) => a.type === 'tree')); // shelters ON
|
||||
const off = funnelOff.speedAt(pinProbe, PIN.t); // venturi OFF
|
||||
const share = (full - off) / full;
|
||||
assert(share > 0.25,
|
||||
`the venturi is worth only ${(share * 100).toFixed(1)}% of the wind at the pinned probe/second `
|
||||
+ `(${full.toFixed(3)} vs ${off.toFixed(3)} m/s). The pin still passes — that is the problem. `
|
||||
+ 'Move the probe/second back onto the funnel or the gate is decoration.');
|
||||
});
|
||||
|
||||
t.test('GATE 2.3 (wider): editor and game agree across the yard and the storm', () => {
|
||||
// The single pin is a point. Shelters live nowhere near the throat, so a
|
||||
// point at the throat cannot see them: this sweep is what covers the tree
|
||||
// half of `windForSite`'s wiring.
|
||||
const ed = editorWind();
|
||||
const gm = gameWind();
|
||||
let checked = 0;
|
||||
let sheltered = 0;
|
||||
const bare = createWind(storms[PIN.storm]); // no shelters, no venturi
|
||||
for (const time of [20, 45, 60, 75]) {
|
||||
for (let x = -11; x <= 11; x += 1.5) {
|
||||
for (let z = -7; z <= 7; z += 1.5) {
|
||||
const p = new THREE.Vector3(x, 0, z);
|
||||
const a = ed.speedAt(p, time);
|
||||
const b = gm.speedAt(p, time);
|
||||
assert(a === b, `editor ${a} vs game ${b} at (${x},${z}) t=${time}`);
|
||||
if (a < bare.speedAt(p, time) * 0.95) sheltered++;
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// This floor caught its own sweep on the first run: at a 2 m step the grid
|
||||
// was 384 samples, not the >500 I had claimed. Densified to 1.5 m rather
|
||||
// than dropping the number to fit — a coverage floor edited down to match
|
||||
// whatever the loop happened to do is not a floor.
|
||||
assert(checked > 500, `only ${checked} samples — the sweep is not sweeping`);
|
||||
assert(sheltered > 0,
|
||||
'not one sampled point was materially slowed — the tree shelters are not reaching either '
|
||||
+ 'chain, so this sweep proves nothing about setSheltersFromTrees');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// The gizmos tell the truth about the maths they draw
|
||||
|
||||
t.test("shelter volume: the drawn attenuation IS weather.core's shelterFactor", () => {
|
||||
// editor.wind.js draws the tree-shelter volume from `shelterAtten`, which
|
||||
// mirrors weather.core's shelterFactor. Mirrors drift, and a gizmo that
|
||||
// disagrees with the sim while looking authoritative is worse than none —
|
||||
// so measure the mirror against the real field instead of trusting it.
|
||||
// speedAt is multiplicative (uniform × noise × shelter × venturi), so the
|
||||
// ratio of a sheltered field to an unsheltered one at the same (x,z,t) IS
|
||||
// shelterFactor, with the noise divided out.
|
||||
const def = storms.storm_02_wildnight;
|
||||
const S = { x: 2, z: -3, radius: 3, strength: 0.45, length: 14 };
|
||||
const withS = createWind(def).setShelters([S]);
|
||||
const noS = createWind(def).setShelters([]);
|
||||
let compared = 0;
|
||||
let sawRealShadow = false;
|
||||
for (const time of [12, 33, 58, 71]) {
|
||||
const d = withS.core.dirAt(time);
|
||||
const dx = Math.cos(d), dz = Math.sin(d);
|
||||
for (let along = 0.5; along <= 13; along += 1.5) {
|
||||
for (let perp = -2.5; perp <= 2.5; perp += 1.25) {
|
||||
const x = S.x + dx * along - dz * perp;
|
||||
const z = S.z + dz * along + dx * perp;
|
||||
const bare = noS.core.speedAt(x, z, time);
|
||||
if (bare <= 1e-9) continue;
|
||||
const factor = withS.core.speedAt(x, z, time) / bare;
|
||||
assertClose(factor, 1 - shelterAtten(S, along, perp), 1e-9,
|
||||
`shelterAtten disagrees with the sim at along=${along} perp=${perp} t=${time} — `
|
||||
+ 'the drawn volume is not the shadow the wind actually casts');
|
||||
if (shelterAtten(S, along, perp) > 0.1) sawRealShadow = true;
|
||||
compared++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(compared > 100, `only ${compared} comparisons`);
|
||||
assert(sawRealShadow, 'never sampled a point the shelter actually shades — vacuous');
|
||||
});
|
||||
|
||||
t.test('the venturi axis is a LINE: axis and axis+π are the same gap', () => {
|
||||
// The fact the editor's UI teaches, asserted rather than asserted-in-a-
|
||||
// comment. weather.core aligns on |dot(wind, axis)|, so adding π must change
|
||||
// nothing anywhere. If this ever fails, the axis has quietly become a
|
||||
// heading and the mod-π UI is lying to whoever authored against it.
|
||||
const def = storms.storm_02_wildnight;
|
||||
const v = pinSite.wind.venturi[0];
|
||||
const mk = (axis) => windForSite(def, { ...pinSite, wind: { venturi: [{ ...v, axis }] } }, pinAnchors);
|
||||
const a = mk(v.axis);
|
||||
const b = mk(v.axis + Math.PI);
|
||||
let maxDiff = 0;
|
||||
let sawFunnel = false;
|
||||
const plain = createWind(def).setSheltersFromTrees(pinAnchors.filter((n) => n.type === 'tree'));
|
||||
for (const time of [30, 60, 60.5, 75]) {
|
||||
for (let x = -10; x <= -2; x += 1) {
|
||||
for (let z = -4; z <= 4; z += 1) {
|
||||
const p = new THREE.Vector3(x, 0, z);
|
||||
const sa = a.speedAt(p, time);
|
||||
maxDiff = Math.max(maxDiff, Math.abs(sa - b.speedAt(p, time)));
|
||||
if (sa > plain.speedAt(p, time) * 1.05) sawFunnel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(sawFunnel, 'the funnel never fired anywhere in the sweep — this proves nothing');
|
||||
// not `===`: cos(θ+π) is only -cos(θ) to within floating point, so the two
|
||||
// fields agree to rounding, not to the bit. Rounding is the honest bar here.
|
||||
assert(maxDiff < 1e-9,
|
||||
`axis and axis+π gave different winds (max ${maxDiff} m/s) — the venturi is not mod π`);
|
||||
});
|
||||
|
||||
t.test('normalizeAxis folds any angle into [0, π) without moving the gap', () => {
|
||||
const cases = [2.1, 2.1 + Math.PI, 2.1 - Math.PI, -1.08, 0, 7 * Math.PI, -3 * Math.PI / 4];
|
||||
for (const raw of cases) {
|
||||
const n = normalizeAxis(raw);
|
||||
assert(n >= 0 && n < Math.PI, `normalizeAxis(${raw}) = ${n} is outside [0, π)`);
|
||||
// same LINE: the direction vectors must be parallel or antiparallel
|
||||
const cross = Math.cos(raw) * Math.sin(n) - Math.sin(raw) * Math.cos(n);
|
||||
assertClose(cross, 0, 1e-12, `normalizeAxis(${raw}) rotated the gap instead of folding it`);
|
||||
}
|
||||
// the Sprint 11 pair, which cost two exchanges: 2.1 is the gap, -1.08 is the
|
||||
// southerly's heading, and they are NOT the same number — 2.2° apart.
|
||||
const gap = normalizeAxis(2.1);
|
||||
const heading = normalizeAxis(-1.08);
|
||||
assert(Math.abs(gap - heading) > 0.03,
|
||||
'the gap axis and the southerly heading have collapsed to the same value — they are '
|
||||
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
||||
+ 'mistake THREADS records A and C both nearly making');
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert } from '../testkit.js';
|
||||
import { ANCHOR_TYPE } from '../contracts.js';
|
||||
|
||||
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
|
||||
//
|
||||
@ -90,6 +91,13 @@ const ASSETS = [
|
||||
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
|
||||
{ name: 'bike_kid_01', h: [0.60, 0.84],
|
||||
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
|
||||
{ name: 'tree_jacaranda_01', h: [5.7, 6.5],
|
||||
nodes: ['trunk', 'canopy', 'canopy_01', 'canopy_02', 'canopy_03', 'canopy_04',
|
||||
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
|
||||
{ name: 'swing_set_01', h: [2.00, 2.15],
|
||||
nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] },
|
||||
{ name: 'swing_set_01_wrecked', h: [0.80, 1.05],
|
||||
nodes: ['frame', 'crossbar', 'swings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -491,6 +499,7 @@ export default async function run(t) {
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_snapped'],
|
||||
['swing_set_01', 'swing_set_01_wrecked'],
|
||||
['house_yardside', 'house_yardside_wrecked']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
@ -522,6 +531,265 @@ export default async function run(t) {
|
||||
'window_glow must carry hidden_by_default or the house is lit at noon');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT14 gate 3.1 — THE PALETTE AUDIT, as three rules that can go red.
|
||||
//
|
||||
// The editor is about to offer these GLBs to an author by name, which turns
|
||||
// every silent field in them into a trap for whoever places one. These three
|
||||
// tests are the audit made permanent: run them, not a spreadsheet.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Every node in every loaded GLB, as [assetName, node] pairs. */
|
||||
const allNodes = () => {
|
||||
const out = [];
|
||||
for (const [name, gltf] of loaded) {
|
||||
gltf.scene.traverse((o) => out.push([name, o]));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// RULE 1 — no silent anchors. `world.js:adoptAnchor` does
|
||||
// `anchor.ratingHint = node.userData?.rating_hint ?? 1`, so a node named
|
||||
// `*_anchor` with no hint is not "unrated", it is RATED PERFECT: better than
|
||||
// a gum fork, conjured out of a missing field. Four were sitting in the
|
||||
// palette before this sprint (door_anchor, pickup_anchor, grip_anchor,
|
||||
// window_light_anchor) — all of them carry points or lighting hints, none of
|
||||
// them steel. They now deny it explicitly (`tie_off: false`).
|
||||
//
|
||||
// The failure this prevents is the nastiest kind: a site author picks
|
||||
// `door_anchor` off the editor's node list, the yard boots, the sail holds,
|
||||
// and a shed door quietly outperforms a concreted post for the rest of the
|
||||
// game. Nothing crashes. Nothing looks wrong. The site is just a lie.
|
||||
t.test('no silent anchors: every *_anchor node rates itself or denies being one', () => {
|
||||
const silent = [];
|
||||
for (const [asset, o] of allNodes()) {
|
||||
if (!/_anchor(_\d+)?$/.test(o.name)) continue;
|
||||
const u = o.userData ?? {};
|
||||
const rated = typeof u.rating_hint === 'number';
|
||||
const denied = u.tie_off === false;
|
||||
if (!rated && !denied) silent.push(`${asset}/${o.name}`);
|
||||
assert(!(rated && denied),
|
||||
`${asset}/${o.name} both rates itself and denies being a tie-off — pick one`);
|
||||
}
|
||||
assert(silent.length === 0,
|
||||
`these nodes adopt at rating_hint 1 (the best steel in the game) purely by ` +
|
||||
`saying nothing: ${silent.join(', ')}. Give them a rating_hint, or ` +
|
||||
`not_a_tie_off() them in build_yard_assets.py`);
|
||||
});
|
||||
|
||||
// RULE 2 — no unpriced collateral. The gutter was a FREE FAILURE for two
|
||||
// sprints: the fascia anchors said collateral:"gutter" from Sprint 6 and
|
||||
// nothing anywhere priced one, so collateralFor('gutter') returned null and
|
||||
// ripping the eave off the client's house cost the player a shackle. Fixing
|
||||
// that one string is not the fix; the fix is that a new asset cannot repeat
|
||||
// it. Every collateral string an anchor names must resolve to a number
|
||||
// somewhere in the palette, by construction, before it can ship.
|
||||
t.test('no unpriced collateral: every collateral string an anchor names has a price', () => {
|
||||
const priced = new Map();
|
||||
for (const [, o] of allNodes()) {
|
||||
const u = o.userData ?? {};
|
||||
if (typeof u.collateral_value !== 'number') continue;
|
||||
priced.set(u.collateral_key ?? u.shades_asset ?? o.name, u.collateral_value);
|
||||
}
|
||||
const orphans = [];
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const key = o.userData?.collateral;
|
||||
if (typeof key !== 'string') continue;
|
||||
if (typeof priced.get(key) !== 'number') orphans.push(`${asset}/${o.name} → "${key}"`);
|
||||
}
|
||||
assert(orphans.length === 0,
|
||||
`anchors name collateral nothing prices, so breaking these is FREE: ` +
|
||||
`${orphans.join(', ')}. Priced keys are [${[...priced.keys()].join(', ')}]`);
|
||||
});
|
||||
|
||||
// RULE 3 — the baked type is the checked type. `anchor_type` in a GLB and
|
||||
// ANCHOR_TYPE in contracts.js have to be the same vocabulary or the site's
|
||||
// enum check is validating a different thing than the asset says. The
|
||||
// carport needed the list widened (SPRINT11); the swing frame needed it
|
||||
// widened again this sprint. Widening it is the intended move — inventing a
|
||||
// word only the GLB knows is not.
|
||||
t.test('baked anchor_type strings are all in the checked ANCHOR_TYPE enum', () => {
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const ty = o.userData?.anchor_type;
|
||||
if (ty === undefined) continue;
|
||||
assert(ANCHOR_TYPE.includes(ty),
|
||||
`${asset}/${o.name} is typed "${ty}", which no site may declare — ` +
|
||||
`add it to ANCHOR_TYPE in contracts.js or use one of [${ANCHOR_TYPE.join(', ')}]`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- the swing set: the temptation is the crossbar ------------------------
|
||||
// The prop exists to put an honest middle rung in a palette that only had a
|
||||
// ceiling (gum fork 1.0) and a trap (carport beam 0.22). Its whole design
|
||||
// rests on the rail NOT being an anchor while looking exactly like one, so
|
||||
// that is the thing pinned hardest.
|
||||
t.test('the swing set offers two anchors and a crossbar that is not one', () => {
|
||||
const g = loaded.get('swing_set_01');
|
||||
assert(g, 'swing_set_01 did not load');
|
||||
|
||||
const rail = g.scene.getObjectByName('crossbar');
|
||||
assert(rail, 'crossbar node missing — it must stay its own node so the data can refuse it');
|
||||
assert(rail.userData?.tie_off === false,
|
||||
'the crossbar must carry tie_off:false — it is the most anchor-looking object in the palette');
|
||||
assert(rail.userData?.rating_hint === undefined,
|
||||
'the crossbar carries a rating_hint, which makes it adoptable — that is the whole thing this prop is about');
|
||||
|
||||
// Exactly two, and no third one hiding on the rail or the seats.
|
||||
const anchors = [];
|
||||
g.scene.traverse((o) => { if (typeof o.userData?.rating_hint === 'number') anchors.push(o); });
|
||||
assert(anchors.length === 2,
|
||||
`swing_set_01 offers ${anchors.length} rated anchors (${anchors.map((a) => a.name).join(', ')}) — want exactly the two frame apexes`);
|
||||
|
||||
const CARPORT_BEAM = 0.22, FASCIA = 0.35, GUM_FORK = 1.0;
|
||||
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
|
||||
const o = g.scene.getObjectByName(n);
|
||||
assert(o, `${n} missing`);
|
||||
assert(o.userData?.anchor_type === 'swing_frame',
|
||||
`${n} is typed "${o.userData?.anchor_type}" — a swing frame is not a post, and the type string is what the player reads before committing`);
|
||||
const r = o.userData?.rating_hint;
|
||||
assert(r > FASCIA && r < GUM_FORK,
|
||||
`${n} rates ${r} — the frame junction is sound steel on an unpegged frame: better than the fascia (${FASCIA}), nowhere near a fork (${GUM_FORK})`);
|
||||
assert(r > CARPORT_BEAM,
|
||||
`${n} rates ${r} — if it is worse than the carport beam it is a second trap, not the palette's honest middle`);
|
||||
assert(o.userData?.collateral === 'swing_set',
|
||||
`${n} must name what it takes with it (collateral="swing_set")`);
|
||||
}
|
||||
|
||||
// Priced, in band, and the wreck agrees — the carport/gutter chain again.
|
||||
const root = g.scene.getObjectByName('swing_set_01');
|
||||
const cost = root?.userData?.collateral_value;
|
||||
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
|
||||
const gutter = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside')?.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', 'swing_set_01 carries no collateral_value');
|
||||
assert(root.userData?.collateral_key === 'swing_set',
|
||||
'collateral_key must name the string the anchors carry, or the price prices nothing');
|
||||
assert(cost > gutter && cost < carport,
|
||||
`the swing set (${cost}) must sit between the gutter (${gutter}) and the carport (${carport}) — ` +
|
||||
'a toy costs more than a run of guttering and less than a structure with a roof');
|
||||
|
||||
const w = loaded.get('swing_set_01_wrecked')?.scene.getObjectByName('swing_set_01_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked set must be priced the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'swing_set_01',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// Same rule as the torn fascia: you cannot re-tie to a frame lying on the
|
||||
// grass. An anchor that survives its structure is the free-failure bug in a
|
||||
// costume — world.anchors would keep offering the tie-off after the swap.
|
||||
t.test('the wrecked swing set is over, and offers nothing to tie to', () => {
|
||||
const w = loaded.get('swing_set_01_wrecked');
|
||||
assert(w, 'swing_set_01_wrecked did not load');
|
||||
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
|
||||
assert(!w.scene.getObjectByName(n), `${n} survives the wreck — the frame it was welded to is on the ground`);
|
||||
}
|
||||
|
||||
// It went OVER, not down: measured, because "wrecked" has to mean a
|
||||
// specific pose or the next edit quietly turns it into rubble. The rail
|
||||
// used to be the highest thing on the prop; now it is on the grass, and
|
||||
// the far legs are the highest thing instead.
|
||||
const intact = loaded.get('swing_set_01');
|
||||
const railUp = new THREE.Box3().setFromObject(intact.scene.getObjectByName('crossbar'));
|
||||
assert(railUp.max.y > 2.0,
|
||||
`the intact rail tops out at y=${railUp.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
|
||||
const railDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('crossbar'));
|
||||
assert(railDown.max.y < 0.25,
|
||||
`the wrecked rail tops out at y=${railDown.max.y.toFixed(2)} — it is meant to be lying on the grass`);
|
||||
|
||||
// And it reaches further along the ground than it ever stood tall.
|
||||
const box = new THREE.Box3().setFromObject(w.scene);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
assert(size.z > size.y * 2,
|
||||
`the wreck is ${size.z.toFixed(2)} m deep and ${size.y.toFixed(2)} m tall — a set that fell over lies down`);
|
||||
const hi = new THREE.Box3().setFromObject(intact.scene);
|
||||
assert(size.y < hi.getSize(new THREE.Vector3()).y * 0.6,
|
||||
'the wreck stands too close to full height — it did not fall, it sagged');
|
||||
});
|
||||
|
||||
// THE AXIS TRAP, third time paid for. The wreck goes over toward Blender −Y,
|
||||
// which arrives here as +Z, and the placement note baked in the GLB says so
|
||||
// in words. Words cannot fail; this can. It pins the CLAIM against the
|
||||
// GEOMETRY, so the extras and the mesh have to lie in the same direction at
|
||||
// the same time or the suite says which one moved.
|
||||
//
|
||||
// This matters at placement, not just in a docstring: standing, the set is
|
||||
// 0.95 m deep; fallen, it reaches 2.9 m to +Z. Nothing about the intact
|
||||
// bounds tells the editor that, so a swing set backed onto a fence looks
|
||||
// fine until the night it lays itself through the palings.
|
||||
t.test('the swing set falls toward +Z, and the GLB says so in the same direction', () => {
|
||||
const intact = loaded.get('swing_set_01');
|
||||
const root = intact.scene.getObjectByName('swing_set_01');
|
||||
const claim = root?.userData?.wreck_falls_toward;
|
||||
assert(claim === '+Z', `the GLB claims it falls toward ${JSON.stringify(claim)} — this assert only knows how to check +Z, so if the geometry really changed, change both`);
|
||||
|
||||
const rail = new THREE.Box3()
|
||||
.setFromObject(loaded.get('swing_set_01_wrecked').scene.getObjectByName('crossbar'))
|
||||
.getCenter(new THREE.Vector3());
|
||||
assert(rail.z > 2.0,
|
||||
`the wrecked rail centres at z=${rail.z.toFixed(2)} — the claim says +Z and the mesh disagrees`);
|
||||
assert(Math.abs(rail.x) < 0.1,
|
||||
`the wrecked rail centres at x=${rail.x.toFixed(2)} — it should go over sideways, not slide along its own span`);
|
||||
|
||||
// And the clearance number is the reach, not a guess someone typed.
|
||||
const box = new THREE.Box3().setFromObject(loaded.get('swing_set_01_wrecked').scene);
|
||||
const clear = root?.userData?.wreck_clearance_m;
|
||||
assert(typeof clear === 'number' && box.max.z <= clear,
|
||||
`the wreck reaches z=${box.max.z.toFixed(2)} but the GLB tells the editor to leave ${clear} m — the advice must cover the wreckage`);
|
||||
});
|
||||
|
||||
// --- the second species: the ladder IS the feature ------------------------
|
||||
// Both gums carry 1.0/0.88/0.76 — forgiving by design, so height up a gum is
|
||||
// nearly free and "which tree" was never a real question. The jacaranda's
|
||||
// ladder collapses instead of stepping down, which is what makes tree choice
|
||||
// a decision. That claim is a NUMBER, so it gets an assert rather than a
|
||||
// docstring: flatten the ladder and this goes red.
|
||||
t.test('the jacaranda ladder falls away far harder than the gum ladder', () => {
|
||||
const rungs = (asset) => {
|
||||
const g = loaded.get(asset);
|
||||
assert(g, `${asset} did not load`);
|
||||
return ['branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03']
|
||||
.map((n) => g.scene.getObjectByName(n)?.userData?.rating_hint)
|
||||
.filter((v) => typeof v === 'number');
|
||||
};
|
||||
const gum = rungs('tree_gum_01'), jac = rungs('tree_jacaranda_01');
|
||||
assert(gum.length === 3 && jac.length === 3,
|
||||
`need three rungs each; got gum=${gum.length}, jac=${jac.length}`);
|
||||
|
||||
// Rung 1: the jacaranda's low fork is genuinely good steel — that is the
|
||||
// trade, not a consolation. It must stay close to the gum's.
|
||||
assert(jac[0] > 0.9, `jacaranda fork rates ${jac[0]} — the low fork is meant to be the best thing on the tree`);
|
||||
assert(jac[0] <= gum[0], `jacaranda fork (${jac[0]}) must not out-rate a gum fork (${gum[0]})`);
|
||||
|
||||
// ...and then it must actually fall away. Twice the gum's drop is the
|
||||
// design claim, stated as the threshold it has to clear.
|
||||
const dGum = gum[0] - gum[2], dJac = jac[0] - jac[2];
|
||||
assert(dJac > dGum * 2,
|
||||
`the jacaranda drops ${dJac.toFixed(2)} across its ladder and the gum drops ${dGum.toFixed(2)} — ` +
|
||||
'if height costs the same on both species, the second tree is just a repaint');
|
||||
assert(jac[2] < 0.5,
|
||||
`the jacaranda's top rung rates ${jac[2]} — the whole point is that the high anchor is a gamble`);
|
||||
|
||||
// Unpriced BY RULING, same as the bike: no limb-failure event exists for
|
||||
// a player to watch, and billing an unseen event is the lie the invoice
|
||||
// exists to kill. Price it when the limb can come down.
|
||||
const root = loaded.get('tree_jacaranda_01').scene.getObjectByName('tree_jacaranda_01');
|
||||
assert(root?.userData?.collateral_value === undefined,
|
||||
'the jacaranda is unpriced BY RULING — build the limb failure first');
|
||||
});
|
||||
|
||||
// The silhouette carries the species read at 30 m, which is what an author
|
||||
// in the editor actually picks on. A jacaranda is broader than it is tall;
|
||||
// a gum is the other way round. If this flips, the palette has two trees
|
||||
// that look the same and one of them is lying about its ladder.
|
||||
t.test('the jacaranda reads as a different tree: broader than tall, unlike the gums', () => {
|
||||
const size = (n) => new THREE.Box3().setFromObject(loaded.get(n).scene).getSize(new THREE.Vector3());
|
||||
const j = size('tree_jacaranda_01'), g = size('tree_gum_01');
|
||||
assert(Math.max(j.x, j.z) > j.y,
|
||||
`jacaranda is ${Math.max(j.x, j.z).toFixed(2)} m across and ${j.y.toFixed(2)} m tall — it should be broader than tall`);
|
||||
assert(g.y > Math.max(g.x, g.z),
|
||||
`gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`);
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
@ -197,7 +197,24 @@ export function createWorld(scene, opts = {}) {
|
||||
const GARDEN_BED = site.gardenBed;
|
||||
const SUN_DIR = sunDirOf(site);
|
||||
const HOUSE = site.house;
|
||||
const SHED = site.shed;
|
||||
// SPRINT14 — `rotY` was MISSING here, and it made the shed invisible.
|
||||
//
|
||||
// Every other rotatable prop gets its degrees converted on the way in
|
||||
// (SHED_TABLE, GNOME, BIKE, all one line below); the shed alone was bound
|
||||
// raw, so `SHED.rotY` was `undefined`, `shed.rotation.y = undefined` gave the
|
||||
// object an all-NaN quaternion, and three.js quietly declines to draw a mesh
|
||||
// whose world matrix is NaN. No throw, no warning, nothing on the console —
|
||||
// the Hendersons' shed has simply never been in the yard, through every
|
||||
// sprint and onto the public URL, while `ladder.js` has been parking the
|
||||
// ladder "leaning on the shed" against thin air and the camera has been
|
||||
// colliding with it (it is in `solids`).
|
||||
//
|
||||
// Found by opening backyard_01 in the yard editor and noticing an object in
|
||||
// the scene graph that was not on the glass. Proven by assigning a real
|
||||
// radian value at runtime and watching the shed appear. There is now an
|
||||
// a.test assert on the composed matrix, because "nothing is NaN" is exactly
|
||||
// the kind of claim that has to be able to fail.
|
||||
const SHED = site.shed ? { ...site.shed, rotY: rad(site.shed.rotYDeg) } : null;
|
||||
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
|
||||
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
|
||||
// SPRINT12: the per-client prop, the gnome pattern — a named top-level key,
|
||||
@ -280,36 +297,50 @@ export function createWorld(scene, opts = {}) {
|
||||
// Rear wall sits exactly on z = -10 so the fascia anchors have a round
|
||||
// number to live on. Lane E's house_yardside.glb replaces this group and
|
||||
// should keep fascia_anchor_* at these positions.
|
||||
const house = new THREE.Group();
|
||||
house.name = 'house_yardside';
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16, 3.0, 6),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
|
||||
);
|
||||
wall.position.set(0, 1.5, -13);
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
house.add(wall);
|
||||
//
|
||||
// SPRINT14 — ONLY WHEN THE SITE DECLARES A HOUSE. This graybox used to be
|
||||
// built unconditionally, and dress() only retires it when a house GLB loads,
|
||||
// so a site with no `house` key kept it forever. site_02_corner_block has no
|
||||
// house — it has two streets — and has therefore been shipping a 16 × 3 × 6 m
|
||||
// featureless grey slab across the whole north horizon of night 3, in
|
||||
// `solids`, on the public URL. Found by opening the corner block in the yard
|
||||
// editor and standing in it at eye height; nothing else in the repo looks
|
||||
// north from inside that yard, which is why five sprints of green tests never
|
||||
// said a word. The anchor loop below was already `HOUSE?.anchors ?? []` — the
|
||||
// data path was guarded and the geometry path was not.
|
||||
let house = null;
|
||||
if (HOUSE) {
|
||||
house = new THREE.Group();
|
||||
house.name = 'house_yardside';
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16, 3.0, 6),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
|
||||
);
|
||||
wall.position.set(0, 1.5, -13);
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
house.add(wall);
|
||||
|
||||
const roof = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16.8, 0.22, 6.8),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
|
||||
);
|
||||
roof.position.set(0, 3.1, -13);
|
||||
roof.castShadow = true;
|
||||
house.add(roof);
|
||||
const roof = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16.8, 0.22, 6.8),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
|
||||
);
|
||||
roof.position.set(0, 3.1, -13);
|
||||
roof.castShadow = true;
|
||||
house.add(roof);
|
||||
|
||||
// The fascia line — the lie the player will be tempted by (DESIGN.md).
|
||||
const fascia = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16, 0.24, 0.12),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
|
||||
);
|
||||
fascia.position.set(0, 2.72, -9.98);
|
||||
house.add(fascia);
|
||||
// The fascia line — the lie the player will be tempted by (DESIGN.md).
|
||||
const fascia = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(16, 0.24, 0.12),
|
||||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
|
||||
);
|
||||
fascia.position.set(0, 2.72, -9.98);
|
||||
house.add(fascia);
|
||||
|
||||
root.add(house);
|
||||
solids.push(wall, roof);
|
||||
graybox.house = house;
|
||||
root.add(house);
|
||||
solids.push(wall, roof);
|
||||
graybox.house = house;
|
||||
}
|
||||
|
||||
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
|
||||
// these onto the position Lane E baked, which is why the numbers here don't
|
||||
@ -460,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;
|
||||
@ -558,9 +617,17 @@ export function createWorld(scene, opts = {}) {
|
||||
// and the selftest build a yard without a server — and Lane D's
|
||||
// wireYardActions reads world.shedTable at wiring time. dress() refines the
|
||||
// point to Lane E's baked `pickup_anchor` if it's there.
|
||||
const shedTable = {
|
||||
// SPRINT14: null when the site declares no table, rather than throwing on
|
||||
// `SHED_TABLE.x`. Both shipped sites have one, so this had never been reached
|
||||
// — the editor reaches it on its first frame, because the empty template has
|
||||
// no shed and you can delete the table off a yard. Every consumer already
|
||||
// guards it (`if (world.shedTable)` in interact.js, ladder.js, broom.js,
|
||||
// whose comment even says "until it lands, the pickup self-skips"), so the
|
||||
// null branch was designed for and merely unreachable. `shedTable` is not in
|
||||
// CONTRACT.world, so this stays contract-legal.
|
||||
const shedTable = SHED_TABLE ? {
|
||||
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
|
||||
};
|
||||
} : null;
|
||||
|
||||
/**
|
||||
* Show one of E's three wilt states. No-op against the graybox bed, so the
|
||||
@ -611,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;
|
||||
};
|
||||
|
||||
@ -855,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 };
|
||||
@ -880,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;
|
||||
@ -908,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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
web/world/models/swing_set_01_v1.glb
Normal file
BIN
web/world/models/swing_set_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/swing_set_01_wrecked_v1.glb
Normal file
BIN
web/world/models/swing_set_01_wrecked_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/tree_jacaranda_01_v1.glb
Normal file
BIN
web/world/models/tree_jacaranda_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user