Dims, tri counts and node names all pass on a bike leaning the wrong way and
a gutter that never fell. The Blender contact sheet is close but renders
through Blender's view transform — it showed this sprint's jacaranda as
near-white lavender when the material is #8C7FC0, so a colour call made there
is a call about Blender, not about the game.
Smallest thing that answers the question: the game's own vendored three.js,
daylight, a ground plane, and the 1.7 m ref capsule beside whatever you name.
/tools/assetcheck/look.html?a=swing_set_01,tree_jacaranda_01&cam=10,4.5,22
It caught two things this sprint the numbers could not: the lilac reads lilac
in the real renderer (the thumb was lying), and the swing set's wreck lands on
+Z with a 2.9 m reach against a 0.95 m standing footprint — which is now baked
as a placement extra and asserted.
Sized at draw time, every time: a tab that boots hidden reports 0x0 and you
get a correct scene rendered into nothing. That cost me the first screenshot
of this very page, so it is in the MANUAL gotcha list where it already was.
167 lines
10 KiB
Markdown
167 lines
10 KiB
Markdown
# HARD YARDS — dev manual & cookbooks
|
||
|
||
*Written at Sprint 13 integration, distilled from THREADS. The canon docs
|
||
(DESIGN.md, PLAN3D.md) say what the game IS; this says HOW to work on it.
|
||
When this file and a lane's THREADS entry disagree, THREADS wins — fix this
|
||
file.*
|
||
|
||
## The tool map — what answers what
|
||
|
||
| question | tool | notes |
|
||
|---|---|---|
|
||
| does everything still work? | `web/world/selftest.html` | must be N/0/0; the count is the per-lane sum |
|
||
| is this yard rig-able by mouse? | `web/world/dev_rigging.html` | N switches yards; `__laneB.switchSite(i)` |
|
||
| what does the sky/storm look like at t=X? | `web/world/dev_skyfx.html` | scrubbable clock; flies debris; warning: it aims you SOUTH |
|
||
| player anims/movement | `web/world/dev_player.html` | |
|
||
| is this site winnable, at what price? | `tools/site_audit/` (audit.html browser, audit.mjs node) | funnel state printed in the header — check it |
|
||
| 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 |
|
||
|
||
## Adding an asset (a prop, a structure, a thing in the yard)
|
||
|
||
**House rules first (PLAN3D §0):** the runtime asset is always `.glb`, committed
|
||
in `web/world/models/`. Raw `.blend`/`.fbx` never enter the repo (gitignored).
|
||
Two legitimate routes in:
|
||
|
||
**Route 1 — the factory (canon assets).** Add a builder to
|
||
`tools/blender/build_yard_assets.py`, re-run, commit the GLBs. Nothing is
|
||
hand-edited after export; the factory must produce byte-identical output across
|
||
runs (this is asserted — three full runs, same hashes). This is Lane E's lane.
|
||
|
||
**Route 2 — vendored external (John's hand-built / MeshGod / library assets).**
|
||
Precedent exists: `models/debris/*` is copied verbatim from the 3D-STORE
|
||
library. Drop the GLB in `web/world/models/`, then wire it (below). If it's a
|
||
one-off, say in THREADS where it came from; if it'll be regenerated, it belongs
|
||
in the factory instead.
|
||
|
||
**Wiring a prop — the gnome pattern (~5 lines + data, one commit).** There is
|
||
deliberately no generic `props: []` — every prop is a named top-level key in
|
||
the site JSON, wired explicitly, so a site says what it contains. The bike
|
||
(Sprint 12) is the worked example — copy its diff:
|
||
1. Put `bike_kid_01_v1.glb` in `web/world/models/`.
|
||
2. Site JSON gains a key: `"bike": { "x": -7, "z": 9.69, "rotYDeg": 180 }`.
|
||
3. `world.js` loader: one entry in the `Promise.all` + a placement block
|
||
(position via `heightAt(x,z)`, rotation from the JSON).
|
||
4. Optional collateral: bake `collateral` / `collateral_value` extras in the
|
||
GLB as the *proposal*; the site JSON's number is *canonical* (the carport's
|
||
`collateralValue: 180` pattern). Nothing is billed until `collateralFor()`
|
||
can resolve it — an unpriced collateral returns null and reads as "not
|
||
scored", never as free… and never as billed either (the gutter was a free
|
||
failure for two sprints).
|
||
5. Wrecks: same origin, same footprint, mesh-for-mesh swap on
|
||
`world.wreckStructure(id)` — build the wreck variant from a shared helper
|
||
so intact/wrecked can't drift (E's `_house_facade()` pattern).
|
||
6. An assert that can actually fail, negative-control-proven (E rebuilt the
|
||
bike at lean 0° to watch the suite go red before trusting it).
|
||
|
||
**Conventions the loader reads (bake as GLB extras / node names):**
|
||
- `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 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.
|
||
|
||
**⚠ THE AXIS TRAP (bitten every sprint since 3):** the exporter maps Blender
|
||
`(x, y, z)` → three.js `(x, z, −y)`. A lean toward Blender +Y arrives leaning
|
||
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 —
|
||
`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")
|
||
|
||
Sites are data: one JSON in `web/world/data/sites/`. Top-level shape (see
|
||
`site_02_corner_block.json`): `id, name, blurb, yard, sun, gardenBed,
|
||
structures, trees, posts, fence, wind, [props…], [separation], _design/_why`.
|
||
Anchors are not listed directly — they come from structures/trees/posts (and
|
||
GLB nodes), typed against the checked `ANCHOR_TYPE` enum in contracts.js.
|
||
|
||
The authoring loop that works (Sprint 10–13 distilled):
|
||
1. Copy the nearest existing site JSON; keep `_design`/`_why` notes in-file —
|
||
they're the level's design doc and the repo expects them.
|
||
2. `validateSite` runs at load and fails loud (types, wind block, enum). Boot
|
||
with `boot({site:'your_site', bank:N})` to jump straight to it.
|
||
3. Wind personality lives in the SITE (venturi axis/gain/throat, tree
|
||
shelters), storm personality in the storm def. **The venturi axis is site
|
||
GEOMETRY (a line, mod π), not a wind heading** — reconciled twice in
|
||
THREADS before that sentence earned its place.
|
||
4. Every trap must be: visible before commit (the enum gives it its pre-rig
|
||
read), priced (collateral), and mechanically real (rating_hint) — "the
|
||
price is the trap" vs "the steel is the trap" is a ruling, not an accident.
|
||
5. Run the gauntlet before calling it a level: `site_audit` (winnable lines at
|
||
$80, funnel ON — check the header), `gardenfly`/`garden_bench` (does
|
||
rigging separate held-vs-bare? pin a `separation` block if this site makes
|
||
that promise), `storm_envelope` (per-anchor exposure), then **a cold
|
||
playthrough by someone who didn't author it** (Lane D's job) — Sprint 11's
|
||
soft-lock shipped behind 296 green tests and was found in one minute of
|
||
play.
|
||
6. The blurb and brief carry real design load (night 3's "plenty to tie off
|
||
to" is the trap's cover story). Client/brief/pay live in week.js NIGHTS;
|
||
a site without a night is only half real (`boot({site})` seeks the night).
|
||
|
||
## The earned gotchas (each cost a real sprint-day; receipts in THREADS)
|
||
|
||
- **Measure through the real chain.** Any garden/load number that didn't go
|
||
through `commit → attach → skyfx` is suspect: session-level `commit()`
|
||
bypasses attach and scores every rig as bare (D, S13); a 12 s pre-settle
|
||
skews SailRig's internal storm clock (B catching A, S13).
|
||
- **Site wind is built in ONE place**: `windForSite()` in weather.js. Three
|
||
harnesses independently rebuilt it wrong (funnel off ×2, frozen tree sway).
|
||
Import the helper; never hand-wire venturi/shelters in a tool again.
|
||
- **Margin rule:** any bench corner within ~15% of its rating breaks in the
|
||
real game (residual bench-vs-UI under-read, cause unfound). Marginal ≠ PASS.
|
||
- **`world.dress()` needs the importmap** — without it the bare-specifier
|
||
throw is swallowed by callers' try/catch and every ratingHint silently
|
||
becomes 1.0 (no carport trap, invincible house).
|
||
- **`setHardware()` refuses over-budget quietly** — check `.ok` or fly with
|
||
`budget: 9999` in tools (this ate four of C's measurements).
|
||
- Documentation cannot fail: an unchecked enum, a JSDoc contract, a comment
|
||
about geometry — if nothing asserts it, expect it to be wrong someday.
|
||
- Two harnesses, one number. When they disagree, find the variable before
|
||
tuning anything; the variable is usually the harness.
|
||
- One clone per lane; ports 8823 (integration) / 8824–8828 (A–E) via
|
||
untracked `.claude/launch.json` (copy the example).
|
||
- Hidden tabs throttle rAF dead: drive with `SHADES.step(dt)` / `.render()`;
|
||
hidden-canvas boot needs one resize before projections are sane.
|
||
|
||
## Design canon quick-reference (ruled, don't relitigate casually)
|
||
|
||
- Business: HARD YARDS. ABN stays sequential (never make it realistic).
|
||
- Carport $180 / gnome $25 / gutter $90; bike deliberately unpriced until the
|
||
sim can knock it over (tripwire assert enforces this).
|
||
- The wild night sells no clean line — the pyrrhic win is intended. Night 5's
|
||
garden is beyond saving BY DESIGN and the brief says so. GARDEN_DRAIN does
|
||
not move. 18–45 m² is an availability floor, not a ceiling.
|
||
- Hail is why sails matter (steep, blockable); rain walks under cloth (don't
|
||
reopen the rain-angle argument).
|
||
|
||
## Where knowledge actually lives
|
||
|
||
READ ORDER for anyone new: README → DESIGN.md → PLAN3D.md → THREADS from the
|
||
last `[I]` entry backwards until you have context → the current SPRINTn.md.
|
||
THREADS is append-only memory, not a reference — when you learn something from
|
||
it the hard way, promote it into THIS file so the next person reads one page
|
||
instead of 300 entries.
|