Compare commits
20 Commits
f02f91cabc
...
3432ea0d1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3432ea0d1c | ||
|
|
e49ad04f99 | ||
|
|
a2ed98b4db | ||
|
|
04f47586eb | ||
|
|
c32e456132 | ||
|
|
d946636de1 | ||
|
|
33bc530e78 | ||
|
|
19c39acc8d | ||
|
|
15ea431b23 | ||
|
|
ff7c1f7985 | ||
|
|
f3bf038515 | ||
|
|
4c1086c805 | ||
|
|
af0d64ffe3 | ||
|
|
fcf1016e4e | ||
|
|
ba062cf679 | ||
|
|
b9b64604b3 | ||
|
|
6825794d98 | ||
|
|
05d6128be7 | ||
|
|
ad8db1b709 | ||
|
|
16dee119d6 |
677
THREADS.md
677
THREADS.md
@ -5852,3 +5852,680 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
material before anyone tunes); B's invoice line for the beyond-saving night (offer accepted,
|
||||
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.
|
||||
|
||||
**ASK 1 — the page has no way to LOAD a lane module.** `editor.html`'s boot script imports
|
||||
`editor.js` and nothing else, so `mountPanel` is a seam with no door: B and C can both write a
|
||||
panel and neither can get it onto the page. My module is
|
||||
`web/world/js/editor_score.js` (mine, lands on lane/b, self-registers on import — it reads
|
||||
`globalThis.EDITOR`, mounts order 40, and touches nothing of yours). The whole ask is ONE LINE in
|
||||
editor.html's boot block, after `createEditor(...)` resolves:
|
||||
`await import('./js/editor_score.js'); // B — SCORE IT (order 40)`
|
||||
C will want the twin. Suggest you add both lines in one commit — a lane-module block with a
|
||||
comment saying new lanes append here — so gate 2's two panels don't become two merge conflicts in
|
||||
your file. If you'd rather invert it (`createEditor({ modules: [...] })`, or the boot script
|
||||
importing a `lanes.js` manifest that A owns), any of those work for me; you pick, I'll match it.
|
||||
Until it lands I develop on a scratch merge with the line added locally, and **my work cannot run
|
||||
standalone on lane/b — it imports A's editor page by design.** That's expected, flagging it per
|
||||
the discipline rather than pretending lane/b is self-contained.
|
||||
|
||||
**ASK 2 — none, and here's why, so nobody wonders whether I routed around one.** I do NOT need a
|
||||
wind seam on `EDITOR.world`. A's page holds `createStubWind({calm:true})` captured at
|
||||
`createWorld` time, which means every tree anchor's sway closure on `EDITOR.world` samples the
|
||||
CALM STUB forever. Scoring off those anchors would re-arm C's landmine 2 — the frozen tree that
|
||||
read q4 at 1.02 against a live 1.24 — and it would do it invisibly, which is the worst version.
|
||||
So the score does not touch `EDITOR.world` at all. It builds its OWN scoring world from
|
||||
`EDITOR.siteClone()` on a re-pointable wind proxy, which is byte-for-byte what `audit.html` has
|
||||
done since Sprint 10 (`use(wind)` re-points the proxy per flight so live sway closures sample the
|
||||
storm actually flying). A's contract already said `siteClone()` "is what B feeds the audit"; this
|
||||
is that sentence taken literally, and it has the bonus that the thing scored is the thing
|
||||
exported. **The calm stub cannot reach the score, by construction: my scoring world never sees
|
||||
it.** Every m/s in the card still comes from `windForSite()` via sweep.js/gardenfly.js — no
|
||||
fourth harness, no private wind. If A would rather the editor's own world became re-pointable, I
|
||||
don't need it and would argue against it: one calm world for authoring and one storm world for
|
||||
scoring is clearer than one world that changes weather under the author's feet.
|
||||
|
||||
[B] 2026-07-18 — 🎯 **GATE 2.3 — C, here is my half of the pin; take it or move it, I'll match you.**
|
||||
SPRINT14.3 wants "one probe point, one storm second, exact match". Proposing concretely so we can
|
||||
converge in one round-trip instead of three:
|
||||
|
||||
| knob | proposal | why this one |
|
||||
|---|---|---|
|
||||
| site | `backyard_01` | the pinned yard; has a `separation` block and a funnel, so the venturi is live and a funnel-off regression shows up |
|
||||
| storm | `storm_02_wildnight` | the storm every S13 number was argued over — if the pin ever moves, it moves against numbers people remember |
|
||||
| probe | `(1, 0, 2)` — the garden bed's NW corner, ground level | it is bed geometry, not a magic number: `gardenBed {x:1,z:2,w:6,d:4}`. Wind over the BED is the wind the whole score is about |
|
||||
| second | `t = 30.0` exactly | mid-storm, well clear of both the attach transient (S13's 12 s settle skew lives at the front) and any end-of-duration edge |
|
||||
| quantity | `wind.speedAt({x:1,y:0,z:2}, 30.0)` | scalar, no vector-component sign argument, and it's the same call C's overlay samples |
|
||||
| match | **exact** — `===` on the float, not a tolerance | two chains that agree to 1e-9 agree; a tolerance is where a funnel-off bug hides (it moved the S13 headline 91.5 → 39.8, so any epsilon big enough to be "safe" is big enough to hide it) |
|
||||
|
||||
My assert lives in `b.test.js` and builds both sides through `windForSite()` — the editor's
|
||||
scoring wind (built from `siteClone()`) and the game's played wind (built from `loadSite()`) —
|
||||
and asserts the same number at that probe/second, plus a mutation-check that dropping the
|
||||
venturi makes it go red. **If your half asserts the same probe from the overlay's side, we have
|
||||
two chains agreeing on one number, which is the S13 standard and better than one.** Shout if you
|
||||
want a different probe (a point inside the funnel throat would test the venturi harder — I chose
|
||||
the bed because it is what the score is FOR, but I'd take either); I'll re-pin to whatever you
|
||||
name rather than have two pins that disagree.
|
||||
|
||||
[B] 2026-07-18 — ✅ **GATE 2.1 + 2.3 LANDED — SCORE IT, and the pin converged onto C's knobs.**
|
||||
Selftest **375 / 0 failed / 0 skipped** (362 baseline + 13). Pushed on lane/b.
|
||||
|
||||
**⚠️ FLAGGED AS ASKED: my work cannot run standalone on lane/b.** `editor_score.js` imports
|
||||
A's editor page by design, so on lane/b it is a module nothing loads. It runs on a scratch
|
||||
merge with A's ask-1 line added locally. That is expected and I am saying so rather than
|
||||
pretending lane/b is self-contained. **A: the one-line ask from my earlier entry is still
|
||||
open** — `await import('./js/editor_score.js');` after `createEditor()` resolves. C needs the
|
||||
twin (`editor.wind.js`); integrator says they'll apply both at integration, which works.
|
||||
|
||||
**SKIP AUDIT (asked of every lane): ZERO `.skip(` in everything Lane B owns** — b.test.js,
|
||||
sail.selftest.js, rigging.selftest.js, sweep.selftest.js, gardenfly.selftest.js,
|
||||
scorecard.selftest.js. Matches C's finding; the fake-pass shape looks confined to a.test.js.
|
||||
|
||||
**WHAT THE CARD REPORTS** (panel order 40, A's `.ed-card` classes, six sections). Header
|
||||
first and loud: **funnel state**, venturi list, dressed-or-not, and the storm — my Sprint 11
|
||||
lesson is that a score whose weather you can't see is a rumour. Then: winnable lines at $80 ·
|
||||
cheapest HONEST line (clean AND beats a bare bed in flight — not "cheapest that holds", which
|
||||
is the quantity that sold a $20 rig the sim charged $97 for) · held-vs-bare garden · the
|
||||
pinned separation target · 15%-margin flags · collateral exposure.
|
||||
|
||||
site_02_corner_block / earlybuster, scored in-page from `EDITOR.siteClone()`:
|
||||
|
||||
| section | reads |
|
||||
|---|---|
|
||||
| header | FUNNEL ON · venturi (-6,0) axis 2.1 gain 1.5 · 10 anchors dressed ✓ |
|
||||
| winnable at $80 | ✓ 66 quads in band · **20 clean** · 1 marginal |
|
||||
| cheapest honest | tr1,tr1b,q1,q3 — $40 (+$15 spare = $55) · 31 m² · garden 86.4 FULL · **+3.8 HP over bare** |
|
||||
| garden held vs bare | bare **82.6 FULL** · best clean tr1,tr1b,q1,q4 → **91.3 FULL** · separation +8.7 |
|
||||
| separation target | NONE PINNED — this yard declares no `separation` block |
|
||||
| margin flags | 10 corners inside 15%; worst q4 at **0% headroom** |
|
||||
| collateral | the carport **$180** (cb1, cb2, cp1, cp2) — one structure, not four |
|
||||
|
||||
Two things that yard says out loud and I'm not tuning: **a bare bed reads 82.6 FULL and the
|
||||
best clean line is worth +8.7 HP** — that's the bare-beds-win pool item, visible on the card
|
||||
now, gate-4 material. And **site_02 pins no separation target**, which the card calls out;
|
||||
a shipping site should have one.
|
||||
|
||||
**THE SCORE NEVER TOUCHES `EDITOR.world`.** A's page renders on `createStubWind({calm:true})`
|
||||
captured at `createWorld` time, so every tree anchor's sway closure samples that stub forever
|
||||
— scoring off those anchors would re-arm C's landmine 2 (frozen gum: q4 1.02 vs live 1.24)
|
||||
silently. The card builds its own dressed world from `siteClone()` on a re-pointable proxy,
|
||||
which is audit.html's Sprint-10 pattern and A's contract taken literally. **The stub cannot
|
||||
reach a score by construction.** No fourth wind harness: every m/s is `windForSite()`.
|
||||
|
||||
**ONE ENGINE, NOT TWO FRONT-ENDS DRIFTING.** The sweep-fly-pick-judge logic audit.html had
|
||||
grown moved into `tools/site_audit/scorecard.js`; audit.html keeps its rendering and imports
|
||||
the rest. Pinned both shipped sites BEFORE the refactor and reproduced them to the digit
|
||||
after — backyard_01/wildnight (16 cands, 3 clean, 2 marginal, bare 35.7 tattered, best
|
||||
p1,p2,p3,p4 60.5, sep MEETS held 63.8) and site_02/earlybuster (66/20/1, bare 82.6 full,
|
||||
best 91.3). That check caught a real bug in my own refactor: `__audit.cands` went undefined
|
||||
because `scoreSite` returns the COUNT where the old local was the LIST.
|
||||
|
||||
**GATE 2.3 — C WAS RIGHT AND I WAS WRONG THREE TIMES.** C measured my proposed pin and it
|
||||
could not have failed. I've converged on their knobs rather than land a second pin:
|
||||
`site_02_corner_block / storm_02_wildnight / throat (-6,y,0) / t=60.0 / exact ===`.
|
||||
|
||||
| # | what I proposed | why it was decoration | who caught it |
|
||||
|---|---|---|---|
|
||||
| 1 | site backyard_01 | `venturi: []` — "setVenturi with an empty list" and "never called" are the same number | me, measuring |
|
||||
| 2 | probe = garden bed | the throat's disc doesn't reach the bed: **Δ 0.0000 m/s** | both, independently |
|
||||
| 3 | second t=30 | funnel worth **0.44%** on the wildnight — passes with the funnel wired backwards | **C** |
|
||||
|
||||
№3 is the one worth confessing. My throat probe measured a healthy **Δ +4.948 m/s** and I
|
||||
trusted it — but that was on `storm_03b_earlybuster`, not the wildnight the pin flies. **A
|
||||
sensitivity measured on one storm says nothing about another**, and I generalised across
|
||||
exactly that gap. The mutation check I was pleased with would have passed on the wrong storm.
|
||||
Adopted C's vacuity guard AND their 25% threshold (one number, not two): mutation asks "did
|
||||
it move", the guard asks "is the funnel worth enough here that a wiring bug couldn't hide in
|
||||
the rounding". Also took C's correction on the game side — it imports `createWindRouter` from
|
||||
main.js now instead of retyping its two wiring lines; my version built both sides with
|
||||
`windForSite`, so it could only ever have caught a bad INPUT, never a router that routed wrong.
|
||||
|
||||
**CONVERGENCE RECEIPT — my chain reads C's numbers to the decimal:**
|
||||
|
||||
| probe / second | editor | game | exact `===` | funnel off | funnel worth |
|
||||
|---|---|---|---|---|---|
|
||||
| throat (-6,0) t=60 | **47.38** | **47.38** | ✓ | 31.60 | **33.3%** |
|
||||
| throat (-6,0) t=30 | 18.55 | — | — | 18.46 | 0.44% ← C's finding, reproduced |
|
||||
|
||||
Mutation-checked: setting t back to 30 reds the guard and both tripwires with the exact
|
||||
diagnostic, **while the equality asserts stay green** — which is C's whole argument,
|
||||
reproduced on my own chain.
|
||||
|
||||
**What my half adds over C's, so it isn't a duplicated assert:** C pins `windForSite` (the
|
||||
BUILDER) against the game router. Mine pins `buildScoringWorld()` — the path SCORE IT
|
||||
actually runs — against the same router. Three chains, one number. **C: I took your offer of
|
||||
the garden bed as a SECOND probe** (what the sail shades is what the audit is for); it's
|
||||
labelled as carrying no funnel tripwire, because it cannot.
|
||||
|
||||
**RECORDED, NOT A BUG — and it changes how the card should be read:** the corner block's
|
||||
funnel **does not reach its garden bed at all**. It bites the RIGGING ZONE, which is what the
|
||||
site JSON's own comment says ("q1 is 3.5 m away, inside the radius"). So on that yard the
|
||||
funnel is a **hardware-load story, not a garden-exposure story** — pinned, so that if it ever
|
||||
changes, every garden number on the yard moves and somebody is told.
|
||||
|
||||
**COLLATERAL ON THE CARD** (integrator's flag). "Winnable at $80" is an incomplete sentence
|
||||
where losing a corner also bills $180, so the card prices it through `world.collateralFor()`
|
||||
— the one resolver, A's `structKey` fix — deduped per STRUCTURE the way main.js bills. The
|
||||
bill also rides on the margin-flag line itself, because "knife-edge" and "knife-edge AND
|
||||
$180" are different authoring decisions. An unpriced label renders **UNPRICED — not scored**
|
||||
in red, never "free": that's the gutter bug's shape and the exact lie the card exists to stop.
|
||||
**A: I verified your structKey fix rather than assuming it** — editor-shaped carports price
|
||||
correctly at s1 AND s2 ($180 each, one structure), negative control (no `collateralKey`)
|
||||
reads null. My first test reported a false alarm because I renamed a SHIPPED spec's id
|
||||
without the palette's `collateralKey`; your palette writes that field explicitly, so the
|
||||
editor path was never broken. Sorry for the scare, and the negative control is now in the file.
|
||||
|
||||
**POOL ITEM LANDED — the night-5 invoice line** (offered S13, A accepted). Night 5's garden
|
||||
is beyond saving by design, and the job sheet was the last surface still dangling "up to $45 /
|
||||
paid on what's left of the bed at dawn" — a maximum nobody can earn, phrased as a rigging
|
||||
problem the player could solve. **NO ECONOMY CHANGE, deliberately:** week.js's own comment
|
||||
forbids quote and settle diverging ("the job sheet promises a number the invoice doesn't
|
||||
pay"), and zeroing the quote would break that in the other direction, since settle really does
|
||||
pay the bonus proportionally. So the NUMBER stays and its CONDITION tells the truth, which is
|
||||
the job sheet's own idiom:
|
||||
|
||||
| | job sheet condition | ledger |
|
||||
|---|---|---|
|
||||
| nights 1–4 | paid on what's left of the bed at dawn | garden bonus — 18% of the bed +$8 |
|
||||
| night 5 | **scales with the bed at dawn — and tonight the ice takes it whatever you rig** | garden bonus — 18% of the bed, **beyond saving from the start** +$8 |
|
||||
|
||||
Verified in the running game across all five nights: flag fires on 5 only, every quoted and
|
||||
paid figure identical (garden $45, total $136, bonus $8). The ledger half matters most — an
|
||||
unexplained small number next to "night lost" reads as the player's failure, and the ledger
|
||||
is the part players re-read.
|
||||
|
||||
**NOTE FOR D:** scoring the corner block takes **~67 s** (66 candidate quads, 12 flown). Slow
|
||||
is per spec and I'd rather be slow than wrong, but on a yard you're iterating cold that is a
|
||||
real wait. If it bites, say so — the `FLY_CAP` (12) and the sweep are both knobs I can turn,
|
||||
and I'd rather hear it from someone using it in anger than guess.
|
||||
|
||||
@ -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 |
@ -25,6 +25,19 @@
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||
|
||||
Served from the repo root (server.py), so the relative imports resolve.
|
||||
|
||||
SPRINT14: this page's RUN LOGIC moved to scorecard.js and it kept only its
|
||||
rendering. A third front-end arrived (A's editor, gate 2.1, where the yard
|
||||
being scored has never been a file), and the sweep-fly-pick-judge sequence
|
||||
this page had grown was about to exist in two places — which is the exact
|
||||
failure sweep.js was extracted to prevent, one level up. `scoreSite()` takes
|
||||
a site OBJECT; fetching one is this page's business and nobody else's.
|
||||
|
||||
Verified by measurement, not by reading: the refactor was pinned against
|
||||
both shipped sites BEFORE it happened and reproduced them to the digit —
|
||||
backyard_01/wildnight (16 cands, 3 clean, 2 marginal, bare 35.7 tattered,
|
||||
best p1,p2,p3,p4 60.5, sep MEETS held 63.8) and site_02/earlybuster (66
|
||||
cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3).
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>site_audit</title>
|
||||
@ -51,11 +64,10 @@
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { loadSite } from '../../web/world/js/world.js';
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||||
import { AUDIT } from './sweep.js';
|
||||
import { scoreSite, FLY_CAP } from './scorecard.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const siteName = q.get('site') || 'backyard_01';
|
||||
@ -65,41 +77,30 @@ const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
async function run() {
|
||||
// Build the site the way the game does — data in, dressed yard out — on a
|
||||
// RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly
|
||||
// world.anchors THEMSELVES. The old version here remapped every anchor to a
|
||||
// static `sway: () => pos`, which froze the gum tree — the same landmine
|
||||
// that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig
|
||||
// eats the tree's sway as dynamic load). Backyard posts never noticed;
|
||||
// every tr1/t1/t2 line was optimistic.
|
||||
// Fetching the site is this page's job; scoring it is scorecard.js's. The
|
||||
// whole "build a dressed world on a re-pointable wind proxy so live tree-sway
|
||||
// closures sample the storm being flown" dance lives in buildScoringWorld()
|
||||
// now — including the reason it must (a static `sway: () => pos` remap froze
|
||||
// the gum tree and under-read every tree corner: C's landmine 2).
|
||||
const site = await loadSite(siteName);
|
||||
const scene = new THREE.Scene();
|
||||
let currentWind = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
|
||||
|
||||
// world.anchors, LIVE — sway closures and ratingHint intact. The sweep and
|
||||
// every garden flight below re-point the proxy at their own wind via `use`.
|
||||
const anchors = world.anchors;
|
||||
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
|
||||
const vlist = site.wind?.venturi ?? [];
|
||||
// The separation block names its own storm; load it if it differs.
|
||||
const sepKey = site.separation?.stormKey ?? null;
|
||||
const sepStormDef = sepKey
|
||||
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
|
||||
: null;
|
||||
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
|
||||
const {
|
||||
dressed, cands, rows, verdict, winners, marginalWinners,
|
||||
flown, skipped, bare, separation: sep, sepStormName,
|
||||
} = score;
|
||||
const anchors = { length: score.anchorCount };
|
||||
const bestGarden = score.bestGarden ? { key: score.bestGarden.ids, g: score.bestGarden } : null;
|
||||
const bestMarginal = score.bestMarginal ? { key: score.bestMarginal.ids, g: score.bestMarginal } : null;
|
||||
|
||||
const vlist = score.venturi;
|
||||
const vtxt = vlist.length
|
||||
? vlist.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
|
||||
: 'none';
|
||||
@ -110,55 +111,9 @@ async function run() {
|
||||
`venturi: ${vtxt}\n` +
|
||||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||||
|
||||
// The venturi rides in on the SITE def — windForSite (C's shared builder,
|
||||
// inside sweep.js and gardenfly.js) is the ONE door site wind comes through
|
||||
// now. Three harnesses independently mis-built it; there is no fourth copy.
|
||||
const bed = world.gardenBed;
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
// ── SPRINT13: fly the garden for every line the budget can actually buy ──
|
||||
// The scoring quantity is the FLOWN outcome (gardenfly.js — real attach,
|
||||
// real skyfx exposure, real garden.js), not static cover%. Marginal lines
|
||||
// fly too, deliberately: they are the trap the margin rule exists to name.
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
const FLY_CAP = 12;
|
||||
const flown = new Map();
|
||||
for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) {
|
||||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||||
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId
|
||||
flown.set(r.ids.join(','), flyGarden({
|
||||
anchors, bed, stormDef, siteDef: site, use,
|
||||
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
|
||||
}));
|
||||
}
|
||||
const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP);
|
||||
|
||||
// best GARDEN line the budget buys — the audit's answer to "what should I
|
||||
// rig", which used to be "the cheapest line that holds" and is now "the line
|
||||
// that saves the garden" — cheapest on ties, and never a marginal one (a
|
||||
// marginal flight is C's 91.9-FULL illusion; it gets reported, not sold).
|
||||
// `rowBy` is the only helper the RENDER still needs (the picking it used to
|
||||
// serve moved into scoreSite).
|
||||
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
|
||||
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
|
||||
if (!best) return { key, g };
|
||||
if (g.hp > best.g.hp + 0.05) return { key, g };
|
||||
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
|
||||
return best;
|
||||
}, null);
|
||||
const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean);
|
||||
const bestGarden = pickBest(cleanFlown);
|
||||
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
|
||||
|
||||
// ── the site's pinned separation target (A's gate-1.4 ruling), judged on the
|
||||
// block's OWN storm — the target is site data, not a per-run choice.
|
||||
let sep = null, sepStormName = null;
|
||||
if (site.separation) {
|
||||
sepStormName = site.separation.stormKey;
|
||||
const sepStorm = sepStormName === stormName ? stormDef
|
||||
: await loadJSON(`../../web/world/data/storms/${sepStormName}.json`);
|
||||
sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||||
}
|
||||
|
||||
// render rows — cover% is a DIAGNOSTIC column now (static overhead projection
|
||||
// of the taut attach shape); the flown garden column is what scores.
|
||||
@ -261,7 +216,7 @@ async function run() {
|
||||
// a machine-readable line, so this page can also be driven headless-in-browser
|
||||
window.__audit = {
|
||||
site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length,
|
||||
cands: cands.length, verdict,
|
||||
cands, verdict, // scoreSite already returns the COUNT, not the list
|
||||
winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })),
|
||||
marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw,
|
||||
marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })),
|
||||
|
||||
287
tools/site_audit/scorecard.js
Normal file
287
tools/site_audit/scorecard.js
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* scorecard.js — score a site OBJECT, in-memory, through the real chain.
|
||||
* [Lane B, SPRINT14 gate 2.1]
|
||||
*
|
||||
* Sprint 10 gave the audit two front-ends (audit.mjs, audit.html) and ONE
|
||||
* engine (sweep.js), on the theory that a tool built to catch reimplemented-
|
||||
* formula drift must not carry two copies of its own math. Sprint 14 adds a
|
||||
* THIRD front-end — A's editor, where the yard being scored has never been a
|
||||
* file — so the run logic that audit.html had grown (build the world, fly the
|
||||
* winners, pick the best line, judge the separation block) moves HERE, where
|
||||
* both pages import it. audit.html keeps its rendering and loses its logic.
|
||||
*
|
||||
* ── The one thing this module exists to get right ──────────────────────────
|
||||
*
|
||||
* The audit's input has always been a site JSON fetched off disk. The editor's
|
||||
* yard is an object that has never been written down. That is the whole change,
|
||||
* and it is smaller than it looks: `loadSite()` returns a parsed object, so
|
||||
* every engine below already took an object — the fetch was the front-end's
|
||||
* business, never the audit's. `scoreSite({ site })` takes the object; where it
|
||||
* came from is not this module's problem. A's `EDITOR.siteClone()` hands over
|
||||
* the canonically-ordered clone that the export writes, so an editor score is a
|
||||
* score of the bytes you would ship, not of an editor-private object.
|
||||
*
|
||||
* ── Why this builds its OWN world, and why that is not a private harness ────
|
||||
*
|
||||
* A's editor renders on `createStubWind({ calm: true })`, captured at
|
||||
* `createWorld` time and deliberately calm: gate 1 is geometry, and a yard you
|
||||
* can only place a post in during a gale is not authorable. That stub must
|
||||
* never reach a score — and it would, silently, if this module read
|
||||
* `EDITOR.world.anchors`, because a tree anchor's `sway` closure samples the
|
||||
* wind its world was built with, forever. That is C's landmine 2 wearing a new
|
||||
* hat: the frozen gum tree read q4 at 1.02 against a live 1.24, and a tree rig
|
||||
* eats the tree's sway as dynamic load. So the scoring world is a SEPARATE
|
||||
* world, built here from the site object on a re-pointable wind proxy, exactly
|
||||
* as audit.html has done since Sprint 10 — `use(wind)` re-points the proxy per
|
||||
* flight so live sway closures sample the storm actually flying.
|
||||
*
|
||||
* This is not a fourth wind harness. Every m/s still comes from `windForSite()`
|
||||
* (C's shared builder) inside sweep.js and gardenfly.js; this module never
|
||||
* builds a wind, it only owns the proxy the flights re-point. The stub cannot
|
||||
* reach the score because the scoring world has never seen it.
|
||||
*
|
||||
* ── What it does NOT do ────────────────────────────────────────────────────
|
||||
*
|
||||
* No I/O. Storm defs arrive parsed, because a storm is content and the caller
|
||||
* knows where content lives (the editor is on a page with an importmap; the
|
||||
* node front-end has a filesystem). This module fetches nothing so it can be
|
||||
* driven from a selftest without a network.
|
||||
*
|
||||
* Browser-only: `dress()` needs GLTFLoader and gardenfly needs `document`.
|
||||
* audit.mjs stays node-side and blind, and still points here for garden truth.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld } from '../../web/world/js/world.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||||
|
||||
/** How many affordable lines get flown. Flights are seconds each; the card is
|
||||
* on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
|
||||
* anchors is a hang, not a score. */
|
||||
export const FLY_CAP = 12;
|
||||
|
||||
/**
|
||||
* Build a world for SCORING from a site object — dressed, on a re-pointable
|
||||
* wind proxy. Never the editor's own world (see the header).
|
||||
*
|
||||
* The proxy starts on a calm placeholder purely so `createWorld` and `dress()`
|
||||
* have something to call during construction; no score is ever taken against
|
||||
* it, because every flight calls `use()` first. It is not the editor's stub and
|
||||
* it is not reachable from one.
|
||||
*
|
||||
* @param {object} site parsed/cloned site object (loadSite's shape)
|
||||
* @returns {Promise<{world, anchors, bed, use, dressed, dressError}>}
|
||||
*/
|
||||
export async function buildScoringWorld(site) {
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
let currentWind = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
/** C's bench pattern: re-point the yard's wind at the storm being flown, so
|
||||
* live tree-sway closures move with it. Handed to every sweep/flight below. */
|
||||
const use = (w) => { currentWind = w; };
|
||||
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
|
||||
let dressed = false, dressError = null;
|
||||
if (world.dress) {
|
||||
try { await world.dress(); dressed = true; }
|
||||
catch (err) { dressError = err?.message ?? String(err); }
|
||||
}
|
||||
|
||||
return { world, anchors: world.anchors, bed: world.gardenBed, use, dressed, dressError };
|
||||
}
|
||||
|
||||
/**
|
||||
* The full gauntlet against one site object.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object} o.site site object (EDITOR.siteClone() / loadSite())
|
||||
* @param {object} o.stormDef the storm to sweep and fly
|
||||
* @param {string} [o.stormName] label only
|
||||
* @param {object} [o.sepStormDef] storm for the site's pinned separation block;
|
||||
* omit and separation is judged on stormDef if
|
||||
* the keys match, else reported unjudged
|
||||
* @param {number} [o.flyCap]
|
||||
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
|
||||
* @returns {Promise<object>} pure data — no DOM, no strings-as-verdicts
|
||||
*/
|
||||
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
|
||||
const built = prebuilt ?? await buildScoringWorld(site);
|
||||
const { world, anchors, bed, use, dressed, dressError } = built;
|
||||
|
||||
// The funnel state is REPORTED, never assumed. Sprint 11 shipped an audit
|
||||
// whose headline was wrong because the venturi lives in the SITE def and a
|
||||
// storm def's `wind.venturi` LOOKS right and never fires; the fix was
|
||||
// windForSite, and the discipline that came with it is that any front-end
|
||||
// printing a score must also print whether the funnel was on. Three
|
||||
// harnesses got this wrong; the card says it out loud so a fourth can't.
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
|
||||
// What a lost corner BILLS, resolved through the world's own pricing path.
|
||||
//
|
||||
// The card's headline is "winnable at $80", and that sentence is incomplete
|
||||
// if letting a corner go also costs $180 of carport. Priced here rather than
|
||||
// in the front-end for the reason everything else is: `world.collateralFor()`
|
||||
// is the one resolver, it reads site JSON first and the GLB extra second
|
||||
// (SPRINT14 — A's structKey fix, after id-equality made the first
|
||||
// editor-made carport free), and a second copy of that lookup in a panel is
|
||||
// how a yard's trap silently prices to nothing.
|
||||
//
|
||||
// `null` from collateralFor means UNPRICED, never free — the house's fascia
|
||||
// anchors say `collateral:"gutter"` and a gutter had no price for two
|
||||
// sprints. The card must say "not scored" there, because "free" is the lie
|
||||
// that makes a dangerous yard look safe.
|
||||
const collateral = {};
|
||||
for (const a of anchors) {
|
||||
if (!a.collateral) continue;
|
||||
const priced = world?.collateralFor?.(a.collateral) ?? null;
|
||||
collateral[a.id] = priced
|
||||
? { key: a.collateral, cost: priced.cost, label: priced.label }
|
||||
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
|
||||
}
|
||||
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
// The bare bed — the control every garden number is read against.
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
|
||||
// they are the trap the margin rule exists to name, and a card that hid them
|
||||
// would be the 91.9-FULL illusion with better CSS.
|
||||
const flown = new Map();
|
||||
for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) {
|
||||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||||
// Aligned by anchorId, never input order — attach reorders picks into ring
|
||||
// order and a tier quoted against input order arms the wrong corner.
|
||||
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier]));
|
||||
flown.set(r.ids.join(','), flyGarden({
|
||||
anchors, bed, stormDef, siteDef: site, use,
|
||||
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
|
||||
}));
|
||||
}
|
||||
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
|
||||
|
||||
// Best GARDEN line the budget buys — cheapest on ties, and never a marginal
|
||||
// one. A marginal flight gets reported, not sold.
|
||||
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
|
||||
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
|
||||
if (!best) return { key, g };
|
||||
if (g.hp > best.g.hp + 0.05) return { key, g };
|
||||
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
|
||||
return best;
|
||||
}, null);
|
||||
const bestGarden = pickBest([...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean));
|
||||
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
|
||||
|
||||
// The site's pinned separation target (A's gate-1.4 ruling), on the block's
|
||||
// OWN storm — the target is site data, not a per-run choice.
|
||||
let separation = null, sepStormName = null, sepUnjudged = null;
|
||||
if (site.separation) {
|
||||
sepStormName = site.separation.stormKey;
|
||||
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
|
||||
if (sepStorm) {
|
||||
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||||
} else {
|
||||
// Judging a pinned target on the wrong storm is worse than not judging it.
|
||||
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
site: site.id ?? site.name ?? '(unnamed)',
|
||||
storm: stormName, dressed, dressError,
|
||||
venturi, funnelOn: venturi.length > 0,
|
||||
anchorCount: anchors.length, bed,
|
||||
cands: cands.length, rows, winners, marginalWinners, verdict,
|
||||
flown, skipped, flyCap,
|
||||
bare,
|
||||
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
|
||||
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
|
||||
separation, sepStormName, sepUnjudged,
|
||||
collateral,
|
||||
MARGIN: AUDIT.MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest line that is HONEST — clean at the shop's clean price, and
|
||||
* proven to beat a bare bed in flight. Sprint 13's lesson priced into one
|
||||
* helper: "cheapest that holds" sold a $20 rig the sim paid −$97 for, so
|
||||
* cheapness is only a virtue among lines that actually saved something.
|
||||
*
|
||||
* @returns {{row, garden, total}|null}
|
||||
*/
|
||||
export function cheapestHonest(score) {
|
||||
const cands = score.winners
|
||||
.map((r) => ({ row: r, garden: score.flown.get(r.ids.join(',')) ?? null }))
|
||||
.filter((c) => c.garden && !c.garden.marginal.length && c.garden.hp > score.bare.hp);
|
||||
if (!cands.length) return null;
|
||||
cands.sort((a, b) => a.row.cleanHw - b.row.cleanHw);
|
||||
const best = cands[0];
|
||||
return { ...best, total: best.row.cleanHw + AUDIT.SPARE_COST };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every corner, across every flown line, sitting inside the margin band —
|
||||
* the 15% rule's flag list, deduped by anchor and worst-first.
|
||||
*
|
||||
* MANUAL.md records the rule as POLICY while the cause is unfound: a bench
|
||||
* under-reads the real UI's peaks by ~5–15%, so a corner that holds on paper
|
||||
* with 4% headroom is D's 39.8 TATTERED in play. Marginal is not PASS.
|
||||
*/
|
||||
export function marginFlags(score) {
|
||||
const worst = new Map();
|
||||
for (const r of score.rows) {
|
||||
for (const c of r.marginal) {
|
||||
const prev = worst.get(c.id);
|
||||
if (!prev || c.headroom < prev.headroom) {
|
||||
worst.set(c.id, { id: c.id, headroom: c.headroom, peak: c.peak, hint: c.hint, line: r.ids.join(','),
|
||||
// what this corner bills if it lets go — the flag and the bill belong
|
||||
// on the same line, because "knife-edge" and "knife-edge AND $180"
|
||||
// are different decisions for the person authoring the yard
|
||||
collateral: score.collateral[c.id] ?? null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...worst.values()].sort((a, b) => a.headroom - b.headroom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every priced thing in the yard a lost corner could wreck, deduped by
|
||||
* structure — main.js bills per STRUCTURE, not per corner ("two beams letting
|
||||
* go is one carport gone, not $360"), so exposure has to dedupe the same way
|
||||
* or the card invents money.
|
||||
*
|
||||
* @returns {{priced: Array, unpriced: Array, total: number}}
|
||||
*/
|
||||
export function collateralExposure(score) {
|
||||
const byKey = new Map();
|
||||
for (const [anchorId, c] of Object.entries(score.collateral)) {
|
||||
const e = byKey.get(c.key) ?? { ...c, anchors: [] };
|
||||
e.anchors.push(anchorId);
|
||||
byKey.set(c.key, e);
|
||||
}
|
||||
const all = [...byKey.values()];
|
||||
const priced = all.filter((e) => !e.unpriced);
|
||||
return {
|
||||
priced,
|
||||
unpriced: all.filter((e) => e.unpriced),
|
||||
total: priced.reduce((s, e) => s + e.cost, 0),
|
||||
};
|
||||
}
|
||||
343
tools/site_audit/scorecard.selftest.js
Normal file
343
tools/site_audit/scorecard.selftest.js
Normal file
@ -0,0 +1,343 @@
|
||||
/**
|
||||
* scorecard.selftest.js — GATE 2.3: the editor scores the wind the game plays.
|
||||
* [Lane B, SPRINT14; co-owned with Lane C]
|
||||
*
|
||||
* SPRINT13's whole lesson, made into an assert. Three harnesses independently
|
||||
* rebuilt site wind by hand and each got it wrong in a different way (funnel
|
||||
* off twice, frozen tree sway once); the fix was `windForSite()`, one door, and
|
||||
* the rule that no tool builds site wind again. Sprint 14 adds a fourth place
|
||||
* that scores a yard — A's editor — so the rule needs a tripwire rather than a
|
||||
* promise. This is it:
|
||||
*
|
||||
* the wind the EDITOR scores == the wind the GAME plays,
|
||||
* at one probe point, at one storm second, EXACTLY.
|
||||
*
|
||||
* Exactly, not nearly. A tolerance is precisely where the funnel-off bug hid:
|
||||
* it moved the Sprint-13 headline from 91.5 FULL to 39.8 TATTERED, so any
|
||||
* epsilon loose enough to feel "safe" is loose enough to hide the bug this
|
||||
* assert exists to catch. Two chains that agree agree to the last bit.
|
||||
*
|
||||
* ── The two chains, built independently on purpose ─────────────────────────
|
||||
*
|
||||
* GAME loadSite(name) → createWorld → dress() → **main.js's own
|
||||
* `createWindRouter`**, wired by main.js's own two lines
|
||||
* (loadSiteInto: setVenturi then setSheltersFromTrees). Importing the
|
||||
* router rather than retyping those lines is C's correction to my
|
||||
* first draft, and it matters: my version built BOTH sides with
|
||||
* `windForSite`, so it could only ever have caught a bad INPUT, never
|
||||
* a router that routed wrong.
|
||||
*
|
||||
* EDITOR siteClone-shaped object → buildScoringWorld() → dress() →
|
||||
* windForSite(storm, clone, scoringWorld.anchors). The editor's yard
|
||||
* is an object that was never a file, and its scoring world is built
|
||||
* fresh (A's page renders on a calm stub whose sway closures must
|
||||
* never reach a score — C's landmine 2 wearing a new hat). This is
|
||||
* the path SCORE IT actually runs, which is what my half adds over
|
||||
* C's: C pins the BUILDER against the game, this pins the thing the
|
||||
* BUTTON runs against the game. Three chains, one number.
|
||||
*
|
||||
* The clone goes through a JSON round-trip because that is what `siteClone()`
|
||||
* and the export actually produce: if canonicalisation ever dropped or
|
||||
* reshaped something wind-relevant, the editor would score a yard whose
|
||||
* weather nobody could play. This file does not import `editor.js` — the pin
|
||||
* is about the two WIND CHAINS, and importing A's page would make lane/b's
|
||||
* selftest unable to run on lane/b.
|
||||
*
|
||||
* ── WHERE the probe goes: C's knobs, and the three wrong answers before them ──
|
||||
*
|
||||
* **This pin uses Lane C's knobs, not the ones I proposed.** C co-owns gate
|
||||
* 2.3, measured my proposal, and moved all three; converging beats landing a
|
||||
* second pin that disagrees, so the numbers below are theirs.
|
||||
*
|
||||
* site site_02_corner_block the only yard with a shipped venturi
|
||||
* storm storm_02_wildnight (mine, kept)
|
||||
* probe (-6, y, 0) the authored throat centre — site geometry,
|
||||
* not a magic number; speedAt ignores y
|
||||
* second t = 60.0 the alignment plateau, where the funnel is
|
||||
* worth about a third of the answer
|
||||
* match exact `===`
|
||||
*
|
||||
* I proposed backyard_01 / bed corner / t=30. All three were wrong, and it is
|
||||
* worth writing down because they are the SAME failure three times:
|
||||
*
|
||||
* 1. backyard_01 declares `venturi: []`. There, "setVenturi called with an
|
||||
* empty list" and "setVenturi never called" are the same number — the
|
||||
* regression the pin exists to catch is invisible. (Found while measuring;
|
||||
* it is why the pin moved to site_02 at all.)
|
||||
* 2. site_02's BED cannot see the funnel either — the throat's disc doesn't
|
||||
* reach it. Measured Δ 0.0000 m/s, independently by both of us.
|
||||
* 3. t = 30 is a second where the wildnight's direction does not line up with
|
||||
* the gap: C measured the funnel worth 0.4% at the throat there. **This one
|
||||
* I missed.** My throat measurement read a healthy Δ +4.948 m/s and I
|
||||
* trusted it — but that was on `storm_03b_earlybuster`, not on the
|
||||
* wildnight this pin flies. A sensitivity measured on one storm says
|
||||
* nothing about another, and I generalised across exactly that gap. The
|
||||
* mutation check I was proud of would have passed on the wrong storm.
|
||||
*
|
||||
* A pin at 0.4% passes with the funnel wired backwards. Hence the vacuity
|
||||
* guard below: it re-measures what the funnel is WORTH at the pin and fails
|
||||
* under 25% — the check that would have caught my proposal on its own.
|
||||
*
|
||||
* ── The bed is kept, as a SECOND probe, on C's offer ───────────────────────
|
||||
*
|
||||
* C offered to carry the garden bed as a second probe rather than the one, and
|
||||
* I took it: what the sail SHADES is what the audit is ultimately about, and
|
||||
* equality there is a real claim even though the funnel is not what decides it.
|
||||
* It is labelled honestly and carries no funnel tripwire, because it cannot. A
|
||||
* second probe that measures a different thing is worth having; a first probe
|
||||
* that measures nothing is not.
|
||||
*
|
||||
* A finding worth more than the pin, which C reached independently: the corner
|
||||
* block's funnel does not reach its garden bed at all. It bites the RIGGING
|
||||
* ZONE — what the site JSON's own comment says ("q1 is 3.5 m away, inside the
|
||||
* radius"). The funnel on that yard is a HARDWARE-LOAD story, not a
|
||||
* garden-exposure story, and the score card should be read that way.
|
||||
*
|
||||
* ── Vacuous-pass guard ─────────────────────────────────────────────────────
|
||||
*
|
||||
* `a === b` also passes when both are 0, or both are garbage from a chain that
|
||||
* silently did nothing. Every equality assert here is paired with a liveness
|
||||
* assert (finite, non-trivial) and every claim of sensitivity is paired with a
|
||||
* mutation assert. An assert that cannot fail is documentation, and
|
||||
* documentation cannot fail.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.js';
|
||||
// main.js's OWN router, not a retyped copy of its two wiring lines — C's
|
||||
// correction, and the right call: a pin that retypes the thing it pins agrees
|
||||
// with itself by construction.
|
||||
import { createWindRouter } from '../../web/world/js/main.js';
|
||||
import { buildScoringWorld } from './scorecard.js';
|
||||
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
/**
|
||||
* GATE 2.3's pinned knobs — Lane C's, adopted. One copy, both lanes, no drift.
|
||||
* If either half moves these, it moves them here and says so in THREADS.
|
||||
*/
|
||||
export const PIN = {
|
||||
site: 'site_02_corner_block',
|
||||
storm: 'storm_02_wildnight',
|
||||
t: 60.0,
|
||||
probeY: 0,
|
||||
/** The funnel must be worth at least this much AT THE PIN or the pin is
|
||||
* decoration. C's threshold, shared rather than re-chosen. */
|
||||
MIN_FUNNEL_SHARE: 0.25,
|
||||
};
|
||||
|
||||
/** Probe sets per yard. Each probe says what it is ALLOWED to claim. */
|
||||
const PROBES = {
|
||||
site_02_corner_block: {
|
||||
// THE pin: the authored throat centre, where the funnel decides the answer
|
||||
throat: { x: -6, z: 0 },
|
||||
// C's offered SECOND probe — what the sail shades. Funnel-blind by
|
||||
// geometry (Δ 0.0000), so it carries equality only, and says so.
|
||||
bed: 'gardenBed',
|
||||
tree: { x: 7, z: -1 }, // tr1 — the shelter half
|
||||
},
|
||||
backyard_01: {
|
||||
bed: 'gardenBed',
|
||||
tree: { x: -9, z: 2 }, // t1, the gum whose sway is dynamic load
|
||||
},
|
||||
};
|
||||
|
||||
const vec = (p) => new THREE.Vector3(p.x, PIN.probeY, p.z);
|
||||
const resolveProbes = (name, world) => Object.fromEntries(
|
||||
Object.entries(PROBES[name]).map(([k, v]) =>
|
||||
[k, vec(v === 'gardenBed' ? { x: world.gardenBed.x, z: world.gardenBed.z } : v)]));
|
||||
|
||||
/**
|
||||
* The GAME chain: main.js's OWN router, wired by main.js's own two lines
|
||||
* (loadSiteInto — `setVenturi(siteDef.wind.venturi)` then
|
||||
* `setSheltersFromTrees(anchors.filter(type === 'tree'))`).
|
||||
*/
|
||||
async function gameChain(siteName, stormDef) {
|
||||
const site = await loadSite(siteName);
|
||||
const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site });
|
||||
await world.dress();
|
||||
const inner = createWind(stormDef);
|
||||
const router = createWindRouter([inner]);
|
||||
router.use(inner);
|
||||
router.setVenturi(site.wind?.venturi ?? []);
|
||||
router.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
return { site, world, wind: router };
|
||||
}
|
||||
|
||||
/** The EDITOR chain: a siteClone-shaped object through the scoring world. */
|
||||
async function editorChain(siteName, stormDef) {
|
||||
// What EDITOR.siteClone() hands over: a deep clone that has been through the
|
||||
// canonical export shape. JSON round-trip stands in for the key ordering —
|
||||
// the pin is that this survives as the SAME WEATHER.
|
||||
const clone = JSON.parse(JSON.stringify(await loadSite(siteName)));
|
||||
const built = await buildScoringWorld(clone);
|
||||
assert(built.dressed, `gate 2.3: scoring world for ${siteName} did not dress (${built.dressError}) — `
|
||||
+ 'an undressed yard has no fascia hints and is a different game');
|
||||
return { site: clone, built, wind: windForSite(stormDef, clone, built.anchors) };
|
||||
}
|
||||
|
||||
function stubProxy() {
|
||||
return {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, setVenturi() {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildScorecardTests() {
|
||||
const tests = [];
|
||||
|
||||
// ── the two shipped yards ────────────────────────────────────────────────
|
||||
// The pinned case first (C's knobs). backyard_01 rides along because
|
||||
// equality on a funnel-less yard is still a real claim about the clone and
|
||||
// the dress — it just carries no funnel tripwire, and says so.
|
||||
const cases = [
|
||||
{ site: PIN.site, storm: PIN.storm },
|
||||
{ site: 'backyard_01', storm: 'storm_02_wildnight' },
|
||||
];
|
||||
|
||||
const measured = {};
|
||||
for (const c of cases) {
|
||||
const stormDef = await loadStorm(c.storm);
|
||||
const game = await gameChain(c.site, stormDef);
|
||||
const editor = await editorChain(c.site, stormDef);
|
||||
|
||||
const gProbes = resolveProbes(c.site, game.world);
|
||||
const eProbes = resolveProbes(c.site, editor.built.world);
|
||||
measured[c.site] = { stormDef, editor, probes: eProbes };
|
||||
|
||||
tests.push([`gate 2.3: ${c.site} — both chains agree where the yard's probes ARE`, () => {
|
||||
for (const k of Object.keys(gProbes)) {
|
||||
assert(gProbes[k].equals(eProbes[k]),
|
||||
`gate 2.3: the chains disagree about the '${k}' probe on ${c.site} — `
|
||||
+ `game (${gProbes[k].x},${gProbes[k].z}) vs editor (${eProbes[k].x},${eProbes[k].z}). `
|
||||
+ 'A pin on two different points is not a pin.');
|
||||
}
|
||||
}]);
|
||||
|
||||
for (const k of Object.keys(gProbes)) {
|
||||
const gSpeed = game.wind.speedAt(gProbes[k], PIN.t);
|
||||
const eSpeed = editor.wind.speedAt(eProbes[k], PIN.t);
|
||||
const gVec = game.wind.sample(gProbes[k], PIN.t, new THREE.Vector3());
|
||||
const eVec = editor.wind.sample(eProbes[k], PIN.t, new THREE.Vector3());
|
||||
|
||||
tests.push([`gate 2.3: ${c.site} @${k} — editor-scored wind === game-played wind (t=${PIN.t})`, () => {
|
||||
// liveness first: `a === b` on two zeroes is not agreement, it is silence
|
||||
assert(Number.isFinite(gSpeed) && gSpeed > 1,
|
||||
`gate 2.3: the GAME chain read ${gSpeed} m/s at '${k}' — that is not a storm, it is a `
|
||||
+ 'broken chain, and an equality assert against it would pass vacuously');
|
||||
assert(gSpeed === eSpeed,
|
||||
`gate 2.3 FAILED on ${c.site} @${k}: the editor would score a wind the game never plays.\n`
|
||||
+ ` game ${gSpeed}\n editor ${eSpeed}\n delta ${eSpeed - gSpeed}\n`
|
||||
+ 'Both sides go through windForSite(); if they differ, the INPUTS differ — check the '
|
||||
+ 'venturi list survived the clone, and that the scoring world dressed.');
|
||||
assert(gVec.x === eVec.x && gVec.y === eVec.y && gVec.z === eVec.z,
|
||||
`gate 2.3: speeds matched but DIRECTIONS did not on ${c.site} @${k} — `
|
||||
+ `game (${gVec.x},${gVec.y},${gVec.z}) vs editor (${eVec.x},${eVec.y},${eVec.z}). `
|
||||
+ 'A sail cares which way the wind blows, so scalar agreement alone is not the pin.');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
// backyard_01 carries no funnel, and the pin must SAY so rather than let a
|
||||
// future reader assume the funnel-off tripwire covers both yards.
|
||||
tests.push(['gate 2.3: backyard_01 declares no venturi (so site_02 carries the funnel tripwire)', () => {
|
||||
const v = measured['backyard_01'].editor.site.wind?.venturi ?? [];
|
||||
assert(v.length === 0,
|
||||
`backyard_01 now declares ${v.length} venturi. That is not a failure — but the funnel `
|
||||
+ 'mutation check below lives on site_02 BECAUSE this yard had none, and if this yard grew '
|
||||
+ 'weather the pin should cover it too. Update the probe table.');
|
||||
}]);
|
||||
|
||||
// ── the mutation checks, on the yard that HAS weather ────────────────────
|
||||
// These are what stop the pin above from being decoration. Each one breaks an
|
||||
// input that a real harness has actually broken, and demands the number move.
|
||||
const fun = measured['site_02_corner_block'];
|
||||
const baseWind = () => windForSite(fun.stormDef, fun.editor.site, fun.editor.built.anchors);
|
||||
|
||||
/** Minimum move a mutation must produce to count as "this probe can feel it".
|
||||
* Not `!== 0`: a floating-point hair would satisfy that while the probe sat
|
||||
* effectively blind. Measured deltas here are ~4.9 m/s, so 1.0 is a floor
|
||||
* with two orders of headroom, not a threshold anyone tuned to pass. */
|
||||
const MIN_MOVE = 1.0;
|
||||
|
||||
// ── C's vacuity guard, adopted — threshold and all ───────────────────────
|
||||
// The strongest of the three checks, and the one that would have caught my
|
||||
// proposal on its own. A mutation check asks "did the number MOVE"; this asks
|
||||
// "is the funnel worth ENOUGH here that a wiring bug couldn't hide in the
|
||||
// rounding". At t=30 on this storm the answer was 0.4%.
|
||||
tests.push([`GATE 2.3 guard: the funnel is worth ≥${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% at the pinned probe/second`, () => {
|
||||
const probe = fun.probes.throat;
|
||||
const on = baseWind().speedAt(probe, PIN.t);
|
||||
const off = windForSite(fun.stormDef, { ...fun.editor.site, wind: { venturi: [] } },
|
||||
fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
const share = (on - off) / on;
|
||||
assert(Number.isFinite(share) && share >= PIN.MIN_FUNNEL_SHARE,
|
||||
`gate 2.3 GUARD FAILED: at (${probe.x},${probe.z}) t=${PIN.t} on ${PIN.storm} the funnel is `
|
||||
+ `worth ${(share * 100).toFixed(2)}% (${off.toFixed(2)} → ${on.toFixed(2)} m/s), under the `
|
||||
+ `${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% floor. The equality pin is now decoration — it `
|
||||
+ 'would pass with setVenturi deleted. Do NOT lower this floor: re-measure and move the '
|
||||
+ 'second, the way C moved it off t=30 (0.4%) onto the alignment plateau.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 mutation: dropping the venturi MOVES the throat (funnel-off tripwire)', () => {
|
||||
const probe = fun.probes.throat;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
||||
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
assert(Math.abs(before - after) >= MIN_MOVE,
|
||||
`gate 2.3 mutation FAILED: starving site_02 of its venturi moved the THROAT probe by `
|
||||
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}). This probe cannot SEE the `
|
||||
+ 'funnel, so the equality pin above would stay green through exactly the funnel-off bug it '
|
||||
+ 'exists to catch — the one that moved the S13 headline 91.5 → 39.8. This already happened '
|
||||
+ 'THREE times on this pin (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a '
|
||||
+ 'radius-5 throat; t=30 on the wildnight is worth 0.4%). Re-measure and move the probe; '
|
||||
+ 'do not relax the assert.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 mutation: hiding the trees MOVES the tree probe (shelter tripwire)', () => {
|
||||
// setSheltersFromTrees filters on type; anchors that no longer say "tree"
|
||||
// register no shelter. The frozen-tree landmine's cousin — the wind arrives
|
||||
// unsheltered and the yard is easier than the one that ships.
|
||||
const probe = fun.probes.tree;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const noTrees = fun.editor.built.anchors.map((a) => ({ ...a, type: a.type === 'tree' ? 'post' : a.type }));
|
||||
const after = windForSite(fun.stormDef, fun.editor.site, noTrees).speedAt(probe, PIN.t);
|
||||
assert(Math.abs(before - after) >= MIN_MOVE,
|
||||
`gate 2.3 mutation FAILED: hiding every tree moved the tree probe by `
|
||||
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}) — the ANCHOR half of `
|
||||
+ 'windForSite is not reaching this probe, so a harness that forgot tree shelters would '
|
||||
+ 'pin green.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.3 (2nd probe): the corner block\'s funnel does NOT reach its garden bed — recorded, not a bug', () => {
|
||||
// Not a defect — it is what the site JSON's own comment describes ("q1 is
|
||||
// 3.5 m away, inside the radius"): the funnel is aimed at the RIGGING
|
||||
// ZONE, so it is a hardware-load story rather than a garden-exposure one.
|
||||
// Pinned because it is load-bearing for anyone reading the score card: if
|
||||
// this ever changes, the bed probe silently becomes funnel-sensitive and
|
||||
// every garden number on that yard moves with it.
|
||||
const probe = fun.probes.bed;
|
||||
const before = baseWind().speedAt(probe, PIN.t);
|
||||
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
||||
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
||||
assert(before === after,
|
||||
`The funnel now reaches site_02's garden bed: ${before} → ${after} with the venturi removed. `
|
||||
+ 'That is a real design change (the bed sat 6.08 m from a radius-5 throat and felt nothing). '
|
||||
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
|
||||
}]);
|
||||
|
||||
// ── the clone is the same weather as the file ────────────────────────────
|
||||
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
|
||||
const v = fun.editor.site.wind?.venturi ?? [];
|
||||
assert(v.length === 1,
|
||||
`gate 2.3: the cloned site_02 has ${v.length} venturi, not 1 — the editor would score, and `
|
||||
+ 'export, a corner block with no weather. The funnel lives in the SITE def; if the clone '
|
||||
+ 'loses it the whole yard silently becomes an easier one.');
|
||||
assert(v[0].gain === 1.5 && v[0].axis === 2.1,
|
||||
`gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
|
||||
}]);
|
||||
|
||||
return tests;
|
||||
}
|
||||
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');
|
||||
}
|
||||
355
web/world/js/editor_score.js
Normal file
355
web/world/js/editor_score.js
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* editor_score.js — the SCORE IT panel. [Lane B, SPRINT14 gate 2.1]
|
||||
*
|
||||
* One button in A's editor runs the whole audit gauntlet against the yard
|
||||
* currently on the glass, in-page, on demand, and renders the answer as a card:
|
||||
* winnable lines at $80 with the funnel state SAID OUT LOUD, the cheapest
|
||||
* honest line, held-vs-bare garden separation, and the 15%-rule margin flags.
|
||||
*
|
||||
* ── This file's entire job is to be a front-end ─────────────────────────────
|
||||
*
|
||||
* It contains no audit logic and no wind. The gauntlet is `scorecard.js`, which
|
||||
* is the same engine `audit.html` runs; the wind inside it is `windForSite()`,
|
||||
* which is C's shared builder and the only door site wind comes through. If you
|
||||
* find yourself about to compute a load, a coverage or a m/s in this file, that
|
||||
* is the fourth harness arriving and Sprint 13 says it will be wrong in a new
|
||||
* way. Put it in the engine where both front-ends get it.
|
||||
*
|
||||
* ── What it deliberately does NOT read ──────────────────────────────────────
|
||||
*
|
||||
* `EDITOR.world`. Not once. A's page renders on a calm stub wind (correctly —
|
||||
* gate 1 is geometry), and a tree anchor's sway closure samples the wind its
|
||||
* world was built with forever, so scoring off the editor's anchors would
|
||||
* freeze every tree at calm and under-read every tree corner exactly the way
|
||||
* C's landmine 2 did (q4: 1.02 frozen vs 1.24 live). The score builds its own
|
||||
* dressed world from `EDITOR.siteClone()` — which is A's contract taken
|
||||
* literally ("that is what B feeds the audit"), and has the bonus that the
|
||||
* yard scored is the yard exported, byte for byte.
|
||||
*
|
||||
* ── Registration ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Self-registering on import: no exports anyone must call, no edit to
|
||||
* editor.html beyond the one import line that loads it (A's ask-1 seam,
|
||||
* THREADS 2026-07-18). Mounts at reserved order 40 via `EDITOR.mountPanel`,
|
||||
* fills `body`, and styles with A's contract classes only.
|
||||
*
|
||||
* Because it imports A's page, this module cannot run standalone on lane/b.
|
||||
* That is by design and is flagged in THREADS rather than worked around.
|
||||
*/
|
||||
|
||||
import { loadStorm } from './weather.js';
|
||||
import { START_BUDGET } from './contracts.js';
|
||||
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
|
||||
|
||||
/** The storms a yard gets judged against. `storm_02_wildnight` leads because
|
||||
* it is the storm every Sprint-13 number was argued over — score against the
|
||||
* one people remember. */
|
||||
const STORMS = [
|
||||
'storm_02_wildnight',
|
||||
'storm_01_gentle',
|
||||
'storm_02b_icenight',
|
||||
'storm_03_southerly',
|
||||
'storm_03b_earlybuster',
|
||||
];
|
||||
|
||||
const el = (tag, cls, text) => {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
};
|
||||
|
||||
/** A `.ed-card-row`: dim key on the left, value on the right. */
|
||||
function kv(parent, key, value, cls) {
|
||||
const row = el('div', 'ed-card-row');
|
||||
row.append(el('span', 'ed-kv', key));
|
||||
row.append(el('span', cls || null, value));
|
||||
parent.append(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function card(parent, head, headCls) {
|
||||
const c = el('div', 'ed-card');
|
||||
const h = el('div', `ed-card-head${headCls ? ' ' + headCls : ''}`, head);
|
||||
c.append(h);
|
||||
parent.append(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const pct = (x) => `${(x * 100).toFixed(0)}%`;
|
||||
const money = (n) => `$${n}`;
|
||||
|
||||
function boot() {
|
||||
const EDITOR = globalThis.EDITOR;
|
||||
if (!EDITOR) {
|
||||
console.error('[score] EDITOR missing — editor_score.js must be imported AFTER createEditor() resolves.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 });
|
||||
body.replaceChildren();
|
||||
|
||||
// --- controls ------------------------------------------------------------
|
||||
const rowStorm = el('div', 'ed-row');
|
||||
rowStorm.append(el('span', 'ed-label', 'storm'));
|
||||
const sel = el('select', 'ed-sel');
|
||||
for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s));
|
||||
rowStorm.append(sel);
|
||||
body.append(rowStorm);
|
||||
|
||||
const rowBtn = el('div', 'ed-row');
|
||||
const btn = el('button', 'ed-btn primary', 'SCORE IT');
|
||||
rowBtn.append(btn);
|
||||
body.append(rowBtn);
|
||||
|
||||
const out = el('div');
|
||||
body.append(out);
|
||||
|
||||
const note = el('div', 'ed-note',
|
||||
'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
|
||||
+ 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
|
||||
+ 'commit→attach chain. Takes a few seconds; that is the point.');
|
||||
body.append(note);
|
||||
|
||||
// A score describes the yard as it was WHEN SCORED. The moment anything
|
||||
// moves, the card is a claim about a yard that no longer exists — so it says
|
||||
// so rather than quietly ageing on screen. (Same instinct as A clearing the
|
||||
// overlay on rebuild: one place remembers staleness, nobody else has to.)
|
||||
let stale = false;
|
||||
const markStale = () => {
|
||||
if (!out.firstChild || stale) return;
|
||||
stale = true;
|
||||
const warn = el('div', 'ed-card-head ed-warn', '⚠ YARD CHANGED SINCE THIS SCORE — re-run');
|
||||
out.prepend(warn);
|
||||
};
|
||||
EDITOR.on('change', markStale);
|
||||
EDITOR.on('siteload', markStale);
|
||||
|
||||
async function run() {
|
||||
stale = false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'scoring…';
|
||||
out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…'));
|
||||
// yield so the button repaints before we block the thread for seconds
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const site = EDITOR.siteClone();
|
||||
const stormName = sel.value;
|
||||
const stormDef = await loadStorm(stormName);
|
||||
|
||||
// The separation block names its OWN storm — judging A's pinned target on
|
||||
// whatever storm the dropdown happens to show would be a different claim
|
||||
// wearing the target's name.
|
||||
const sepKey = site.separation?.stormKey ?? null;
|
||||
const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;
|
||||
|
||||
const built = await buildScoringWorld(site);
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built });
|
||||
render(out, score, performance.now() - t0);
|
||||
globalThis.__editorScore = score; // for the selftest + console spelunking
|
||||
} catch (err) {
|
||||
out.replaceChildren();
|
||||
const c = card(out, 'SCORE FAILED', 'ed-err');
|
||||
c.append(el('div', 'ed-note', String(err?.stack ?? err)));
|
||||
console.error('[score]', err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'SCORE IT';
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', run);
|
||||
}
|
||||
|
||||
/** Render a scorecard result. Pure DOM from pure data — no scoring here. */
|
||||
function render(out, s, ms) {
|
||||
out.replaceChildren();
|
||||
|
||||
// ── the header card: WHAT WAS SCORED, and the funnel state, loudly ────────
|
||||
// Sprint 11 shipped a headline built with the funnel off, and the number
|
||||
// looked entirely reasonable. Three harnesses made that mistake because
|
||||
// nothing printed the funnel state next to the score. This card does, first,
|
||||
// before any result — a score whose weather you can't see is a rumour.
|
||||
const head = card(out, s.funnelOn ? 'FUNNEL ON' : 'NO FUNNEL', s.funnelOn ? 'ed-warn' : null);
|
||||
kv(head, 'yard', s.site);
|
||||
kv(head, 'storm', s.storm ?? '—');
|
||||
kv(head, 'venturi', s.funnelOn
|
||||
? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
|
||||
: 'none declared in site.wind');
|
||||
kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`,
|
||||
s.dressed ? 'ed-ok' : 'ed-err');
|
||||
if (!s.dressed) {
|
||||
head.append(el('div', 'ed-note ed-err',
|
||||
`dress() failed (${s.dressError ?? 'unknown'}) — these are GRAYBOX positions and every `
|
||||
+ 'ratingHint silently became 1.0. That is the missing-importmap gotcha; the numbers below '
|
||||
+ 'are not what ships. Fix the page before believing this card.'));
|
||||
}
|
||||
kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`);
|
||||
kv(head, 'took', `${(ms / 1000).toFixed(1)} s`);
|
||||
|
||||
// ── 1. winnability at $80 ────────────────────────────────────────────────
|
||||
const V = s.verdict;
|
||||
const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err';
|
||||
const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY',
|
||||
unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code;
|
||||
const cw = card(out, `${vhead} — at ${money(START_BUDGET)}`, vcls);
|
||||
kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`);
|
||||
kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err');
|
||||
kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null);
|
||||
|
||||
if (V.code === 'no-cover') {
|
||||
cw.append(el('div', 'ed-note ed-err',
|
||||
'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
|
||||
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
|
||||
+ 'a yard failing here is failing on geometry, not on the heuristic.)'));
|
||||
} else if (V.code === 'marginal-only') {
|
||||
const b = V.best;
|
||||
cw.append(el('div', 'ed-note ed-warn',
|
||||
`Every affordable line carries a corner inside the ${pct(s.MARGIN)} break-in-game band. `
|
||||
+ `Best is ${b.ids.join(',')} at ${money(b.hw)} — on this bench it holds; in the real game it is `
|
||||
+ 'the wild night’s 39.8 TATTERED. Clean would cost '
|
||||
+ `${b.cleanHw != null ? money(b.cleanHw) : 'steel over the shop ceiling'}.`));
|
||||
} else if (!V.ok) {
|
||||
const b = V.best;
|
||||
cw.append(el('div', 'ed-note ed-err',
|
||||
`No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a `
|
||||
+ `${money(START_BUDGET)} budget`
|
||||
+ (b.unholdable.length
|
||||
? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop’s ceiling at any price.`
|
||||
: '.')));
|
||||
}
|
||||
|
||||
// ── 2. the cheapest HONEST line ──────────────────────────────────────────
|
||||
// Not the cheapest that holds: Sprint 13 proved that quantity sells rigs the
|
||||
// sim then charges you for. Cheapest among lines that are clean AND beat a
|
||||
// bare bed in flight.
|
||||
const ch = cheapestHonest(s);
|
||||
const cc = card(out, 'CHEAPEST HONEST LINE', ch ? 'ed-ok' : 'ed-err');
|
||||
if (ch) {
|
||||
kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' 📌 pinned' : ''));
|
||||
kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? ` (holds at ${money(ch.row.hw)})` : ''));
|
||||
kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn');
|
||||
kv(cc, 'area', `${ch.row.area.toFixed(0)} m²`);
|
||||
kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`,
|
||||
ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err');
|
||||
kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`);
|
||||
if (ch.total > START_BUDGET) {
|
||||
cc.append(el('div', 'ed-note ed-warn',
|
||||
`Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare — `
|
||||
+ 'a night with no spare is a repair the player cannot make.'));
|
||||
}
|
||||
} else {
|
||||
cc.append(el('div', 'ed-note ed-err',
|
||||
'No line is BOTH clean and better than leaving the bed bare. Either nothing clean is '
|
||||
+ 'buyable, or the clean lines that are buyable do not actually save the garden — which is '
|
||||
+ 'the wild night’s intended reading, and a bug on any other night.'));
|
||||
}
|
||||
|
||||
// ── 3. the garden: held vs bare (gardenfly) ──────────────────────────────
|
||||
const cg = card(out, 'GARDEN — HELD vs BARE (flown)');
|
||||
kv(cg, 'bare bed', `${s.bare.hp} ${s.bare.state.toUpperCase()}`,
|
||||
s.bare.state === 'dead' ? 'ed-err' : s.bare.state === 'tattered' ? 'ed-warn' : 'ed-ok');
|
||||
if (s.bestGarden) {
|
||||
kv(cg, 'best clean', `${s.bestGarden.ids} → ${s.bestGarden.hp} ${s.bestGarden.state.toUpperCase()}`,
|
||||
s.bestGarden.state === 'full' ? 'ed-ok' : 'ed-warn');
|
||||
kv(cg, 'separation', `${s.bestGarden.hp - s.bare.hp >= 0 ? '+' : ''}${(s.bestGarden.hp - s.bare.hp).toFixed(1)} HP`,
|
||||
s.bestGarden.hp - s.bare.hp > 0 ? 'ed-ok' : 'ed-err');
|
||||
kv(cg, 'by hail / rain', `${s.bestGarden.byHail} / ${s.bestGarden.byRain}`);
|
||||
} else {
|
||||
kv(cg, 'best clean', 'none buyable', 'ed-err');
|
||||
}
|
||||
if (s.bestMarginal && (!s.bestGarden || s.bestMarginal.hp > s.bestGarden.hp)) {
|
||||
cg.append(el('div', 'ed-note ed-warn',
|
||||
`⚠ The best-reading line ${s.bestMarginal.ids} is MARGINAL: `
|
||||
+ `${s.bestMarginal.hp} ${s.bestMarginal.state.toUpperCase()} on paper, inside the `
|
||||
+ `${pct(s.MARGIN)} band. Treat it as the trap, not the answer — it is the shape of result `
|
||||
+ 'that shipped a 91.9 FULL the player experienced as 39.8 TATTERED.'));
|
||||
}
|
||||
if (s.skipped) {
|
||||
cg.append(el('div', 'ed-note', `${s.skipped} affordable line(s) beyond the ${s.flyCap}-flight cap were not flown.`));
|
||||
}
|
||||
|
||||
// ── 4. the pinned separation target ──────────────────────────────────────
|
||||
const sep = s.separation;
|
||||
if (sep) {
|
||||
const cs = card(out, `SEPARATION TARGET — ${sep.ok ? 'MEETS' : 'FAILS'}`, sep.ok ? 'ed-ok' : 'ed-err');
|
||||
kv(cs, 'storm', s.sepStormName);
|
||||
kv(cs, 'held', `${sep.held.hp} ${sep.held.state.toUpperCase()} (${money(sep.held.cost)}, ${sep.held.cornersLost}/4 lost)`,
|
||||
sep.heldOk ? 'ed-ok' : 'ed-err');
|
||||
kv(cs, 'bare', String(sep.bare.hp), sep.bareOk ? 'ed-ok' : 'ed-err');
|
||||
kv(cs, 'held wins?', sep.heldWin ? 'yes' : 'no', sep.heldWin ? 'ed-ok' : 'ed-err');
|
||||
if (sep.ok && !sep.heldClean) {
|
||||
cs.append(el('div', 'ed-note ed-warn',
|
||||
'⚠ Meets the target, but the pinned line is KNIFE-EDGED: '
|
||||
+ sep.held.marginal.map((id) => {
|
||||
const p = sep.held.peaks.find((x) => x.id === id);
|
||||
return `${id} ${pct(p.headroom)} headroom (${p.peakN}/${p.effN} N)`;
|
||||
}).join(', ')
|
||||
+ `, inside the ${pct(s.MARGIN)} band. The pin is A’s ruling and this card does not `
|
||||
+ 'overrule it — but nobody has PLAYED this line.'));
|
||||
}
|
||||
} else if (s.sepUnjudged) {
|
||||
const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note', s.sepUnjudged));
|
||||
} else {
|
||||
const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note',
|
||||
'This yard declares no `separation` block, so there is no held-vs-bare target to judge it '
|
||||
+ 'against. A shipping site should pin one: it is the difference between "a sail helps here" '
|
||||
+ 'and "a sail is the point of this night".'));
|
||||
}
|
||||
|
||||
// ── 5. margin flags — the 15% rule ───────────────────────────────────────
|
||||
const flags = marginFlags(s);
|
||||
const cm = card(out, `MARGIN FLAGS — the ${pct(s.MARGIN)} rule`, flags.length ? 'ed-warn' : 'ed-ok');
|
||||
if (!flags.length) {
|
||||
kv(cm, 'knife-edge corners', 'none', 'ed-ok');
|
||||
} else {
|
||||
for (const f of flags) {
|
||||
// the bill rides on the same line as the flag: "knife-edge" and
|
||||
// "knife-edge AND $180 of carport" are different authoring decisions
|
||||
const bill = f.collateral
|
||||
? (f.collateral.unpriced
|
||||
? ` · wrecks ${f.collateral.label} (UNPRICED)`
|
||||
: ` · wrecks ${f.collateral.label} ${money(f.collateral.cost)}`)
|
||||
: '';
|
||||
const row = kv(cm, `${f.id}${f.hint !== 1 ? ` ×${f.hint}` : ''}`,
|
||||
`${pct(f.headroom)} headroom — ${(f.peak / 1000).toFixed(1)} kN on ${f.line}${bill}`, 'ed-warn');
|
||||
row.title = `worst case across every swept line; first seen on ${f.line}`;
|
||||
}
|
||||
}
|
||||
cm.append(el('div', 'ed-note',
|
||||
`A corner within ${pct(s.MARGIN)} of its effective rating breaks in the real game — benches `
|
||||
+ 'under-read the UI’s peaks by 5–15% and the cause is still unfound, so MANUAL.md '
|
||||
+ 'carries this as policy. Marginal is not PASS.'));
|
||||
|
||||
// ── 6. collateral exposure ───────────────────────────────────────────────
|
||||
// "Winnable at $80" is an incomplete sentence on a yard where letting a
|
||||
// corner go bills $180. Priced through world.collateralFor() — the one
|
||||
// resolver, site JSON first, GLB extra second.
|
||||
const exp = collateralExposure(s);
|
||||
const ce = card(out, 'COLLATERAL EXPOSURE', exp.total > 0 ? 'ed-warn' : null);
|
||||
if (!exp.priced.length && !exp.unpriced.length) {
|
||||
kv(ce, 'at risk', 'nothing priced in this yard', 'ed-ok');
|
||||
} else {
|
||||
for (const e of exp.priced) kv(ce, e.label, `${money(e.cost)} (${e.anchors.join(', ')})`, 'ed-warn');
|
||||
kv(ce, 'total if all lost', money(exp.total), exp.total > START_BUDGET ? 'ed-err' : 'ed-warn');
|
||||
for (const e of exp.unpriced) {
|
||||
kv(ce, e.label, `UNPRICED — not scored (${e.anchors.join(', ')})`, 'ed-err');
|
||||
}
|
||||
if (exp.unpriced.length) {
|
||||
ce.append(el('div', 'ed-note ed-err',
|
||||
'An unpriced label reads "not scored", never "free" — that distinction is the whole '
|
||||
+ 'point. A yard whose trap prices to nothing scores as SAFER than it is, which is '
|
||||
+ 'exactly the lie this card exists to prevent.'));
|
||||
}
|
||||
ce.append(el('div', 'ed-note',
|
||||
'Billed per STRUCTURE, not per corner — two beams letting go is one carport gone, not two. '
|
||||
+ `Against a ${money(START_BUDGET)} budget, this is what the yard can take off the player `
|
||||
+ 'on top of the hardware.'));
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@ -779,7 +779,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
|
||||
<div class="cond">the night, rigged and stood up</div>
|
||||
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
|
||||
<div class="cond">paid on what's left of the bed at dawn</div>
|
||||
<div class="cond">${q.beyondSaving
|
||||
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
|
||||
// cannot reach the win line at any price (week.js's flag, A's S13
|
||||
// ruling), so "paid on what's left of the bed at dawn" quotes a
|
||||
// maximum nobody can earn and reads as a rigging problem the
|
||||
// player could solve. The AMOUNT stays honest — settle() really
|
||||
// does pay this proportionally, and zeroing it here would make the
|
||||
// sheet lie in the other direction — but the CONDITION now says
|
||||
// what kind of night this is, up front, where the brief already
|
||||
// levels with you. The steel is still yours to save.
|
||||
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
|
||||
: "paid on what's left of the bed at dawn"}</div>
|
||||
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
|
||||
<div class="cond">nothing of theirs broken</div>
|
||||
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
|
||||
@ -939,7 +950,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
const money = w ? `
|
||||
<div class="ledger">
|
||||
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
|
||||
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
||||
// night this row is always small, and next to "callout fee — night
|
||||
// lost" it reads as the player's failure. It isn't: the bed was
|
||||
// gone at the forecast (week.js gardenBeyondSaving). Naming that
|
||||
// HERE, in the ledger, matters more than in the verdict prose —
|
||||
// the ledger is the part players re-read, and an unexplained small
|
||||
// number is the most persuasive lie on the card.
|
||||
w.gardenBeyondSaving ? ', beyond saving from the start' : ''
|
||||
}</span><b>+$${w.bonus}</b></div>
|
||||
${cleanRow}
|
||||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${collateralRows}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import { SAIL_TESTS } from '../sail.selftest.js';
|
||||
import { RIGGING_TESTS } from '../rigging.selftest.js';
|
||||
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
|
||||
import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js';
|
||||
import { buildScorecardTests } from '../../../../tools/site_audit/scorecard.selftest.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
@ -42,4 +43,10 @@ export default async function run(t) {
|
||||
// precedent). run() is async now — runAll awaits it.
|
||||
const gardenflyTests = await buildGardenflyTests();
|
||||
for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn);
|
||||
// SPRINT14 gate 2.3 (co-owned with C): the editor's scored wind and the
|
||||
// game's played wind are the same number, exactly, at one probe and one
|
||||
// second. Same async-prelude shape — the two chains are built here and the
|
||||
// asserts read the captured measurements.
|
||||
const scorecardTests = await buildScorecardTests();
|
||||
for (const [name, fn] of scorecardTests) t.test(name, fn);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -303,6 +303,23 @@ export function createWeek(opts = {}) {
|
||||
base: PAY.feeFor(gustPeakOf(def)),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
/**
|
||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||
* to say when the garden bonus is money the night cannot pay.
|
||||
*
|
||||
* Deliberately a FLAG and not a zero. The comment in settle() below is
|
||||
* the rule — quote and settle must read the same source or the sheet
|
||||
* promises a number the invoice doesn't pay — and zeroing this would
|
||||
* break it in the opposite direction: settle still pays the bonus
|
||||
* proportionally (hp is low on a beyond-saving night, not zero), so a
|
||||
* quoted $0 would be its own lie on paper. The NUMBER is honest. What
|
||||
* was missing is its CONDITION, which on this night is "and the ice
|
||||
* takes the bed whatever you rig". The job sheet's own idiom is that
|
||||
* every amount carries its condition underneath; this is a night where
|
||||
* the condition is the entire story and the schedule was the last place
|
||||
* still not telling it.
|
||||
*/
|
||||
beyondSaving: j.gardenBeyondSaving ?? false,
|
||||
get total() { return this.base + this.garden + this.clean; },
|
||||
};
|
||||
},
|
||||
@ -371,6 +388,11 @@ export function createWeek(opts = {}) {
|
||||
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
|
||||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
|
||||
// B's pool item: the invoice needs this to explain a small garden bonus
|
||||
// as the night's design rather than the player's failure. Carried on the
|
||||
// settlement so the HUD never has to reach back into week state.
|
||||
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
||||
gardenMax,
|
||||
client: job.client, addr: job.addr, site: job.site };
|
||||
log.push(s);
|
||||
return s;
|
||||
|
||||
@ -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