Compare commits

...

7 Commits

Author SHA1 Message Date
m3ultra
9277238462 Lane F R16 (v3.1 THE FLIP) — close: default-boot tour + week soak + docs
The closing half of F's bookend. All gates green on the flipped tree (qa.sh --strict --soak: classic
regression + default-boot gate + smokes + no-giants incl. the seated drummer + the full-week soak).

- tour_shots.py -> docs/shots/release_v3_1/: the money shots on the DEFAULT boot (no per-shot flag juggling
  now the flip covers it) + a ?classic=1 before/after ("the town before v3"). 7 shots, settled, 0 page errors.
- week_soak.py: re-pointed to the default boot; the leak check now treats an at/below-baseline OR non-climbing
  trail as leak-free (a decreasing tex trail is GC/atlas-settle, not a leak — it was false-failing at warn-level).
- docs: README v3.1 flags table (defaults on + ?classic=1), CITY_SPEC v3.1 amendment (the rewritten prime
  law + classic covenant, A's deferred edit), LANE_F_NOTES §16, F-progress R16.

Numbers (seed 20261990): default @ venue block 124 draws / 57k tris (<=300/200k); ?classic 161 draws / 24k
tris (the v2 baseline); seated drummer 1.32 m stature; week soak 42 cycles leak-free (net +0/-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:32:22 +10:00
m3ultra
a4946b7ca2 Lane F R16 (v3.1 THE FLIP): defaults on + ?classic covenant + seated-stature gate (ledger #1, #3)
The town shows its best by default. gigs / weather / winmap / tram flip ON (each keeps a ?flag=0 opt-out;
?classic=1 forces the exact v2 boot). The prime-law covenant MOVED from "flags off" to "?classic=1" —
byte-identical forever (synthetic fingerprint 0x3fa36874). The default boot is now the full experience,
deterministic to the gig golden 0xb1d48ea1 (A's R16 corner-poster re-pin).

- index.html: CLASSIC + flagOn(); gigs/weather/tram default-on; loadPedFleet({sit:!CLASSIC}) for the seated
  drummer (?classic keeps the zero-fetch-delta covenant); a window.PROCITY.flags intent surface for the gates
- buildings.js: winmap default-on + classic-aware (the one cross-lane flip line the flag placement requires)
- flags_check.py: flags_off_regression -> classic_regression (?classic=1 byte-identical); a new default_boot_gate
  (all four flags on, gig layer present, gig golden, budget); every gig smoke re-pointed at the DEFAULT boot
- seated-stature gate (ledger #3, published for D): interior_scale_check.py + flags_check.py accept
  fig.userData.procitySeated at stature [0.9,1.5] m (the drummer); standing figs stay [1.4,2.0]
- qa.sh flags-harness gate name updated (classic regression + default-boot gate)

Verified: ?classic byte-identical (0x3fa36874, gig layer absent); default boot = the full town (14 gigs, 15
posters, tram, gig golden green, 112 draws @ the venue block); the seated drummer passes at 1.32 m stature;
district (3 venues / 3 genres), continuity, and cover all green on the default boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:20:43 +10:00
m3ultra
ab7a8d0edf Lane D R16 (v3.1): the drummer sits + backline unification (ledger #2, #5)
Ledger #2 — drummer sits. E's sit.glb is wired into the shared rig stack:
- FINDING: Fable's prescribed _rotWithHips (keep Hips.quaternion) MANGLES the
  pose in-shell (head folds to hip height / laid flat) — the fleet rigs' bind
  Hips orientation is incompatible with the Mixamo clip's, exactly what _rotOnly
  documents. So the sit clip rides _rotOnly (drops Hips.quaternion) like every
  clip; _rotWithHips removed. The seated read comes from the leg bends + a new
  spawnRig({seated}) foot-replant (opt-in; standing spawns skip it).
- Drummer uses fleet.sitClip when present (retires SEAT_DROP), tags
  fig.userData.procitySeated=true (F's gate). Stature ~1.1-1.3m in [0.9,1.5];
  reads seated from the crowd POV (kit hides the legs). Graceful SEAT_DROP
  fallback when sitClip absent (?noassets / before F wires {sit}).
- BYTE-IDENTICAL proof (load-bearing): with sitClip null AND loaded, keepers are
  standing rigs (stature 1.718, innerRotY pi, replanted:false) and walkers 188/188
  face travel. Parallel/opt-in path; _rotOnly + makeActor untouched.
- F seam: loadPedFleet(base, {sit}) — pass sit:<gig active> (false under
  ?classic → no sit.glb fetch, classic covenant intact). Default off, graceful.

Ledger #5 — backline unification. _addBackline plants E's guitar_amp at C's
stage.backline[0] (== D's R15 pose, 0.75m bass clearance) instead of a hardcoded
pose. C dropped its primitive ampStacks, so D owns the backline: placeholder-
persists (primitive amp → GLB swap) with the primitive as the ?noassets fallback.
Verified: GLB swaps, primitive under noassets, no crash, 0 fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:49:44 +10:00
m3ultra
b322c998db Lane E R16 (v3.1): the drummer's sit clip — sit.glb produced + published
Ledger #2 asset half (E produces, D wires). Fetched Adobe Mixamo Sitting_Idle.fbx (533KB) by scp
from the tailnet source box, converted on-device via Blender (new pipeline/fbx_to_clip.py: import ->
skinned GLB export -> pure-python mesh-strip) to web/models/peds/sit.glb (109KB, mesh-free,
structurally identical to walk/idle: 66 nodes, 65 mixamorig bones, dangling skin, 195 channels).
Verified: three.js loads + 195/195 tracks bind to peds + 0 NaN; Blender confirms seated (hips
0.955->0.299m, stature 1.276->0.758m) + eyes-on bone-dot render (docs/shots/laneE/sit_clip.png).
Published to depot via tailnet under John's R13 auth (_published.json 38->39; benign non-procity_
drift note). Provenance: Adobe Mixamo royalty-free, same zone as the shipped walk/idle; LANE_E_NOTES
v3.1 amendment marker.

-> Lane D: sit.glb ready at web/models/peds/sit.glb (+ on depot); canon binds 195/195; seated confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:37:27 +10:00
m3ultra
2b13dd4ad7 Lane A round 16: v3.1 corner-poster clearance bias (#6) + no-spine osm fixture (#7)
Ledger #6 fix + the round's one sanctioned gig-golden re-pin, and ledger #7. Classic/synthetic
golden 0x3fa36874 frozen (classic boot byte-identical across all seeds); base plan untouched.

- Corner poster: the R15 finding (frontage poster near a crossing kerb) is NOT fixable by the brief's
  'nudge along the facade' — proved the crossing edge is PARALLEL and spans the facade (0/187 slides
  help); band_room/rsl land on through-lots on narrow warehouse blocks whose +Z wall (B's door side)
  overlaps the block's other street by up to ~3 m. Surfaced to Fable; ruling: bias pickVenues. It now
  prefers a band_room/rsl candidate whose +Z facade clears every carriageway by >= POSTER_CLEAR (0.5 m),
  broadening off-spine if the fringe is uniformly narrow. Result: 0/851 frontage posters below floor
  across 400 seeds (worst 0.51 m); pub untouched (hero shot unmoved).
- Gig golden re-pinned 0x4f4a549d -> 0xb1d48ea1 (posters moved). selfcheck's R15 blanket frontage
  exemption becomes a real floor: frontage >= POSTER_CLEAR, spine >= 0 (POSTER_CLEAR exported from the
  barrel). Mutation-tested non-vacuous.
- No-spine osm fixture 'silverton' (single-row town, 0 main edges) proves pickVenues' no-spine branch +
  buildPosters' no-main fallback, closing the R14 sweep gap. Base golden pinned 0xd4b351c9; sweep asserts
  0 main edges + graceful 2-4-venue placement. selfcheck ALL GREEN 14769/14769; scaffold + consistency GREEN.
- CITY_SPEC v3 amended (golden, poster clearance-bias prose, no-spine note, recorded amendment). Left the
  prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's amendment). For F:
  default-boot golden = 0xb1d48ea1; ?classic=1 target = 0x3fa36874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:35:30 +10:00
m3ultra
54a030b38b Lane B round 16 (v3.1 THE FLIP): town-wide streetlamp pools + default-boot budget re-baseline
Ledger #4. Verified fresh Chromium seed 20261990. No new rng, goldens untouched.

- Town-wide pools (venue.js): extended the R13 instanced-pool pass from venue frontages to EVERY
  streetlight + bus shelter. New townPoolSpecs(plan) replicates furniture.js's lamp cadence off the
  plan street graph (lamp every 18m from s=8, alternating sides, off=halfRoad+0.7) and reuses the
  exported busShelterStops(plan) -- read-only, furniture.js untouched (imported one function). All
  pools ride the ONE existing InstancedMesh -> still ~1 draw: 381 instances / 1 draw / ~760 tris.
  Night-driven; fog:true now so distant pools fade into night fog (pools aren't chunk-streamed) instead
  of floating as dots. Gig-gated -> ?classic=1 is pool-free = v2 parity by construction (no always-on
  furniture change, the v3.0 blocker). Deterministic cadence, no seeded draws (release law).

- Default-boot re-baseline: measured the default content (gigs + weather=rain + winmap + tram all
  live), settled. Busiest venue block (pub, NIGHT, rain) ~157 scene / ~170 total draws, 66k tris;
  venue_night 122, queue_night 121, district_posters/street_noon 185 (worst tour shot), market_square
  fresh 97/52k. ALL <=300 draws / <=200k tris. Flip adds ~3 draws (rain 1 + tram 2; winmap 0), pools
  add ~0 (instanced). Daytime dense-citizen ceiling (~275, Lane D, live-walk) unchanged (pools night-only).

- Bookmark spot-check: all 19 resolve, un-letterboxed, 0 errors under the default boot; rain+tram+winmap
  compose cleanly in old frames (street_noon rainy 177/114k, nothing blocked). No pose fixes.

Ran before F's flip landed (out of the stated order): pools are gig-gated so they work under ?gigs now;
re-baseline uses explicit all-flags URLs = post-flip default content. F re-confirms at close.

-> Lane F (B->F handshake): default-boot busiest block ~170 total draws / 66k tris; worst street view
   185 / 131k; densest daytime 97 / 52k. All under budget. Town-wide pools ~1 draw. Pool sprite is
   procedural (E has nothing to do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:21:19 +10:00
m3ultra
42b11e717d Lane C R16 (v3.1): stage.backline[] amp unification, drop primitive ampStacks (ledger #5, C half)
C exposes room.stage.backline[] — 2 seeded up-stage amp slots {x,z,ry,y} (pure data) — and
drops its primitive ampStacks from placeStage. Lane D plants E's guitar_amp GLB at backline[0]
(D's primitive fallback under ?noassets): one unified backline, GLB when present.

- backline[0] = (stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22) == D's R15-verified amp
  pose exactly, so plumbing D's plant to backline[0] moves nothing on screen. backline[1] = spare
  stage-left mirror. ry=pi (audience-facing), y=deckY.
- Verified fresh (seed 20261990): slots up-stage, on-deck, clear of all 4 bandPoses (nearest-bass
  0.87 pub / 0.70 band_room / 0.92 rsl) and all watchPoints (>=1.95 m); determinism byte-identical;
  0 leftover ampStack groups; drawSweep venues 122/120/131 (was 126/124/135, -~4 draws each) <=350.
- Determinism/golden-safe: dropping the amp loop only drops draws on the 'stage' sub-stream (nothing
  downstream reads it); placement snapshot excludes ampStacks; interiors aren't hashed; ?classic has
  no venues. Disjoint from F's flip. LANE_C_PUB §3 amended (v3.1 marker).

C->D handshake: backline shape live. Verify with D once the GLB is wired at backline[0].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:16:38 +10:00
39 changed files with 898 additions and 181 deletions

View File

@ -3,7 +3,35 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-16 (round 15) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-16 (round 16) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 16, v3.1 THE FLIP) — the backline handoff (C's half of ledger #5)
R16 §Lane C (ledger #5, C's half): unify the amp. **`room.stage.backline[]`** now exposes 2 seeded up-stage
amp slots `{x,z,ry,y}` (pure data, no meshes), and C's **primitive ampStacks are dropped** from `placeStage`
Lane D plants E's `guitar_amp` GLB at `backline[0]` (its own primitive fallback under `?noassets`), one amp
system. Shape published in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3 (v3.1 amendment marker; rest of the
frozen doc unchanged). **C→D handshake: backline shape is live.**
- `backline[0]` = stage-right `(stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22)` == D's R15-verified
amp pose exactly, so wiring D's plant to it moves **nothing** on screen. `backline[1]` = stage-left spare
(mirror). ry=π, y=deckY.
- **Verified fresh (port-isolated, seed 20261990):** both slots up-stage, on-deck, clear of all 4 bandPoses
(nearest-bass **0.87 m pub / 0.70 m band_room / 0.92 m rsl**) and all watchPoints (≥1.95 m); determinism
byte-identical (placement + stage + watchPoints); 0 leftover ampStack prop groups; drawSweep glb-off venues
**pub 122 · band_room 120 · rsl 131** (was 126/124/135 — the dropped primitives shaved ~4 draws each), worst
161 (opshop/hall, unchanged). ≤350 ✓.
- **Determinism/golden safety:** removing the ampStack loop only drops draws on the `stage` rng sub-stream,
which nothing downstream reads; the `placement` snapshot excludes ampStacks (never tracked), so it's
byte-identical. Interiors aren't hashed into any golden; the `?classic=1` boot has no venues → untouched.
- Disjoint from F's flip (interiors don't read flags) — landed early so D isn't blocked. **Verify with D
once wired** (ledger #5 D-half): re-render each archetype, confirm the GLB at `backline[0]` doesn't
interpenetrate (pending D's R16 commit).
**Code:** `layout.js` placeStage (backline[] + drop the amp loop), `interiors.js` API doc. `theme.js` untouched
(recipes carry no ampStack fitting — the primitives were placeStage-planted, not recipe fittings).
---

View File

@ -4,6 +4,27 @@
---
## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default
The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default.
F owns the flip; AE landed their polish against it (seated drummer, town-wide pools, backline, corner-poster).
**Opening commit `a4946b7` (the round's foundation):**
- **The flip**`index.html` `CLASSIC` + `flagOn()`: gigs/weather/tram default-on, `loadPedFleet({sit:!CLASSIC})`,
`window.PROCITY.flags`; `buildings.js` winmap default-on + classic-aware. **`?classic=1`** forces the exact v2 boot.
- **The covenant moved**`flags_off_regression → classic_regression` (byte-identical `0x3fa36874`); a new
`default_boot_gate` (all four flags on, gig layer present, gig golden `0xb1d48ea1`, budget); every gig smoke
re-pointed to the DEFAULT boot; qa.sh gate renamed.
- **Seated-stature gate** (ledger #3) — `interior_scale_check.py` + `flags_check.py` accept `procitySeated` figs
at [0.9,1.5] m; the drummer passes at **1.32 m**.
Verified: `?classic` byte-identical (0x3fa36874, gig absent); default = full town (3 venues / 3 genres, gig golden,
112 draws @ venue block); classic/default/district/continuity/cover all green. **Close: re-baseline, re-shot tour
`release_v3_1/` + a `?classic=1` before/after, docs (CITY_SPEC v3.1 flip+classic law, README flags table,
LANE_F_NOTES §16), tag `v3.1`.**
---
## Round 15 (v3.0 RELEASE CLOSE, `?gigs=1`) — F ran last; the release is closed
R14 landed AE's hardening but **F never ran**, so v3.0 stayed untagged. R15 = the close. All deps were in

View File

@ -48,30 +48,35 @@ Seeded everything, `Math.random` banned in gen code · the game must run with ze
instancing from day one · canvas text, no font files · characters = shared base rigs + one
clip bank, canonical sources live outside this repo · 🟢 web-ok licensing only.
## v3.0 — venues & gigs, THE DISTRICT (`?gigs=1`)
## v3.1 — THE FLIP: the town shows its best by default
The Friday-night layer, shipped behind a flag so the default boot stays byte-identical to v2 (the
synthetic golden `0x3fa36874` never moves). Turn it on and each town seeds **24 venues** — a pub, a
band room, an RSL — with a **full seeded week of gigs** (`plan.gigs`), town-wide **posters**, and a
Friday night that reads: a **4-piece band** (front-line trio + drummer) with real instruments on the
stage, a mixed standing/dancing **crowd**, an outdoor **queue** at the door, a **cover charge** per
venue (paid & free nights), muffled **gig spill** through the wall + **walla and applause** inside,
**streetlamp pools** and lit frontages for the night walk, and **identity continuity** — the ped you
followed in off the street turns up in the crowd. Deterministic from the seed; asset-free
(`?noassets=1`) it all still happens with placeholders and zero fetches.
The Friday-night layer soaked through alpha, beta, and the v3.0 release — so **v3.1 flips it on by
default**, and the mature v2 flags flip with it. Boot the plain URL and each town seeds **24 venues**
(pub, band room, RSL) with a **full seeded week of gigs**, town-wide **posters**, and a Friday night that
reads: a **4-piece band** (trio + a **seated drummer**) with real instruments, a mixed **crowd**, an
outdoor **queue**, a per-venue **cover charge**, muffled **gig spill** + **walla and applause**,
town-wide **streetlamp pools**, and **identity continuity** — the ped you followed in is in the crowd.
Plus **weather**, the **tram**, and the parallax interior-**window shader**, all live by default.
**Flags** (all default-off unless noted; compose freely):
**The covenant moved, it didn't disappear.** `?classic=1` boots the **exact v2 town** — byte-identical
forever (synthetic golden `0x3fa36874`, zero gig layer, zero fetch delta). That is the new frozen
regression target; the default boot is deterministic to the gig golden `0xb1d48ea1`. Asset-free
(`?noassets=1`) composes with the default boot and stays silent-and-fine.
| flag | what |
|---|---|
| `?seed=N` | town seed (default `20261990`) |
| `?gigs=1` | the v3.0 gig layer — venues, week schedule, band/crowd/queue, cover, posters, night pass |
| `?weather=rain` · `?tram=1` · `?winmap=1` | wet strip · tram loop · parallax interior-window shader |
| `?roster=stream` | chunk-streamed citizen density (**on by default**) · `?pop=N` tunes it |
| `?dig=1` · `?stock=real` | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?noassets=1` | asset-free — zero network, 100% placeholders, still legible |
| `?plansrc=osm` · `?mute=1` · `?dbg=1` | OSM plan source · silence · the `window.DBG` capture/soak harness |
**Flags** — the four headliners are **ON by default**, each with a `=0` opt-out; `?classic=1` is the
master off:
**Release tour:** [`docs/shots/release_v3/contact.html`](docs/shots/release_v3/) — the six money shots
(`queue_night` is the hero) with settled measurements. **QA:** `tools/qa.sh --strict` (6 gates) +
`tools/qa/week_soak.py` (the full-week soak) + the continuity/gig smokes in `tools/flags_check.py`.
| flag | default | what |
|---|---|---|
| `?classic=1` | off | **the exact v2 boot** — all four below off, byte-identical to the frozen baseline |
| `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass |
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
| `?seed=N` | `20261990` | town seed |
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on
the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression +
default-boot gate + smokes + no-giants) `--soak` (the full-week soak); the seated drummer passes the
no-giants gate at the seated stature band `[0.9,1.5] m`.

View File

@ -1,10 +1,37 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (14264/14264); rounds 215 closed** (see below).
v3.0 release close-out: frontage-poster +Z flip (ledger #1) + the one sanctioned gig-golden move
(`0x1f636349 → 0x4f4a549d`); synthetic golden `0x3fa36874` frozen.*
*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 216 closed** (see below).
v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden
`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.*
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
## Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7)
Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot
gate). The R15 finding was that a corner venue's frontage poster can sit near a crossing kerb.
Delivered (one commit):
1. **Corner-poster fix — via `pickVenues`, not a nudge.** I proved the brief's proposed fix (nudge along
the facade) is geometrically impossible: the crossing edge is **parallel** to the facade and spans it
(0/187 along-facade slides help). Root cause: band_room/rsl land on through-lots on narrow warehouse
blocks, so the `+Z` wall (B's door side) faces the block's other street, overlapping its carriageway by
up to ~3 m. **Surfaced to Fable; ruling: bias `pickVenues`.** It now prefers a band_room/rsl candidate
whose `+Z` facade clears every carriageway by ≥ `POSTER_CLEAR` (0.5 m), broadening off-spine when the
fringe is uniformly narrow. Result: **0/851** frontage posters below floor across 400 seeds (worst
0.51 m); pub untouched (hero shot unmoved). Feasibility pre-checked: a clear lot always exists.
2. **Gig golden re-pinned `0x4f4a549d → 0xb1d48ea1`** — the sanctioned move (posters moved); synthetic/
classic `0x3fa36874` frozen. §v3 FROZEN marker records the amendment.
3. **Selfcheck: exemption → floor.** R15's blanket frontage exemption is now a real invariant — frontage
posters must clear their kerb by ≥ `POSTER_CLEAR` (exported from the barrel), spine posters keep ≥ 0.
4. **No-spine osm fixture `silverton`** (ledger #7): a constructed single-row town (all shops one latitude
⇒ 0 `main` edges). Closes the R14 gap (both real fixtures had a spine); proves `pickVenues`' no-spine
branch + `buildPosters`' no-main fallback. Base golden pinned `0xd4b351c9`; sweep asserts 0 main edges +
graceful 24-venue placement. Selfcheck **ALL GREEN 14769/14769**; scaffold + consistency GREEN.
5. **CITY_SPEC §v3 amended** (golden line, poster clearance-bias prose, no-spine note, recorded amendment).
Left the prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's §v3 amendment).
For F: default-boot golden = `0xb1d48ea1`; `?classic=1` regression target = `0x3fa36874`.
## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + one sanctioned golden move
Lane A ran **first** (B's hero re-shoot + F's tour hang off it). The R14 review found every venue's

View File

@ -218,12 +218,34 @@ night in v1; interior-mapping shader is a stretch goal).
> facade (it had been on the `Z` back, invisible from the street); this moved `plan.posters`, so the gig
> golden re-pinned `0x1f636349 → 0x4f4a549d` in the same commit under the amendment law above. The
> synthetic golden `0x3fa36874` did not move — this is the round's one sanctioned gig-golden move.)*
>
> *(**Recorded amendment — ROUND16, ledger #6:** on a narrow through-lot a band_room/rsl's `+Z` facade
> can abut the block's *other* street, putting its frontage poster over that carriageway. `pickVenues`
> now **prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (0.5 m;
> broadens off-spine if the warehouse fringe is uniformly narrow). This moved `plan.posters`, so the gig
> golden re-pinned `0x4f4a549d → 0xb1d48ea1`; synthetic `0x3fa36874` still frozen. The v3.1 default flip
> + classic law is F's amendment to this section — see the flags table.)*
> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when
> `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan —
> **all v2 goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does
> not touch the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND
> osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).
> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector when the gig
> flag is on. With it off, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — **all v2
> goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does not touch
> the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND osm towns.
> Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).
>
> **Prime flag law — REWRITTEN at `v3.1` (ROUND16, THE FLIP; Lane F amendment).** The covenant MOVED, it
> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full
> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and
> **`?classic=1`** forces all four off at once:
> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2
> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no
> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate.
> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover
> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block)
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays
> silent-and-fine.
> - The four flags read through one rule in the shell — `flagOn(name) = !classic && param !== '0'` (winmap
> reads the same rule in `buildings.js`); the shell publishes the intent on `window.PROCITY.flags`, and the
> seated drummer's `sit.glb` rides `!classic` so classic keeps its zero-fetch-delta.
>
> *(v3.0-alpha was one pub, one genre, one night's posters. **ROUND13 → v3.0-beta, the district:** 24
> venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)*
@ -286,8 +308,10 @@ Only **tonight's** (night 0) playing venues are advertised. Each such venue gets
frontage** — seated on the **+Z street facade** (B's canon: `buildings.js` facade + `venue.js` door/queue/
camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to read *from* the street
(`ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera — **ROUND15 ledger #1**; before
R15 it was on the `Z` back and invisible from the street). Plus a seeded run along the spine — 2 per main
edge, at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole
R15 it was on the `Z` back and invisible from the street). So the poster never sits over a back-street's
bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`**
(ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along
the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole
runs — that IS the district reading. `ry` follows the house convention (a poster's printed face =
`(sin ry, cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)`
rotated `ry`; F wires "poster → that venue's tonight-gig".
@ -316,9 +340,11 @@ by seed, spread **night-major across the whole week's slots town-wide** (not one
canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else.
**Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic:
**`0x4f4a549d`** (**ROUND15 re-pin** — the frontage-poster +Z flip, ledger #1, moved `plan.posters`; was
`0x1f636349` the R13 district, `0xa6ae5a5e` the R12 alpha). Asserted by `selfcheck.js` alongside the frozen
v2 goldens; changing gig output re-pins it.
**`0xb1d48ea1`** (**ROUND16 re-pin** — the band_room/rsl facade-clearance bias, ledger #6, moved
`plan.posters`; was `0x4f4a549d` the R15 +Z flip, `0x1f636349` the R13 district, `0xa6ae5a5e` the R12
alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; changing gig output re-pins it.
The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues`
places venues with no main spine (ROUND16 ledger #7).
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)

View File

@ -2,6 +2,41 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7)
### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)
The round's one sanctioned move. **F**: the default-boot gate pins to `0xb1d48ea1` (not the R15 value the
brief cited). **B**: re-shoot against this. Amendment law; §v3 marker records it.
### The corner-poster fix (NOT a nudge — Fable's brief premise was wrong)
The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed
nudging the poster ALONG the facade away from the crossing-street *end* — but I proved that's geometrically
impossible: the crossing edge is **parallel** to the facade and spans it (0/187 along-facade slides recover
any clearance). Root cause: band_room/rsl land on **through-lots on narrow warehouse-fringe blocks**, so the
`+Z` wall (B's door side, where the poster seats) faces the block's *other* street, overlapping its
carriageway by up to ~3 m. **Fable's ruling (asked mid-round): bias `pickVenues`.** So:
- `gigs.js pickVenues` now prefers a band_room/rsl candidate whose `+Z` facade clears every carriageway by
**`POSTER_CLEAR` (0.5 m)**; if the warehouse fringe is uniformly narrow it broadens to any off-spine
clear lot before falling back. Proven: **0 of 851** frontage posters across 400 seeds now sit below the
floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, `+Z` open — the hero shot doesn't move).
- The R15 blanket frontage *exemption* in the selfcheck is now a real **floor**: frontage posters must clear
their kerb by ≥ `POSTER_CLEAR`; spine posters keep the ≥ 0 no-bitumen floor. `POSTER_CLEAR` is exported
from the barrel — import it, don't re-guess. (`0.02` in the assert absorbs r2 poster-coord rounding.)
### The no-spine osm fixture (ledger #7): `silverton`
`osm_fixture.js` gains **`silverton`** — a *constructed* single-row town (all shops at one latitude ⇒
plan_osm bins them into one avenue ⇒ **0 `main` edges**). Closes the R14 sweep gap (both real fixtures had a
spine): it proves `pickVenues`' no-spine branch (pub's `onMain` filter is empty) and `buildPosters`' no-main
fallback (spine run over every edge). Base golden pinned `0xd4b351c9`; the sweep asserts 0 main edges +
graceful 24-venue placement. `osmTownKeys()` now returns 3 towns — anything iterating it (shell selector,
your gates) picks it up; it's a valid town, `?plansrc=osm&town=silverton`.
### → F: the classic-law / flags-table §v3 amendment is yours
I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip
(default-on, `?classic=1` covenant, the flags table) is your §v3 amendment — I left the prime-flag-law
blockquote untouched to avoid racing you on the same lines. My classic golden (`0x3fa36874`) is your
`?classic=1` regression target; my gig golden (`0xb1d48ea1`) is your default-boot target.
## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move
### → B + F: **new gig golden `0x1f636349 → 0x4f4a549d`** (synthetic `0x3fa36874` unchanged)

View File

@ -1,5 +1,55 @@
# LANE B — NOTES (measured)
## Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline
Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are
gig-gated so they work under `?gigs=1` now, and the re-baseline uses **explicit** all-four-flags URLs =
the post-flip default-boot content; F re-confirms against the real default at close.)
### 1. Town-wide streetlamp pools (ledger #4) — `venue.js`
Extended the R13 instanced-pool pass from venue frontages to **every streetlight + bus shelter**. New
`townPoolSpecs(plan)` replicates furniture.js's exact lamp cadence off the plan's street graph (a lamp
every 18 m from `s=8`, alternating sides, at `off = halfRoad+0.7`) and reuses the exported
`busShelterStops(plan)`**read-only, `furniture.js` untouched** (imported the one function). All pools
(venue + town) ride the **one existing InstancedMesh**, so town-wide is still **~1 draw**:
- **381 pool instances / 1 draw / ~760 tris** for Boolarra Heads (22 edges, 27 nodes). Night-driven via
the existing opacity ramp; `fog: true` now (was false) so distant pools fade into the night fog
instead of floating as dots over un-built chunks (pools aren't chunk-streamed). A/B'd `fog:false`
no dramatic difference and a dot-risk on bigger towns, so `fog:true` is the keeper.
- **Gig-gated → classic parity by construction:** venue.js only exists under the gig layer, so
`?classic=1` (gigs off) is pool-free = v2 parity. No always-on `furniture.js` change (the v3.0 blocker).
- Pure function of the plan (deterministic cadence, no rng) → **no new seeded draws** (release law).
### 2. Default-boot budget re-baseline (ledger #4) — the number the budget law now means
Measured on the **default boot content** (gigs + `weather=rain` worst-case + winmap + tram all live),
settled (shoot-twice / clean-load to defeat the orphan-chunk teleport inflation):
| view (default boot, all flags) | draws (composer total) | tris | gate |
|---|---|---|---|
| **busiest venue block** (pub, NIGHT, rain, 3/4) | **~157 scene / ~170 total** | **66k** | ≤300 / ≤200k ✅ |
| `venue_night` | 122 | 53k | ✅ |
| `queue_night` | 121 | 61k | ✅ |
| `district_posters` / `street_noon` (wide main spine) | **185** | 131k | ✅ (worst tour shot) |
| `market_square` (densest daytime, fresh) | 97 | 52k | ✅ |
**No budget bust.** The flip adds ~3 draws (rain Points 1 + tram 2; winmap 0 — it's a shader on
existing windows), and the **town-wide pools add ~0** (1 instanced mesh). Worst default-boot street
view measured is **185 total draws / 131k tris** — ~115 draw / ~70k tri margin. (Pools are night-only,
so the pre-existing daytime dense-citizen ceiling — Lane D territory, ~275 total per R13's live-walk —
is unchanged by this round; winmap/rain add ≤3 there. F's live week-soak confirms the full-density number.)
### 3. Bookmark spot-check on the default boot
All **19 bookmarks resolve, un-letterboxed, 0 page errors** with all four flags on. Rain particles +
tram + winmap now appear in the old opt-in frames and compose cleanly — `street_noon` rainy (177 draws
/ 114k tris) frames the strip with rain + storm sky + wet road, nothing blocked. No pose fixes needed.
**→ Lane F (B→F handshake — the default worst-case numbers):** default boot, busiest venue block =
**~170 total draws / 66k tris**; worst street view (wide main spine) = **185 / 131k**; densest daytime
= 97 / 52k. All ≤300 / ≤200k. Town-wide pools are ~1 draw. Re-confirm against the real default once
your flip lands; the pool sprite is procedural (no asset, E has nothing to do).
---
## Round 15 (Fable's ROUND15 → Lane B) — verify the hero after A's poster flip (v3.0 release)
Task: re-verify `queue_night` after A's ledger-#1 flip (`35da83c`: `buildPosters` item 1 → `+sin,+cos`

View File

@ -2,6 +2,8 @@
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. Describes the *shipped* state;
> changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (release audit).
> **v3.1 amendment (2026-07-16):** §3 becomes the `stage.backline[]` contract (ledger #5 — the amp
> unification); C dropped its primitive ampStacks. Everything else in this doc is unchanged and stands.
*The district ships **three** venue archetypes behind `?gigs=1``pub`, `band_room`, `rsl`. Lane A
converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off
@ -97,17 +99,22 @@ No `opts.gig` → a normal interior: `stage` + `watchPoints` are still returned
`gigKey`** (room-tone + maybe the seeded radio `musicKey`), and D spawns no crowd. A seeded dark night reads
true — the venue is just a quiet pub/club.
## 3. `guitar_amp` placement (v3.0 — RESOLVED R15)
## 3. `stage.backline[]` — the amp slots (v3.1, ledger #5)
**Resolved.** D plants E's `guitar_amp` GLB (`band.js _addBackline`) up-stage-right at **`(stage.x +
stage.w·0.36, deckY, stage.z stage.d·0.22)`** — C's corrected pose (ledger #4). It's on-deck, up-stage of
the front line, and flanks C's own primitive ampStack (stage-left) as a clean backline. **Verified R15 (fresh
context, seed 20261990):** clearance to the bass rig **0.87 m (pub) / 0.92 m (rsl)** — no interpenetration
(the original R14 pose `stage.z + 0.15` was down-stage, 0.29 m off the bass, and clipped it); amp↔primitive
4.4 m (pub) / 3.5 m (rsl); on-deck both. C's `placeStage` plants **12 primitive `ampStack` fittings** at
`(±stage.w·0.28, deckY, stage.z stage.d·0.18)` as the asset-free backline fallback (no GLB slot, so they
stay primitive). *v3.1:* full unification — C exposes a `stage.backline[]` slot list that D's GLB fills,
replacing the primitive — stays deferred (no new contract fields in v3.0).
`room.stage.backline = [ { x, z, ry, y } ×2 ]` — seeded up-stage amp/spare-cab poses, **pure data** (C plants
no amp meshes as of v3.1). This is the unified backline: **Lane D plants E's `guitar_amp` GLB here** (its own
primitive fallback under `?noassets`) — one amp system, GLB when present. C's old primitive ampStacks are
**dropped** from all three venue recipes.
- **slot[0]** = stage-right (the primary amp) at `(stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22)`
identical to D's R15-verified plant, so wiring D's amp to `backline[0]` moves nothing on screen.
**slot[1]** = stage-left spare cab (mirror). `ry = π` faces the audience (+Z, the §0 convention); `y = deckY`.
- **Guarantees** (verified fresh, seed 20261990, pub/band_room/rsl): every slot is **up-stage of the front
line**, flanks the drum riser, **clear of all 4 bandPoses** (nearest-bass clearance **0.87 m pub / 0.70 m
band_room / 0.92 m rsl**) and **all watchPoints** (≥1.95 m), and **on-deck** (within the deck footprint).
Deterministic per `shop.seed`. **Lane D:** verify no interpenetration once wired (C's R15 clearance discipline).
- **Draw impact:** dropping C's primitives shaved ~4 draws off each empty venue shell (drawSweep glb-off:
pub 122 · band_room 120 · rsl 131 ≤350); D's GLB/primitive at `backline[0]` adds it back on the gig night.
- *v3.2 (parked):* the drummer sit clip, instrument LOD, and C's `audioEmitter` idea.
## 4. Budgets (measured R14, seed 20261990, fresh context)
- **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst

View File

@ -6,6 +6,56 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,
`band.js`. Verified against C's landed `stage.backline[]` + E's `sit.glb`; F's flip/gate pending (I built to
F's published seated-stature contract). **Byte-identical proof holds** (the load-bearing requirement).*
### The drummer sits (ledger #2) — and Fable's prescribed `_rotWithHips` is WRONG for these rigs
The R14 recon + F's brief prescribed a `_rotWithHips` variant (keep `Hips.quaternion` to preserve the
pelvic tilt). **In-shell it MANGLES the pose** — head folds to hip height, body laid flat — because the
fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure `_rotOnly`'s own
comment documents ("a different-orientation source lays it flat"). So the sit clip **drops `Hips.quaternion`
like every other clip** (rides the existing `_rotOnly`); the seated read comes from the leg-bone bends +
a new **seated foot-replant** in `spawnRig`. `_rotWithHips` is removed.
- **`spawnRig({seated})`** (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED
lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns
skip it entirely.
- **The drummer**: uses `fleet.sitClip` when present (retiring the R14 `SEAT_DROP` sink — it sits on the
riser and the re-plant seats it), tags `fig.userData.procitySeated = true` (F's gate). Reads seated from
the **crowd POV** (the kit hides the lower body); measured stature **~1.11.3 m** (F's seated gate
[0.9,1.5] ✓). Falls back to the `SEAT_DROP` standing-sunk hack if `sitClip` is absent (?noassets, or
before F wires `{sit}`), so it never breaks a boot.
- **Honest limit**: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a
bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual
pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on
the deck, passes the seated gate) — good enough for the flip.
### Byte-identical proof (keepers / browsers / walkers) — the load-bearing part
The changes are a PARALLEL/opt-in path: `_rotOnly` untouched (walk/idle unchanged), `spawnRig`'s re-plant
is `seated`-gated (default off), `fleet.sitClip` is additive, `makeActor` (walkers) untouched. Verified
in-shell with `fleet.sitClip` **null AND loaded**: streamed walkers **188/188** face travel, keeper is a
standing rig (stature **1.718**, `innerRotY:π`, `replanted:false` — the seated re-plant does NOT run). No
existing consumer moves.
### → Lane F: the `{sit}` load seam (classic-safe by construction)
`loadPedFleet(base, { sit })` — pass **`sit: <gig-layer active>`** (true on the default boot, **false under
`?classic=1`**). Gated because a raw `sit.glb` fetch would break the classic zero-fetch-delta covenant;
default is `sit:false`, and absent/off → `fleet.sitClip` null → the drummer falls back gracefully. My code
is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes `sit:true`
(same commit as F's [0.9,1.5] seated-stature gate).
### Amp at C's `stage.backline[]` (ledger #5)
`_addBackline` now plants at **C's `stage.backline[0]`** (`{x,z,ry,y}`, up-stage, clear of every bandPose
+ watchPoint) instead of D's R15 hardcoded pose — C confirmed `backline[0]` == that pose, so nothing moves
on screen (pub 1.44, 0.32, 3.27, ry=π; **0.75 m bass clearance**). Since C dropped its primitive
ampStacks, D now owns the whole backline: **placeholder-persists** (a primitive amp shows immediately, the
`guitar_amp` GLB swaps in), and the primitive is the **`?noassets` fallback** (fleet-gated, no fetch).
Verified: GLB swaps in, primitive under `?noassets`, no crash, 0 fetch.
---
## ROUND 15 — v3.0 close: the amp one-liner (ledger #4)
*PROCITY-D, 2026-07-16. Sole task: move `band.js _addBackline` to C's §3 pose. Everything else (continuity,

View File

@ -1,5 +1,43 @@
# LANE E — cross-lane notes
## Round 16 — v3.1 THE FLIP: the drummer's sit clip (E asset half) ⟨v3.1 amendment⟩
> Ledger #2 asset half — the drummer finally sits. **E produces the clip; D wires it** (`_rotWithHips`
> + `spawnRig` foot re-plant + F's seated-stature gate). **`sit.glb` delivered, verified, committed +
> on depot.** → **Lane D: it's ready** (`web/models/peds/sit.glb`, canon binds 195/195, seated confirmed).
**Asset:** `web/models/peds/sit.glb` — a mesh-free animation clip, **structurally identical to the
shipped `walk.glb`/`idle.glb`**: 66 nodes (65 `mixamorig` bones + root), 0 meshes, 1 dangling skin
(inverseBindMatrices + 65 joints, referenced by 0 nodes — the vestigial artifact Blender glTF I/O
leaves, which makes glTF tools read it as an armature), 195 channels (65 bones × TRS). 109 KB, no Draco.
**Source + provenance:** Adobe **Mixamo `Sitting_Idle.fbx`** (533 KB) — fetched by `scp` from the
tailnet source box `johnking@100.91.239.7:~/Documents/mixamo-fetch/out/` per Lane D's recipe (the
local `~/Documents/mixamo-fetch` path is stale/empty). Converted on-device via Blender headless
(`pipeline/fbx_to_clip.py`, **new tool**): import FBX → export skinned GLB → pure-python mesh-strip
that keeps the skeleton + dangling skin + animation and re-indexes/compacts. Raw Mixamo `mixamorig:`
naming is already canonical (`rigs.js._canon` normalizes `mixamorig<N>``mixamorig`, so no bone
rename). **Licence zone: Adobe Mixamo royalty-free animation license — the SAME zone as the
already-shipped `walk`/`idle` clips** (the whole ped fleet is Mixamo-rigged). Mesh stripped, so only
the skeleton *motion* ships (retargeted onto the game's own peds), never Mixamo character geometry.
**Verified (E's half — D verifies the wired in-scene pose):**
- **three.js** (vendored GLTFLoader): loads, dur 4.33 s, 195 tracks, 65 bones, **195/195 bind** to a
ped skeleton after `_canon`, **0 NaN** track values (mesh-strip re-index is clean).
- **Blender** (the tool that measures `walk` at 1.276 m standing): the clip poses **seated** — Hips
descend 0.955 m (standing) → **0.299 m**, stature 1.276 → 0.758 m. Eyes-on bone-dot render confirms
the folded seated silhouette vs walk's upright one: `docs/shots/laneE/sit_clip.png` (left walk, right sit).
**Depot:** published to `digalot.fyi/3god` via the passwordless tailnet ingress under **John's R13
authorization** (`_published.json` 38 → 39; confirmed 200 on the public depot). **NOTE (benign):**
`sit.glb` — like `vintage-cash-register.glb` — is non-`procity_`-prefixed, so `publish.py --verify`'s
`procity_`-only drift filter lists it as "not live" (a **false drift** — it IS live). Cosmetic only;
`validate_manifest` gates are unaffected (clips are not manifest GLBs).
**"Manifest":** ped clips load by **convention** (`rigs.js` `base='models/peds/'`), NOT via
`web/assets/manifest.json` — so there is no asset-manifest entry, same as `walk`/`idle`. D owns the
loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses this same clip.
## Round 14 — v3.0 PROVENANCE FREEZE ✅ (release round)
> **FROZEN for the v3.0 tag** (2026-07-16). Lane E R14 is a provenance close-out — **no new synthesis**
@ -30,6 +68,7 @@
| Skins (2D) | 96 | 90sDJsim ports (7 facade + 2 ground) + thriftgod R1 store-art + `flux_local` gap-fill fronts/posters | `gen_skins.py --local` (on-device) | 🟢 web-ok |
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
**GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever:
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).

View File

@ -594,3 +594,47 @@ pathspec:
- **Drive live-spawned things explicitly**: automation throttles rAF, so the queue's seeded auto-drain may
not fire on schedule — `smoke_continuity`/`week_soak`/`tour_shots` all call `admitOne()` / step the loop
explicitly rather than trust the timer.
---
## 16. Round 16 — v3.1 THE FLIP (`?classic=1` is the new covenant). F bookends; opening below, close after.
The town shows its best by default. After three v3 stages the gig layer flips ON, and the mature v2 flags
(`weather`, `winmap`, `tram`) flip with it. F owns the flip; AE landed their v3.1 polish against it (D's
seated drummer, B's town-wide pools + default re-baseline, C's backline unification, A's corner-poster +
no-spine fixture, E's `sit.glb`).
### The flip (ledger #1) — the covenant MOVED from "flags off" to `?classic=1`
- **Shell** (`index.html`): `const CLASSIC = has('classic') && get('classic')!=='0'`, `const flagOn = name
=> !CLASSIC && get(name)!=='0'`. `gigs`/`weather`/`tram` construct via `flagOn`; `loadPedFleet({sit:!CLASSIC})`
gives the drummer his clip while keeping classic zero-fetch; `window.PROCITY.flags` publishes the intent.
- **`buildings.js`**: `winmap` reads the SAME rule (default-on, classic-off) — the one cross-lane flip line,
because winmap's flag lives in B's file. Marked `[Lane F R16 THE FLIP]`.
- **Two goldens, two gates:** classic → `0x3fa36874` (byte-identical v2 forever, the frozen regression
target); default → the gig golden `0xb1d48ea1`. `flags_off_regression` became **`classic_regression`**
(boot `?classic=1`); a new **`default_boot_gate`** proves the no-flag boot is the full experience (all four
flags on, gig layer present, gig golden green, budget). Every gig smoke re-points to the DEFAULT boot.
### Seated-stature gate (ledger #3) — published in the brief, no mid-round handshake
A seated figure fails the standing no-giants floor by design. Contract (in the round brief so D built to it):
D tags seated figs `fig.userData.procitySeated = true`; F's `interior_scale_check.py` + `flags_check.py`
accept them at **stature ∈ [0.9, 1.5] m** (still < ceiling, feet planted); standing stays [1.4, 2.0]. Landed
in the opening commit. Verified live: the seated drummer passes at **1.32 m stature**.
### Numbers (measured, seed 20261990 → "Boolarra Heads")
| thing | number |
|---|---|
| `?classic=1` | byte-identical v2: fingerprint 0x3fa36874, gig layer absent, streamed roster on, 160 draws / 21k tris |
| default boot | all four flags on · gig golden 0xb1d48ea1 · 14 gigs, 15 posters · **112 draws @ the venue block** (≤300) |
| district (default) | 3 venues, **3 playing** (corner-poster re-pin shifted the schedule) · 3 genres live |
| seated drummer | **1.32 m** stature — passes the seated band [0.9,1.5]; the standing crew 1.681.81 m |
### The gotchas closed this round
- **A flag whose parse lives in another lane's file** (winmap in `buildings.js`) still flips as part of F's
structural change — one marked line, same `!classic && !=='0'` rule as the shell.
- **selfcheck prints the base fingerprint in its summary but asserts the GIG golden silently**, so the
default-boot gate gates on the ALL-GREEN run (which includes the gig-golden assert), not a stdout substring.
- **`P.winmap` isn't exposed** — the default gate verifies winmap *intent* via `window.PROCITY.flags`; the
actual shader is B's own `winmap` smoke.
- **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a
raw sit fetch under classic would break the covenant even though the drummer never spawns there.

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>PROCITY v3.1 — release tour</title><style>body{background:#14110c;color:#f4efe6;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;margin:24px}h1{font-weight:700}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:18px}figure{margin:0;background:#1d1810;border-radius:10px;overflow:hidden}img{width:100%;display:block}figcaption{padding:8px 12px;color:#cdc4b2}b{color:#ffd9a0}</style><h1>PROCITY v3.1 — release money-shot tour (THE FLIP) <small>· seed 20261990 · settled measurements</small></h1><div class="grid"><figure><img src="venue_night.png" alt="venue_night" loading="lazy"><figcaption><b>venue_night</b><br>124 draws · 57,669 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="district_posters.png" alt="district_posters" loading="lazy"><figcaption><b>district_posters</b><br>189 draws · 139,838 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="queue_night.png" alt="queue_night" loading="lazy"><figcaption><b>queue_night</b><br>122 draws · 62,763 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="tram_stop.png" alt="tram_stop" loading="lazy"><figcaption><b>tram_stop</b><br>111 draws · 178,122 tris · queue 0</figcaption></figure><figure><img src="gig_interior.png" alt="gig_interior" loading="lazy"><figcaption><b>gig_interior</b><br>The Exchange Hotel · Screaming Utes · 4-piece · crowd 8 (2 from roster) · 54 draws · 35,216→182,633 tris (pre-load→settled)</figcaption></figure><figure><img src="rain_verandah.png" alt="rain_verandah" loading="lazy"><figcaption><b>rain_verandah</b><br>137 draws · 77,338 tris · queue 2</figcaption></figure><figure><img src="district_posters_classic.png" alt="district_posters_classic" loading="lazy"><figcaption><b>district_posters_classic</b><br>?classic=1 — the town before v3 (no gig layer, no pools)</figcaption></figure></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

View File

@ -36,5 +36,6 @@
"procity_street_novelty_record_01.glb",
"procity_street_park_bench_01.glb",
"procity_street_streetlight_01.glb",
"sit.glb",
"vintage-cash-register.glb"
]

134
pipeline/fbx_to_clip.py Normal file
View File

@ -0,0 +1,134 @@
"""PROCITY mixamo-clip converter — a Mixamo `.fbx` animation → a mesh-free house clip GLB, the exact
shape of the shipped `web/models/peds/walk.glb` / `idle.glb`: armature + one animation, NO mesh, but
WITH the (now dangling) skin that Blender's glTF I/O leaves behind — 65 `mixamorig` joints +
inverseBindMatrices, referenced by zero nodes. That vestigial skin is what makes glTF tooling (and
Blender re-import) reconstruct the file as an armature; three.js binds the clip by track name via
Lane D's `rigs.js` `_canon` regardless, so no bone rename is needed.
Runs headless on this box's Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python fbx_to_clip.py -- IN.fbx OUT.glb [ClipName]
Two stages, one command: (1) Blender imports the FBX (mesh+armature+anim) and exports a SKINNED GLB
(the skin needs the mesh to be emitted); (2) a pure-python glTF strip removes the mesh + its geometry
accessors, re-indexing/compacting so only the skeleton + dangling skin + animation remain (100 KB).
"""
import bpy, sys, os, json, struct, tempfile
ARGV = sys.argv[sys.argv.index("--") + 1:]
FBX, OUT = ARGV[0], ARGV[1]
CLIP = ARGV[2] if len(ARGV) > 2 else "Clip"
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures, bpy.data.actions):
for d in list(coll):
if d.users == 0:
coll.remove(d)
# ── stage 2: pure-python glTF mesh-strip (keep skeleton + dangling skin + animation) ──────────────
def read_glb(p):
d = open(p, "rb").read()
off = 12; js = None; binblob = b""
while off < len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8]); off += 8
chunk = d[off:off + clen]; off += clen
if ctype == 0x4E4F534A: js = json.loads(chunk)
elif ctype == 0x004E4942: binblob = chunk
return js, binblob
def write_glb(js, binblob, out):
jb = json.dumps(js, separators=(",", ":")).encode()
jb += b" " * ((4 - len(jb) % 4) % 4)
bb = binblob + b"\x00" * ((4 - len(binblob) % 4) % 4)
total = 12 + 8 + len(jb) + 8 + len(bb)
with open(out, "wb") as f:
f.write(b"glTF"); f.write(struct.pack("<II", 2, total))
f.write(struct.pack("<II", len(jb), 0x4E4F534A)); f.write(jb)
f.write(struct.pack("<II", len(bb), 0x004E4942)); f.write(bb)
def strip_mesh(inpath, outpath):
js, binblob = read_glb(inpath)
keep = set() # accessors to retain: skin IBMs + all anim I/O
for sk in js.get("skins", []):
if "inverseBindMatrices" in sk:
keep.add(sk["inverseBindMatrices"])
for an in js.get("animations", []):
for s in an["samplers"]:
keep.add(s["input"]); keep.add(s["output"])
mesh_nodes = {i for i, n in enumerate(js["nodes"]) if "mesh" in n}
js.pop("meshes", None) # drop mesh defs; skins[] stay (become dangling)
for n in js["nodes"]:
n.pop("mesh", None); n.pop("skin", None)
# orphan the former-mesh nodes from the scene graph (leave them in the array so node indices —
# skin.joints, animation channel targets — stay valid; three.js ignores unreachable nodes)
for sc in js.get("scenes", []):
if "nodes" in sc:
sc["nodes"] = [x for x in sc["nodes"] if x not in mesh_nodes]
for n in js["nodes"]:
if "children" in n:
n["children"] = [x for x in n["children"] if x not in mesh_nodes]
if not n["children"]:
n.pop("children")
# re-index accessors → keep only the retained set
old_acc = js["accessors"]
keep_sorted = sorted(keep)
accmap = {old: i for i, old in enumerate(keep_sorted)}
js["accessors"] = [old_acc[i] for i in keep_sorted]
# keep bufferViews referenced by retained accessors; compact the binary
bv_keep = sorted({old_acc[i]["bufferView"] for i in keep_sorted if "bufferView" in old_acc[i]})
bvmap = {old: i for i, old in enumerate(bv_keep)}
old_bv = js["bufferViews"]
newbin = bytearray(); newbvs = []
for old in bv_keep:
bv = dict(old_bv[old]); start = bv.get("byteOffset", 0); length = bv["byteLength"]
pad = (4 - len(newbin) % 4) % 4; newbin += b"\x00" * pad
bv["byteOffset"] = len(newbin); newbin += binblob[start:start + length]
newbvs.append(bv)
js["bufferViews"] = newbvs
for a in js["accessors"]:
if "bufferView" in a:
a["bufferView"] = bvmap[a["bufferView"]]
for sk in js.get("skins", []):
if "inverseBindMatrices" in sk:
sk["inverseBindMatrices"] = accmap[sk["inverseBindMatrices"]]
for an in js.get("animations", []):
for s in an["samplers"]:
s["input"] = accmap[s["input"]]; s["output"] = accmap[s["output"]]
js["buffers"] = [{"byteLength": len(newbin)}]
write_glb(js, bytes(newbin), outpath)
return js
def main():
wipe()
bpy.ops.import_scene.fbx(filepath=FBX)
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if not arms:
raise SystemExit("no armature in FBX")
arm = arms[0]
n_mesh = len([o for o in bpy.data.objects if o.type == "MESH"])
if arm.animation_data and arm.animation_data.action:
arm.animation_data.action.name = CLIP
bones = [b.name for b in arm.data.bones]
mixamo = [b for b in bones if b.startswith("mixamorig")]
bpy.ops.object.select_all(action="SELECT") # armature + mesh → Blender emits a skin
bpy.context.view_layer.objects.active = arm
tmp = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name
bpy.ops.export_scene.gltf(filepath=tmp, export_format="GLB", use_selection=True,
export_animations=True, export_yup=True, export_skins=True)
js = strip_mesh(tmp, OUT)
os.unlink(tmp)
sz = os.path.getsize(OUT)
print(f"CLIP_DONE {os.path.basename(OUT)} bones={len(bones)} (mixamorig {len(mixamo)}) "
f"mesh_stripped={n_mesh} meshes_out={len(js.get('meshes', []))} skins_out={len(js.get('skins', []))} "
f"nodes={len(js['nodes'])} anims={len(js.get('animations', []))} {sz // 1024}KB")
main()

View File

@ -27,7 +27,8 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
GOLDEN_HASH = 0x3fa36874 # frozen determinism anchor (Lane A selfcheck) — UNCHANGED by the R7 flip
GOLDEN_HASH = 0x3fa36874 # frozen v2 determinism anchor (?classic boot) — UNCHANGED by the flips
GIG_GOLDEN = 0xb1d48ea1 # [R16] the DEFAULT boot's gig-augmented plan fingerprint (A's R16 corner-poster re-pin)
# flags-off DBG.shot('street_noon') snapshot. RE-PINNED at the R7 roster flip (prime-law amendment):
# old (v1.1, roster-off): 163 draws, 19000 tris
# new (R7, roster default-on): 164 draws, ~66000 tris
@ -52,6 +53,12 @@ def WARN(m): warns.append(m); print(f" \033[33m! WARN\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def head(m): print(f"\n\033[1m{m}\033[0m")
def stature_ok(f):
# [R16 ledger #3] seated figs (the drummer — fig.userData.procitySeated) pass the no-giants floor at a
# SEATED band [0.9,1.5] m; standing figs stay [1.4,2.0]. A seated human fails the standing floor by design.
lo, hi = (0.9, 1.5) if f.get('seated') else (1.4, 2.0)
return lo <= f['stature'] <= hi
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
@ -89,7 +96,7 @@ def gigs_landed(p):
"""Boot ?gigs=1; landed iff Lane A's augmentation produced a schedule (else the flag is inert)."""
b, pg, _ = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
return bool(pg.evaluate("() => !!(window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"))
except Exception:
return False
@ -110,11 +117,15 @@ def plansrc_landed(p):
b.close()
# ── the three checks ─────────────────────────────────────────────────────────
def flags_off_regression(p):
head('FLAGS-OFF REGRESSION (prime-law enforcement)')
def classic_regression(p):
# [R16 — THE FLIP] The prime-law covenant MOVED from "flags off" to "?classic=1": the classic boot must
# stay byte-identical to the frozen v2 baseline FOREVER — synthetic fingerprint 0x3fa36874, zero gig
# layer, no opt-in flags, the R7 streamed roster (which IS the v2.0 baseline). Same assertions as the old
# flags-off gate, now booted with ?classic=1 (the four flipped flags are off, everything else v2-default).
head('CLASSIC REGRESSION (?classic=1 → the frozen v2 covenant)')
b, pg, errs = new_page(p)
try:
boot(pg, '')
boot(pg, 'classic=1')
state = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG;
D.shot('street_noon'); // fixed pose comparable draws/tris
@ -138,26 +149,26 @@ def flags_off_regression(p):
if hash_ok and r.returncode == 0: OK(f'plan fingerprint == golden {hex(GOLDEN_HASH)} (selfcheck green)')
else: FAIL(f'plan fingerprint != golden {hex(GOLDEN_HASH)} — determinism/prime-law broken')
# winmap + dig stay OPT-IN (off by default). The streamed roster is now the shipping DEFAULT
# (R7 flip / prime-law amendment) — so it must be ON, not off, on a no-flag boot.
# winmap flips OFF under ?classic (it's default-on now); dig stays opt-in. The streamed roster IS the
# v2.0 baseline (R7 flip / prime-law amendment) — so it must be ON under ?classic.
if state['winmap'] or state['digActive']:
FAIL(f"an opt-in v2 flag is active on default boot (winmap/dig): {state}")
else: OK('opt-in flags off on default boot (winmap/dig)')
FAIL(f"a flipped/opt-in flag leaked into ?classic (winmap/dig): {state}")
else: OK('flipped + opt-in flags off under ?classic (winmap/dig)')
if state['streamMode']:
OK('R7 roster flip in effect — streamed roster ACTIVE on default boot')
else: FAIL('roster flip REGRESSED — default boot is not streaming (expected streamMode:true)')
OK('R7 roster flip in effect — streamed roster ACTIVE under ?classic (the v2 baseline)')
else: FAIL('roster flip REGRESSED — ?classic is not streaming (expected streamMode:true)')
# [R12] gig layer absent flags-off (prime flag law for v3). Any of these leaking means ?gigs=1
# has escaped its flag and the frozen v2 goldens are living on borrowed time.
# [R12/R16] gig layer wholly absent under ?classic. Any of these present means the gig layer escaped
# the classic gate and the frozen v2 covenant is broken.
gig_leaks = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
if not gig_leaks:
OK('v3 gig layer wholly absent flags-off (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
OK('v3 gig layer wholly absent under ?classic (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
else:
FAIL(f"v3 gig layer LEAKED into the default boot: {[(k, state[k]) for k in gig_leaks]}")
FAIL(f"v3 gig layer LEAKED into ?classic: {[(k, state[k]) for k in gig_leaks]}")
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
OK(f"flags-off street view within budget (draws {state['draws']}{STREET_DRAWS_MAX}, tris {state['tris']}{STREET_TRIS_MAX})")
else: FAIL(f"flags-off street view OVER budget: draws {state['draws']}, tris {state['tris']}")
OK(f"?classic street view within budget (draws {state['draws']}{STREET_DRAWS_MAX}, tris {state['tris']}{STREET_TRIS_MAX})")
else: FAIL(f"?classic street view OVER budget: draws {state['draws']}, tris {state['tris']}")
# Drift WARN is DRAWS-ONLY — tris is crowd-variable post-flip (guarded by the hard cap above).
dd = abs(state['draws'] - BASE_DRAWS) / BASE_DRAWS
@ -166,8 +177,8 @@ def flags_off_regression(p):
else:
WARN(f"draws drifted >40% from baseline ({state['draws']} vs {BASE_DRAWS}) — investigate")
if errs: FAIL(f"{len(errs)} console error(s) on default boot; first: {errs[0][:140]}")
else: OK('0 console errors on default boot')
if errs: FAIL(f"{len(errs)} console error(s) under ?classic; first: {errs[0][:140]}")
else: OK('0 console errors under ?classic')
# R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors.
b2, pg2, errs2 = new_page(p)
@ -183,6 +194,54 @@ def flags_off_regression(p):
finally:
b.close()
def default_boot_gate(p):
# [R16 — THE FLIP] The enforcement arm of the DEFAULT law: the no-flag boot is now the full experience —
# all four flags on, the gig layer present, deterministic to the gig golden. This is the number every
# budget/smoke now means (its inverse is classic_regression above).
head('DEFAULT-BOOT GATE (the flip → the town shows its best by default)')
b, pg, errs = new_page(p)
try:
boot(pg, '')
state = pg.evaluate("""() => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5); D.shot('venue_night'); // NIGHT at a venue the busiest default view (gigs+pools+winmap+tram)
const i = D.info();
return { flags: P.flags,
gigState: !!P.gigs, planGigs: (P.plan.gigs||[]).length, posters: (P.plan.posters||[]).length,
pub: !!(P.plan.shops||[]).find(s => s.venue), venueGeo: !!P.scene.getObjectByName('venue-presentation'),
winmapActive: !!(P.winmap && P.winmap.active), tram: !!P.scene.getObjectByName('tram'),
draws: i.drawCalls, tris: i.tris };
}""")
f = state['flags'] or {}
if f.get('gigs') and f.get('weather') and f.get('winmap') and f.get('tram') and not f.get('classic'):
OK('default boot flips all four ON (gigs · weather · winmap · tram), classic off')
else:
FAIL(f"default boot flag intent wrong: {f}")
present = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
if len(present) == 5 and state['tram']:
OK(f"the town is live: gig layer present ({state['planGigs']} gigs, {state['posters']} posters, venue geo) · tram up (winmap is a window shader — its own smoke)")
else:
FAIL(f"default boot not fully live: gig={present}, tram={state['tram']}")
# The default boot's plan IS the gig-augmented plan; selfcheck pins its fingerprint to the gig golden
# (0xb1d48ea1) INTERNALLY and is ALL GREEN only if it matches. (selfcheck prints the base fingerprint
# in its summary but asserts the gig golden silently — so we gate on the green run, not a substring.)
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT), capture_output=True, text=True)
if r.returncode == 0 and 'ALL GREEN' in r.stdout:
OK(f"default plan pins to the gig golden {hex(GIG_GOLDEN)} (selfcheck ALL GREEN — the gig-layer golden asserts pass)")
else:
FAIL(f"selfcheck not green — the gig golden {hex(GIG_GOLDEN)} or determinism broke: {r.stdout[-160:]}")
# budget on the DEFAULT boot at the busiest venue block (the number the budget law now means)
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
OK(f"default-boot street budget @ venue block: {state['draws']} draws ≤ {STREET_DRAWS_MAX}, {state['tris']:,} tris ≤ {STREET_TRIS_MAX:,}")
else:
FAIL(f"default-boot street OVER budget @ venue block: {state['draws']} draws / {state['tris']:,} tris (Fable scope call — not silent-trim)")
if errs: FAIL(f"{len(errs)} console error(s) on the default boot; first: {errs[0][:140]}")
else: OK('0 console errors on the default boot')
finally:
b.close()
def smoke(p, label, query, is_combo=False):
head(f'SMOKE: {label} (?{query})')
b, pg, errs = new_page(p)
@ -612,7 +671,7 @@ def smoke_gigs(p):
for _ in range(2):
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
sigs.append(pg.evaluate("() => JSON.stringify({g: window.PROCITY.plan.gigs, p: window.PROCITY.plan.posters})"))
if errs: FAIL(f"gigs boot: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
@ -626,7 +685,7 @@ def smoke_gigs(p):
# 2) the gig night itself: band · crowd · dance mix · budget · the live bed · no giants
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
pg.keyboard.press('Space') # gesture → AudioContext unlock (headless-safe, no pointer-lock)
night = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
@ -650,7 +709,8 @@ def smoke_gigs(p):
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
return { part: m.part, kind: m.kind, dance: !!m.dance,
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3) };
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3),
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
});
// Force one clean interior frame rather than trusting whatever the last rAF left in
// renderer.info a throttled/backgrounded tab otherwise reports the last STREET frame
@ -693,7 +753,7 @@ def smoke_gigs(p):
FAIL(f"gig-night interior budget: {night['draws']} draws > {GIG_DRAWS_MAX} (tris {night['tris']:,})")
# no giants on the stage/crowd rigs (R10 gate, inherited by the gig path)
rigs = [f for f in night['figs'] if f['kind'] == 'rig']
bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0) or f['crown'] >= night['H']]
bad = [f for f in rigs if not stature_ok(f) or f['crown'] >= night['H']] # [R16 #3] seated exemption
if not rigs:
WARN(f"no-giants: crew spawned as placeholders (no fleet) — rig check n/a ({len(night['figs'])} figs)")
elif bad:
@ -719,7 +779,7 @@ def smoke_gigs(p):
WARN(f"district: could not read manifest music keys ({e}) — gigKey-resolves check is JS-only")
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
pg.keyboard.press('Space')
district = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
@ -737,7 +797,8 @@ def smoke_gigs(p):
let crown = 0; m.actor.fig.updateWorldMatrix(true, true);
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3) };
return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3),
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
});
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
out.push({ id, kind: shop && shop.venueKind, genreKey: shop && shop.genreKey,
@ -767,7 +828,7 @@ def smoke_gigs(p):
if d['band'] == 4: OK(f" {tag}: 4-piece band")
else: FAIL(f" {tag}: expected a 4-piece, got {d['band']}")
rigs = [f for f in d['figs'] if f['kind'] == 'rig']
bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0)]
bad = [f for f in rigs if not stature_ok(f)] # [R16 #3] seated exemption
if rigs and bad: FAIL(f" {tag}: {len(bad)} giant rig(s) by stature: {bad[:2]}")
if len(genres_seen) >= 2:
OK(f"district: {len(genres_seen)} distinct genres verified live ({', '.join(sorted(genres_seen))})")
@ -803,7 +864,7 @@ def smoke_gigs(p):
# NIGHT with the gig on, let the block stream in, and sample the settled composer frame.
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
st = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5); // NIGHT frontage lit, posters up
@ -832,7 +893,7 @@ def smoke_gigs(p):
# 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
paid = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias the primary venue by id
@ -879,7 +940,7 @@ def smoke_gigs(p):
# 4) cover ruling, FREE night (seed 1234 → cover 0): walk straight in, wallet untouched
b, pg, errs = new_page(p)
try:
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1&gigs=1')
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1')
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
free = pg.evaluate("""async () => {
@ -905,7 +966,7 @@ def smoke_gigs(p):
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')
or 'custom_bands' in r.url or r.url.endswith('.glb')) else None)
try:
boot(pg, 'gigs=1&noassets=1')
boot(pg, 'noassets=1')
pg.keyboard.press('Space')
na = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
@ -945,7 +1006,7 @@ def smoke_continuity(p):
return
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
boot(pg, '')
pg.keyboard.press('Space')
res = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
@ -1023,7 +1084,8 @@ def main():
else:
print("· plansrc=osm not landed (Lane A) — skipping that flag")
flags_off_regression(p)
classic_regression(p) # [R16] the frozen v2 covenant moved here: ?classic=1 → byte-identical
default_boot_gate(p) # [R16] the flip: the no-flag boot is the full experience (all four on)
interior_budget(p)
for fl in flags:
smoke(p, fl.split('=')[0], fl)

View File

@ -90,7 +90,7 @@ FLAGS_SKIP=0; for a in "$@"; do [ "$a" = "--no-flags" ] && FLAGS_SKIP=1; done
if [ "$FLAGS_SKIP" = 1 ]; then
soft_skip "v2 flags harness" "--no-flags"
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "v2 flags harness (flags-off regression · per-flag · all-on combo · STRICT r7)" \
run_gate "flags harness (classic regression · default-boot gate · per-flag · smokes · STRICT · R16 flip)" \
tools/.venv/bin/python tools/flags_check.py
else
soft_skip "v2 flags harness" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"

View File

@ -34,6 +34,7 @@ if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
SEAT_LO, SEAT_HI = 0.9, 1.5 # [R16 ledger #3] seated figs (the drummer): a seated human's feet→crown span, still < ceiling, feet planted
FOOT_EPS = 0.05 # feet must plant at floor y=0
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
@ -76,7 +77,8 @@ MEASURE_JS = r"""
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3),
stature: +(crown - foot).toFixed(3) }; // [R13 debt #6] feet→crown span (platform-independent)
stature: +(crown - foot).toFixed(3), // [R13 debt #6] feet→crown span (platform-independent)
seated: !!(a.fig && a.fig.userData && a.fig.userData.procitySeated) }; // [R16 ledger #3] seated-fig tag
});
return { H: +H.toFixed(2), figs };
}
@ -124,7 +126,8 @@ def main():
bad = []
for f in figs:
total_figs += 1
if not (LO <= f['stature'] <= HI): bad.append(f"stature {f['stature']}m out of [{LO},{HI}]")
lo, hi = (SEAT_LO, SEAT_HI) if f.get('seated') else (LO, HI) # [R16 #3] seated exemption
if not (lo <= f['stature'] <= hi): bad.append(f"stature {f['stature']}m out of [{lo},{hi}]{' seated' if f.get('seated') else ''}")
elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m")
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
if bad:

View File

@ -24,14 +24,18 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3'
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3_1'
if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
# shot groups — each is one clean page load with the flags that shot needs (deterministic, same seed).
# [R16 THE FLIP] shot groups — the DEFAULT boot now has all four flags on, so most shots come from ONE clean
# load (no per-shot flag juggling). Rain is still forced (the default seed rolls clear); ?classic=1 gives the
# before/after comparison. Deterministic, same seed.
GROUPS = [
{'flags': 'gigs=1', 'street': ['venue_night', 'district_posters', 'queue_night'], 'interior': True},
{'flags': 'weather=rain&tram=1&winmap=1', 'street': ['rain_verandah', 'tram_stop'], 'interior': False},
{'flags': '', 'street': ['venue_night', 'district_posters', 'queue_night', 'tram_stop'], 'interior': True},
{'flags': 'weather=rain', 'street': ['rain_verandah'], 'interior': False},
# the town before v3 — the exact v2 boot; same main-street pose as district_posters for a clean A/B
{'flags': 'classic=1', 'street': ['district_posters'], 'interior': False, 'suffix': '_classic'},
]
# settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures,
@ -112,12 +116,12 @@ def contact_html(shots):
f'<figure><img src="{s["file"]}" alt="{s["name"]}" loading="lazy">'
f'<figcaption><b>{s["name"]}</b><br>{cap}</figcaption></figure>')
return (
'<!doctype html><meta charset="utf-8"><title>PROCITY v3.0 — release tour</title>'
'<!doctype html><meta charset="utf-8"><title>PROCITY v3.1 — release tour</title>'
'<style>body{background:#14110c;color:#f4efe6;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;margin:24px}'
'h1{font-weight:700}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:18px}'
'figure{margin:0;background:#1d1810;border-radius:10px;overflow:hidden}img{width:100%;display:block}'
'figcaption{padding:8px 12px;color:#cdc4b2}b{color:#ffd9a0}</style>'
f'<h1>PROCITY v3.0 — release money-shot tour <small>· seed {SEED} · settled measurements</small></h1>'
f'<h1>PROCITY v3.1 — release money-shot tour (THE FLIP) <small>· seed {SEED} · settled measurements</small></h1>'
f'<div class="grid">{"".join(cards)}</div>')
@ -127,7 +131,7 @@ def main():
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
print(f"\033[1mv3.0 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3/)")
print(f"\033[1mv3.1 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3_1/)")
OUTDIR.mkdir(parents=True, exist_ok=True)
srv = ensure_server()
shots, errs_total = [], 0
@ -139,15 +143,17 @@ def main():
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
boot(pg, grp['flags'])
sfx = grp.get('suffix', '')
for name in grp['street']:
stat = pg.evaluate(STREET_JS, name)
out = OUTDIR / f'{name}.png'
pg.screenshot(path=str(out))
cap = f"{stat['draws']} draws · {stat['tris']:,} tris"
if stat.get('queue') is not None: cap += f" · queue {stat['queue']}"
if name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters"
shots.append({'name': name, 'file': f'{name}.png', 'caption': cap})
print(f" \033[32m●\033[0m {name}: {cap}")
fname = f'{name}{sfx}.png'
pg.screenshot(path=str(OUTDIR / fname))
cap = (f"?classic=1 — the town before v3 (no gig layer, no pools)" if sfx
else f"{stat['draws']} draws · {stat['tris']:,} tris")
if not sfx and stat.get('queue') is not None: cap += f" · queue {stat['queue']}"
if not sfx and name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters"
shots.append({'name': name + sfx, 'file': fname, 'caption': cap})
print(f" \033[32m●\033[0m {name}{sfx}: {cap}")
if grp['interior']:
info = pg.evaluate(INTERIOR_JS)
if info.get('ok'):
@ -168,7 +174,7 @@ def main():
if srv: srv.terminate()
(OUTDIR / 'contact.html').write_text(contact_html(shots))
print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3/ (+ contact.html) · {errs_total} page errors")
print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3_1/ (+ contact.html) · {errs_total} page errors")
sys.exit(1 if errs_total or len(shots) < 6 else 0)

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). NON-STRICT qa extra.
"""PROCITY Lane F — the full-week gig soak (round 15→16, on the DEFAULT boot (v3.1 flip)). NON-STRICT qa extra.
Rolls the clock through all 7 nights in ONE browser context, entering every playing venue each night,
and proves the shell's per-night gig machinery holds over the week with no leaks:
@ -135,7 +135,7 @@ def main():
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · ?gigs=1)")
print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · default boot)")
srv = ensure_server()
try:
with sync_playwright() as p:
@ -144,7 +144,7 @@ def main():
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1')
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1') # [R16] the DEFAULT boot now IS the gig boot
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
@ -190,15 +190,17 @@ def main():
lk = res['leakAfter']
trail = res['memTrail']
tail = trail[-3:] if len(trail) >= 3 else trail
flat = all(m['geo'] == tail[0]['geo'] and m['tex'] == tail[0]['tex'] for m in tail)
# a real leak CLIMBS every cycle; a flat OR DECREASING back half (GC / atlas settle) is not a leak, and
# ending AT OR BELOW the warmed baseline can't be one either. Only monotone growth into the tail fails.
climbing = (tail[-1]['geo'] > tail[0]['geo']) or (tail[-1]['tex'] > tail[0]['tex'])
traj = "".join(f"{m['geo']}/{m['tex']}" for m in trail)
if lk['geo'] == 0 and lk['tex'] == 0:
OK(f"leak-free: geo/tex back to baseline across {res['cycles']} cycles (night trail {traj})")
elif flat:
OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then FLAT across the back half "
f"— no per-cycle growth (night trail {traj}, {res['cycles']} cycles)")
if lk['geo'] <= 0 and lk['tex'] <= 0:
OK(f"leak-free: geo/tex ended at/below the warmed baseline (net {lk['geo']:+d}/{lk['tex']:+d}) across {res['cycles']} cycles (trail {traj})")
elif not climbing:
OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then flat/settling across the back half "
f"— no per-cycle growth (trail {traj}, {res['cycles']} cycles)")
else:
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles")
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (trail {traj}) across {res['cycles']} cycles")
if res['cycles'] < 20:
FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)")

View File

@ -82,12 +82,18 @@ const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
// LANE_A_NOTES F2). Zero network either way. Guarded: falls back to synthetic generatePlan, then fixture.
const PLANSRC = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = params.get('town') || undefined; // [Lane F R8] &town=<key> selects an OSM town (A: melbourne|katoomba)
// [Lane F R12 — v3.0-alpha gig layer] ?gigs=1 turns on Lane A's post-hoc gig augmentation (the pub venue,
// plan.gigs, plan.posters) and everything F wires off it: the state machine, B's lit frontage + muffled
// spill, C's band room, D's band + crowd, the cover charge at the door. Default OFF ⇒ generatePlanFor
// returns the byte-identical v2 plan and NONE of the gig modules are ever constructed (prime flag law —
// the flags-off regression gate in tools/flags_check.py enforces exactly that).
const GIGS_ON = params.has('gigs') && params.get('gigs') !== '0';
// [Lane F R16 — THE FLIP (v3.1)] The town shows its best by DEFAULT: gigs / weather / winmap / tram are ON
// unless individually opted out (?gigs=0, ?weather=0, …) or ?classic=1 forces the exact v2 boot. The prime
// law is REWRITTEN, not repealed: the byte-identical covenant (synthetic fingerprint 0x3fa36874, zero gig
// layer, zero fetch delta) moved from "flags off" to "?classic=1". `flagOn(name)`: default-on, `=0` opts
// out, classic is the master off. winmap flips the same way in buildings.js; the drummer's sit clip rides !CLASSIC.
const CLASSIC = params.has('classic') && params.get('classic') !== '0';
const flagOn = (name) => !CLASSIC && params.get(name) !== '0';
// [Lane F R12/16] The gig layer — now DEFAULT-ON: Lane A's post-hoc augmentation (venues, plan.gigs,
// plan.posters) + everything F wires off it (the state machine, B's frontage/spill, C's rooms, D's
// band/crowd/queue, the cover charge). ?gigs=0 or ?classic=1 ⇒ generatePlanFor returns the byte-identical
// v2 plan and NONE of the gig modules are constructed (the classic-regression gate enforces exactly that).
const GIGS_ON = flagOn('gigs');
// John's OPTIONAL band-name drop-in (web/assets/custom_bands.json — Lane A owns the file + schema; edit it
// and reload, the names get priority on the town's posters). Absent file → pure generator, zero errors.
// ZERO fetch when the flag is off or under ?noassets=1 (asset law). `no-cache` on purpose: this file exists
@ -166,7 +172,7 @@ const minimap = createMinimap(plan);
// unless) a GLB lands, so the town is fully playable the instant it boots.
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
const fleet = NOASSETS ? null : loadPedFleet('models/peds/');
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC }); // [R16] sit.glb on the default boot (the seated drummer); ?classic keeps the zero-fetch-delta covenant
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
// [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1.
@ -232,14 +238,16 @@ document.addEventListener('visibilitychange', () => citizens.setPaused(document.
// [Lane F R8 — Lane B weather] ?weather=1 seeded (seed 20261990 default town rolls 'clear'); ?weather=rain|
// overcast|clear forces a state. Self-contained module; flag-off never constructs it (byte-identical). D reads
// window.PROCITY.weather (always a valid {state,intensity} — {clear,0} when off), it does NOT import weather.js.
// [Lane F R16 flip] weather DEFAULT-ON (seeded — the default town rolls its own state); ?weather=rain|overcast|
// clear forces one; ?weather=0 or ?classic=1 → off (byte-identical). Self-contained; D reads window.PROCITY.weather.
const _wp = params.get('weather');
const weather = (_wp && _wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp) ? _wp : null })
const weather = flagOn('weather')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp || '') ? _wp : null })
: null;
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ scene, plan, camera, lighting }) : null;
const tram = flagOn('tram') ? createTram({ scene, plan, camera, lighting }) : null; // [R16 flip] default-on; ?tram=0 / ?classic=1 → off
// [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name
// overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state.
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
@ -426,6 +434,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it)
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so

View File

@ -59,6 +59,17 @@ function micStandPrim() {
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
return tagOwn(g);
}
// R16: the backline amp fallback (C dropped its primitive ampStacks; this stands in when the guitar_amp
// GLB is absent / ?noassets). A room prop (not fig-local): base at y=0, grille on the +Z (crowd) face.
function ampPrim() {
const g = new THREE.Group();
const cab = new THREE.MeshStandardMaterial({ color: AMP, roughness: 0.7, metalness: 0.05 });
const cloth = new THREE.MeshStandardMaterial({ color: AMPCLOTH, roughness: 0.9 });
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.29), cab); box.position.y = 0.3;
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.46, 0.02), cloth); grille.position.set(0, 0.3, 0.15);
g.add(box, grille);
return tagOwn(g);
}
// a cheap kit: kick drum + snare + a floor tom + two cymbals on stands. Reads as "drum kit" behind the
// seated drummer; the GLB replaces it when present. Sits on the riser (y = SEAT_DROP compensates the sink).
function drumKitPrim() {
@ -125,15 +136,18 @@ export class GigCrew {
}
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
let actor, kind, usedIndex = null;
if (this.fleet && this.fleet.ready) {
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() });
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; } // usedIndex: the fleet ped shown — F's continuity smoke reads it
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : this.fleet.idleClip, phase: r(), seated: useSit });
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
}
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
actor.fig.position.set(x, y, z);
@ -158,9 +172,12 @@ export class GigCrew {
stage.bandPoses.forEach((p, i) => {
const seated = p.role === 'drums' || !!p.seated;
const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser)
const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs
// R16: a real sit clip seats the rig on the riser (spawnRig re-plants the feet) → no sink; only the
// no-clip fallback still sinks SEAT_DROP so the kit hides the standing legs.
const seatedRig = seated && !!(this.fleet && this.fleet.sitClip);
const y = seated ? poseY - (seatedRig ? 0 : SEAT_DROP) : poseY;
const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
{ x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
{ x: p.x, y, z: p.z, ry: p.ry, seated, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated,
base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] };
@ -218,24 +235,31 @@ export class GigCrew {
});
}
// A guitar amp as backline dressing, planted on the deck just off the riser (the classic pub backline).
// Dressing, not an instrument — planted in the room group (this.props), GLB-only (skipped under ?noassets).
// The backline amp, planted at C's up-stage `stage.backline[0]` (R16 ledger #5 — C dropped its primitive
// ampStacks; D owns the whole backline now). Placeholder-persists: a primitive amp shows immediately (the
// ?noassets fallback too), then E's guitar_amp GLB swaps in when it resolves. Falls back to the R15
// hardcoded up-stage-right pose if C's backline isn't present. Room-local coords; grille to the crowd.
_addBackline(roomGroup, stage, gen) {
if (!this.fleet || !this.fleet.ready) return;
const slot = (stage.backline && stage.backline[0]) ||
{ x: stage.x + (stage.w || 3) * 0.36, z: stage.z - (stage.d || 3) * 0.22, ry: 0 };
const place = (obj) => {
obj.position.set(slot.x, stage.deckY || 0, slot.z);
if (slot.ry != null) obj.rotation.y = slot.ry;
roomGroup.add(obj); this.props.push(obj); return obj;
};
const prim = place(ampPrim()); // primitive amp immediately (asset-free backline)
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
loadManifest().then((mani) => {
if (!mani || !mani.fittings || this._gen !== gen) return;
const entry = mani.fittings.guitar_amp;
if (!entry || !entry.file) return;
return loadGLB(`depot:${entry.file}`).then((gltf) => {
if (!gltf || this._gen !== gen) return;
if (!gltf || this._gen !== gen || !prim.parent) return; // room rebuilt / disposed mid-flight
const amp = gltf.scene.clone(true);
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
// R15 (C's §3 ruling): up-stage-right behind the front line — clears the bass rig and offsets from
// C's primitive ampStacks (at ±w·0.28, zd·0.18), so GLB + primitive read as a flanking backline.
// The old z+0.15 was down-stage and interpenetrated the bassist. Room-local coords; grille to the crowd.
amp.position.set(stage.x + (stage.w || 3) * 0.36, (stage.deckY || 0), stage.z - (stage.d || 3) * 0.22);
roomGroup.add(amp);
this.props.push(amp);
place(amp); // GLB swaps in over the primitive
const i = this.props.indexOf(prim); if (i >= 0) this.props.splice(i, 1);
disposePrim(prim); // free the primitive's own geo/mats
});
});
}
@ -277,7 +301,7 @@ export class GigCrew {
for (const ex of m.extra) disposePrim(ex); // GLB clones (shared geo/mats) are skipped inside
m.actor.dispose && m.actor.dispose();
}
for (const p of this.props) { if (p.parent) p.parent.remove(p); } // GLB dressing — cached geo/mats, detach only
for (const p of this.props) disposePrim(p); // GLB dressing → detach only; the ?noassets primitive amp → freed (ownGeo)
this.members.length = 0;
this.props.length = 0;
this._gen++; // invalidate any GLB load still in flight

View File

@ -36,6 +36,12 @@ function canonRig(r) {
// flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
// (head folded to hip height — verified in-shell), exactly the failure the comment above documents for
// walk/idle. So the sit clip drops Hips.quaternion like every other clip; the seated read comes from the
// leg-bone bends + spawnRig's seated foot-replant. Reads seated from the crowd POV (the kit hides the legs).
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
function loadRig(ref) {
@ -45,10 +51,14 @@ function loadRig(ref) {
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
export function loadPedFleet(base = 'models/peds/') {
// `opts.sit` (R16): also load sit.glb → fleet.sitClip (the seated-drummer pose). GATED because a raw
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) {
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null,
walkClip: null, idleClip: null, sitClip: null,
ready: false, whenReady: null,
};
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
@ -69,6 +79,9 @@ export function loadPedFleet(base = 'models/peds/') {
jobs.push(loadRig(`${base}idle.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean);
@ -169,14 +182,28 @@ function _disposeInner(inner) {
}
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0 } = {}) {
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, seated = false } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head } = buildFigure(rig, height);
fig.rotation.y = ry;
const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen);
if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); }
if (act) {
act.time = phase * (act.getClip().duration || 0); act.play();
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
// buildFigure planted the BIND pose; a sit clip lowers the hips + swings the legs, so re-plant the POSED
// feet to y=0: tick the mixer once to bake the pose, find the lowest bone, drop the figure so the soles
// return to the deck. One-time (a drummer's feet don't travel); this recovers the hip descent that
// _rotWithHips leaves out by dropping the (scale-unsafe) Hips.position track.
if (seated) {
mixer.update(0);
inner.updateWorldMatrix(true, true);
let m = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
if (isFinite(m)) inner.position.y -= m;
}
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, dispose };
}

View File

@ -10,12 +10,26 @@
// → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off.
import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js';
import { SHOP_TYPES, poleOffset } from '../core/registry.js';
import { SHOP_TYPES, poleOffset, roadWidth } from '../core/registry.js';
import { shopName, bandName } from './names.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
// A venue's own frontage poster seats on the +Z street facade, `d/2 + FACADE_PROUD` out from the lot
// centre (shared by pickVenues' clearance test and buildPosters). ROUND16 (ledger #6): band_room/rsl
// selection prefers a lot whose facade clears every carriageway by ≥ POSTER_CLEAR, so the poster can
// never sit on a back-street's bitumen (a narrow through-lot otherwise abuts the block's other street).
// selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it.
export const POSTER_CLEAR = 0.5;
const FACADE_PROUD = 0.06;
// nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure).
const nearestSeg = (px, pz, ax, az, bx, bz) => {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
return [ax + dx * t, az + dz * t];
};
// A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map.
const NIGHTS = 7;
const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on"
@ -55,11 +69,25 @@ function pickVenues(plan, citySeed) {
const blockById = new Map(plan.blocks.map(b => [b.id, b]));
const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const lotOf = s => lotById.get(s.lot);
const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; };
const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); };
const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; };
const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id);
// clearance of a shop's +Z frontage-poster point to the nearest carriageway (ROUND16 ledger #6).
const facadeClear = s => {
const l = lotOf(s); if (!l) return Infinity;
const off = l.d / 2 + FACADE_PROUD;
const px = l.x + Math.sin(l.ry) * off, pz = l.z + Math.cos(l.ry) * off;
let best = Infinity;
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestSeg(px, pz, a.x, a.z, b.x, b.z);
best = Math.min(best, Math.hypot(px - nx, pz - nz) - roadWidth(e) / 2);
}
return best;
};
const base = plan.shops.filter(s => {
if (s.openLate) return false; // must not displace the night landmark
@ -93,6 +121,16 @@ function pickVenues(plan, citySeed) {
if (far.length) cand = far;
}
}
// ROUND16 ledger #6: keep the off-spine kinds off a back-street's bitumen. Prefer candidates whose
// +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the warehouse fringe is uniformly narrow,
// broaden to ANY off-spine clear lot before falling back. (pub is spine-fronted — its +Z is open —
// so it keeps its placement untouched; the hero shot doesn't move.) Always a clear lot exists in the
// synthetic town; the fallback only ever fires on a degenerate/osm plan.
if (kind === 'band_room' || kind === 'rsl') {
let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR);
if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR);
if (clear.length) cand = clear;
}
if (!cand.length) cand = pool; // last resort: anything left
const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length));
const shop = pick(rng(citySeed, 'venuepick', i), shortlist);
@ -208,7 +246,7 @@ function buildPosters(plan, citySeed, venues, gigs) {
// read FROM the street: venue.js renders a poster's printed face along (sin ry, cos ry) = A's
// outward normal, so ry = lot.ry + π points that face outward (else DoubleSide shows a mirrored name).
const fx = Math.sin(lot.ry), fz = Math.cos(lot.ry); // +Z: the street facade (B's canon)
const off = lot.d / 2 + 0.06; // out onto the facade plane, proud of it
const off = lot.d / 2 + FACADE_PROUD; // out onto the facade plane, proud of it
add(lot.x + fx * off, lot.z + fz * off, lot.ry + Math.PI, gig.gigId);
}

View File

@ -10,7 +10,7 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
export { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.js';
export { withGigs, gigKeyFor } from './gigs.js';
export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js';
// The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract
// from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of
// `edge.width` — see the note in registry.js; `gigKeyFor` is the one and only genre→audio-key mapping.

View File

@ -134,6 +134,30 @@ export const OSM_TOWNS = {
{"id":11242974232,"name":"Sanctus Catholic Books and Gift Shop","type":"book","lat":-33.715718,"lon":150.31095,"suburb":""}
],
},
// ROUND16 (ledger #7): a CONSTRUCTED single-row town — every shop shares one latitude, so plan_osm
// bins them into ONE avenue and builds NO main spine (spineNodes.length === 1 ⇒ 0 'main' edges). Unlike
// Melbourne/Katoomba (8 lat rows ⇒ a multi-node spine), this proves the no-spine placement branch of
// gigs.js pickVenues + the no-main-spine poster fallback in buildPosters. Not a scraped cluster; the
// names are flavour (Silverton is a real one-street NSW town). ids are synthetic (9-prefixed) seeds.
silverton: {
town: "Silverton",
source: "constructed single-row fixture (no-spine test, ROUND16 ledger #7)",
center: {"lat":-31.8600,"lon":141.2255},
shops: [
{"id":9000001,"name":"Silverton Op Shop","type":"opshop","lat":-31.8600,"lon":141.2200,"suburb":"Silverton"},
{"id":9000002,"name":"Old School Book Exchange","type":"book","lat":-31.8600,"lon":141.2210,"suburb":"Silverton"},
{"id":9000003,"name":"Outback Vinyl","type":"record","lat":-31.8600,"lon":141.2220,"suburb":"Silverton"},
{"id":9000004,"name":"Silverton Video","type":"video","lat":-31.8600,"lon":141.2230,"suburb":"Silverton"},
{"id":9000005,"name":"Desert Trading Post","type":"pawn","lat":-31.8600,"lon":141.2240,"suburb":"Silverton"},
{"id":9000006,"name":"Mundi Collectables","type":"toy","lat":-31.8600,"lon":141.2250,"suburb":"Silverton"},
{"id":9000007,"name":"CWA Op Shop","type":"opshop","lat":-31.8600,"lon":141.2260,"suburb":"Silverton"},
{"id":9000008,"name":"Ghost Town Books","type":"book","lat":-31.8600,"lon":141.2270,"suburb":"Silverton"},
{"id":9000009,"name":"Silverton Corner Store","type":"milkbar","lat":-31.8600,"lon":141.2280,"suburb":"Silverton"},
{"id":9000010,"name":"The Old Wares","type":"pawn","lat":-31.8600,"lon":141.2290,"suburb":"Silverton"},
{"id":9000011,"name":"Dusty Grooves","type":"record","lat":-31.8600,"lon":141.2300,"suburb":"Silverton"},
{"id":9000012,"name":"Salvos Silverton","type":"opshop","lat":-31.8600,"lon":141.2310,"suburb":"Silverton"}
],
},
};
export const DEFAULT_TOWN = "melbourne";

View File

@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM, osmTownKeys } from './plan_osm.js';
import { generatePlanFor, gigKeyFor } from './index.js';
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
@ -190,25 +190,24 @@ function districtInvariants(plan, label) {
}
ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : ''));
// ── posters: town-wide, on the verge, never on the bitumen (debt #5) ──
// ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ──
const gigIds = new Set(plan.gigs.map(g => g.gigId));
ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`);
ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`);
// The no-bitumen rule (the R12 regression: spine posters at node.x+2) is for FREE-STANDING verge
// posters. A venue's own FRONTAGE poster is bound to the building B draws — since R15 it sits on the
// +Z street facade (B's canon: buildings.js facade + venue.js door/queue/camera are all +Z). A corner
// venue whose facade abuts a crossing street legitimately seats its poster near that street's kerb —
// B's building geometry, not a mid-road defect — so frontage posters are exempt here and validated by
// the placement asserts above (they reference tonight's gigs with finite coords).
// Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon:
// buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/
// rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster
// must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps
// the plain no-bitumen floor (≥ 0, the R12 regression guard). 0.02 absorbs r2 poster-coord rounding vs
// pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m).
const lotByIdD = new Map(plan.lots.map(l => [l.id, l]));
const onFacade = p => venues.some(v => {
const lot = lotByIdD.get(v.lot); if (!lot) return false;
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD)
return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5;
});
let onRoad = null;
for (const p of plan.posters) {
if (onFacade(p)) continue; // frontage poster: on the venue building, not the verge
let best = Infinity, bestEdge = null;
for (const e of plan.streets.edges) {
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
@ -217,11 +216,13 @@ function districtInvariants(plan, label) {
const d = Math.hypot(p.x - nx, p.z - nz);
if (d < best) { best = d; bestEdge = e; }
}
// 1e-6 immunises the exact kerb-line boundary against r2 coord rounding (only reachable on a
// no-main-spine fallback where poleOffset == roadWidth/2; never hit by the current sweep).
if (bestEdge && best < roadWidth(bestEdge) / 2 - 1e-6) { onRoad = [p.id, bestEdge.id, +best.toFixed(2)]; break; }
if (!bestEdge) continue;
const frontage = onFacade(p);
const clearance = best - roadWidth(bestEdge) / 2;
const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6;
if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; }
}
ok(onRoad === null, `${label}: no poster stands on a carriageway` + (onRoad ? ` (poster ${onRoad[0]} is ${onRoad[2]}m from edge ${onRoad[1]}'s centreline)` : ''));
ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : ''));
}
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
@ -271,7 +272,7 @@ for (const s of SEEDS) {
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
section('OSM plan source parity (fixture-driven, zero-network)');
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510 };
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 };
const OSM_SEEDS = [20261990, 1, 42, 777];
for (const town of osmTownKeys()) {
const report = {};
@ -307,7 +308,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
}
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
const GIG_GOLDEN = 0x4f4a549d; // ROUND15 re-pin: frontage posters flipped to the +Z street face (ledger #1 bug fix). Was 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
{
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
@ -333,14 +334,22 @@ for (const s of SEEDS) if (s > SWEEP_N) districtInvariants(generatePlanFor(s, 's
ok([2, 3, 4].every(n => sweepVenueCounts.has(n)),
`sweep: venue count spans {2,3,4} across ${SWEEP_N} seeds (saw {${[...sweepVenueCounts].sort((a, b) => a - b).join(',')}})`);
// osm graceful placement: katoomba is the smallest fixture (19 shops) with NO warehouse district, so
// pickVenues' band_room/rsl rules hit their off-spine fallback — yet still land 24 valid venues incl.
// a pub. (Both osm fixtures keep a main spine, so this proves the no-warehouse branch, not no-spine.)
// osm graceful placement — two branches of pickVenues' degradation, each proven by a fixture that
// genuinely lacks the structure the synthetic town has:
// • katoomba (19 shops): NO warehouse district ⇒ band_room/rsl hit their off-spine fallback.
// • silverton (single-row): NO main spine at all ⇒ pub's onMain filter is empty and buildPosters'
// spine run falls back to every edge (ROUND16 ledger #7 — closes the R14 gap where both osm
// fixtures had a spine). Both still land 24 valid venues incl. a pub.
for (const seed of GIG_SEEDS) {
const plan = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
ok(plan.blocks.every(b => b.kind !== 'warehouse'),
const kt = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
ok(kt.blocks.every(b => b.kind !== 'warehouse'),
`sweep osm/katoomba ${seed}: fixture has no warehouse district (graceful-placement precondition)`);
districtInvariants(plan, `sweep osm/katoomba ${seed}`);
districtInvariants(kt, `sweep osm/katoomba ${seed}`);
const sv = generatePlanFor(seed, 'osm', { gigs: true, town: 'silverton' });
ok(!sv.streets.edges.some(e => e.kind === 'main'),
`sweep osm/silverton ${seed}: fixture has no main spine (no-spine placement precondition)`);
districtInvariants(sv, `sweep osm/silverton ${seed}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────

View File

@ -25,10 +25,12 @@
// watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
// // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
// // frame + ry convention as counter.stand (rig-front = local Z).
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses }, // VENUE (R12/13, else null) — the band stage. bandPoses[4]
// // (R13): front-line trio (guitar/vocal/bass) on the deck + a drummer
// // (role 'drums', seated) up-stage on the riser. Each pose carries y
// // (front line = deckY, drummer = riserY). ry=π faces the audience.
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses,backline }, // VENUE (else null) — the band stage. bandPoses[4]:
// // front-line trio (guitar/vocal/bass) on the deck + a drummer (role
// // 'drums', seated) up-stage on the riser. Each pose carries y (front
// // line = deckY, drummer = riserY). ry=π faces the audience. backline[]
// // (v3.1) = up-stage amp slots {x,z,ry,y} where Lane D plants the
// // guitar_amp GLB (primitive fallback under ?noassets).
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@ -176,7 +178,7 @@ export function buildInterior(shop, THREE, opts) {
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4] } (4-piece: trio + drummer on the riser); else null
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },

View File

@ -209,10 +209,12 @@ function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
return pts;
}
// ── VENUE stage (pub, round 12 · behind ?gigs=1) ──────────────────────────────────────
// Places a low stage centred on the back wall, PA cabinets flanking it on the floor, and 12 amps on the
// deck. Stage + PA go into `placed` at priority 8 so the door→counter path loop never pulls them. Returns
// the stage frame the crowd faces + 3 seeded band poses on the deck (Lane D drops the band trio in).
// ── VENUE stage (v3, ?gigs) ───────────────────────────────────────────────────────────
// Places a low stage centred on the back wall + PA cabinets flanking it on the floor (both into `placed` at
// priority 8 so the door→counter path loop never pulls them). Returns the stage frame the crowd faces, the
// 4 band poses on the deck, and `backline[]` — up-stage amp slots (v3.1, ledger #5) where Lane D plants E's
// guitar_amp GLB. C no longer plants its own primitive amps: one unified backline, GLB when present, D's
// primitive fallback under ?noassets. Pure pose data; Lane D drops the rigs + amp in.
function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
const wf = spec.deckWidthFrac || 0.58, deckH = spec.deckH || 0.32; // per-kind (round 13): band_room
const sw = Math.min(W - 1.4, Math.max(3.0, W * wf)), sd = spec.deckDepth || 2.0; // barely a riser, rsl bigger
@ -234,12 +236,7 @@ function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
}
const deckY = f.deckY || deckH; // 12 amps on the deck behind the band
for (let i = 0, n = 1 + (r() < 0.5 ? 1 : 0); i < n; i++) {
const af = buildFitting('ampStack', ctx, {}, r);
af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18);
roomGroup.add(af.group);
}
const deckY = f.deckY || deckH; // deck top surface (band + backline stand height)
// 4-PIECE BAND (round 13, debt #3): front-line trio across the deck lip + a drummer up-stage on the
// riser. ry=π faces the audience (+Z) under the rig-front=Z house convention (see LANE_C_PUB.md). Each
@ -251,7 +248,18 @@ function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
}));
const drums = { x: round(cx), z: round(cz - sd / 4), ry: round(Math.PI), role: 'drums', y: riserY, seated: true };
const bandPoses = [...front, drums];
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses } };
// BACKLINE (v3.1, ledger #5): up-stage amp/spare-cab slots — the unified backline that replaces C's old
// primitive ampStacks. Pure data (no meshes): Lane D plants E's guitar_amp GLB here (primitive fallback
// under ?noassets). Up-stage of the front line, flanking the drum riser, clear of every bandPose AND all
// watchPoints (which sit downstage of frontZ), on-deck. slot[0] = stage-right (primary), slot[1] = spare
// stage-left. ry=π faces the audience (+Z), same Z convention as the band; y = deckY. Deterministic.
// slot[0] == D's R15-verified guitar_amp pose (stage.w·0.36, deckY, stage.z stage.d·0.22, 0.87 m bass
// clearance), so plumbing D's plant to backline[0] moves nothing on screen.
const backline = [sw * 0.36, -sw * 0.36].map(bx => ({
x: round(cx + bx), z: round(cz - sd * 0.22), ry: round(Math.PI), y: round(deckY),
}));
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses, backline } };
}
// ── WATCH POINTS (venue) — seeded audience floor poses facing the stage ──

View File

@ -59,7 +59,8 @@ function windowMaterial() {
// all windows (per-shop variation via aTint/aSeed attributes), same 1 draw/chunk as v1. Respects §3.5
// via the same uHour/uNight + aHours: closed-at-night rooms go dark, open rooms glow warm. Fully
// procedural (no fetch). Flag-off path is untouched (v1 windowMaterial()).
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); return p.has('winmap') && p.get('winmap') !== '0'; } catch (e) { return false; } })();
// [Lane F R16 — THE FLIP] winmap DEFAULT-ON; ?winmap=0 opts out, ?classic=1 forces off (the v2 covenant).
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); const classic = p.has('classic') && p.get('classic') !== '0'; return !classic && p.get('winmap') !== '0'; } catch (e) { return false; } })();
const ROOM_TINTS = [ // muted 90s interior wall colours (RGB 0..1), seeded per shop
[0.56, 0.44, 0.33], [0.42, 0.47, 0.52], [0.52, 0.47, 0.37], [0.47, 0.42, 0.46],
[0.54, 0.50, 0.41], [0.40, 0.45, 0.40], [0.58, 0.49, 0.40], [0.45, 0.48, 0.55],

View File

@ -19,6 +19,7 @@
// ry under A's poster convention (front = local Z); posters are rendered at A's (x,z,ry) verbatim.
import * as THREE from 'three';
import { busShelterStops } from './furniture.js'; // read-only: shelter (tram-stop) positions for pools
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; // E's poster pack (assets/gen/poster-*.jpg)
@ -169,11 +170,18 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
});
}
// ── one instanced streetlamp-pool mesh for the whole district (night-driven) ──────────────────────
// ── town-wide streetlamp pools (R16 ledger #4): a warm pool under every streetlight + bus shelter,
// not just venue frontages. Positions are replicated from the plan's street graph using furniture.js's
// exact lamp cadence (READ-ONLY — we don't touch furniture.js), so a pool lands under each real lamp.
// Gig-gated by construction (venue.js only exists under the gig layer ⇒ ?classic stays pool-free); one
// instanced mesh ⇒ still ~1 draw town-wide. Night-driven via the same opacity ramp as the venue pools.
for (const s of townPoolSpecs(plan)) poolSpecs.push(s);
// ── one instanced streetlamp-pool mesh for the whole town (night-driven) ──────────────────────────
let poolMesh = null, poolMat = null;
if (poolSpecs.length) {
const poolTex = poolTexture(); disposables.push(poolTex);
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false, toneMapped: false });
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: true, toneMapped: false });
const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2); // lies flat, normal +Y
disposables.push(poolMat, pg);
poolMesh = new THREE.InstancedMesh(pg, poolMat, poolSpecs.length);
@ -265,6 +273,30 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
const r2 = (v) => Math.round(v * 100) / 100;
const r4 = (v) => Math.round(v * 10000) / 10000;
// ── town-wide pool positions: one under every streetlight + bus shelter, derived from the plan's street
// graph. This MIRRORS furniture.js's streetlight rule (a lamp every 18 m from s=8, alternating sides, at
// off = halfRoad+0.7) so pools sit under the real lamps without importing furniture internals. Pure
// function of the plan (no rng — the cadence is deterministic), so it adds no seeded draws (release law).
function townPoolSpecs(plan) {
const specs = [];
const nodeById = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
for (const e of (plan.streets?.edges || [])) {
const a = nodeById.get(e.a), b = nodeById.get(e.b);
if (!a || !b) continue;
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len, px = -uz, pz = ux;
const halfRoad = (e.width || 8) / 2;
let side = 1;
for (let s = 8; s < len - 4; s += 18) {
const off = (halfRoad + 0.7) * side;
specs.push({ x: a.x + ux * s + px * off, z: a.z + uz * s + pz * off, diam: 3.4 });
side = -side;
}
}
for (const st of busShelterStops(plan)) specs.push({ x: st.x, z: st.z, diam: 3.6 });
return specs;
}
// ── a warm radial streetlamp-pool sprite (procedural, no fetch): bright warm centre → transparent edge.
function poolTexture() {
const S = 128;

BIN
web/models/peds/sit.glb Normal file

Binary file not shown.