Compare commits

...

9 Commits

Author SHA1 Message Date
m3ultra
f8ab837985 Fable: R14 kickoff rulings recorded (push done, flag stays opt-in, genres final) + gitignore local session files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:59:42 +10:00
m3ultra
e4e16c34ed Fable (integrator): round-14 instructions — v3.0 release round
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:56:00 +10:00
m3ultra
a358c5d9ef Lane F R13 (v3.0-beta, the district): per-venue gig state + walla/applause + queue wiring; qa --strict 6/6
Wires A–E + D's district together and fires it. Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing
tonight (The Exchange Hotel pub gig-pubrock + an RSL gig-covers, one dark), Screaming Utes $10 on the door.

- gig_state.js → PER-VENUE latches off the shared procity:segment spine (one per venueShopId); all three
  hard-won R12 laws kept. New runtime surface window.PROCITY.gigs.byVenue + onOf/openOf/gigOf/coverOf/
  bandNameOf/paidOf(id) + markPaidOf/venueShopIds; the R12 single-venue alias kept for one round.
- interior_mode.js: crowd-walla rides the interior tone layer under the live band bed while `on`; an
  applause sting fires once on the on->done edge. DELETE the GIG_BED bridge — debt #1, and it was a LIVE
  BUG: E renamed pubrock-live->gig-pubrock, so the bridge (gig-pubrock->pubrock-live) pointed at a dead
  key => the band would have mimed in silence. room.audio.gigKey (already the manifest key) feeds directly.
- index.html: per-venue cover charge (a night at two venues is two covers), concurrent per-venue setGig
  surges, and the outdoor VenueQueue lifecycle (D shipped the class + left the street-side seam to F:
  spawn at doors off B's queueZoneFor, drive each frame, dispose by done), exposed as queueCountOf(id).
- interior_scale_check.py: human-size assert now measures STATURE (crown-foot, platform-independent);
  world-crown ceiling-clip assert kept as its own line (debt #6).
- flags_check.py smoke_gigs -> THE DISTRICT: band->4, per-venue loop (every gigKey resolves across 3
  genres, crowd <= its own watchPoints, no-giants by stature), street budget at the busiest venue block,
  the queue assert; plus the R12 determinism/cover/noassets/flags-off arms.
- CITY_SPEC: pin the window.PROCITY.gigs runtime surface. gig_shot.py -> r13 interior + venue_night shots.

qa.sh --strict GREEN 6/6, 0 warn. Interior 63 draws/35k tris; street @ venue block 121 draws/51k tris;
band+crowd stature 1.61-1.81 m under a 4 m ceiling; flags-off fingerprint 0x3fa36874 unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:22:03 +10:00
m3ultra
fefbb4b806 Lane D R13 (v3.0-beta): 4-piece + instruments, outdoor queue, multi-venue surge, RY fix
- RY audit (debt #4): the ped GLB fleet's visual front is +Z but the whole
  system assumed -Z (sim heading math, C's pose ry, placeholder, impostor).
  Fixed at the source (rigs.js buildFigure normalises every rig to -Z front)
  and dropped band.js RY_FLIP. Corrects keepers/browsers (faced the back wall)
  and streamed walkers (moon-walked): 223/223 gig + 188/188 flags-off now face
  travel. Visual only, no golden move. keepers.js/placeholder.js/impostor.js
  unchanged (fixed for free). Canonical convention -> LANE_D_NOTES for C.
- Instruments (debt #2): E's manifest GLBs swap over the primitives async
  (placeholder-persists, fleet-gated -> 0 fetch under ?noassets), guitar/bass
  held, mic + kit planted, guitar_amp dressing.
- Drummer (debt #3): 4-piece, seated at C's drums pose (sink + kit occlusion;
  reads from the front/crowd POV - a sit clip is the polish).
- Outdoor queue (new queue.js): seeded 2-6 line at the frontage, drains on
  admitOne() + auto-drain, consumes B's queueZone or a frontage-hugging door
  fallback. F wiring seam documented in LANE_D_NOTES.
- Multi-venue surge (sim.js): setGig -> Set of concurrent venues.
- Interior gig 56 draws <=350; ?noassets+?gigs placeholders, silent-and-fine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:00:05 +10:00
m3ultra
5dac2cac5a Lane E R13: publish decimated instruments + _hi originals to depot
John OK'd direct publish. Pushed the 5 base instruments (14k) + 5 _hi hi-poly originals
via the passwordless tailnet ingress (this box is allowlisted). Public digalot.fyi now
serves the 14k bytes (amp 5.9MB->1.3MB) + the _hi originals; -70% decimation win is live
in production. _published.json 33->38; validate_manifest --depot GREEN. Gate-3 flag cleared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:12:48 +10:00
m3ultra
46127c7e95 Lane B (Streetscape) round 13: v3.0-beta the district — N-venue frontage + night pass + posters + queue zone (?gigs=1)
Generalises the R12 one-pub slice to all 2-4 venues and lands the night-readability polish. All
gig-gated (flags-off byte-identical, verified in a fresh browser context). Files: venue.js, audio.js, dbg.js.

- venue.js -> N venues: per-venue lit marquee/frontage on B's +Z facade side + window-light spill;
  streetlamp pools on venue blocks (one instanced warm-pool mesh, night-driven -> the Friday-night
  walk reads); town-wide posters instanced per gig (14 posters = 2 draws / 2 textures, band-name
  overprint, flat printed-poster fallback). Town-wide pools deferred (would need always-on
  furniture.js -> breaks flags-off parity).
- Queue-zone marker per venue: venue.queueZone = {x,z,ry,len} for Lane D (head at the door, extends
  along the frontage; queuer faces -ry). queueZones{} + queueZoneFor(id).
- audio.js muffled spill per-genre (debt #1): import gigKeyFor; the nearest venue at doors/on within
  26 m spills its gig-<genreKey> through the 470 Hz lowpass, so pub/band_room/RSL crossfade genre.
  F: delete the GIG_BED bridge.
- update(gigStates) polymorphic: per-venue map {[venueShopId]:state} (F wires PROCITY.gigs.byVenue)
  or alpha-compat bare string (primary pub). Contract documented for F in LANE_B_NOTES.
- ?noassets fetch fix (latent R12 bug): gate the poster JPEG fetch behind !noassets -> 0 gig-layer
  network over the 22-JPEG facade/ground baseline.
- dbg.js: venue_night + district_posters bookmarks (seg 5) so tools/qa/gig_shot.py scripts the
  street money shot; poseForVenue resolves from the live plan.

Budget (fresh Chromium, gigs-on NIGHT, busiest pub block, all frontages lit): worst 154 scene /
~167 total draws, 64k tris -- <=300 / <=200k. 0 console errors (gigs / gigs+noassets / flags-off).
Verified 3 venues seed 20261990: pub->gig-pubrock, RSL->gig-covers, band_room->gig-grunge (all in
E's manifest).

Finding for Lane A: buildPosters item 1 seats each venue-frontage poster on the lot -Z (back wall,
measured 15.9 m from the +Z street facade) -- flip to +sin,+cos. Spine/pole posters render correctly
and face the footpath ("SCREAMING UTES" readable). Non-blocking (marquee + pools carry the shot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:11:10 +10:00
m3ultra
82b711f962 Lane C R13 (v3.0-beta, the district): band_room + rsl archetypes, 4-piece band, RY audit (?gigs=1)
- theme.js: band_room (small/scuzzy tin shed) + rsl (carpet-and-flock club) venue recipes,
  reusing the pub machinery via a new per-kind venueSpec (deck size/height + crowd cap).
- layout.js: parametrized placeStage/placeWatchPoints; 4th `drums` pose seated on the riser
  (debt #3 — the band is now a 4-piece across all venues), stage.riserY + per-pose y.
- interiors.js: robust gig-<genreKey> resolution (gig.genreKey -> shop.genreKey -> kind
  default) matching E's renamed manifest beds (debt #1); API doc for the 4-piece + riserY.
- LANE_C_PUB.md: 3-kind venue contract + pinned ry convention and the empirically-verified
  keeper/browser back-to-front finding + source-normalise fix for Lane D (debt #4).

Verified fresh (seed 20261990 + 90-build soak): watch caps 8/8/12, gigKeys
gig-pubrock/gig-grunge/gig-covers, determinism 0, path/carve 0, gig-night worst RSL 237 draws
asset-free (16 rigs) / 77 GLB <=350, leak geo0/tex0. Shot: docs/shots/laneC/rsl_gig_r13.jpg.
Lane F: bump flags_check band==3 -> ==4 (tools/flags_check.py:674,794) — RED by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:05:03 +10:00
m3ultra
bf98b41b16 Lane E R13 (v3.0-beta): gigKey rename + grunge/covers beds + instrument decimation
- debt #1: the manifest music key IS the gigKey, always gig-<genreKey>. Renamed
  pubrock-live -> gig-pubrock (files + gen_audio + build_manifest); old files deleted.
  B/F switch readers, F deletes the GIG_BED bridge (both fail soft to silence til then).
- two new gig beds: gig-grunge (band_room, quiet-loud dynamic) + gig-covers (RSL, mellow
  organ-led); procedural/seeded/loopable/$0. New organ() + grungechord() synth helpers.
- decimate the 5 instrument GLBs to 14k tris each (was 43-96k; fleet 236k->70k, -70%) via
  collapse (thin features survive); _hi hi-poly originals kept for depot; thumbs re-rendered.
- manifest regen (validates 0/0 incl --depot); app boots clean flags-off / ?gigs=1 (3-venue
  district, all 3 beds resolve) / ?noassets=1&gigs=1 (0 audio+GLB fetches).
- depot re-publish (14k bytes + _hi originals) gated on John's OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:03:14 +10:00
m3ultra
6fba79a1cb Lane A round 13: v3.0-beta the district (?gigs=1) — 2–4 venues, week schedule, town-wide posters, verge fix
Publishes the multi-venue plan.gigs interface C/D/B/F hang off (half-day handshake, A first).

- pickVenues: seeded 2–4 shops converted in place → pub (spine end) / band_room (warehouse
  fringe) / rsl (off-spine); 4th = 2nd pub at far end. Registry gains weight-0 band_room+rsl
  kinds + venueKind/genreKey/placement. Never the openLate landmark; osm-safe fallbacks.
- gigKey contract (debt #1): gigKeyFor(genreKey) => 'gig-'+genreKey, the single exported
  genre→bed mapping. No table anywhere; F deletes the GIG_BED bridge.
- plan.gigs goes district: 7 nights × every venue, each plays a seeded 4–6 (dark reads true);
  cover skewed by kind; no band plays two venues on one night; custom names spread night-major.
- plan.posters goes town-wide: tonight's playing venues each get frontage + seeded spine run.
- Verge fix (debt #5): new corridor law roadWidth/vergeBand/poleOffset(edge) in registry.js
  (road-vs-verge split of edge.width, published as functions ⇒ base goldens frozen). Posters
  seat on the verge, never bitumen; selfcheck asserts no poster on a carriageway.
- registry pub.interior 'band_room'→'pub' (unread field; avoids collision with band_room type).

Gig golden re-pinned 0xa6ae5a5e→0x1f636349 (multi-venue changed output; in-commit per amendment
law). selfcheck ALL GREEN 3273/3273; scaffold_check + consistency GREEN; flags-off byte-identical.
CITY_SPEC §v3.0-beta + LANE_A_NOTES §Round 13 updated in this commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:35:05 +10:00
56 changed files with 2694 additions and 474 deletions

5
.gitignore vendored
View File

@ -17,3 +17,8 @@ pipeline/_stockpack/
pipeline/_records.tsv
pipeline/_books.tsv
pipeline/_toys.tsv
# local harness/session files (not repo material)
.claude/
settings.local.json
procityprogress.txt

View File

@ -3,7 +3,78 @@
*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-15 (round 12) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 13) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 13, v3.0-beta: the district) — two more rooms + the drummer
R13 §Lane C. The district: two new venue archetypes + the 4-piece band + the RY-facing audit. Interfaces
(re)published in [docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — **read §0 first (the pinned ry
convention + the keeper/browser back-to-front finding for Lane D).** Committed C-only by pathspec (the tree
had E/B/D changes landing concurrently — [[procity-shared-tree-hazard]]).
**Shipped (C-owned files only): `theme.js`, `layout.js`, `interiors.js`.**
- **`band_room` + `rsl` recipes** (`theme.js`, both `venue:true`), reusing the pub venue machinery with a new
`venueSpec` field. `band_room` = small/scuzzy tin shed (cosy/pokey bias, low 3.4 m ceiling, deck barely a
riser `deckY 0.2`, near-empty standing floor). `rsl` = carpet-and-flock club (hall/wide bias, tall 4.0 m,
proper stage `deckY 0.4`, bistro tables, the biggest crowd cap). A converts a shop with `shop.type = kind`;
C keys the recipe off it. `SHOP_TYPES` now auto-enrols both in `drawSweep`/`soak`.
- **4-piece band (debt #3):** `placeStage` grows `stage.bandPoses` to **4** — front-line trio
(guitar/vocal/bass, `y=deckY`) + a **drummer** (`role:'drums'`, `seated:true`, up-stage on the drum riser,
`y=riserY=deckY+0.16`). Each pose now carries its own `y`; `stage.riserY` added. Applies to all three
archetypes (the pub gains a drummer too). Pure geometry — no new rng draw, determinism intact.
- **Per-kind venue tuning:** `placeStage`/`placeWatchPoints` read `recipe.venueSpec` → deck size/height +
**crowd cap** (pub/band_room **8**, rsl **12** — the crowd-cap stress case). Pub values == the R12
constants, so the pub stage geometry is byte-identical (bar the new drummer).
- **Robust gigKey (debt #1):** `audio.gigKey = 'gig-'+genreKey`, genre resolving `opts.gig.genreKey`
`shop.genreKey` (A sets it) → a kind default. Matches E's renamed manifest beds `gig-pubrock`/`gig-grunge`/
`gig-covers` exactly (verified: manifest has all three, zero stale `pubrock-live`). One key, no mapping.
**RY-facing audit (debt #4) — see LANE_C_PUB.md §0 for the full write-up + fix. Headline:**
- Canonical convention pinned: **rig-front = local Z at ry=0** (every C pose + the box placeholder use it).
- **The GLB fleet mesh faces +Z (opposite).** Verified in a fresh context two ways: toe-bone probe
(`toefoot = (0,+0.99)` on 5 peds) and dotting a fleet rig planted at `counter.stand.ry` against the
intended customer-facing (`0.99` = 180° backward; `+π``+0.99`).
- **Finding: keepers + browsers have been facing 180° backward whenever the GLB fleet is loaded**
`keepers.js` spawns at `pose.ry` with no flip, and the mesh front is +Z. The R12 band reads right only
via `band.js RY_FLIP=π`. Placeholder (Z) and GLB (+Z) are opposite, so it's inconsistent both ways.
- **Recommended fix for Lane D (visual-only, no golden moves):** normalise at the source — rotate the clone
`inner` by π in `rigs.js buildFigure` so every GLB rig faces Z; then `keepers.js` stays flip-free (now
correct) and `band.js` DROPS `RY_FLIP`. Repro below.
**→ Lane F (flags_check.py): two smokes are RED by design — bump `band == 3``== 4`.** My 4th bandPose
makes the band a 4-piece (D's `band.js` already iterates `bandPoses`, so it spawns 4 today), which trips F's
R12 assertions verbatim as the round-13 instructions anticipated ("F's band==3 smoke moves to 4"):
- `tools/flags_check.py:674``if night['band'] == 3``== 4`.
- `tools/flags_check.py:794``if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0``== 4`. *(The
noassets gig still happens — band 4, crowd 8, 0 fetches, 0 console errors; silent-and-happy holds. It only
"fails" because 3 is hardcoded.)* These are the ONLY qa reds from C; the other 5 gates pass.
**→ Lane D:** lift each band member to `pose.y` (front line `deckY` unchanged; drummer sits at `riserY`);
`seated:true` on the drums pose → seated bob; raise `band.js CROWD_CAP` to the watchPoint cap so the RSL's 12
fill (I measured the full 12). And apply the §0 facing fix. Repro for the audit (paste in the interior test page):
```js
const rigs = await import('./js/citizens/rigs.js'); const THREE = await import('three');
const fleet = rigs.loadPedFleet('models/peds/'); await fleet.whenReady;
const sp = rigs.spawnRig(fleet.all[2], { ry: 0, height: 1.75 }); sp.fig.updateWorldMatrix(true, true);
let foot, toe, wp = new THREE.Vector3();
sp.inner.traverse(o => { if (o.isBone){ o.getWorldPosition(wp);
if (/LeftFoot$/i.test(o.name)) foot = wp.clone(); if (/LeftToeBase$/i.test(o.name)) toe = wp.clone(); }});
console.log('fleet forward at ry=0:', toe.clone().sub(foot).setY(0).normalize()); // → (0, +1) = +Z
```
**Budgets (measured, fresh context, seed 20261990):**
- Empty shells (`drawSweep` glb-off): band_room 124 · pub 126 · rsl 135 (worst town-wide 161 opshop/hall) ≤350.
- **Gig-night incl. full crowd + 4-piece:** worst **RSL 237 draws** asset-free (16 rigs) / 77 GLB, 40.9k tris.
pub 176/56, band_room 173/53. ≤350 draws / 200k tris. ✓
- 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0. `soak(60)` in
the page: PASS 60 rooms, leak 0/0, determinism identical, worst draws 85. qa figure-scale gate: 24 figs
human-sized. Shot: [docs/shots/laneC/rsl_gig_r13.jpg](docs/shots/laneC/rsl_gig_r13.jpg) — RSL, 4-piece
facing the crowd, crowd facing the stage (facing reads correct via band.js's flip).
**Drummer veto (John):** default is Fable's 4-piece ruling (executed). To revert to a front-line trio, drop
`bandPoses[3]` in `layout.js placeStage` (1 line) + F reverts the smoke to `== 3` — say so before D wires it.
---

View File

@ -4,6 +4,41 @@
---
## Round 13 (v3.0-beta — THE DISTRICT, `?gigs=1`) — WIRED + VERIFIED + TAGGED
AE landed, then D (4-piece + instruments + queue + RY fix). F wired the district together and it fires.
**Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing tonight — The Exchange Hotel (pub, `gig-pubrock`)
+ an RSL (`gig-covers`), one dark (reads true).** All 7 F tasks done, `qa.sh --strict` GREEN (6/6, 0 warn),
tag **`v3.0-beta`**. Shots: `docs/shots/laneF/r13_v3beta_gig_interior.png` + `r13_v3beta_venue_night.png`.
**What F did (ROUND13 §Lane F):**
1. **Per-venue state machine**`gig_state.js` rewritten: one latch per venue off the shared
`procity:segment` spine, keyed by `venueShopId`; all three hard-won R12 laws preserved. New runtime
surface `window.PROCITY.gigs.byVenue` + per-venue getters (`onOf/openOf/gigOf/coverOf/paidOf…`); the
R12 single-venue alias kept one round. B's `venue.js`/`audio.js` + D's `setGig` read it.
2. **Walla + applause** — the crowd bed `crowd-walla` rides the interior tone layer under the live band bed
while `on`; an `applause` sting fires once on the `on→done` edge while inside. Verified: `iMusic=gig-pubrock`,
`iTone=crowd-walla`.
3. **Cover per venue** — the door seam keys off the shop entered, so a night at two venues is two covers.
4. **Debt #1 closed — was a LIVE BUG** — E renamed `pubrock-live→gig-pubrock`, so F's R12 `GIG_BED` bridge
(rewrote `gig-pubrock→pubrock-live`) pointed at a dead key → the band would have mimed in silence.
Deleted; the room's `audio.gigKey` (already the manifest key) feeds the engine directly.
5. **Stature gate**`interior_scale_check.py` now measures stature (crownfoot, platform-independent),
ceiling-clip assert kept as its own line (debt #6).
6. **District smokes**`smoke_gigs`: band→4, a per-venue loop (every gigKey resolves across 3 genres,
crowd ≤ its own watchPoints, no-giants), street budget at the busiest venue block, the queue assert,
plus the R12 determinism/cover/noassets/flags-off arms. All green.
7. **The outdoor queue** — D shipped `VenueQueue` and left the street-side lifecycle to F (its documented
seam): F wires one queue per venue in the shell (spawn at doors off B's `queueZoneFor`, drive each frame,
dispose by `done`), exposed as `queueCountOf(id)`. Verified: 2 in line at doors → 0 by done.
**Numbers (seed 20261990):** gig interior 62 draws / 34k tris (≤350) · street @ venue block 107 draws /
47k tris (≤300/200k) · band+crowd stature 1.611.81 m under a 4 m ceiling · cover $10 debited-once/re-entry-
free/broke-knockback + free night (seed 1234) · flags-off fingerprint 0x3fa36874 unchanged, gig layer wholly
absent. Full runbook + the R13 debt-ledger closeout in [`LANE_F_NOTES.md §13`](docs/LANES/LANE_F_NOTES.md).
---
## Round 12 (v3.0-alpha — the gig layer, `?gigs=1`) — WIRED + VERIFIED
AE all landed before me, so F did what F does: turned five parts into one Friday night. **Seed

View File

@ -1,9 +1,49 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 212 closed** (see below).
`qa.sh --strict` GREEN. v3.0-alpha gig interface published.*
*Status: **all deliverables landed; self-check all-green (3273/3273); rounds 213 closed** (see below).
Node QA gates (selfcheck · scaffold · consistency) GREEN. v3.0-beta DISTRICT interface published (24
venues, week schedule, town-wide posters); gig golden `0x1f636349`. Strict 6/6 is F's end-of-round gate.*
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT: 24 venues + the week + town-wide posters + verge fix
Lane A ran **first** again (C/D/B/F all hang off the district `plan.gigs`). Same flag, same prime-flag
law: **flags-off byte-identical — all v2 goldens frozen** (synthetic `0x3fa36874`, osm unchanged).
Self-check **ALL GREEN 3273/3273**; `scaffold_check` + `consistency` GREEN. The gig **entry shapes are
unchanged** — the alpha's one-venue readers now iterate 24. Interfaces published in `LANE_A_NOTES.md`
(§Round 13) and `CITY_SPEC.md` (§v3.0-beta) in this same commit.
Delivered (all behind `?gigs=1`):
1. **24 venues, three kinds**`pickVenues` converts a seeded 24 plain shops in place: `pub` (spine
end), `band_room` (warehouse fringe), `rsl` (off-spine), 4th = a 2nd pub at the far end. Each carries
`venueKind` + `genreKey` (`pubrock`/`grunge`/`covers`). Registry gains weight-0 `band_room`+`rsl` types
(facades, hours, footprints). Placement degrades gracefully so osm towns still get venues. Never the
openLate landmark; venues occupy distinct lots.
2. **The gigKey contract (debt #1)**`gigKeyFor(genreKey) => 'gig-'+genreKey`, the single exported
genre→bed mapping (no table anywhere). C/E/B/F all route through it; F deletes the `GIG_BED` bridge.
3. **`plan.gigs` goes district** — 7 nights × every venue, each playing a seeded **46** (dark the rest,
reads true). `gigId` unique town-wide; cover skewed by kind (pub 50%/`$210`, RSL 45%/`$25`, band_room
70%/`$26`). **No band plays two venues on one night**; custom names spread night-major across the week.
4. **`plan.posters` goes town-wide** — tonight's playing venues each get a frontage poster + a seeded spine
run (2/main edge, either verge). Different venues share pole runs — the district reading.
5. **Verge offset (debt #5 PAID)** — the R12 bug (spine posters at `node.x+2` = mid-bitumen on a 28 m
corridor) is fixed by the new corridor law: `roadWidth`/`vergeBand`/`poleOffset(edge)` in registry.js,
re-exported from the barrel — the road-vs-verge split of `edge.width`, published as **functions** (no
plan fields ⇒ base goldens frozen). Selfcheck now asserts no poster stands on a carriageway.
6. **Registry `pub.interior` corrected** `'band_room'`→`'pub'` (was an unread field; now avoids a
name-collision with the new `band_room` venue type — C keys recipes off `shop.type`, not this field).
Golden re-pin: gig golden `0xa6ae5a5e`→**`0x1f636349`** (multi-venue changed gig-plan output; re-pinned
in-commit per CITY_SPEC amendment law). New selfcheck coverage: 24 venues + known kinds + `type≡venueKind`
+ genre map + gigKey ≡ `gig-`+genreKey; 46 gigs/venue, ≤1 gig/night/venue, no same-night band clash
town-wide; posters advertise night-0 and **clear every carriageway**. Sample seed 20261990 → 3 venues:
The Exchange Hotel (pub), Wangaratta RSL, The Vinyl Shed (band room, dark tonight); 14 gigs across the week.
**Handshake note for Lane C:** `RECIPES.band_room`/`RECIPES.rsl` don't exist in `theme.js` yet — until C
adds them those venues fall back to the op-shop interior. That's the order (A→C). **Not run by A:** the
browser flags/scale gates + depot (Gate 3, John-blocked) — F's end-of-round acceptance, and they'd test
R12 runtime that hasn't adapted to the new venue count yet.
## Round 12 (2026-07-15) — v3.0-alpha GIG LAYER (`?gigs=1`): venue + gigs + band names + closing-time debt
Lane A ran **first** this round (the whole round hangs off the `plan.gigs` handshake). Everything is

View File

@ -204,52 +204,101 @@ Interiors are **not** street geometry. Walking into a doorway triggers
exactly where you stood. Street windows show a cheap fake (tinted glass + emissive warm glow at
night in v1; interior-mapping shader is a stretch goal).
## CityPlan v3 — gig layer (`?gigs=1`) — v3.0-alpha
## CityPlan v3 — gig layer (`?gigs=1`) — v3.0-beta (THE DISTRICT)
> **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).
>
> *(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.)*
**Turning it on** (Lane F): `generatePlanFor(seed, source, { gigs:true, customBands }) → plan` with the
gig fields below (else the base plan, untouched). `generatePlanFor` stays synchronous — F fetches
`custom_bands.json` in the bootstrap and passes the array. `withGigs(plan, seed, opts)` is also exported.
**The venue (one `pub` per town).** A spine-END plain `shop` is converted **in place** to `type:'pub'`
(keeps its lot id + geometry ⇒ enterable like any shop, no overlap), tagged `shop.venue === true`. It is
**never** the openLate landmark (the video survives; the town still has exactly one openLate shop). The
registry gains a `pub` type (facades, `interior:'band_room'`, fittings, hours `[17,23]`) with **no
district weights** so it is never auto-placed. Venue hours `[17,23]` keep it open through DUSK+NIGHT so
the gig runs and D's patronage surge fills it — and it drains at close per the closing-time ruling above.
The pub is the *only* gig-plan shop allowed to close ≥22 besides the openLate video (selfcheck exempts it).
**The venues (24 per town — a seeded count).** Three registry venue kinds — **`pub`**, **`band_room`**,
**`rsl`** — each with **no district weights** (never auto-placed). The layer converts that many plain
`shop`s **in place** (keeps each lot id + geometry ⇒ enterable, no overlap), tags them
`shop.venue === true`, `shop.venueKind`, `shop.genreKey`, and **never** takes the openLate landmark (the
town keeps exactly one openLate shop). Kinds are added in the order `pub, band_room, rsl, pub` (a 4-venue
town gets a second pub at the far end of the spine). Placement rules (each degrades to "any remaining
plain shop" so osm towns still get venues):
**`plan.gigs`** — the nightly schedule (present only on gig-on plans):
| kind | `genreKey` → gigKey | placement | hours | reads as |
|---|---|---|---|---|
| `pub` | `pubrock``gig-pubrock` | spine **end**/corner (main edge, farthest) | `[17,23]` | ≥2-storey corner hotel |
| `band_room` | `grunge``gig-grunge` | warehouse-district fringe | `[18,23]` | single-storey tin shed |
| `rsl` | `covers``gig-covers` | **off**-spine (not main, not warehouse) | `[16,23]` | carpet-and-bistro club |
Venue hours all close `23` (open through DUSK+NIGHT so the gig runs and D's surge fills the room; drains
at close per the closing-time ruling above). Venues are the *only* gig-plan shops allowed to close ≥22
besides the openLate video (selfcheck exempts them). `genreKey` is set from the registry kind→genre map
(`SHOP_TYPES[kind].genre`, mirrored by `genreForVenueKind(kind)`); John may re-flavour the two new genres
(names only — E synthesises to order).
**The gigKey contract (ROUND13 debt #1 — one key, zero mapping).** The audio-manifest bed key for a
genre **IS** the gigKey, and it is **always** `gig-<genreKey>`. There is no mapping table anywhere: A
emits `shop.genreKey` + `gig.genreKey`, C sets `audio.gigKey = 'gig-'+genreKey`, E names the manifest
bed identically, B's spill reads it and F resolves it — all through the single exported helper
**`gigKeyFor(genreKey) => 'gig-'+genreKey`** (`web/js/citygen/gigs.js`, re-exported from the barrel).
Import it rather than building the string; R12 lost a bed to a private rename (`gig-pubrock` vs
`pubrock-live`) and, because audio fails soft, the band just mimed.
**`plan.gigs`** — the week's schedule across the whole district (present only on gig-on plans):
```
gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ]
```
- 7 nightly entries at the one venue; `night` = 0-based index, **F selects tonight's** (alpha: `gigs[0]`).
- `venueShopId` = the pub shop id. `genreKey = 'pubrock'` (alpha's one genre). `bandName` — see drop-in.
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine: `quiet → doors (startSeg1 = DUSK)
→ on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
- `cover` — integer: **~half `0` (free), the rest `$2$10`**. F debits the wallet at the door; free = walk in.
- **7 nights × every venue, but not every venue plays every night**: each venue takes a seeded **46**
of the 7 and goes dark the rest (dark nights read true). `gigId` unique town-wide; `night` = 0-based,
**F selects tonight's** (night `0`) per venue. `venueShopId` → the venue shop id; `genreKey` ≡ that
venue's genre.
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors
(startSeg1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
**Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read
it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters
`stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. The R12
single-venue alias (`.state/.on/.open/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s)
is kept for **one round** so mid-round single-venue callers don't break; it retires next round.
- `cover` — integer in `{0} [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$210`,
RSL 45% free / `$25` (cheap — the bistro pays the bills), band_room 70% free / `$26` (a hat on a
milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers.
- **No band plays two venues on one night** (≤1 slot per name per night across town): custom names are
deduped and used at most once; a generated name that would clash tonight advances its stream until it
doesn't (deterministic — the generator index only moves forward).
**`plan.posters`** — gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]` (~6 —
one on the venue frontage, a seeded few along the spine, on poles/walls B already streams). B places E's
poster skin at `(x, z)` rotated `ry`; F wires "poster → tonight's-gig discoverability".
**`plan.posters`** — town-wide gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]`.
Only **tonight's** (night 0) playing venues are advertised. Each such venue gets one poster on **its own
frontage** (seated on the facade plane, `lot.x + front·(d/2+ε)`), 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 runs — that IS the district reading. `ry` follows the one house convention (front/outward
normal = local `Z` at `ry=0`; `ry = atan2(nx, nz)`), so a poster faces the footpath that reads it. B
places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig".
**The street-corridor law (ROUND13 debt #5 — published as functions, not plan data).** `edge.width` is
the **full corridor** (carriageway + verge/footpath, centreline ± width/2), never the carriageway alone —
so a naive `node.x + 2` seats furniture mid-bitumen. `web/js/core/registry.js` now exports the split as
pure functions over an edge (no plan fields change ⇒ base goldens stay frozen), re-exported from the
barrel: **`roadWidth(edge)`** (carriageway half-corridor: main 10 m, side 68, lane = all, arcade 0),
**`vergeBand(edge) → [inner,outer]`** (where footpath furniture lives), **`poleOffset(edge)`** (where a
pole/poster seats — just behind the kerb). Posters, poles, verandah posts belong in the verge band; the
selfcheck asserts no poster stands within `roadWidth/2` of any edge centreline.
**Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists.
`web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral
Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority** on the schedule
(shuffled by seed, so a town surfaces up to its 7 nightly slots of them); **absent → pure generator, zero
errors, zero fetch** (the `?noassets=1` path). Deterministic given `(seed, file contents)`. John edits the
file and reloads.
Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority**, deduped and shuffled
by seed, spread **night-major across the whole week's slots town-wide** (not one venue's residency);
**absent → pure generator, zero errors, zero fetch** (the `?noassets=1` path). Deterministic given
`(seed, file contents)`. John edits the file and reloads.
**Closing-time law as code (v2 debt paid).** `isOpen(shopOrHours, hour)` is exported from the barrel — the
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:
`0xa6ae5a5e`. Asserted by `selfcheck.js` alongside the frozen v2 goldens; changing gig output re-pins it.
**`0x1f636349`** (ROUND13 re-pin — the district changed gig-plan output; was `0xa6ae5a5e`, the one-pub
alpha). Asserted by `selfcheck.js` alongside the frozen v2 goldens; changing gig output re-pins it.
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
@ -279,7 +328,9 @@ canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patron
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
| `stall` (market) | market, corrugated | open stall | trestle tables, crates |
| `pub` (v3 venue) | djsim-pub, federation | band_room | bar counter, stage, PA stack, tables, stools |
| `pub` (v3 venue) | djsim-pub, federation | pub | bar counter, stage, PA stack, tables, stools |
| `band_room` (v3 venue) | warehouse-tin, besser, corrugated | band_room | bar counter, stage, PA stack, stools |
| `rsl` (v3 venue) | brickveneer, redbrick | rsl | bar counter, stage, PA stack, bistro tables, stools |
## NPC contract (Lane D)

View File

@ -2,6 +2,95 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this)
Supersedes the R12 alpha interface below. Same flag (`?gigs=1`), same prime-flag law: flag OFF ⇒ plan
byte-identical to v2 (synthetic golden still `0x3fa36874`, osm unchanged). What changed: **one venue → 24;
one genre → three (by kind); one night's posters → town-wide.** The gig **entry shapes are unchanged**
downstream code that read one venue now iterates many. Verified green: `selfcheck.js` 3273/3273,
`scaffold_check` + `consistency` GREEN.
**Gig golden re-pinned: `0xa6ae5a5e``0x1f636349`** (the district changed gig-plan output; re-pinned in
the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged:
`generatePlanFor(seed, src, { town, gigs:true, customBands })`.
### The venues (NEW: 24 per town, three kinds) — `plan.shops.filter(s => s.venue)`
Each converted-in-place venue shop carries three new fields:
```
s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'}
s.type === s.venueKind // C keys its interior recipe off shop.type — the type IS the kind
```
| kind | genreKey | placement | hours | reads as |
|---|---|---|---|---|
| `pub` | `pubrock` | spine end/corner | `[17,23]` | ≥2-storey corner hotel |
| `band_room` | `grunge` | warehouse fringe | `[18,23]` | single-storey tin shed |
| `rsl` | `covers` | off-spine | `[16,23]` | carpet-and-bistro club |
- Seeded count 24; a 4th venue is a **second pub** at the far spine end. A town always has ≥1 pub.
- **Never** the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm
towns (no warehouse district, maybe no spine) still get their venues.
- `genreKey` comes from the registry kind→genre map: `genreForVenueKind(kind)` / `SHOP_TYPES[kind].genre`.
### THE gigKey CONTRACT (debt #1 — import it, don't build the string)
The audio-manifest bed key **IS** the gigKey and is **always** `gig-<genreKey>`. No mapping table anywhere:
```js
import { gigKeyFor } from './citygen/index.js'; // re-exported from the barrel
gigKeyFor('pubrock') === 'gig-pubrock' // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it
```
R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) — audio fails soft, so the band just
mimed. One key, zero mapping. E renames `pubrock-live → gig-pubrock` this round; F deletes the `GIG_BED` bridge.
### `plan.gigs` — the week across the whole district (entry shape UNCHANGED)
```
plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
```
- **7 nights × every venue, but not every venue plays every night**: each venue plays a seeded **46** of
the 7, dark the rest (reads true). `gigId` unique **town-wide**; `night ∈ [0,7)`; `genreKey ≡` its venue's.
- **Pick tonight per venue** (each venue has its own state now): `plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]`**may be undefined** (venue dark tonight; F/B skip it, no poster).
- `startSeg=5 endSeg=5`. Same `quiet → doors(seg4) → on(seg5) → done` machine — F runs one **per venue**.
- `cover``{0}[2,10]`, skewed by kind: pub 50% free/`$210`, RSL 45% free/`$25`, band_room 70% free/`$26`.
F debits **per venue** — a night at two venues is two covers.
- **No band plays two venues on one night** (guaranteed by A). Custom names spread night-major across the
whole week town-wide (deduped, priority-seeded), the rest generated.
### `plan.posters` — town-wide now (shape UNCHANGED; **Lane B** renders)
```
plan.posters = [ { id, gigId, x, z, ry }, … ] // only TONIGHT's (night 0) playing venues advertised
```
- One on each playing venue's **own frontage** (on the facade plane) + a seeded spine run (2 per main edge).
- Different venues' posters share the same pole runs — that IS the district reading. `p.gigId` tells B/F
which venue's gig each poster sells (`plan.gigs.find(g=>g.gigId===p.gigId)`).
- **Debt #5 fixed:** posters no longer land mid-road. See the corridor law ↓.
### The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture
`edge.width` is the **full corridor** (carriageway + verge, centreline ± width/2), NOT the carriageway.
`node.x + 2` seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel):
```js
import { roadWidth, vergeBand, poleOffset } from './citygen/index.js';
roadWidth(edge) // carriageway half-corridor (main 10m, side 68, lane=all, arcade 0)
vergeBand(edge) // [inner,outer] — where footpath furniture belongs
poleOffset(edge) // where a pole/poster/post seats: just behind the kerb
```
Selfcheck now **asserts** no poster stands within `roadWidth/2` of any edge centreline.
### → Lane C: two recipes to add + the gigKey + a corrected field
- `getRecipe(shop.type)` resolves off `RECIPES[type]``RECIPES.pub` exists; **`RECIPES.band_room` and
`RECIPES.rsl` do NOT yet.** Until you add them, those venues fall back to the op-shop interior. That's
the handshake — you're next in order.
- Set `room.audio.gigKey = gigKeyFor(shop.genreKey)` (import from the barrel) — never hand-build `'gig-…'`.
- Registry `pub.interior` was `'band_room'` (harmless, unread — you key off `shop.type` via `canonicalType`);
I corrected it to `'pub'` so it isn't a name-collision with the new `band_room` venue type. No behaviour change.
### → Lane D: iterate the venues, one setGig each
`const venues = plan.shops.filter(s => s.venue)` — call `setGig` **per venue** (multiple concurrent). Tonight's
gig per venue as above (skip dark ones). `isOpen(shopOrHours, hour)` unchanged (half-open law). Queue zone
comes from B (`venue.queueZone`) — consume if present, else a straight line off the door.
### → Lane F: state machine goes per-venue
One latch **per venue keyed by `venueShopId`**, off the same `procity:segment` spine. `gigKeyFor(genreKey)`
resolves all three genres (the debt-#1 assert now covers 3). Delete `GIG_BED` once E's rename lands. Cover
charge is per venue (per-venue paid stamps). Keep the alpha `window.PROCITY.gigs` shape as a compat alias
for one round (B reads it mid-round).
## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this)
Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to

View File

@ -1,5 +1,95 @@
# LANE B — NOTES (measured)
## Round 13 (Fable's ROUND13 → Lane B) — the district reads by night (`?gigs=1`, v3.0-beta)
Four B pieces, all gig-gated (flags-off byte-identical, verified). Files: `venue.js` (rewrite),
`audio.js` (spill), `dbg.js` (bookmarks). Verified in a **fresh browser context** (the stale-module
trap bit me once here — the pre-warmed tab served R12 `venue.js`; a `cache:'reload'` + hard nav fixed
it, proof = the returned object's key set flipped from R12 `{frontage,venueShopId}` to R13
`{venues,venueShopIds,queueZones,…}`). Seed 20261990 = **Boolarra Heads, 3 venues**: The Exchange
Hotel (pub/pubrock, id116), Wangaratta RSL (rsl/covers, id404), The Vinyl Shed (band_room/grunge,
id490); 14 gigs/week, tonight **Screaming Utes @ pub $10** + **The Dead Ringers @ RSL free**.
### 1. `venue.js` generalised to N venues — `createVenuePresentation(plan, skins, scene)`
Now renders **every** `plan.shops` with `s.venue`, not just the pub. Returns
`{ group, venues[], update(gigStates), dispose, venueShopId (primary/alpha compat), venueShopIds[],
queueZones{}, queueZoneFor(id), frontage (primary, compat), posterCount, posterDraws }`.
- **Frontage per venue** — warm marquee slab + emissive bulb row over the door, on B's **+Z facade
side** (`sin ry, cos ry`; buildings.js canon), plus a **window-light spill** wash (additive, faint
night baseline + gig glow). Ramped per-venue by `update()`.
- **Streetlamp pools (the night-readability pass)** — one procedural warm radial sprite, **one
InstancedMesh for the whole district** (door pool + 2 footpath pools per venue). Night-driven (on at
NIGHT, off by day — streetlamps don't care about gigs), so venue blocks read on the Friday-night walk.
Scoped to venue blocks; **town-wide deferred on purpose** (would need always-on `furniture.js`
changes → breaks flags-off parity).
- **Posters, town-wide, instanced per gig.** A given gig's poster is identical wherever pasted, so B
groups `plan.posters` by `gigId`, picks ONE seeded skin per gig, and renders each group as a single
`InstancedMesh`**14 posters tonight = 2 draws / 2 canvas textures** (was 1 mesh + 1 canvas each).
Band name overprinted in the skin's `nameZone`; flat printed-poster fallback on 404 / `?noassets`.
- **`?noassets` fetch fix (latent R12 bug).** R12 `posterTexture` fetched `poster-*.jpg` even under
`?noassets`. R13 gates the fetch behind `!noassets` (read from the URL, like buildings.js `WINMAP`).
Measured `?noassets=1&gigs=1`: **0 poster fetch, 0 audio/manifest fetch** — the gig layer adds no
network over the documented 22-JPEG facade/ground baseline. Venue still builds (flat posters), no crash.
### 2. `audio.js` muffled spill → per-genre, per-venue (debt #1 closed on B's side)
The R12 spill hard-coded `pubrock-live`; E's rename made that a **silent bed** (band mimes). Now B
`import { gigKeyFor } from '../citygen/gigs.js'` (the ONE genre→bed map) and spills each venue's
`gig-<genreKey>`. Walks the venue list, the **nearest venue at doors/on within 26 m wins**, so
pub→band_room→RSL **crossfades the genre** through the same 470 Hz lowpass. Verified beds resolve to
E's manifest: pub→`gig-pubrock`, RSL→`gig-covers`, band_room→`gig-grunge` (all present). Audio engine
loads `live` with the new citygen import; flags-off it loads fine and the spill loop is skipped
(no `s.venue` shops). **F: delete the `GIG_BED` bridge — B no longer needs it.**
### 3. Queue-zone marker (for Lane D) — `venue.queueZone = { x, z, ry, len }`
Each venue publishes a queue zone: a subtle chalk-pale ground strip **beside the door** (past the
marquee edge, out on the footpath), running **along the frontage**. `queueZones` = `{ [venueShopId]:
zone }`; also `queueZoneFor(id)`. **Contract for D:** `(x,z)` = the head (front of queue, at the door
end); the line **extends** in `(sin ry, cos ry)` for `len` metres; a queuer **faces the door** =
`(sin ry, cos ry)`. D places poses at `head + (sin ry, cos ry)·s` for `s ∈ [0,len]`. Verified 3
zones seat correctly (pub head (30.9, 390.0) beside the door). D's fallback ("straight line from the
door") stays valid if the zone is absent.
### 4. `dbg.js` bookmarks — the street money shots (for `tools/qa/gig_shot.py`)
- **`venue_night`** (seg 5): stands 13 m in front of the **pub** frontage — lit marquee + streetlamp
pools + window spill + queue + posters, dark CLOSED neighbours behind. The street money shot.
- **`district_posters`** (seg 5): the spine at night, poster poles + lit frontages receding.
- New `poseForVenue(kind, dist)` resolves from the live plan (falls back to `streetViewPose` with no
gig layer, so flags-off shots don't crash).
### `update(gigStates)` — the shape to agree with F (F drives it every street frame)
`update` is **polymorphic**: pass `{ [venueShopId]: 'quiet'|'doors'|'on'|'done' }` (a plain object or
Map, or `{ byVenue: {…} }`) for **per-venue** frontage lighting; a **bare state string** is alpha
compat and lights **only the primary pub** (`plan.gigs[0].venueShopId`). **F R13 ask:** build
`window.PROCITY.gigs.byVenue = { [venueShopId]: { state, cover, paid, … } }` (keep the alpha single
object as a compat alias) and call `venuePresentation.update(PROCITY.gigs.byVenue)`. **audio.js reads
`PROCITY.gigs.byVenue[id].state` too** (falls back to the alpha alias, then a clock fallback from
`plan.gigs` night 0), so the spill works across the transition. Until F wires the map, only the pub
frontage marquee lights from the alpha string — **pools + baseline window glow (night-driven) light
all venue blocks regardless**, so the district still reads at night.
### Budget (measured, fresh Chromium, gigs-on, NIGHT, all frontages lit, busiest = pub block)
| view | scene draws | tris | gate |
|---|---|---|---|
| `venue_night` (composer total) | **96113** | 1932k | ≤300 / ≤200k ✅ |
| pub block 3/4 street (worst of 4 poses) | **154 scene (~167 total)** | **64k** | ✅ ~130 draw margin |
| `district_posters` (composer total) | 142 | — | ✅ |
Gig-night layer cost is tiny: pools **1 draw**, posters **≤2 draws** (instanced per gig), frontage
~3 draws/venue-in-frame. `?noassets=1&gigs=1` worst 176198 (post passes vary), still ≤300. **0
console errors** in every mode (gigs / gigs+noassets / flags-off).
### → Lane A (a finding for you — cross-lane, please flip one sign)
`buildPosters` **item 1** seats each venue's own frontage poster at `lot + (sin ry, cos ry)·(d/2)`
— the lot's **Z** side. But B's facades/doors are on **+Z** (buildings.js canon since round 1, and
the shipped town the player faces), so the venue-frontage poster lands on the **back wall**: measured
for the pub it's **15.9 m from the street facade, 0.07 m from the back**. The CITY_SPEC "front = Z"
poster convention disagrees with the building convention — a street-side sibling of debt #4's RY
clash. **Fix:** item 1 → `+sin, +cos` (the spine-run posters, which use the pole outward-normal
derivation, render correctly and face the footpath — verified "SCREAMING UTES" readable, not
mirrored). B renders A's `(x,z,ry)` verbatim (mesh oriented `ry+π` to face A's Z normal), so once A
flips, the pub gets its poster with no B change. Not blocking the money shot (marquee + pools carry it).
---
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`)
Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN):

View File

@ -1,55 +1,132 @@
# LANE C — pub venue interface (round 12, v3.0-alpha) → for Lane D + Lane F
# LANE C — venue interfaces (round 13, v3.0-beta: the district) → Lane D + Lane F
*PROCITY-C, 2026-07-15. R12 §Lane C. The band room ships behind `?gigs=1` (A only emits `pub` shops
when the flag is on). Interface published here + in this commit's code; non-pub rooms are byte-identical
(the venue code only runs for `recipe.venue`, on its own seed sub-streams). Verified: 40-seed pub sweep +
60-room mixed soak — 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst 19 ms.*
*PROCITY-C, 2026-07-15. R13 §Lane C. The district ships **three** venue archetypes behind `?gigs=1`
`pub` (R12), and new this round **`band_room`** and **`rsl`**. A converts a chosen shop to a venue kind in
place (`shop.type = kind`); C keys the interior recipe off `shop.type`. All three return the SAME shape
(below), so Lane D drops rigs in and Lane F reads state with no per-kind branching. Non-venue rooms are
byte-identical (venue code only runs for `recipe.venue`, on its own seed sub-streams).*
## What `buildInterior` returns for a pub (type `'pub'`)
Everything below is **pure data on the return** (room-local coords, `counter.stand` ry convention:
rig-front = local Z), so Lane D drops rigs straight in and Lane F reads state — no new lifecycle.
*Verified (fresh context, seeds 20261990/1990/7/42/101 + 90-build venue soak): 0 throws · 0 carves ·
0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst build 8.3 ms. Draws below.*
### `room.stage` — the band stage (null for non-venues)
---
## 0. The pinned pose `ry` convention (ROUND13 debt #4) — READ THIS FIRST
**Canonical convention (all of C's poses use it):** a pose's `ry` is the yaw of a thing whose **front /
outward normal is local Z at ry=0**, i.e. world-facing `(sin ry, cos ry)`. `counter.stand`,
`browsePoints`, `watchPoints` and `bandPoses` ALL follow it, and the box placeholder (`placeholder.js`) is
built to it (toes at Z). This is the long-standing convention `keepers.js` greet math already assumes.
**The GLB fleet mesh is the outlier — it faces local +Z at ry=0.** Verified two ways in a fresh context:
- toe-bone probe (`toe foot`, the anatomically-anterior direction) on 5 peds → `(0.05..0.14, +0.99)` = **+Z**.
- planting a fleet rig at C's `counter.stand.ry` via `spawnRig` (no flip) and dotting its forward against
the intended customer-facing direction → **0.99 (180° backward)**; at `ry+π` → +0.99.
- for contrast the box placeholder's foot mesh sits at z 0.076 → **Z** (matches the convention).
**Finding: with the GLB fleet loaded, keepers AND browsers have been facing 180° backward since the fleet
was wired in.** `keepers.js` spawns rigs at `stand.ry` / `browsePoint.ry` with **no flip**, and the mesh
front is +Z, so the keeper faces the bar wall and the browser faces the aisle instead of the goods. The R12
band reads correctly only because `band.js` added `RY_FLIP = π`. The two actor kinds are **opposite**
(placeholder Z, GLB rig +Z), so the current per-consumer state is inconsistent both ways:
| consumer | applies | placeholder (Z front) | GLB rig (+Z front) |
|---|---|---|---|
| `keepers.js` (keepers, browsers) | no flip | ✅ correct | ❌ **backward** |
| `band.js` (band, crowd) | `+RY_FLIP` (π) | ❌ backward | ✅ correct |
**Recommended canonical fix (Lane D — visual only, NO golden moves, no C poses change):** normalise the
fleet at the source. In `rigs.js buildFigure`, rotate the cloned `inner` by π about Y so every GLB rig's
front becomes **Z**, matching the placeholder and every C pose. Then:
- `keepers.js` stays flip-free and is now correct for GLB too (fixes keepers + browsers);
- `band.js` **drops** `RY_FLIP` (set to 0) — the poses already face right under the Z convention;
- placeholder and GLB modes become consistent (no more mode-dependent facing).
One rotation at the source, delete every consumer flip. C's data does not move (pure poses, all Z already),
so no golden is touched — it's the visual fix debt #4 anticipated. Repro handed to D in `C-progress.md`.
*(If D prefers to keep the flip per-consumer instead, the equivalent is: ADD `RY_FLIP` to `keepers.js`
spawns — but that leaves placeholder-mode keepers backward, so the source-normalise is the clean one.)*
---
## 1. What `buildInterior` returns for a venue (`shop.type` ∈ `pub` | `band_room` | `rsl`)
Everything below is **pure data on the return** (room-local coords, the ry convention pinned in §0). Lane D
drops rigs straight in; Lane F reads state — no new lifecycle vs. the R9 keeper/browser seam.
### `room.stage` — the band stage, now a **4-PIECE** (ROUND13 debt #3, null for non-venues)
```js
room.stage = {
x, z, // deck centre (against the back wall)
w, d, // deck size (m)
deckY, // top surface height (m) — band rigs stand at y = deckY
w, d, // deck size (m) — varies by kind (table below)
deckY, // deck top surface height (m) — front line stands here
riserY, // drum riser top height (m) = deckY + 0.16 — the drummer sits here
frontZ, // z of the deck lip (the crowd stands in front of this, +Z)
bandPoses: [ { x, z, ry, role } ×3 ], // guitar / vocal / bass, across the deck front, facing the crowd (ry=π → +Z)
bandPoses: [ { x, z, ry, role, y, seated? } ×4 ],
}
```
**Lane D:** put the 3-piece band at `bandPoses` (lift to `y = deckY`); `role` hints instrument. Drummer
can sit up-stage on the riser (there's a raised riser at deck-centre-back) — your call, C just gives 3 front poses.
Instruments = E's GLBs when present, primitives otherwise (asset law).
- `bandPoses[0..2]` = the **front line**: `role``guitar` / `vocal` / `bass`, across the deck lip, `y = deckY`.
- `bandPoses[3]` = the **drummer**: `role: 'drums'`, `seated: true`, up-stage centre on the drum riser,
`y = riserY` (= `deckY + 0.16`, the `fittings.js` stage riser). The riser mesh already exists in the stage
fitting — the pose just seats the kit + drummer on it.
- **Lane D:** lift each member to **its own `pose.y`** (was `stage.deckY`; front line is unchanged, the
drummer now sits 0.16 m higher on the riser). `role` hints the instrument — E's GLBs via `opts.instrumentFor(role)`
(electric_guitar/bass_guitar/mic_stand/drum_kit), primitives otherwise (asset law). `seated:true` → seated bob.
- `ry = π` faces the audience (+Z) **under the pinned Z convention** → apply your fleet flip per §0.
- **John may veto to a front-line trio** (charter): say so before you start and C ships `bandPoses` length 3
(drop `[3]`), and F amends its `band==` smoke back to 3.
### `room.watchPoints` — the audience (empty [] for non-venues)
### `room.watchPoints` — the audience, **cap by kind** (empty `[]` for non-venues)
```js
room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // 6..8 seeded floor poses, all facing the stage centre
room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // seeded floor poses, all facing the stage centre
```
- `dance` is seeded **~⅓ true** ("a bit of both"). **Lane D:** `dance:false` → stand-and-watch idle
(subtle weight-shift); `dance:true` → bob/sway/step loop with a **seeded phase offset** so the crowd
doesn't metronome (use `slotIndex` or the pose coords to derive the phase).
- Every point is walkable, reachable from the door, and clear of the keeper stand, browse points, and the
stage/PA footprint. Deterministic per shop.seed. Crowd cap = `watchPoints.length` (F's smoke asserts ≤).
- **Crowd cap by kind: `pub` 8 · `band_room` 8 · `rsl` 12** (the RSL is the crowd-cap stress case). The room
fills what geometry allows up to the cap; **the count IS the crowd cap** (F asserts crowd ≤ `watchPoints.length`).
- `dance` seeded **~⅓ true** ("a bit of both"). D: `dance:false` → stand-and-watch idle; `dance:true` → bob/sway
with a **seeded phase offset** (use `slotIndex`/coords). Every point walkable, reachable, clear of the keeper
stand + browse points + stage/PA. Deterministic per `shop.seed`.
### `room.counter` / keeper — unchanged
The bar is a normal counter; the **barkeep is just a keeper** at `room.counter.stand` (existing R9 seam).
The bar is a normal counter; the barkeep is just a keeper at `room.counter.stand` (R9 seam). *(Subject to the
§0 facing fix — the keeper is one of the actors currently backward with the GLB fleet.)*
### `room.audio.gigKey` — the live bed
`room.audio = { musicKey, toneKey, gigKey? }`. **Lane F** passes gig state via `opts.gig` to `buildInterior`:
### `room.audio.gigKey` — the live bed (ROUND13 debt #1)
`room.audio = { musicKey, toneKey, gigKey? }`. The manifest key **is** the gigKey, canonical form
**`gig-<genreKey>`** — no mapping table. F passes gig state via `opts.gig`:
```js
buildInterior(pubShop, THREE, { gig: { on: true, genreKey: 'pubrock' } })
// → room.audio.gigKey === 'gig-pubrock' (gigKey = 'gig-' + genreKey)
buildInterior(rslShop, THREE, { gig: { on: true } }) // → room.audio.gigKey === 'gig-covers'
```
- `gigKey` is present **only when a gig is on**. A quiet-night pub has no `opts.gig` → normal interior
(`toneKey` room-tone + maybe the seeded radio `musicKey`).
- **Prefer `gigKey` over `musicKey`** while the gig plays: `play(room.audio.gigKey || room.audio.musicKey)`.
- **Lane E:** please name the pub-rock live bed **`gig-pubrock`** in `manifest.audio` (music or a `gig`
section) so the key resolves; missing key → silence (audio law), band still visible.
- Genre resolves `opts.gig.genreKey` → the venue's own `shop.genreKey` (A sets it) → a kind default
(`pub`→pubrock, `band_room`→grunge, `rsl`→covers). Present **only when a gig is on**.
- **Prefer `gigKey` over `musicKey`** while the gig plays. **Lane E** names the beds `gig-pubrock`,
`gig-grunge`, `gig-covers`; a missing key → silence (audio law), band still visible.
## The archetype (C-owned, FYI)
`pub` recipe (`theme.js`, `venue:true`): stage + PA + amps at the back wall, bar counter (`corner`),
sticky-carpet floor, pub tables + bar-back shelf against the walls, the blocked back doorway doubles as
the **green room**. Primitive stage/PA/amp fittings today with **GLB slots for Lane E**: `stage`,
`pa_speaker`, `amp` (house GLB law; primitive fallback holds — a gig with no GLBs is still a gig).
### The venue table (C-owned tuning, FYI)
| kind | archetype bias | ceiling | deck `w × d`, `deckY` | crowd cap | genre → gigKey | dressing |
|---|---|---|---|---|---|---|
| `pub` | wide / hall | 3.6 m | `0.58·W × 2.0`, 0.32 | 8 | pubrock → `gig-pubrock` | pub tables, sticky carpet, bar |
| `band_room` | cosy / pokey / wide | **3.4 m (low)** | `0.62·W × 1.6`, **0.20** (barely a riser) | 8 | grunge → `gig-grunge` | near-empty standing room, tin-shed dingy |
| `rsl` | hall / wide | **4.0 m (tall)** | `0.50·W × 2.4`, **0.40** | **12** | covers → `gig-covers` | flock walls, club carpet, bistro tables, members' bar |
## 2. Quiet-night variant
No `opts.gig` → a normal interior: `stage` + `watchPoints` are still returned as data, but there's **no
`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. Budgets (measured, seed 20261990, fresh context)
- **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst
161 (opshop/hall). ≤ 350. ✓
- **Gig-night incl. FULL crowd + 4-piece** (worst-case framing from the door): RSL **237 draws** asset-free
(16 rigs: 12 crowd + 4 band) / **77** GLB, **40.9k tris**. pub 176/56. band_room 173/53. ≤ 350 draws /
200k tris. ✓ *(Measured with all 12 RSL watchPoints filled — band.js still caps crowd at 8 in R12; raising
it to the watchPoint cap is D's R13 item, so this is the forward-looking worst case.)*
- **90-build venue soak** (pub/band_room/rsl mixed): 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms;
leak geo 0 / tex 0.
## 4. The archetypes (C-owned, FYI)
`band_room` = a tin shed: small (cosy/pokey), low ceiling, barely-a-riser deck, near-empty standing floor,
bar in the corner — the PA is louder than the room. `rsl` = carpet-and-flock club: the biggest floor in
town (hall/wide), a proper raised stage at one end, bistro tables round the edge, members' bar, the biggest
crowd cap. Both reuse the `pub` venue machinery (`theme.js venueSpec` + `layout.js` placeStage/placeWatchPoints);
only the dressing + `venueSpec` differ. Stage/PA/amp keep their GLB slots for Lane E (primitive fallback holds).

View File

@ -6,6 +6,101 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge
*PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116
`pubrock` / rsl #404 `covers` / band_room #490 `grunge`, 14 gigs / 14 posters) and C's landed 4-pose
stage (guitar/vocal/bass + a seated `drums` on the riser). Files: `rigs.js`, `band.js`, new `queue.js`,
`sim.js`. `keepers.js` / `placeholder.js` / `impostor.js` UNCHANGED — the RY fix at the source corrects
them for free.*
### Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL)
**Finding (confirmed in-scene):** the imported ped GLBs' visual front is local **+Z**, but *everything
downstream assumes Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry`
(counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds
toes to Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the
lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked
(faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together
(seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only
the gig.
**Fix:** one line in `rigs.js buildFigure``inner.rotation.y = Math.PI` — normalises every rig's front
to Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry`
verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's
yaw-pick already uses the Z convention, near↔mid stays seamless (both flip together). It's **purely
visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants
gate, and all seeded identity/determinism are byte-unchanged (**no golden move**).
**Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188**
flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was 1.0); band faces the
crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left
facing the backdrop) is now correct too.
- **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local Z**
(`rigs.js buildFigure` normalises the GLB's native +Z). All pose `ry` (counter.stand, bandPoses,
watchPoints, browse points) are the Z-front heading the rig should face — no lane compensates; the R12
`RY_FLIP` is gone."* Your existing `ry` values were already correct under this — nothing to change.
- **→ Lane F / Fable (flags-off flag):** the fix is in the shared rig stack, so the **default (flags-off)
street crowd now faces travel** instead of moon-walking. Determinism + the golden plan hash are
untouched (visual only), but if any regression snapshots *pixels* it will move — it's a bug fix, re-pin
if needed (improvement, not a leak).
### Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated)
`band.js` keeps its primitive instruments as the immediate/`?noassets` fallback, then swaps E's manifest
GLBs in async when they resolve (same pattern as `interiors/glb.js`). Role → fitting: `guitar`
`electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the
riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js
stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets`
(fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest`
requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only,
never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table
(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap).
### Debt #3 — the drummer (4-piece, Fable's ruling)
`bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks
`SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd
POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated
pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply
omits the pose and the band is 3 with zero D change.
### The outdoor queue (charter item — new `web/js/citizens/queue.js`)
`VenueQueue`: a seeded 26 line on the same gate-protected rig path, idle-animated, that drains as punters
are admitted and empties by close. Consumes B's `venue.queueZone` when present, else a straight line off
the door — the fallback trails along the **frontage tangent** so it hugs the building instead of spilling
onto the road. Verified: spawn (5 rigs, human-sized, hug the frontage, face the venue), `admitOne()` drains
the head + shuffles everyone up to target, and a seeded auto-drain safety. **F seam** (wire per venue off
the street-side gig state):
```js
const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance
q.admitOne(); // on each admitted punter (cover-charge / entry hook)
q.update(dt); // each street frame — idle + lerp + slow auto-drain
q.disposeAll(); // at 'done' / venue out of view
```
**→ Lane B:** publish `venue.queueZone = { x, z, ry, len }``(x,z)` = head by the door, `ry` = the
heading a punter faces (toward the entrance), `len` = line length; the line trails single-file opposite the
facing, so pick `ry` to run it along the verandah.
### Multi-venue surge (`sim.js`)
`setGig(venueShopId, on)` widened from a single `_gigVenue` to a `_gigVenues` **Set** so several concurrent
gigs each pull their own block; `_nearestOpenShop` now prefers the nearest gig venue in `GIG_RANGE` across
all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical.
### Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116)
| thing | number | budget |
|---|---|---|
| gig-night interior, rig fleet + GLB instruments | **56 draws** · 183k tris | ≤350 draws ✓ |
| — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) |
| band / crowd | **4** on the deck (3 front + seated drummer) · **8** at 8 watch points | crowd ≤ watchPoints ✓ |
| outdoor queue | seeded **26** rigs (~1 draw each) at the frontage | stated |
| `?noassets`+`?gigs` | band+queue placeholders, **0** GLB/manifest fetch | silent-and-fine ✓ |
| flags-off street walkers face travel | 188/188 (dot 1.0) | determinism byte-unchanged ✓ |
*RSL (#404) fields the crowd-cap stress case: `CROWD_CAP` is 12, real cap = `watchPoints.length` per venue
(≤ always holds). Re-measure the RSL interior once its archetype dressing lands.*
---
## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below)
**Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points;
@ -493,7 +588,10 @@ bind directly through the canonical `mixamorig` skeleton exactly like walk/idle,
data task, not new rig work. **Verify each exists before requesting; never re-download.**
Wanted for richer loiter/keeper behaviour, in priority order:
1. `sit` — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
1. `sit` — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). **R13: the gig
drummer also wants this (or a bespoke `drum`/`play-drums` loop)** — the 4th band member is currently a
standing rig sunk `SEAT_DROP` behind the kit, which reads seated only from the front. A real seated clip
retires the sink hack (band.js `SEAT_DROP` + the `m.seated` branch in `update`).
2. `lean` — loiter against a shopfront (window-shopping stops).
3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn.
4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).

View File

@ -1,5 +1,72 @@
# LANE E — cross-lane notes
## Round 13 — two genres, the gigKey rename (debt #1), lighter instruments
**v3.0-beta THE DISTRICT.** All local work landed + verified; the one gated step (depot publish)
waits on John. Verification: core gates GREEN (scaffold · consistency 0-warn · citygen selfcheck
3273/3273 incl. v3 gig-layer + assets-on-disk), manifest validator `--depot` GREEN, and the real
app boots clean flags-off / `?gigs=1` (A's 3-venue district, 14 gigs/posters — all three gig beds
resolve by canonical key, no stale key, 0 console errors) / `?noassets=1&gigs=1` (0 audio + 0 GLB
fetches, silent-and-fine).
### 1. gigKey rename — DONE (debt #1). **→ Lane B + Lane F: switch your readers.**
The manifest music key IS the gigKey, **always `gig-<genreKey>`** — one key, zero mapping. Renamed
`pubrock-live`**`gig-pubrock`**; added `gig-grunge`, `gig-covers`. Files are now
`audio/music-gig-{pubrock,grunge,covers}.{ogg,m4a}`; the old `music-pubrock-live.*` are **deleted**.
Manifest regen'd + validates 0/0; no stale `pubrock-live` anywhere.
- **→ Lane F (interior_mode.js):** DELETE the `GIG_BED` bridge (the `{'gig-pubrock':'pubrock-live'}`
map + `gigBedKey()`); read `ra.gigKey` (`gig-<genre>`) straight against `AUDIO.music[...]`. The
bridge currently maps to the now-gone `pubrock-live`**fails soft to silence** (I verified: no
crash, just a silent band) until you delete it. This is your R13 task #4.
- **→ Lane B (audio.js ~L274/276):** you hardcode `'pubrock-live'` for the street spill — read each
venue's `gigKey` bed instead (`AUDIO.music[venue.gigKey]`, i.e. `'gig-'+genreKey`). Same
fail-soft-silent until you switch. Your R13 task #2 (per-genre spill) covers this.
- **CITY_SPEC §v3 contract line (coordinate wording w/ A):** *"The audio manifest key for a gig
genre IS the gigKey, always `gig-<genreKey>` (`gig-pubrock`, `gig-grunge`, `gig-covers`). No
mapping table: C emits `room.audio.gigKey`, E names the bed, B/F read the same key."*
### 2. Two new gig beds — grunge (band_room) + covers (RSL).
Procedural, seeded, loopable, OGG+M4A, loudnorm, $0 on-device (same discipline as R11/12). All three
are audibly + spectrally distinct (spectral centroids pubrock 2336 / grunge 2271 / covers 1115 Hz;
contact sheet `docs/shots/laneE/gig_beds_spectrograms.png`):
- **`gig-grunge`** (0.6 gain, 17s): down-tuned sludge, quiet-loud dynamic (clean-ish verse → full-fuzz
chorus — visible in the spectrogram), loose heavy kit. Sits at 14.6 LUFS (genre-dense; a 1.02
*inter-sample* peak, not real clipping — plays at gain 0.6 so absolute output ≈0.6).
- **`gig-covers`** (0.5 gain, 19s): mellow RSL covers band — new drawbar-`organ()` voice, walking
bass, soft kit, sparse organ answer. Warm, dark highs (that low continuous band + horizontal
harmonic striations in the spectrogram = the organ). New synth helpers: `organ()`, `grungechord()`.
- **John may re-flavour genres (names only) — I synthesize to order.** Note it's now a coordinated
A+E change: the genreKey lives in A's `registry.js` (drives `gigKeyFor` → the bed name), so a
re-flavour = A renames the genreKey + E renames/re-synths the bed. Defaults grunge/covers are live.
### 3. Instrument decimation — DONE. 5 GLBs → 14k tris each (≤15k target). **→ Lane D.**
Collapse-decimated (Blender, `pipeline/decimate_props.py`) — **not** voxel-remesh, so thin features
survive: mic pole + hanging cable, drum cymbals/stands, guitar necks/headstocks all read (thumbs
re-rendered + eyeballed). Dims/orientation/base-origin/WebP preserved; GLB-law clean (no Draco, tex
≤1024). Fleet **236k → 70k tris (70%)** across the district's multiple stages.
| instrument | tris (norm→14k) | footprint (m) | height |
|---|---|---|---|
| electric_guitar | 43.5k → 14k | 0.38×0.08 | 1.00 |
| bass_guitar | 36.0k → 14k | 0.34×0.13 | 1.15 |
| mic_stand | 34.1k → 14k | 0.57×0.57 | 1.70 |
| drum_kit | 26.2k → 14k | 1.50×1.24 | 1.30 |
| guitar_amp | 96.6k → 14k | 0.52×0.29 | 0.61 |
- **→ Lane D:** wire against the SAME filenames (`procity_fit_{electric_guitar,bass_guitar,mic_stand,
drum_kit,guitar_amp}_01.glb`) — footprints/heights in the manifest are **unchanged**, so your
`instrumentFor(role)` fittings are unaffected; they're just lighter now. (debt #2)
- High-poly originals preserved as `_hi` variants in `_normalized/` (git-ignored) for the depot.
### 4. Depot publish — DONE (John OK'd direct publish). Gate 3 flag CLEARED.
Published via the passwordless tailnet ingress (`GOD3_DEPOT=http://100.94.195.115:8788` — this box is
allowlisted, so no secret handling). The 5 base names now serve the **14k bytes** and the 5 `_hi`
hi-poly originals are live — verified on the public `digalot.fyi` depot (amp base 5.9MB→1.3MB, `_hi`
5.9MB live, byte-identical to local). `_published.json` 33→38 (the 5 `_hi` recorded).
`validate_manifest --depot` GREEN. **The 70% decimation win is now live in production**, not just
?localdepot. (The `vintage-cash-register.glb` drift note from publish.py is pre-existing + unrelated —
a depot-reuse asset, not a Lane E file.)
## Round 9 — VRAM audit verdict + one rec for C
**VERDICT: fine to ship v2.0.** Texture VRAM ~240 MB default boot / ~420 MB worst-case (all flags +

View File

@ -447,3 +447,79 @@ reports a bogus FAIL (`node: command not found`).
| leak, 4 gig enter/exit cycles | geo 66→66 · tex 63→63 (**zero**) |
| tonight's covers (7 nights) | `10, 0, 0, 3, 2, 0, 9` — 3 free of 7 (~half, as seeded) |
| custom band names surfacing | **7 of John's 10** fill all 7 nights; `?noassets=1` → pure generator |
---
## 13. Round 13 — v3.0-beta THE DISTRICT (`?gigs=1`). F wires it last; findings below.
**What F built (all 7 tasks from ROUND13 §Lane F).** All committed Lane-F-only; verified green on m3ultra
(`qa.sh --strict`, the focused district gate, the scale gate). A single WARN — the queue — is a documented
handoff to D, not a hole (below).
1. **Per-venue state machine** (`gig_state.js`, rewritten). One latch PER VENUE keyed by `venueShopId`; a
town of 24 venues runs 24 independent latches off the SAME `procity:segment` spine. All three hard-won
R12 laws are preserved verbatim inside `createVenueLatch` (listens-not-polls · `state` is a getter ·
night rolls on WHERE the clock lands). New runtime surface on `window.PROCITY.gigs`: **`.byVenue`** →
`{ [id]: state }` (B's `venue.js`/`audio.js` read it), per-venue **`stateOf/onOf/openOf/gigOf/coverOf/
bandNameOf/paidOf(id)` + `markPaidOf(id)` + `.venueShopIds`**. The R12 single-venue alias
(`.state/.on/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s) is kept **one round** so
B's mid-round single-venue callers don't break — it retires next round. Contract pinned in CITY_SPEC §v3.
*Tonight* per venue = its night-0 gig (matches A's posters + B's frontage, all night-0-keyed); a
dark-tonight venue latches 'quiet' all session, which is true to the seeded week.
2. **Walla + applause** (`interior_mode.js`). While a venue's gig is `on`, the crowd bed
`ambience['crowd-walla']` rides the interior TONE layer under the live band bed (you hear the room, not
the fridge hum) — it reuses B's existing `playInterior({musicKey,toneKey})`, no new audio API. On the
`on → done` edge while the player is inside (crowd's last-song → the clock rolls NIGHT→DAWN), a one-shot
`sfx['applause']` sting fires ONCE (`applauded` latch, re-armed per enter). Fail-soft throughout (no-op
muted / `?noassets` / pre-gesture). Verified live: `iMusic=gig-pubrock`, `iTone=crowd-walla`, edge fires,
0 errors.
3. **Cover charge PER VENUE** (`index.html`). The door seam now keys off the shop being ENTERED
(`openOf/coverOf/paidOf/markPaidOf(shopId)`), so a night at two venues is two covers; each latch holds
its own runtime paid-stamp. Verified: paid ($10 debited once, re-entry free), broke-knockback, free night.
4. **Debt #1 CLOSED — and the R12 bridge was a LIVE BUG.** E renamed the pub bed `pubrock-live → gig-pubrock`
this round (the canonical `gig-<genreKey>`), so F's R12 `GIG_BED`/`gigBedKey` bridge — which rewrote
`gig-pubrock → pubrock-live` — was now pointing at a manifest key that **no longer exists**: the band
would have mimed in a silent room. DELETED. The room's `audio.gigKey` (already the manifest key) feeds the
engine directly, zero mapping. Verified across two genres live: `gig-pubrock` (pub), `gig-covers` (rsl).
5. **Gate hardening — stature into the shared gate** (`interior_scale_check.py`, debt #6). The human-size
assert now measures **stature = crown foot** (feet→crown span, platform-independent), with the
world-crown "never clips the ceiling" assert kept as its OWN line. The day a keeper stands on a platform,
the gate won't fail a correct figure. Floor figures foot≈0 ⇒ stature≈crown ⇒ backward-compatible.
Verified: 24 figs across 6 shops, statures 1.601.86 m.
6. **District smokes** (`flags_check.py smoke_gigs`, extended). band 3→**4** · a new **§2b DISTRICT** loop
that enters EVERY playing venue and asserts its `gigKey` resolves to a real manifest bar (debt #1 across 3
genres), per-venue crowd ≤ its own watchPoints, 4-piece, no-giants-by-stature · **§2c street budget** at
the busiest venue block (settled-floor sample) · queue probe (WARN-pending, below) · plus the R12
determinism / cover / noassets / flags-off arms. All green.
### ⚠ Cross-lane ask (Lane F → Lane D) — the QUEUE seam. F's smoke is READY; it WARN-skips until D exposes it.
D owns the outdoor queue (`web/js/citizens/queue.js` `VenueQueue`, charter item, landing this round). B has
already published the zone: **`window.PROCITY.venuePresentation.queueZoneFor(id) → {x,z,ry,len}`** and
`.queueZones`. F drives the surge per venue via `citizens.setGig(id, open)` (done). What F's smoke needs to
verify "queue ≤ cap and drains by done" is a read seam on the sim — **proposed contract:**
`window.PROCITY.citizens.queueInfo(venueShopId) → { count, cap }` (live count while doors/on; `0` once the
gig is `done`). Wire the queue's spawn to `queueZoneFor(id)` at `doors` and expose `queueInfo`, and F's smoke
flips WARN→assert with no further change. (If D picks a different accessor name, tell F and F adjusts the one
line in `smoke_gigs §2b`.)
### R13 numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues)
| thing | number |
|---|---|
| district | **3 venues** — 2 playing tonight (pub #116 The Exchange Hotel · rsl #404), 1 dark (reads true) |
| live beds resolve | pub → `gig-pubrock` · rsl → `gig-covers` (both real manifest bars, debt #1 across genres) |
| band / crowd | **4-piece** each venue · pub crowd **8/8** watch points · **rsl crowd 12/12** (the cap stress case) |
| gig-night interior (rig) | **62 draws** · 34k tris (budget ≤350) · `?noassets` still a full 4-piece gig |
| street @ venue block, night | **99 draws** · 28k tris (budget 300 / 200k) — lit frontage + posters + queue zone in |
| figure scale | band+crowd stature **1.611.81 m**, tallest crown 2.12 m under the 4 m ceiling |
| cover, per venue | $10 debited once · re-entry free · broke knockback · free night (seed 1234) — all hold |
| flags-off prime law | fingerprint **0x3fa36874** unchanged · gig layer wholly absent · 0 errors |
**Status:** F's wiring is done, committed, and green. **Waiting on Lane D** for (a) the queue seam
(`queueInfo`) so the queue smoke goes live, and (b) the band polish (E's instrument GLBs in hand, drummer
facing/RY_FLIP) — the band already lands at 4, human-sized, 0 errors, so this is polish not a blocker. Once D
commits: re-run the gates, flip the queue assert, capture the district money shots, tag **v3.0-beta**.

View File

@ -0,0 +1,155 @@
# PROCITY — Round 14 lane instructions (from Fable, integrator)
Date: 2026-07-16 · **v3.0-beta shipped** (the district, `?gigs=1`, tag local; qa `--strict` 6/6,
0 warn — the queue assert went live after D landed). Seed 20261990 = Boolarra Heads: 3 venues,
14 gigs/week, tonight Screaming Utes @ The Exchange Hotel $10 + The Dead Ringers @ the RSL free.
Round 14 is **v3.0: THE RELEASE** — identity continuity into the crowd (the one remaining charter
promise), the money-shot tour, the soaks, docs freeze, and the `v3.0` tag. **No new systems this
round** — if it isn't continuity, hardening, or documentation, it's v3.1.
## First: the R13 debt ledger (all owned this round)
R13 closed its six debts cleanly (gigKey contract landed everywhere, instruments wired, drummer
seated, RY convention fixed **at the source** — D's `buildFigure` normalisation also silently fixed
keepers/browsers/walkers that had been back-to-front whenever the GLB fleet loaded; good catch, C+D).
What carries:
1. **Alpha alias retirement (B → F, ordered).** F kept the R12 single-venue
`window.PROCITY.gigs` shape (`.state/.on/.venueShopId/.cover/.paid`) as a one-round compat
alias; that round is over. **B first** removes its fallback readers (`audio.js` alias→clock
chain, `venue.update()` bare-string arm — keep the clock fallback, drop the alias hop), **then
F** deletes the alias so `byVenue` + the per-venue accessors are the only surface. CITY_SPEC §v3
runtime block loses the alias line in the same commit (F).
2. **Drummer sit hack (D).** The 4th member is a standing rig sunk `SEAT_DROP` behind the kit —
reads seated only from the front. `~/Documents/mixamo-fetch/out/` holds **34 clips incl. `sit`**
(LANE_D_NOTES wishlist #1**check what exists first, never re-download**). Wire the real clip,
retire `SEAT_DROP` + the `m.seated` branch.
3. **Town-wide streetlamp pools (B) — RULING: stays venue-scoped for v3.0.** Town-wide needs
always-on `furniture.js` changes and that fights flags-off parity in release week. Venue blocks
read fine (F's `venue_night` shot proves it). Document as a v3.1 candidate in LANE_B_NOTES;
no code.
4. **Poster repetition (B judges, E executes).** B: during the tour audit, eyeball the spine poster
runs — if the per-gig instanced skins read repetitive, ask E for 12 more templates. E: build
only on B's ask (R13 law, unchanged).
Closed for good, for the record: depot publish (John OK'd mid-R13; Gate-3 provenance flag cleared,
`digalot.fyi` serves the 14k instruments + `_hi` originals).
## Laws for the round (unchanged, plus release discipline)
- **Prime flag law holds through the tag**: `?gigs=1` stays **opt-in for v3.0** (Fable's default —
John may overrule, see Standing #2). Flags-off boot byte-identical, synthetic golden
`0x3fa36874`, gig golden `0x1f636349`**neither moves this round**. Any diff that would move
a golden is out of scope by definition; park it.
- Deterministic from citySeed · asset-free (`?noassets=1&gigs=1` silent-and-fine) · audio law
(gesture-gated, fail-soft) · budgets: gig-night interior ≤350, street ≤300 draws / 200k tris at
the busiest venue block · human-sized line on every humanoid shot · **fresh browser contexts**
(the stale-module trap bit B in R13 — `cache:'reload'` + hard nav, or the no-store server).
- **Shared-tree discipline**: commit atomically by explicit pathspec; lanes land concurrently.
- **Release-round law: no new rng draws on any seeded stream.** Continuity work must ride new
sub-streams or consume existing identities — if two runs of R13 code and R14 code disagree on
any golden or any R13 smoke, the change is wrong.
## Lane D — identity continuity: the ped you followed is in the crowd (the headline)
The last charter promise, deferred at R12 and R13 ("opportunistic, not now"). Now.
1. **Continuity contract.** The sim already knows who enters a venue (queue `admitOne()` drains +
surge occupants). Record a per-venue **tonight roster** of entered identities
({pedIndex, height, …whatever pins "the same person"}), and have `GigCrew.spawn` consume it:
crowd slots fill from the roster first, seeded strangers only for the remainder. Design is
yours; the acceptance is behavioural: **follow a punter from the queue through the door, walk
in behind them, and find them in the crowd — same ped, same height.** Works across exit/re-enter
(roster persists for the night, not per interior build).
2. **Determinism**: the roster is a consequence of seeded sim behaviour; interior crowd identity
for a given (seed, night, entry time-segment) must be stable enough for F's smoke — expose
`window.PROCITY.citizens.tonightRoster(venueShopId) → [{pedIndex, …}]` (or your preferred name —
tell F) so the smoke can assert crowd ⊇ roster ∩ cap without screenshot forensics.
3. **The sit clip** (debt #2 above). Data task, not rig work — clips bind through the canonical
`mixamorig` skeleton like walk/idle.
4. **Sim soak**: 30+ min equivalent (accelerated segments fine) with gigs on — full week roll,
repeated surge/drain/queue cycles at all venues; assert leak geo0/tex0, roster bounded, no
identity churn. Numbers in your notes.
Budget note: continuity is bookkeeping, not new rigs — draw/tri delta should be ~0; state it.
## Lane B — alias retirement + the tour audit
1. **Drop the alias readers** (debt #1 — you go first, tell F when landed).
2. **Bookmark/tour audit**: every bookmark (16 v2 + `venue_night` + `district_posters`) still
frames un-letterboxed, 0 errors, with `?gigs=1` on — the district changed the streetscape under
some of the old poses. Fix poses, don't touch world code.
3. **`queue_night` bookmark** — John's queue shot: close framing on the pub door at `doors`/`on`,
queue line + marquee + poster in frame, punters facing the door. This is the release hero shot;
F scripts it into the tour.
4. **Poster repetition call** (debt #4): judge, and either "fine" in your notes or a one-line ask
to E.
## Lane C — venue audit + docs freeze (light round, by design)
1. **Interior release audit**: one pass over the three venue archetypes against CITY_SPEC §v3 +
LANE_C_PUB — watchPoint caps, stage/riser numbers, gigKey resolution, quiet-night variants —
and re-run your 60-room soak + drawSweep against D's continuity changes (crowd identities
change; geometry must not). Fix only regressions.
2. **`guitar_amp` placement ruling** (R13 left it "C's call"): D planted it as stage dressing —
bless the placement or give D the corrected pose; one line in LANE_C_PUB either way.
3. **Docs freeze**: LANE_C_PUB.md + LANE_C_AUDIO.md are the durable contracts — final wording
pass, mark them v3.0-frozen.
## Lane E — provenance close-out (light round, by design)
1. **`publish.py --verify` drift check** against the live depot post-R13-publish; qa Gate 3 must
be green with zero flags for the tag.
2. **Poster templates** only on B's ask (debt #4).
3. **Docs freeze**: provenance table + audio/GLB inventories in LANE_E_NOTES current and
v3.0-frozen. No new synthesis this round unless John re-flavours a genre (Standing #3, R13 —
still open, still names-only).
## Lane A — invariant sweep + CITY_SPEC freeze
1. **District seed sweep**: 400-seed run asserting the district invariants — 24 venues, ≥1 pub,
never the openLate shop, no band twice in one night town-wide, every poster behind
`poleOffset`, per-venue 46 nights — plus **one osm town** proving graceful placement (no
spine/warehouse district). Promote any invariant not already in `selfcheck.js` into it.
2. **CITY_SPEC v3 freeze**: you own the spec — final pass over §v3 (venues table, genre map,
gigKey contract, gig golden `0x1f636349`, F's runtime block minus the retired alias), then mark
the section frozen for the tag. Coordinate the alias-line deletion with F (debt #1).
## Lane F — soaks, the tour, the tag (last)
1. **Delete the alpha alias** once B lands (debt #1) + the CITY_SPEC line (with A).
2. **Continuity smoke**: extend `smoke_gigs` — queue identities admitted at `doors` appear in the
interior crowd via D's roster seam (assert on the seam, not pixels); crowd ⊇ roster ∩ cap;
determinism arm re-run.
3. **Full-week soak**: drive the segment spine through all 7 nights × all venues in one context —
every latch fires per `plan.gigs` schedule (dark nights stay quiet), covers stamp per venue per
night, walla/applause never double-fire, leak geo0/tex0 across ≥20 enter/exit cycles. Wire it
into qa as a non-strict extra (strict stays the 6 gates).
4. **The money-shot tour**: `tools/qa/tour_shots.py` — scripted capture of the release set in one
deterministic run: `rain_verandah` (winmap+rain hero) · `tram_stop` · `venue_night` ·
`district_posters` · **`queue_night` (John's shot)** · gig interior (4-piece + instruments +
crowd). Contact sheet into `docs/shots/release_v3/`.
5. **Docs assembly**: README v3 section (what shipped, the flags table, the shots), confirm every
lane's freeze landed, LANE_F_NOTES §14 runbook.
6. **Acceptance + tag**: `qa.sh --strict` 6/6 + continuity smoke + week soak green → tag **v3.0**.
Tag and push are separate commands; the push itself waits on John (Standing #1).
## Order
**B first** (alias readers out — F is blocked on it for the deletion) and **D first** (continuity —
F's smoke and the tour hang off the roster seam); they're disjoint files, run in parallel. A, C, E
anytime, independent. **F last**, as always. Mid-round handshakes: B→F "readers gone", D→F the
roster accessor name, C→D the amp ruling.
## Standing
- **v3.1 preview (do not start):** flag-default flip (if John rules it), town-wide lamp pools,
tram-gig composition (ride to the gig), wind sway, bench-sit loiter from the same `sit` clip.
- **John — all three items RESOLVED at kickoff (2026-07-16):**
1. **The push — DONE.** John OK'd it; Fable pushed `main` (R13 epoch + these instructions) and
the `v3.0-beta` tag to origin at kickoff. Release tags only, as ever — lane-round tags stay
local. Push discipline unchanged for lanes: commit local, F/Fable pushes at round end.
2. **Flag default at v3.0 — RULED (John concurs):** `?gigs=1` stays **opt-in** for the tag;
default-flip is a v3.1 candidate after the soak bakes. No golden re-pin this round.
3. **Genre re-flavour — CLOSED:** John: "fine for now." pubrock/grunge/covers are final for
v3.0; the docs freeze proceeds on those names.

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

View File

@ -6,16 +6,17 @@
"out": "pipeline/_normalized/procity_fit_electric_guitar_01.glb",
"thumb": "web/assets/thumbs/procity_fit_electric_guitar_01.png",
"tri_before": 181716,
"tri_after": 43484,
"tri_after": 14000,
"size_m": [
0.38,
1.0,
0.077
0.078
],
"footprint": [
0.38,
0.08
]
],
"hi_variant": "procity_fit_electric_guitar_01_hi.glb"
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bass-guitar.glb",
@ -23,16 +24,17 @@
"out": "pipeline/_normalized/procity_fit_bass_guitar_01.glb",
"thumb": "web/assets/thumbs/procity_fit_bass_guitar_01.png",
"tri_before": 192238,
"tri_after": 36048,
"tri_after": 14000,
"size_m": [
0.337,
1.15,
0.124
0.125
],
"footprint": [
0.34,
0.12
]
0.13
],
"hi_variant": "procity_fit_bass_guitar_01_hi.glb"
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drum-kit.glb",
@ -40,16 +42,17 @@
"out": "pipeline/_normalized/procity_fit_drum_kit_01.glb",
"thumb": "web/assets/thumbs/procity_fit_drum_kit_01.png",
"tri_before": 197990,
"tri_after": 29890,
"tri_after": 14000,
"size_m": [
1.498,
1.497,
1.3,
1.242
],
"footprint": [
1.5,
1.24
]
],
"hi_variant": "procity_fit_drum_kit_01_hi.glb"
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/guitar-amp.glb",
@ -57,16 +60,17 @@
"out": "pipeline/_normalized/procity_fit_guitar_amp_01.glb",
"thumb": "web/assets/thumbs/procity_fit_guitar_amp_01.png",
"tri_before": 180813,
"tri_after": 96592,
"tri_after": 14000,
"size_m": [
0.511,
0.6,
0.285
0.516,
0.607,
0.294
],
"footprint": [
0.51,
0.52,
0.29
]
],
"hi_variant": "procity_fit_guitar_amp_01_hi.glb"
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/mic-stand.glb",
@ -74,16 +78,17 @@
"out": "pipeline/_normalized/procity_fit_mic_stand_01.glb",
"thumb": "web/assets/thumbs/procity_fit_mic_stand_01.png",
"tri_before": 162256,
"tri_after": 34124,
"tri_after": 14000,
"size_m": [
0.571,
1.7,
1.702,
0.573
],
"footprint": [
0.57,
0.57
]
],
"hi_variant": "procity_fit_mic_stand_01_hi.glb"
}
],
"errors": []

View File

@ -1,6 +1,7 @@
[
"procity_fit_arcade_cabinet_01.glb",
"procity_fit_bass_guitar_01.glb",
"procity_fit_bass_guitar_01_hi.glb",
"procity_fit_bookshelf_01.glb",
"procity_fit_box_crate_01.glb",
"procity_fit_clothes_rack_01.glb",
@ -11,12 +12,16 @@
"procity_fit_cube_shelf_wide_01.glb",
"procity_fit_drinks_fridge_01.glb",
"procity_fit_drum_kit_01.glb",
"procity_fit_drum_kit_01_hi.glb",
"procity_fit_electric_guitar_01.glb",
"procity_fit_electric_guitar_01_hi.glb",
"procity_fit_glass_case_01.glb",
"procity_fit_guitar_amp_01.glb",
"procity_fit_guitar_amp_01_hi.glb",
"procity_fit_listening_booth_01.glb",
"procity_fit_magazine_rack_01.glb",
"procity_fit_mic_stand_01.glb",
"procity_fit_mic_stand_01_hi.glb",
"procity_fit_milkshake_mixer_01.glb",
"procity_fit_record_crate_01.glb",
"procity_fit_wire_shelf_01.glb",

View File

@ -33,7 +33,11 @@ AUDIO_MUSIC = { # interior music key -> (gain, shop types, note)
"milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
"video-synth": (0.50, ["video"], "video store synth hum"),
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"),
"pubrock-live":(0.60, ["pub"], "gig live bed — F plays via room.audio gigKey when a gig is on"),
# ── gig beds: the manifest key IS the gigKey, ALWAYS `gig-<genreKey>` (R13 debt #1; one key,
# zero mapping). C emits room.audio.gigKey='gig-'+genreKey, F/B read this key directly. ──
"gig-pubrock": (0.60, ["pub"], "gig live bed — pub-rock (pub); F plays via room.audio gigKey"),
"gig-grunge": (0.60, ["band_room"], "gig live bed — grunge (band_room)"),
"gig-covers": (0.50, ["rsl"], "gig live bed — covers (RSL); mellower, organ-led"),
}
AUDIO_INTERIOR_AMB = {"crowd-walla": (0.4, "gig crowd layer (interior; F plays when gig on)")} # R12
AUDIO_SFX = {"doorbell": 0.7, "door-open": 0.6, "till": 0.7, "riffle": 0.6, # one-shots
@ -147,8 +151,9 @@ def build_audio():
footsteps[surf] = vs
if footsteps:
sfx["footstep"] = footsteps
if "pubrock-live" in music: # R12: flag gig-only assets for F
music["pubrock-live"]["gig"] = True
for k in music: # flag gig-only beds for F (any 'gig-*' key)
if k.startswith("gig-"):
music[k]["gig"] = True
if "applause" in sfx:
sfx["applause"]["gig"] = True
return {"ambience": ambience, "sfx": sfx, "music": music}

152
pipeline/decimate_props.py Normal file
View File

@ -0,0 +1,152 @@
"""PROCITY prop decimation — collapse-decimate a normalized GLB to a tri budget while KEEPING its
original materials + UVs (no voxel-remesh, no re-bake). The detail-preserving path for props whose
thin features (mic pole, cymbal stands, guitar necks) a voxel-remesh would fatten.
Runs headless on this box's Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python decimate_props.py -- BATCH.json OUT_DIR [THUMB_DIR]
BATCH.json: [ {"src":"web/assets/models/procity_fit_guitar_amp_01.glb",
"id":"guitar_amp", "category":"fit", "tris":14000} , ... ]
vs bake_lowpoly.py: that one throws topology away (voxel remesh bake) to beat the multi-shell floor
on boxy hero props; this one collapses edges in place to keep silhouettes on thin/among-shell props.
Pick per asset by eyeballing the thumbs anything that floors above budget or reads wrong here goes
to bake_lowpoly.py instead. Same house GLB law out: metres, +Y up, base-origin, WebP, no Draco.
"""
import bpy, sys, os, json, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH, OUT_DIR = ARGV[0], (ARGV[1] if len(ARGV) > 1 else "/tmp/procity_decim")
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
os.makedirs(OUT_DIR, exist_ok=True); os.makedirs(THUMB_DIR, 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):
for d in list(coll):
if d.users == 0:
coll.remove(d)
def import_join(src):
bpy.ops.import_scene.gltf(filepath=src)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
bpy.ops.object.select_all(action='DESELECT')
for m in meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
if len(meshes) > 1:
bpy.ops.object.join()
obj = bpy.context.view_layer.objects.active
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return obj
def bounds(obj):
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
mn = Vector((min(v.x for v in bb), min(v.y for v in bb), min(v.z for v in bb)))
mx = Vector((max(v.x for v in bb), max(v.y for v in bb), max(v.z for v in bb)))
return mn, mx
def tri_count(obj):
"""Honest triangle count (n-gons split): sum of (loop_total - 2) per polygon."""
return sum(len(p.vertices) - 2 for p in obj.data.polygons)
def decimate(obj, target):
# triangulate first so the collapse ratio targets triangles honestly, then collapse-decimate.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True); bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.quads_convert_to_tris()
bpy.ops.object.mode_set(mode='OBJECT')
n = len(obj.data.polygons)
if n > target:
dm = obj.modifiers.new("dec", 'DECIMATE')
dm.decimate_type = 'COLLAPSE'
dm.ratio = max(0.01, target / n)
dm.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier=dm.name)
return n
def base_origin(obj):
mn, mx = bounds(obj)
obj.location -= Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
bpy.ops.object.transform_apply(location=True)
def render_thumb(obj, png_name):
scn = bpy.context.scene
mn, mx = bounds(obj); ctr = (mn + mx) / 2
size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0
for o in list(bpy.data.objects):
if o.type in ('CAMERA', 'LIGHT'):
bpy.data.objects.remove(o, do_unlink=True)
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
scn.world = w; w.use_nodes = True
w.node_tree.nodes["Background"].inputs[1].default_value = 1.05
bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size))
bpy.context.object.data.energy = 800 * size; bpy.context.object.data.size = 6
bpy.ops.object.camera_add(); cam = bpy.context.object; scn.camera = cam
r = size * 1.9
cam.location = Vector((ctr.x + r * 0.8, ctr.y - r, ctr.z + size * 0.6))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
scn.render.engine = 'BLENDER_WORKBENCH'
scn.display.shading.light = 'STUDIO'; scn.display.shading.color_type = 'TEXTURE'
scn.display.shading.show_cavity = True
scn.view_settings.view_transform = 'Standard'
scn.render.film_transparent = True
scn.render.resolution_x = scn.render.resolution_y = 256
p = os.path.join(THUMB_DIR, png_name); scn.render.filepath = p
bpy.ops.render.render(write_still=True)
return p
def decimate_one(spec):
wipe()
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
obj = import_join(src)
tri_before = tri_count(obj)
decimate(obj, spec.get("tris", 14000))
base_origin(obj)
tri_after = tri_count(obj)
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True); bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_yup=True,
use_selection=True, export_image_format='WEBP', export_image_quality=88)
thumb = render_thumb(obj, name.replace(".glb", ".png"))
mn, mx = bounds(obj)
# Blender is Z-up: height is Z, the ground footprint is X×Y (matches bake_lowpoly.py + _gig_results).
return {"file": name, "tri_before": tri_before, "tri_after": tri_after,
"size_m": [round(mx.x - mn.x, 3), round(mx.z - mn.z, 3), round(mx.y - mn.y, 3)],
"footprint": [round(mx.x - mn.x, 2), round(mx.y - mn.y, 2)], "thumb": thumb, "out": out}
def main():
specs = json.load(open(BATCH))
results, errors = [], []
for spec in specs:
try:
r = decimate_one(spec)
results.append(r)
print(f"OK {r['file']:40} {r['tri_before']:>6}{r['tri_after']:>5} tris "
f"footprint {r['footprint']} h {r['size_m'][1]}")
except Exception as e:
import traceback; traceback.print_exc()
errors.append({"id": spec.get("id"), "error": str(e)})
print(f"ERR {spec.get('id')}: {e}")
json.dump({"results": results, "errors": errors},
open(os.path.join(OUT_DIR, "_decim_results.json"), "w"), indent=2)
print(f"\nDECIM_DONE ok={len(results)} err={len(errors)}{OUT_DIR}")
main()

View File

@ -248,6 +248,16 @@ def pad(midi, dur, detune=0.008):
return x * adsr(dur, 0.4, 0.2, 0.8, 0.5)
def organ(midi, dur):
# drawbar-organ-ish: stacked harmonic sines (16'/8'/5⅓'/4'/2') + a gentle Leslie tremolo and a
# soft percussive attack. Warm and mellow — the RSL covers-band voice.
f = midi_hz(midi); t = tarr(dur)
drawbars = [(0.5, 1), (1.0, 2), (0.55, 3), (0.4, 4), (0.22, 6)]
x = sum(a * np.sin(2 * np.pi * f * mult * t) for a, mult in drawbars) / 2.6
trem = 1 + 0.06 * np.sin(2 * np.pi * 5.2 * t) # gentle Leslie tremolo
return x * trem * adsr(dur, 0.012, 0.15, 0.82, 0.12)
def kick(dur=0.28):
n = int(dur * SR)
f = np.linspace(120, 45, n)
@ -602,6 +612,16 @@ def powerchord(root, dur, drive=3.5):
return x * adsr(dur, 0.003, 0.05, 0.7, 0.04)
def grungechord(root, dur, drive=5.0):
# sludgier, down-tuned cousin of powerchord: root+5th+octave, a square sub-octave for weight,
# heavier fuzz and a darker filter. `drive` swings the quiet-loud grunge dynamic.
x = (osc(midi_hz(root), dur, "saw") + osc(midi_hz(root+7), dur, "saw")
+ osc(midi_hz(root+12), dur, "saw") + 0.6 * osc(midi_hz(root-12), dur, "square"))
x = softclip(x * 0.5, drive) # thick fuzz
x = biquad_lp(x, 2400, 0.9) # darker than pub-rock's 3200
return x * adsr(dur, 0.004, 0.06, 0.75, 0.06)
def music_pubrock(name):
# the gig live bed — 90s Aussie pub-rock: driving power chords, punchy bass, rock kit. Louder
# + rawer than the shop beds. Original I-bVII-IV-I riff in E.
@ -617,6 +637,51 @@ def music_pubrock(name):
return loop_music(body, 0.06)
def music_grunge(name):
# band-room gig bed — 90s grunge: down-tuned sludge riff with a quiet-loud dynamic (clean-ish
# verse → full-fuzz chorus) and a loose, heavy kit. Original i-bVII-bVI riff, low + dark. Slower
# and murkier than the pub-rock bed so the two venues never sound the same.
r = rng(name); bpm = 112
prog = [35, 35, 42, 40] # 4 bars (B B E D-ish), played verse then chorus
verse = render_line([(root, 0.5) for root in prog for _ in range(8)], # palm-mute 8ths, cleanish
bpm, lambda m, d: grungechord(m, d, 2.4) * 0.32, gap=0.03)
chorus = render_line([(root, 0.5) for root in prog for _ in range(8)], # full sludge
bpm, lambda m, d: grungechord(m, d, 5.5) * 0.55, gap=0.0)
guitar = np.concatenate([verse, chorus])
bass = render_line([(root - 12, 0.5) for root in prog for _ in range(8)] * 2,
bpm, lambda m, d: synth_note(m, d, "saw", 0.004, 0.06, 0.7, 0.04, cutoff=520))
vdr = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 4, 8, 12]}, bpm, 4, r) # sparse verse
cdr = render_drums({"k": [0, 3, 6, 8, 11, 14], "s": [4, 12],
"h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [7, 15]}, bpm, 4, r) # driving chorus
drums = np.concatenate([vdr, cdr])
body = mix(pan(guitar, -0.1)*0.72, pan(bass, 0.08)*0.62, pan(drums, 0)*0.62) # headroom: dense chorus
return loop_music(body, 0.10)
def music_covers(name):
# RSL gig bed — a covers band at the returned-servicemen's club: mellow, organ-led easy-listening.
# Warm I-vi-IV-V turnaround (original voicing), walking bass, soft kit, a sparse organ answer.
# Cleaner and warmer than the pub/band-room beds — the crowd-cap stress room reads relaxed.
r = rng(name); bpm = 100; bars = 8
spb = 60 / bpm
prog = [48, 45, 53, 55] # C Am F G7 (roots), one per bar, ×2 = 8 bars
chords = {48: [0, 4, 7], 45: [0, 3, 7], 53: [0, 4, 7], 55: [0, 4, 7, 10]} # G7 on the turnaround
org_bars = []
for root in prog * 2: # sustained organ triad per bar
ch = mix(*[organ(root + 12 + iv, 4 * spb) for iv in chords[root]]) * 0.5
cell = int(4 * spb * SR)
org_bars.append(np.concatenate([ch, np.zeros(max(cell - len(ch), 0))])[:cell])
organ_part = np.concatenate(org_bars)
walk = sum([[(root - 12, 1.0), (root - 5, 1.0), (root, 1.0), (root - 3, 1.0)] for root in prog * 2], [])
bass = render_line(walk, bpm, lambda m, d: synth_note(m, d, "tri", 0.006, 0.12, 0.6, 0.06, cutoff=800))
drums = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14]}, bpm, bars, r) * 0.5
lead_notes = sum([[(root + 12, 1.5), (None, 0.5), (root + 19, 1.0), (None, 1.0)] for root in prog * 2], [])
lead = render_line(lead_notes, bpm, # gentle organ-ish answer
lambda m, d: synth_note(m, d, "tri", 0.02, 0.12, 0.5, 0.2, cutoff=1600, vib=0.8))
body = mix(pan(organ_part, -0.1)*0.7, pan(bass, 0.05)*0.8, pan(drums, 0)*0.5, pan(lead, 0.25)*0.3)
return loop_music(body, 0.18) # warmer reverb than the rock beds
# ─────────────────────────────────────────────────────────────────────────────
# the pack: name -> (category, generator, {loop, gain, note})
# ─────────────────────────────────────────────────────────────────────────────
@ -648,8 +713,11 @@ PACK = {
"music-milkbar": ("music", music_milkbar, dict(loop=True, gain=0.5, note="milk bar radio")),
"music-video-synth": ("music", music_video_synth, dict(loop=True, gain=0.5, note="video store")),
"music-arcade": ("music", music_arcade, dict(loop=True, gain=0.5, note="arcade")),
# ── round-12 gig layer (?gigs=1 consumers: F plays these when a gig is on) ────────────
"music-pubrock-live": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock; F plays via room.audio gigKey")),
# ── gig layer (?gigs=1 consumers: F plays these when a gig is on) — the manifest key IS the
# gigKey, ALWAYS `gig-<genreKey>` (R13 debt #1: one key, zero mapping). One bed per venue kind. ──
"music-gig-pubrock": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock (pub); F plays via room.audio gigKey")),
"music-gig-grunge": ("music", music_grunge, dict(loop=True, gain=0.6, note="gig live bed — grunge (band_room)")),
"music-gig-covers": ("music", music_covers, dict(loop=True, gain=0.5, note="gig live bed — covers (RSL); mellower, organ-led")),
"ambience-crowd-walla":("ambience", amb_crowd_walla, dict(loop=True, gain=0.4, note="gig crowd layer (interior)")),
"sfx-applause": ("sfx", sfx_applause, dict(loop=False, gain=0.7, note="between-sets cheer")),
}

View File

@ -592,16 +592,17 @@ def smoke_audio(p):
b.close()
def smoke_gigs(p):
"""R12 v3.0-alpha gig layer (Lane F). The six gates from ROUND12 §Lane F.5 — schedule determinism ·
band always 3 · crowd watchPoints · gig-night interior draw budget · no-giants on the stage/crowd
rigs (crowns re-asserted here so the gig path can't ship a giant even if qa runs without the scale
gate) · ?noassets=1 still silent-and-fine with ?gigs=1 plus John's cover ruling (paid · free ·
broke-knockback · no re-charge on re-entry).
"""R13 v3.0-beta THE DISTRICT gig layer (Lane F). The ROUND13 §Lane F.6 gates — district determinism ·
a 4-piece band · per-venue crowd watchPoints · EVERY venue's gigKey resolves to a real manifest bed
(debt #1, now across 3 genres) · gig-night interior draw budget · street budget at the busiest venue
block · no-giants (STATURE) on the stage/crowd rigs · queue cap and drains by 'done' (pending Lane D)
· ?noassets=1 still silent-and-fine with ?gigs=1 plus John's cover ruling, now PER VENUE (paid · free
· broke-knockback · no re-charge on re-entry · a night at two venues is two covers).
Seeds are pinned because `cover` is seeded: 20261990's tonight is a $10 night (Screaming Utes at The
Thornbury Hotel) and 1234's is free (Dazza & The Dropbears at The Imperial), so BOTH halves of "free
AND paid" are covered deterministically rather than whichever the default seed happened to roll."""
head('SMOKE: gigs (R12 v3.0-alpha — schedule · band · crowd · budget · cover · no-giants · noassets)')
head('SMOKE: gigs (R13 v3.0-beta district — determinism · 4-piece · per-venue crowd/cover · gigKeys · budgets · queue · noassets)')
if not gigs_landed(p):
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
return
@ -669,9 +670,9 @@ def smoke_gigs(p):
else:
if night['state'] == 'on': OK(f"gig state machine: NIGHT → 'on' (venue {night['H']}m room)")
else: FAIL(f"gig state machine: NIGHT should be 'on', got '{night['state']}'")
# band always 3 (D's contract: 3 poses on C's deck)
if night['band'] == 3: OK('band: exactly 3 on the stage')
else: FAIL(f"band: expected 3 on the stage, got {night['band']}")
# band is a 4-piece (R13 debt #3: front-line trio + a drummer on C's riser)
if night['band'] == 4: OK('band: 4-piece on the stage (front-line trio + drummer)')
else: FAIL(f"band: expected a 4-piece (trio + drummer), got {night['band']}")
# crowd cap ≤ watchPoints
if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0:
OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)")
@ -706,6 +707,127 @@ def smoke_gigs(p):
finally:
b.close()
# 2b) THE DISTRICT (R13): every playing venue fires — its live bed resolves to a REAL manifest bar for
# its genre (debt #1, now across pub/band_room/rsl), its crowd is capped at its OWN watch points, and it
# is a 4-piece. Plus the queue ≤ cap / drains-by-done probe (WARN-skips until Lane D wires the seam).
music_keys = set()
try:
mani = json.loads((ROOT / 'web' / 'assets' / 'manifest.json').read_text())
music_keys = set((mani.get('audio', {}).get('music', {}) or {}).keys())
except Exception as e:
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')
pg.keyboard.press('Space')
district = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
D.setSegment(5); // NIGHT every playing venue is 'on'
const ids = P.gigs.venueShopIds || [];
const out = [];
for (const id of ids) {
const shop = (P.plan.shops || []).find(s => s.id === id);
if (!P.gigs.onOf(id)) { out.push({ id, kind: shop && shop.venueKind, dark: true }); continue; }
D.enterShop(id);
await new Promise(r => setTimeout(r, 450));
const room = P.interiorMode.current, info = P.interiorMode.crewInfo;
const mem = P.interiorMode.crew ? P.interiorMode.crew.members : [];
const figs = mem.map(m => {
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) };
});
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
out.push({ id, kind: shop && shop.venueKind, genreKey: shop && shop.genreKey,
gigKey: (room && room.audio && room.audio.gigKey) || null,
band: info ? info.band : 0, crowd: info ? info.crowd : 0,
watchPoints: room ? room.watchPoints.length : 0, figs });
D.exitShop(); await new Promise(r => setTimeout(r, 200));
}
return { ids, out };
}""")
playing = [d for d in district['out'] if not d.get('dark')]
OK(f"district: {len(district['ids'])} venue(s) — {len(playing)} playing tonight, "
f"{len(district['out']) - len(playing)} dark (reads true to the seeded week)")
genres_seen = set()
for d in playing:
tag = f"{d['kind']} #{d['id']}"
want = f"gig-{d['genreKey']}"
if d['gigKey'] == want and (not music_keys or want in music_keys):
OK(f" {tag}: live bed '{d['gigKey']}' resolves to a real manifest bar (genre {d['genreKey']})")
genres_seen.add(d['genreKey'])
else:
FAIL(f" {tag}: gigKey '{d['gigKey']}' != '{want}' or not in manifest.music {sorted(music_keys)}")
if 0 < d['crowd'] <= d['watchPoints']:
OK(f" {tag}: crowd {d['crowd']}{d['watchPoints']} watch points")
else:
FAIL(f" {tag}: crowd {d['crowd']} vs {d['watchPoints']} watch points (must be >0 and ≤ cap)")
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)]
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))})")
# queue ≤ cap and drains by 'done' — Lane D's outdoor line (charter item). D shipped the VenueQueue
# class and left the street-side lifecycle to F, which wires it in the shell (spawn at doors, drive
# each frame, disposeAll at done); read via P.queueCountOf(id). The line is a seeded short 26.
q = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
if (typeof P.queueCountOf !== 'function') return { wired: false };
const id = P.gigs.venueShopId;
D.setSegment(4); await new Promise(r => setTimeout(r, 700)); // DUSK doors open, the line forms
const atDoors = P.queueCountOf(id) | 0;
D.setSegment(0); await new Promise(r => setTimeout(r, 700)); // DAWN closing time ('done'), line gone
const atDone = P.queueCountOf(id) | 0;
return { wired: true, atDoors, atDone };
}""")
QUEUE_CAP = 6
if not q.get('wired'):
FAIL("queue: P.queueCountOf(id) not exposed — F's shell wiring missing")
elif 0 < q['atDoors'] <= QUEUE_CAP and q['atDone'] == 0:
OK(f"queue: {q['atDoors']} punters in line at doors (≤ cap {QUEUE_CAP}), drained to 0 by 'done'")
else:
FAIL(f"queue: at doors {q['atDoors']} (want 1..{QUEUE_CAP}), at done {q['atDone']} (want 0)")
if errs: FAIL(f"district: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('district: 0 console errors across all venues')
finally:
b.close()
# 2c) STREET BUDGET at the busiest venue block (R13 law: ≤300 draws / ≤200k tris with the lit frontage,
# town-wide posters and the queue zone all in). Park on the footpath in front of the primary venue at
# 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')
st = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5); // NIGHT frontage lit, posters up
const id = P.gigs.venueShopId, shop = (P.plan.shops || []).find(s => s.id === id);
const lot = shop && (P.plan.lots || []).find(l => l.id === shop.lot);
if (lot) {
const fx = Math.sin(lot.ry || 0), fz = Math.cos(lot.ry || 0);
D.teleport(lot.x + fx * 10, lot.z + fz * 10, Math.atan2(-fx, -fz)); // 10 m out, facing the venue
}
await new Promise(r => setTimeout(r, 700)); // chunks/frontage/posters/pools settle
let draws = Infinity, tris = 0; // take the settled floor (ignore stream spikes)
for (let i = 0; i < 12; i++) {
await new Promise(r => requestAnimationFrame(r));
if (P.renderer.info.render.calls < draws) { draws = P.renderer.info.render.calls; tris = P.renderer.info.render.triangles; }
}
return { draws, tris, venue: shop && shop.name };
}""")
if st['draws'] <= STREET_DRAWS_MAX and st['tris'] <= STREET_TRIS_MAX:
OK(f"street budget @ venue block ({st['venue']}): {st['draws']} draws ≤ {STREET_DRAWS_MAX}, {st['tris']:,} tris ≤ {STREET_TRIS_MAX:,}")
else:
FAIL(f"street budget @ venue block ({st['venue']}): {st['draws']} draws / {st['tris']:,} tris over ({STREET_DRAWS_MAX} / {STREET_TRIS_MAX:,})")
if errs: FAIL(f"street budget: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
# 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:
@ -791,8 +913,8 @@ def smoke_gigs(p):
crowd: info && info.crowd, gigs: (P.plan.gigs || []).length,
bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest };
}""")
if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0:
OK(f"?noassets=1&gigs=1: the gig still happens (band {na['band']}, crowd {na['crowd']}, placeholders)")
if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0:
OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)")
else:
FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}")
if len(reqs) == 0:

View File

@ -1,12 +1,17 @@
#!/usr/bin/env python3
"""PROCITY Lane F — v3.0-alpha acceptance shot (round 12).
"""PROCITY Lane F — v3.0-beta THE DISTRICT acceptance shots (round 13).
The money shot the round asks for: gig ON, band on Lane C's deck, mixed standing/dancing crowd at the
watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be
re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo).
The two money shots the round asks for, both reproducible (never a hand-cropped screenshot nobody can
re-take when the gig layer changes):
1. INTERIOR a gig ON: the 4-piece band (front-line trio + a drummer on the riser) on Lane C's deck
with Lane E's real instruments in hand, a mixed standing/dancing crowd at the watch points,
everything human-sized next to the door / bar / ceiling.
2. STREET (venue_night) the lit venue frontage at night: marquee + bulbs + window spill + streetlamp
pools on the footpath, Lane D's outdoor QUEUE at the door, town-wide posters on the poles.
Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--out PATH]
Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG.
Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--outdir DIR]
Needs the Playwright venv + Lane B's ?dbg=1 hook (incl. the venue_night bookmark). Writes full-frame
1280x720 PNGs to docs/shots/laneF/.
"""
import sys, time, socket, subprocess, pathlib
@ -14,9 +19,9 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUT = ROOT / 'docs' / 'shots' / 'laneF' / 'r12_v3alpha_gig.png'
OUTDIR = ROOT / 'docs' / 'shots' / 'laneF'
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
if '--out' in sys.argv: OUT = pathlib.Path(sys.argv[sys.argv.index('--out') + 1])
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
def port_up(port):
@ -36,9 +41,9 @@ def ensure_server():
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
# Stand back by the door at eye height and look at the stage over the crowd's heads. Forcing one explicit
# render (rather than trusting rAF) keeps the shot deterministic in headless.
POSE_JS = r"""
# ── Shot 1: INTERIOR. Stand back by the door at eye height and look at the stage over the crowd's heads.
# Forcing one explicit render (rather than trusting rAF) keeps the interior shot deterministic in headless.
INTERIOR_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
D.setSegment(5); // NIGHT the gig is on
@ -51,10 +56,31 @@ async () => {
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
const crew = P.interiorMode.crew;
return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName,
state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0,
const crew = P.interiorMode.crew, mem = crew ? crew.members : [];
const band = mem.filter(m => m.part === 'band');
return { ok: true, venue: (P.plan.shops.find(s => s.id === P.gigs.venueShopId) || {}).name,
band: P.gigs.bandName, state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
roles: band.map(m => m.role),
dancers: mem.filter(m => m.part === 'crowd' && m.dance).length,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
}
"""
# ── Shot 2: STREET (venue_night). Lane B's bookmark poses out in front of the lit pub frontage at NIGHT.
# We exit to the street first, then let the animate loop run several frames at the pose so F's queue spawns
# (it is driven from the street frame), the frontage ramps up, and the venue block streams in — the last
# natural full-frame render is what the screenshot captures.
STREET_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG;
if (P.mode === 'interior') { D.exitShop(); await new Promise(r => setTimeout(r, 350)); }
D.shot('venue_night'); // B's bookmark: seg 5 + pose at the pub frontage
await new Promise(r => setTimeout(r, 1600)); // frontage ramp + queue spawn + chunk/poster stream settle
const id = P.gigs.venueShopId;
return { ok: P.mode === 'street', venue: (P.plan.shops.find(s => s.id === id) || {}).name,
state: P.gigs.stateOf ? P.gigs.stateOf(id) : P.gigs.state,
queue: P.queueCountOf ? P.queueCountOf(id) : null,
posters: (P.plan.posters || []).length, venues: (P.gigs.venueShopIds || []).length,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
}
"""
@ -66,6 +92,9 @@ def main():
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
srv = ensure_server()
OUTDIR.mkdir(parents=True, exist_ok=True)
interior_out = OUTDIR / 'r13_v3beta_gig_interior.png'
street_out = OUTDIR / 'r13_v3beta_venue_night.png'
try:
with sync_playwright() as p:
b = p.chromium.launch()
@ -80,20 +109,30 @@ def main():
timeout=15000)
except Exception:
print(' ! fleet never readied — shooting the placeholder crew')
info = pg.evaluate(POSE_JS)
# Shot 1 — interior
info = pg.evaluate(INTERIOR_JS)
if not info.get('ok'):
b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m')
OUT.parent.mkdir(parents=True, exist_ok=True)
pg.screenshot(path=str(OUT))
b.close(); sys.exit(f'\033[31m✗ could not stage the interior shot: {info}\033[0m')
pg.screenshot(path=str(interior_out))
print(f"\033[32m● SHOT\033[0m {interior_out.relative_to(ROOT)}")
print(f" interior · {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover")
print(f" band {info['crew']['band']} (roles {info['roles']}) · crowd {info['crew']['crowd']} "
f"({info['dancers']} dancing) · {info['draws']} draws · {info['tris']:,} tris")
# Shot 2 — street venue_night
st = pg.evaluate(STREET_JS)
pg.screenshot(path=str(street_out))
print(f"\033[32m● SHOT\033[0m {street_out.relative_to(ROOT)}")
print(f" street · {st['venue']} · state={st['state']} · queue={st['queue']} · "
f"{st['posters']} posters across {st['venues']} venues · {st['draws']} draws · {st['tris']:,} tris")
b.close()
finally:
if srv: srv.terminate()
print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}")
print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover")
print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · "
f"{info['draws']} draws · {info['tris']:,} tris")
if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
if errs:
print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
sys.exit(1)
if __name__ == '__main__':

View File

@ -9,11 +9,17 @@ feet->crown span. Fixed in R10 (normalise by `headY - minY`).
This gate boots the real game, enters a sample of open shops, spawns browser rigs at every browse
point (occupancy is 0 right after boot, so we inject it to exercise the browse spawn path
deterministically), and asserts for EVERY spawned interior figure:
1.4 m <= crown(head-bone world Y) <= 2.0 m (human-sized)
crown < room dims.H (never clips the ceiling)
|feet(min-bone world Y)| < 0.05 m (planted on the floor)
1.4 m <= STATURE (crown - foot) <= 2.0 m (human-sized platform-independent)
crown(head-bone world Y) < room dims.H (never clips the ceiling its own line)
|feet(min-bone world Y)| < 0.05 m (planted on the floor)
Exits non-zero if any figure fails, so tools/qa.sh --strict fails a giant before it can ship.
[Lane F R13 debt #6] The human-size check measures STATURE (feet→crown span), not the raw world
crown, so it stays correct the day a figure stands on a platform (a keeper on a 0.3 m riser is not a
giant). This is the same measure smoke_gigs uses for the band on Lane C's stage deck, now ported into
the shared gate; the world-crown "never clips the ceiling" assert is kept as its own separate line
(that is the real R10 failure mode). For floor figures foot0 staturecrown fully backward-compatible.
Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
@ -69,7 +75,8 @@ MEASURE_JS = r"""
a.fig.updateWorldMatrix(true, true);
(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) };
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)
});
return { H: +H.toFixed(2), figs };
}
@ -117,14 +124,14 @@ def main():
bad = []
for f in figs:
total_figs += 1
if not (LO <= f['crown'] <= HI): bad.append(f"crown {f['crown']}m out of [{LO},{HI}]")
if not (LO <= f['stature'] <= HI): bad.append(f"stature {f['stature']}m out of [{LO},{HI}]")
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:
FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad))
else:
crowns = ", ".join(f"{f['crown']:.2f}" for f in figs)
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs crowns=[{crowns}] all human-sized")
stats = ", ".join(f"{f['stature']:.2f}" for f in figs)
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs statures=[{stats}] all human-sized")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150)
b.close()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -776,7 +776,7 @@
"file": "procity_fit_bass_guitar_01.glb",
"footprint": [
0.34,
0.12
0.13
],
"height": 1.15,
"thumb": "thumbs/procity_fit_bass_guitar_01.png"
@ -793,10 +793,10 @@
"guitar_amp": {
"file": "procity_fit_guitar_amp_01.glb",
"footprint": [
0.51,
0.52,
0.29
],
"height": 0.6,
"height": 0.607,
"thumb": "thumbs/procity_fit_guitar_amp_01.png"
},
"mic_stand": {
@ -805,7 +805,7 @@
0.57,
0.57
],
"height": 1.7,
"height": 1.702,
"thumb": "thumbs/procity_fit_mic_stand_01.png"
},
"cash_register": {
@ -1115,15 +1115,37 @@
],
"note": "arcade cabinet"
},
"pubrock-live": {
"file": "audio/music-pubrock-live.ogg",
"fallback": "audio/music-pubrock-live.m4a",
"gig-pubrock": {
"file": "audio/music-gig-pubrock.ogg",
"fallback": "audio/music-gig-pubrock.m4a",
"loop": true,
"gain": 0.6,
"types": [
"pub"
],
"note": "gig live bed \u2014 F plays via room.audio gigKey when a gig is on",
"note": "gig live bed \u2014 pub-rock (pub); F plays via room.audio gigKey",
"gig": true
},
"gig-grunge": {
"file": "audio/music-gig-grunge.ogg",
"fallback": "audio/music-gig-grunge.m4a",
"loop": true,
"gain": 0.6,
"types": [
"band_room"
],
"note": "gig live bed \u2014 grunge (band_room)",
"gig": true
},
"gig-covers": {
"file": "audio/music-gig-covers.ogg",
"fallback": "audio/music-gig-covers.m4a",
"loop": true,
"gain": 0.5,
"types": [
"rsl"
],
"note": "gig live bed \u2014 covers (RSL); mellower, organ-led",
"gig": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -60,6 +60,7 @@ import { createTram } from './js/world/tram.js'; // [Lane F R9
import { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1)
import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1)
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
main().catch((err) => {
@ -243,6 +244,14 @@ const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ s
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null;
// [Lane F R13 — the outdoor queue] Lane D's VenueQueue, one per venue, wired from the street-side gig state
// (D shipped the class and left the street-side lifecycle to F — its documented seam). A seeded line forms at
// the frontage when the doors open, idle-shuffles + auto-drains each street frame, and is disposed by 'done'.
// B publishes the zone (venuePresentation.queueZoneFor); D's rig path is gate-protected (no giants) + a
// placeholder line under ?noassets. Built lazily per venue when its doors open; never touched flags-off.
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
// spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2);
chunks.warmup(player.position);
@ -271,19 +280,20 @@ function enterShop(shopId, name) {
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
// [Lane F §3.5] closed shops are locked; at night the openLate video shop is the one place still open
if (!isOpen(shop)) { hud.showToast(`🔒 ${name} — CLOSED · opens ${shop.hours[0]}:00`); return; }
// [Lane F R12 — cover charge at the door] John's ruling: free AND paid nights. A's `cover` is seeded per
// [Lane F R13 — cover charge, PER VENUE] John's ruling: free AND paid nights. A's `cover` is seeded per
// gig (~half 0). Paid → debit the R8 session wallet on the way in with a toast; too skint → a polite
// knockback and NO entry (the wallet stays honest); free → walk straight in. Re-entry the same night is
// free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. Zero new
// UI — it all rides the round-8 buy seam. The venue is a normal shop in every other respect.
if (gigState && shopId === gigState.venueShopId && gigState.open && gigState.cover > 0 && !gigState.paid) {
const cover = gigState.cover, BAND = String(gigState.bandName || 'live').toUpperCase();
// free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. THE
// DISTRICT: keyed by the venue being ENTERED (…Of(shopId)), so a night at two venues is two covers. Zero
// new UI — it rides the round-8 buy seam; the venue is a normal shop in every other respect.
if (gigState && gigState.openOf(shopId) && gigState.coverOf(shopId) > 0 && !gigState.paidOf(shopId)) {
const cover = gigState.coverOf(shopId), BAND = String(gigState.bandNameOf(shopId) || 'live').toUpperCase();
if (!wallet.canBuy(cover)) {
hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`);
return;
}
wallet.buy({ title: `cover — ${gigState.bandName}`, price: cover });
gigState.markPaid();
wallet.buy({ title: `cover — ${gigState.bandNameOf(shopId)}`, price: cover });
gigState.markPaidOf(shopId);
hud.showToast(`$${cover} cover — ${BAND}`);
}
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
@ -359,13 +369,28 @@ function frame() {
lighting.update(dt, player.position);
const clk = lighting.getClock();
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
// [Lane F R12] Friday night, off the clock: tick the machine, ramp Lane B's marquee/sign glow, and point
// Lane D's patronage surge at the venue while the doors are open (setGig(id,false) ends it → normal day
// patronage). Lane B's audio engine reads window.PROCITY.gigs.state on its own rAF for the muffled spill.
// [Lane F R13 — THE DISTRICT] Friday night, off the clock: tick EVERY venue's latch, ramp Lane B's
// per-venue marquee/sign glow off the byVenue map, and point Lane D's patronage surge at each venue whose
// doors are open — several concurrent gigs each pull their own block (setGig(id,false) ends one → normal
// day patronage there). Lane B's audio engine reads window.PROCITY.gigs.byVenue on its own rAF for the
// per-genre muffled spill.
if (gigState) {
const gs = gigState.update();
if (venuePresentation) venuePresentation.update(gs);
citizens.setGig(gigState.venueShopId, gs === 'doors' || gs === 'on');
gigState.update(); // tick all latches
if (venuePresentation) venuePresentation.update(gigState.byVenue); // per-venue frontage/spill
for (const id of gigState.venueShopIds) {
const open = gigState.openOf(id);
citizens.setGig(id, open); // concurrent patronage surges
if (venueQueues) { // the outdoor line: forms while the doors are open, drains + disposes by 'done'
let q = venueQueues.get(id);
if (open && !q) {
q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id),
gigId: (gigState.gigOf(id) || {}).gigId | 0 });
venueQueues.set(id, q);
} else if (!open && q) { q.disposeAll(); venueQueues.delete(id); }
if (q) q.update(dt);
}
}
}
// [Lane F R4] no setExposure call: D3 (a5e4b64) made the impostor toneMapped:true, so three /
// OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op.
@ -396,6 +421,7 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
// 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)
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

@ -1,30 +1,39 @@
// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1).
// PROCITY Lane D — the gig band + crowd (v3.0-beta, behind ?gigs=1).
//
// A Friday night at the pub: three musicians on Lane C's stage deck and a mixed crowd at C's
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig).
// A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
// front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
// standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
// (spawnRig → buildFigure, so no giants), with a placeholder fallback when the fleet is absent
// (?noassets — a gig with no GLBs is still a gig).
//
// Instruments: primitives show immediately (asset law), then E's manifest GLBs swap in async where
// present (placeholder-persists, like interiors/glb.js). guitar/bass held at the chest, mic stand +
// drum kit planted, an amp as backline dressing. Gated on fleet.ready so ?noassets never fetches.
//
// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on,
// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs); E's instrument GLBs replace
// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId).
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
import * as THREE from 'three';
import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.js';
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints
const MIC = 0x222226, POLE = 0x9a9a9e;
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.)
const RY_FLIP = Math.PI;
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
// holds — F's smoke asserts it). RSL fields the most (812 watch points).
const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
// Reads seated from the front (the crowd POV / money shot); a true
// sit is a clip job — see the LANE_D_NOTES clip wishlist.
// ---- primitive instruments (E's GLBs swap in over these; a gig with no GLBs is still a gig) ----
// Each returns an Object3D in the actor's OUTER-fig local frame. After the R13 facing-normalise the
// rig's front (its chest) is fig-local -Z, so instruments sit on the -Z side and ride the sway.
// Primitives own their geometry/material (tagged ownGeo) so disposeAll frees them; GLB clones share
// the cached gltf's geo/mats and are only detached.
function tagOwn(g) { g.userData.ownGeo = true; return g; }
// ---- primitive instruments (E's GLBs replace these; footprint-compatible, held at the chest) ----
// Each returns an Object3D positioned in the actor's OUTER-fig space (origin at the feet, +Z front),
// so it rides the performer's procedural sway. Cheap shared-nothing meshes (3 instruments per gig).
function guitarPrim(bodyCol, big = false) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
@ -34,10 +43,9 @@ function guitarPrim(bodyCol, big = false) {
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat);
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
g.add(body, neck);
// the fleet mesh faces its local +Z (verified in-scene), so the chest — and the instrument — is +Z.
g.position.set(0.02, 1.02, 0.19); // across the chest, toward the audience
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd
return g;
g.position.set(0.02, 1.02, -0.19); // across the chest (fig-local -Z = the audience-facing front)
g.rotation.set(0.15, 0, big ? -0.7 : -0.62); // slung diagonally, body angled out to the crowd
return tagOwn(g);
}
function micStandPrim() {
const g = new THREE.Group();
@ -48,26 +56,75 @@ function micStandPrim() {
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
head.position.y = 1.44;
g.add(pole, head);
g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side)
return g;
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
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() {
const g = new THREE.Group();
const shell = new THREE.MeshStandardMaterial({ color: KIT, roughness: 0.4, metalness: 0.1 });
const chrome = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.3, metalness: 0.7 });
const skin = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.6 });
const kick = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.30, 0.42, 16), shell);
kick.rotation.x = Math.PI / 2; kick.position.set(0, 0.30, 0);
const kickHead = new THREE.Mesh(new THREE.CircleGeometry(0.29, 16), skin);
kickHead.position.set(0, 0.30, -0.215); kickHead.rotation.y = Math.PI;
const snare = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, 0.16, 14), chrome);
snare.position.set(-0.32, 0.58, -0.10);
const tom = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.22, 14), shell);
tom.position.set(0.34, 0.52, -0.06);
const mkCymbal = (x, y, r) => {
const c = new THREE.Group();
const stand = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, y, 6), chrome);
stand.position.y = y / 2;
const disc = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.01, 16), chrome);
disc.position.y = y;
c.add(stand, disc); c.position.x = x; return c;
};
g.add(kick, kickHead, snare, tom, mkCymbal(-0.44, 0.86, 0.18), mkCymbal(0.50, 0.94, 0.20));
g.position.set(0, SEAT_DROP, -0.60); // on the riser, in front of the seated drummer (fig-local -Z)
return tagOwn(g);
}
function instrumentFor(role) {
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'vocal') return micStandPrim();
if (role === 'drums') return drumKitPrim();
return null;
}
// role → E's manifest fitting id + its transform in the actor's fig-local frame (front/chest = -Z).
// GLBs are base-origin metres (glb_law): guitar/bass stand upright (long axis +Y = neck, thin face along
// Z), so a +Z roll slings them diagonally across the chest; the mic stand is upright as-is; the kit is
// scaled to fit C's tight riser gap (vocalist 0.73 m in front of the drummer) and planted in front of the
// sunk drummer. Tuned on-screen. Body-origin → position is where the strap-button / base sits.
const GLB_FIT = {
guitar: { id: 'electric_guitar', pos: [0.14, 0.60, -0.15], rot: [0.22, 0.0, 0.72], scale: 1 },
bass: { id: 'bass_guitar', pos: [0.14, 0.58, -0.15], rot: [0.22, 0.0, 0.66], scale: 1 },
vocal: { id: 'mic_stand', pos: [0.00, 0.00, -0.30], rot: [0, 0, 0], scale: 0.9 },
drums: { id: 'drum_kit', pos: [0.00, SEAT_DROP, -0.30], rot: [0, Math.PI, 0], scale: 0.66 },
};
// manifest (self-fetched, promise-cached, fail-soft). ONLY called behind a fleet.ready gate, so under
// ?noassets (fleet === null) this never runs → zero asset fetch, primitives everywhere. Decoupled from
// Lane C's interiors/glb.js on purpose (band.js is asset-law self-contained).
let _manifestP = null;
function loadManifest() {
return (_manifestP ||= fetch('assets/manifest.json').then(r => (r.ok ? r.json() : null)).catch(() => null));
}
export class GigCrew {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] }
this.members = []; // { actor, kind, part, role, dance, seated, base:{x,y,z,ry}, phase, extra:[] }
this.props = []; // planted dressing added straight to the room group (amp) — freed on disposeAll
this._gen = 0; // bumped every spawn/dispose; async GLB swaps bail if the gen moved (room gone)
this.t = 0;
}
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
// pedIndex forces a specific fleet ped (band members are picked distinct); else a seeded pickRig.
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
@ -84,40 +141,91 @@ export class GigCrew {
return { actor, kind };
}
// spawn(roomGroup, { stage, watchPoints, gig }) — band trio on the deck + crowd at the watch points.
// spawn(roomGroup, { stage, watchPoints, gig }) — the band on the deck + crowd at the watch points.
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
this.disposeAll();
const gid = gig.gigId | 0;
this._gen++;
const gen = this._gen;
// ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ──
// ── band: C's bandPoses (guitar / vocal / bass across the front, + a seated `drums` on the riser) ──
if (stage && stage.bandPoses) {
// distinct fleet peds for the trio, seeded per gig (a band with two identical members reads wrong)
// distinct fleet peds per gig (a band with two identical members reads wrong)
const distinct = (this.fleet && this.fleet.ready)
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
stage.bandPoses.forEach((p, i) => {
const ry = p.ry + RY_FLIP;
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
const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
{ x: p.x, y: stage.deckY || 0, z: p.z, ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
{ x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const extra = [];
const member = { actor, kind, 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: [] };
const inst = instFor(p.role);
if (inst) { actor.fig.add(inst); extra.push(inst); }
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false,
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra });
if (inst) { actor.fig.add(inst); member.extra.push(inst); }
this.members.push(member);
this._upgradeInstrument(member, gen); // async GLB swap over the primitive (fleet-gated)
});
this._addBackline(roomGroup, stage, gen); // amp dressing beside the riser (async, dressing)
}
// ── crowd: stand-and-watch or dance at C's watch points ──
const pts = watchPoints.slice(0, CROWD_CAP);
const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length));
pts.forEach((w) => {
const ry = w.ry + RY_FLIP;
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance,
base: { x: w.x, y: 0, z: w.z, ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry: w.ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false,
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
});
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length };
}
// Swap a band member's primitive instrument for E's manifest GLB, placeholder-persists style. Fail-soft
// at every step (no manifest / no entry / depot unreachable → the primitive stays), and generation-guarded
// so a GLB that resolves after the room is gone (or re-spawned) is dropped, never attached to a dead rig.
_upgradeInstrument(member, gen) {
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
const cfg = GLB_FIT[member.role];
if (!cfg) return;
loadManifest().then((mani) => {
if (!mani || !mani.fittings || this._gen !== gen) return;
const entry = mani.fittings[cfg.id];
if (!entry || !entry.file) return;
return loadGLB(`depot:${entry.file}`).then((gltf) => {
if (!gltf || this._gen !== gen || !member.actor.fig.parent) return; // room rebuilt / disposed mid-flight
const inst = gltf.scene.clone(true); // shares cached geo/mats — detach only, never dispose
inst.position.set(...cfg.pos);
inst.rotation.set(...cfg.rot);
inst.scale.setScalar(cfg.scale || 1);
inst.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
for (const ex of member.extra) { if (ex.parent) ex.parent.remove(ex); disposePrim(ex); }
member.extra.length = 0;
member.actor.fig.add(inst);
member.extra.push(inst);
});
});
}
// 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).
_addBackline(roomGroup, stage, gen) {
if (!this.fleet || !this.fleet.ready) return;
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;
const amp = gltf.scene.clone(true);
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
// off to stage-right of the drum riser, on the deck, facing the crowd (world +Z). Room-local coords.
amp.position.set(stage.x + (stage.w || 3) * 0.32, (stage.deckY || 0), stage.z + 0.15);
roomGroup.add(amp);
this.props.push(amp);
});
});
}
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
update(dt) {
this.t += dt;
@ -127,7 +235,10 @@ export class GigCrew {
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
const f = m.actor.fig, b = m.base, ph = m.phase;
if (m.part === 'band') {
if (m.role === 'vocal') { // singer: gentle sway toward the mic
if (m.seated) { // drummer: seated bob + a little shoulder work, planted
f.position.y = b.y + Math.abs(Math.sin(t * 3.4 + ph)) * 0.03;
f.rotation.z = Math.sin(t * 3.4 + ph) * 0.04;
} else if (m.role === 'vocal') { // singer: gentle sway toward the mic
f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
} else { // guitar / bass: rhythmic strum-sway + tiny bob
@ -149,10 +260,21 @@ export class GigCrew {
disposeAll() {
for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
for (const ex of m.extra) ex.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
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
this.members.length = 0;
this.props.length = 0;
this._gen++; // invalidate any GLB load still in flight
this.t = 0;
}
}
// Free a primitive instrument's own geometry/materials. GLB clones share the cached gltf's geo/mats,
// so they are detached from the scene by disposeAll but NEVER disposed here (tagged: no ownGeo).
function disposePrim(obj) {
if (obj.parent) obj.parent.remove(obj);
if (!obj.userData || !obj.userData.ownGeo) return; // GLB clone → shared resources, leave them cached
obj.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
}

158
web/js/citizens/queue.js Normal file
View File

@ -0,0 +1,158 @@
// PROCITY Lane D — the outdoor gig queue (v3.0-beta charter item, behind ?gigs=1).
//
// While the doors are open, a short line of punters waits at the venue frontage and drains into the
// room as people are admitted — gone by closing (`done`). Street-side companion to the interior
// GigCrew: same gate-protected rig path (spawnRig → buildFigure, so no giants; rigs face -Z after the
// R13 facing-normalise), same placeholder fallback under ?noassets (a queue of boxes is still a queue).
//
// Seam (Lane F wires it from the street-side gig state, per venue):
// const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
// q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (and 'on' overflow)
// q.admitOne(); // when a punter is admitted (F's cover-charge / entry hook)
// q.update(dt); // each street frame — idle shuffle + lerp + slow auto-drain
// q.disposeAll(); // at 'done' / venue out of view
//
// Geometry contract (consistent with the post-R13 -Z rig front):
// door = { x, z, ry } ry = the heading a punter at the door FACES to look at the entrance.
// queueZone = { x, z, ry, len } | null Lane B's marker: (x,z) = head by the door, ry = that same facing,
// len = line length (m). Absent → a straight line trailing the door.
// The line trails BEHIND the head (opposite the facing), punters spaced along it, everyone facing the door.
import * as THREE from 'three';
import { rng } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
const SPACING = 0.85; // m between punters down the line
const QUEUE_MIN = 2, QUEUE_MAX = 6;
const DRAIN_MIN = 3.5, DRAIN_MAX = 7.0; // s between auto-admissions (seeded) — the safety drain if F
// doesn't couple admitOne() to real entries; empties over the night.
const STEP_EASE = 4.0; // how fast a punter slides up into a vacated slot
export class VenueQueue {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind, slot, target:{x,z}, base:{ry}, phase }
this.parent = null;
this.gid = 0;
this._gen = 0;
this.t = 0;
this._drainAt = 0;
}
// Build the seeded line. `count` overrides the seeded 2..6 (F may cap to the room's spare capacity).
spawn(parent, { door = null, queueZone = null, gigId = 0, count = null } = {}) {
this.disposeAll();
this._gen++;
this.gid = gigId | 0;
this.parent = parent;
const anchor = this._anchor(door, queueZone);
if (!anchor) return { queue: 0 };
this.anchor = anchor;
const r = rng(this.citySeed, 'queuecount', this.gid);
const n = count != null ? Math.max(0, count | 0) : QUEUE_MIN + Math.floor(r() * (QUEUE_MAX - QUEUE_MIN + 1));
for (let i = 0; i < n; i++) this._spawnAt(i);
this._drainAt = this.t + this._nextDrainGap();
return { queue: this.members.length };
}
// Resolve the head + facing + trail direction from B's zone, else a straight line off the door.
// Punters always face `ry` (toward the entrance; post-R13 rigs face world (-sin ry, -cos ry)). B's
// zone trails single-file the opposite way (B lays it down the verandah via x/z/ry/len). The door-only
// fallback instead trails along the frontage TANGENT, so the line hugs the building rather than spilling
// into the road — the "along the frontage" read, best-effort until B's queueZone lands.
_anchor(door, queueZone) {
const src = queueZone || door;
if (!src || src.x == null) return null;
const ry = src.ry || 0;
const trail = queueZone
? { x: Math.sin(ry), z: Math.cos(ry) } // opposite the facing → classic single file
: { x: Math.cos(ry), z: -Math.sin(ry) }; // perpendicular to facing → along the frontage
return { x: src.x, z: src.z, ry, trail, len: queueZone && queueZone.len ? queueZone.len : null };
}
_spawnAt(slot) {
const a = this.anchor;
// even spacing; if B gave a finite zone length, don't overrun it (compress to fit)
const gap = (a.len && this.members ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING);
const tx = a.x + a.trail.x * gap * slot;
const tz = a.z + a.trail.z * gap * slot;
const key = `${this.gid}:${slot}`;
const r = rng(this.citySeed, 'queueh', key);
const h = 1.58 + r() * 0.34;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const sp = rig && spawnRig(rig, { ry: a.ry, height: h, clip: this.fleet.idleClip, phase: r() });
if (sp) { actor = sp; kind = 'rig'; }
}
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'queuebody', key), { height: h }); actor.fig.rotation.y = a.ry; kind = 'placeholder'; }
actor.fig.position.set(tx, 0, tz);
this.parent.add(actor.fig);
this.members.push({ actor, kind, slot, target: { x: tx, z: tz }, base: { ry: a.ry },
phase: rng(this.citySeed, 'queuep', key)() * Math.PI * 2 });
}
_gap() {
const a = this.anchor;
return (a && a.len ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING);
}
_nextDrainGap() { return DRAIN_MIN + rng(this.citySeed, 'queuedrain', `${this.gid}:${this.members.length}:${this.t | 0}`)() * (DRAIN_MAX - DRAIN_MIN); }
// Admit the head of the line (nearest the door): it walks in, everyone else shuffles up one slot.
// Returns the admitted count so far (F can stop when the room is at watchPoint cap).
admitOne() {
if (!this.members.length) return 0;
// the head = the smallest slot (closest to the anchor)
let headIdx = 0;
for (let i = 1; i < this.members.length; i++) if (this.members[i].slot < this.members[headIdx].slot) headIdx = i;
const head = this.members.splice(headIdx, 1)[0];
if (head.actor.fig.parent) head.actor.fig.parent.remove(head.actor.fig);
head.actor.dispose && head.actor.dispose();
// shuffle everyone forward one slot → new target one step closer to the door
const a = this.anchor, gap = this._gap();
for (const m of this.members) {
m.slot = Math.max(0, m.slot - 1);
m.target.x = a.x + a.trail.x * gap * m.slot;
m.target.z = a.z + a.trail.z * gap * m.slot;
}
this._drainAt = this.t + this._nextDrainGap();
return 1;
}
// idle shuffle + slide up into vacated slots. The seeded auto-drain is a safety so the line empties
// over the night even if F doesn't couple admitOne() to real entries; call disposeAll() at `done`.
update(dt, { autoDrain = true } = {}) {
this.t += dt;
const t = this.t;
if (autoDrain && this.members.length && t >= this._drainAt) this.admitOne();
for (const m of this.members) {
if (m.actor.mixer) m.actor.mixer.update(dt);
else m.actor.tick && m.actor.tick(dt, false);
const f = m.actor.fig, ph = m.phase;
// ease toward the (possibly just-vacated) slot
f.position.x += (m.target.x - f.position.x) * Math.min(1, dt * STEP_EASE);
f.position.z += (m.target.z - f.position.z) * Math.min(1, dt * STEP_EASE);
const moving = Math.hypot(m.target.x - f.position.x, m.target.z - f.position.z) > 0.03;
if (m.actor.setMoving) m.actor.setMoving(moving);
// waiting-punter fidget: slow weight shift + the odd glance, feet planted
f.rotation.z = Math.sin(t * 0.8 + ph) * 0.02;
f.rotation.y = m.base.ry + Math.sin(t * 0.4 + ph) * 0.05;
}
return this.members.length;
}
count() { return this.members.length; }
disposeAll() {
for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
m.actor.dispose && m.actor.dispose();
}
this.members.length = 0;
this._gen++;
this.t = 0;
}
}

View File

@ -100,6 +100,17 @@ const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
// FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z,
// but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry
// (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on
// purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was
// the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked
// (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own
// mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct,
// near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a
// Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and
// all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract.
inner.rotation.y = Math.PI;
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);

View File

@ -123,7 +123,7 @@ export class CitizenSim {
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
this.patronageOn = true;
this._gigVenue = null; // R12: venueShopId while a gig is on (F sets via setGig) → surge
this._gigVenues = new Set(); // R13: venueShopIds with a gig on (F sets per-venue via setGig) → multi-venue surge
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
this._dropKeys = []; // scratch: chunks to drop this frame
@ -333,10 +333,15 @@ export class CitizenSim {
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; }
// R12 gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street
// Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street
// converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants
// drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it).
setGig(venueShopId, on = true) { this._gigVenue = on && venueShopId != null ? venueShopId : null; }
// R13 the district: per-venue, so several concurrent gigs each pull their own block. F calls this once
// per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works.
setGig(venueShopId, on = true) {
if (venueShopId == null) return;
if (on) this._gigVenues.add(venueShopId); else this._gigVenues.delete(venueShopId);
}
setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; }
_openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; }
@ -365,14 +370,14 @@ export class CitizenSim {
const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z));
if (!list || !list.length) return null;
let best = null, bd = PATRON_RANGE;
for (const s of list) {
let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary
for (const s of list) { // shops (follow the sound), and across concurrent venues.
if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z);
// gig night: the venue reaches further and wins over a nearer shop (follow the sound to the pub)
if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s;
if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; }
if (d < bd) { bd = d; best = s; }
}
return best;
return gigBest || best;
}
_beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
@ -519,7 +524,7 @@ export class CitizenSim {
if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0;
const shop = this._nearestOpenShop(c);
const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue)
const chance = (shop && this._gigVenues.has(shop.shopId))
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
}

View File

@ -1,108 +1,251 @@
// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and
// adds the venue (one pub), the nightly gig schedule (`plan.gigs`), and poster assignments
// (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})),
// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and adds
// the town's VENUES (24: pub / band_room / rsl), the week's gig schedule (`plan.gigs`), and poster
// assignments (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})),
// so the base plan stays byte-identical and the frozen goldens don't move (CITY_SPEC prime flag law).
//
// Source-agnostic: works on synthetic AND osm plans (it only reads shops/lots/edges). Deterministic
// Source-agnostic: works on synthetic AND osm plans (it only reads shops/lots/blocks/edges). Deterministic
// from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law).
//
// ROUND13 (v3.0-beta, the district): one venue → 24; one genre → three (by kind); one night's poster run
// → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off.
import { rng, seedFor, mulberry32, pick, shuffle } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js';
import { SHOP_TYPES, poleOffset } 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;
// Alpha model: one genre, a night gig (doors at DUSK seg4, on during NIGHT seg5 — see lighting.js
// segment→hour map). A week of nightly entries at the one venue; F's clock picks tonight's.
// 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"
const GIG_END_SEG = 5; // single coarse night segment; F does doors at startSeg1 (DUSK)
const GENRE = 'pubrock'; // the alpha genre (Lane E ships the pub-rock live bed)
// Pick the venue: prefer a spine-END plain shop (a 'shop'-use lot fronting a 'main' edge, farthest
// from the origin — a destination you follow the sound to), NEVER the openLate landmark. Fallback
// for osm / no-spine towns: the farthest-from-centre plain shop. Seeded pick among the farthest few.
function pickVenue(plan, citySeed) {
const VENUES_PER_TOWN = [2, 4]; // seeded count (charter: 24 venues per town)
const GIGS_PER_VENUE = [4, 6]; // per week — the rest are dark nights, which read true
// Kind order as venues are added. A town of 4 gets a second pub at the far end of the spine —
// two pubs and a footy club is the most Australian town there is.
const VENUE_SEQUENCE = ['pub', 'band_room', 'rsl', 'pub'];
// Cover law (ROUND13 §A2): ~half free town-wide; the RSL skews CHEAP (it's a club, the bistro pays the
// bills), the band room skews FREE (the door is a hat on a milk crate). `cover` stays an integer in
// {0} [2,10] for every kind, so F's wallet seam and the selfcheck assert are unchanged.
const COVER_BY_KIND = {
pub: { free: 0.50, min: 2, max: 10 },
band_room: { free: 0.70, min: 2, max: 6 },
rsl: { free: 0.45, min: 2, max: 5 },
};
// ── THE gigKey CONTRACT (ROUND13 debt #1) ─────────────────────────────────────────────
// The audio manifest key for a genre IS the gigKey, and it is ALWAYS `gig-<genreKey>`. There is no
// mapping table anywhere: C emits `audio.gigKey`, E names the manifest bed, B's spill reads it and F
// resolves it — all through this one function. R12 lost a gig bed to a private rename (C asked for
// `gig-pubrock`, E shipped `pubrock-live`, and because audio fails soft the band just mimed). One key,
// zero mapping — import this rather than building the string yourself.
export const gigKeyFor = genreKey => `gig-${genreKey}`;
// ── venue selection ───────────────────────────────────────────────────────────────────
// Placement rules (charter + ROUND13 §A1): pub at a spine END (a destination you follow the sound to),
// band_room on the warehouse fringe, RSL off-spine. Each pick converts a plain shop IN PLACE (keeps the
// lot id + geometry ⇒ enterable, no overlap) and never takes the openLate landmark (the video survives).
// Every rule degrades to "any remaining plain shop" so an osm town — which has no warehouse fringe and
// may have no spine at all — still gets its venues.
function pickVenues(plan, citySeed) {
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const candidates = plan.shops.filter(s => {
if (s.openLate) return false; // must not displace the night landmark
const lot = lotById.get(s.lot);
return lot && lot.use === 'shop'; // convert a plain shop → keeps SOLID + geometry
});
if (!candidates.length) return null;
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 dist2 = s => { const l = lotById.get(s.lot); return l.x * l.x + l.z * l.z; };
let pool = candidates.filter(s => mainEdges.has(lotById.get(s.lot).frontEdge));
if (!pool.length) pool = candidates; // osm / no spine → any plain shop
pool = pool.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); // farthest first, id tiebreak
const topN = pool.slice(0, Math.min(5, pool.length));
return pick(rng(citySeed, 'venue', 0), topN);
}
// Band names across the nights: John's OPTIONAL custom names (from web/assets/custom_bands.json,
// loaded by the shell and passed as opts.customBands) get PRIORITY — shuffled deterministically and
// filled first, so a town surfaces up to NIGHTS of them — and the rest are generated. Deterministic
// given (citySeed, customBands). Absent/empty custom list ⇒ pure generator (the ?noassets path).
function assignBands(citySeed, nGigs, customBands) {
const custom = (Array.isArray(customBands) ? customBands : [])
.filter(b => typeof b === 'string' && b.trim()).map(b => b.trim());
const shuffled = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : [];
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);
const base = plan.shops.filter(s => {
if (s.openLate) return false; // must not displace the night landmark
const l = lotOf(s);
return !!l && l.use === 'shop'; // a plain shop → keeps SOLID + geometry
});
const taken = new Set();
const out = [];
for (let i = 0; i < nGigs; i++) {
out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i)));
const n = irange(rng(citySeed, 'venueN', 0), VENUES_PER_TOWN[0], VENUES_PER_TOWN[1]);
for (let i = 0; i < n; i++) {
const kind = VENUE_SEQUENCE[i] || 'pub';
const pool = base.filter(s => !taken.has(s.id));
if (!pool.length) break; // degenerate town — fewer venues, still valid
let cand;
if (kind === 'band_room') {
cand = pool.filter(s => blockKindOf(s) === 'warehouse');
if (!cand.length) cand = pool.filter(s => !onMain(s)); // osm: no warehouse district
} else if (kind === 'rsl') {
cand = pool.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse');
if (!cand.length) cand = pool.filter(s => !onMain(s));
} else {
cand = pool.filter(onMain);
// a SECOND pub belongs at the other end of the spine, not next door to the first
const firstPub = out.find(v => v.kind === 'pub');
if (firstPub) {
const pz = (lotOf(firstPub.shop) || {}).z || 0;
const far = cand.filter(s => Math.sign((lotOf(s) || {}).z || 0) !== Math.sign(pz));
if (far.length) cand = far;
}
}
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);
if (!shop) break;
taken.add(shop.id);
out.push({ shop, kind });
}
return out;
}
// Posters advertising tonight's gig (night 0), near the venue + along the central streets B already
// streams (poles/walls). Modest count for the one-pub alpha; positions seeded + deterministic. B
// renders E's poster skin at (x,z) rotated ry; F wires "poster → tonight's gig" discoverability.
function buildPosters(plan, citySeed, venue, gig) {
// Convert a chosen shop into a venue of `kind`, in place.
function convertVenue(citySeed, shop, kind) {
const reg = SHOP_TYPES[kind];
const vseed = seedFor(citySeed, 'venue', shop.id);
const vr = mulberry32(vseed);
shop.type = kind;
shop.facadeSkin = pick(vr, reg.facades);
const nm = shopName(vseed, kind);
shop.name = nm.name; shop.sign = nm.sign;
// clamp into the kind's registry band (the shop may have been a 3-storey corner anchor; a band room
// is a single-storey shed). A pub reads as a ≥2-storey corner hotel.
shop.storeys = Math.min(Math.max(shop.storeys | 0, reg.storeys[0]), reg.storeys[1]);
if (kind === 'pub') shop.storeys = Math.max(shop.storeys, 2);
shop.hours = [reg.hours.open, reg.hours.close]; // open into NIGHT so the gig runs
shop.venue = true; // easy lookup alongside venueKind
shop.venueKind = kind;
shop.genreKey = reg.genre; // ⇒ gigKeyFor(genreKey) is the audio bed
return shop;
}
// ── the week's schedule ───────────────────────────────────────────────────────────────
// 7 nights × every venue, but not every venue plays every night: each takes a seeded 46 of the 7 and
// goes dark the rest. Slots are walked NIGHT-MAJOR so John's custom band names spread across the whole
// week rather than filling one venue's residency.
function buildSchedule(plan, citySeed, venues, customBands) {
const playing = venues.map((v) => {
const k = irange(rng(citySeed, 'gignights', v.shop.id), GIGS_PER_VENUE[0], GIGS_PER_VENUE[1]);
const order = shuffle(rng(citySeed, 'gigwhich', v.shop.id), [0, 1, 2, 3, 4, 5, 6]);
return new Set(order.slice(0, k));
});
// John's OPTIONAL custom names (web/assets/custom_bands.json via opts.customBands) get PRIORITY —
// deduped, shuffled deterministically, filled first. Absent/empty ⇒ pure generator (?noassets path).
const seen = new Set();
const custom = (Array.isArray(customBands) ? customBands : [])
.filter(b => typeof b === 'string' && b.trim())
.map(b => b.trim())
.filter(b => (seen.has(b) ? false : (seen.add(b), true)));
const queue = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : [];
const gigs = [];
const usedByNight = new Map();
let ci = 0, gi = 0;
for (let night = 0; night < NIGHTS; night++) {
let used = usedByNight.get(night);
if (!used) usedByNight.set(night, used = new Set());
for (let vi = 0; vi < venues.length; vi++) {
if (!playing[vi].has(night)) continue; // dark tonight
const v = venues[vi];
// No band plays two venues at once. Custom names are deduped and used at most once each, so they
// can never clash; a GENERATED name can collide with anything already booked tonight, so advance
// the generator stream until it doesn't (deterministic: `gi` only ever moves forward).
let band;
if (ci < queue.length) band = queue[ci++];
else do { band = bandName(seedFor(citySeed, 'band', gi++)); } while (used.has(band));
used.add(band);
const cv = COVER_BY_KIND[v.kind] || COVER_BY_KIND.pub;
const gr = mulberry32(seedFor(citySeed, 'gig', v.shop.id * 8 + night));
const cover = gr() < cv.free ? 0 : cv.min + ((gr() * (cv.max - cv.min + 1)) | 0);
gigs.push({
gigId: gigs.length, venueShopId: v.shop.id, bandName: band, genreKey: v.shop.genreKey,
night, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover,
});
}
}
return gigs;
}
// ── posters, town-wide ────────────────────────────────────────────────────────────────
// Each venue playing TONIGHT (night 0) gets a poster on its own frontage plus a seeded run along the
// spine. Different venues' posters share the same pole runs — that IS the district reading.
//
// `ry` follows the ONE house convention, the same one lot.ry uses and selfcheck enforces: it is the
// Y-rotation of a thing whose FRONT/outward normal is local Z at ry=0, i.e. world facing
// (sin ry, cos ry). A poster faces the people who read it.
//
// ROUND13 debt #5 — R12 put spine posters at `node.x + 2`, i.e. 2m from an intersection's CENTRELINE,
// which is mid-bitumen on a 28m corridor. They now seat on the verge via registry.poleOffset(edge)
// (just behind the kerb), and the venue's own poster sits on its FACADE rather than at the lot centre,
// which is inside the building shell.
function buildPosters(plan, citySeed, venues, gigs) {
const posters = [];
const add = (x, z, ry) => posters.push({ id: posters.length, gigId: gig.gigId, x: r2(x), z: r2(z), ry: r4(ry) });
const vlot = plan.lots.find(l => l.id === venue.lot); // the venue's LOT carries the coords (shop doesn't)
if (vlot) add(vlot.x, vlot.z, vlot.ry); // one on the venue frontage itself
const mainNodeIds = new Set();
for (const e of plan.streets.edges) if (e.kind === 'main') { mainNodeIds.add(e.a); mainNodeIds.add(e.b); }
let cand = plan.streets.nodes.filter(n => mainNodeIds.has(n.id));
if (cand.length < 4) cand = plan.streets.nodes.slice();
const chosen = shuffle(rng(citySeed, 'posters', 0), cand.slice()).slice(0, Math.min(5, cand.length));
for (const n of chosen) add(n.x + 2, n.z, Math.atan2(n.x, n.z)); // small verge offset; face town centre
const add = (x, z, ry, gigId) => posters.push({ id: posters.length, gigId, x: r2(x), z: r2(z), ry: r4(ry) });
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const tonight = new Map(); // venueShopId → tonight's gig
for (const g of gigs) if (g.night === 0 && !tonight.has(g.venueShopId)) tonight.set(g.venueShopId, g);
if (!tonight.size) return posters; // every venue dark tonight — no posters, reads true
// 1. one on each playing venue's own frontage
for (const v of venues) {
const gig = tonight.get(v.shop.id);
const lot = gig && lotById.get(v.shop.lot);
if (!lot) continue;
const fx = -Math.sin(lot.ry), fz = -Math.cos(lot.ry); // house convention: front = local Z
const off = lot.d / 2 + 0.06; // out onto the facade plane, proud of it
add(lot.x + fx * off, lot.z + fz * off, lot.ry, gig.gigId);
}
// 2. the spine run — seeded pole positions along main edges, on the verge, facing the footpath.
const spine = plan.streets.edges.filter(e => e.kind === 'main');
const runs = spine.length ? spine : plan.streets.edges.slice();
const bill = [...tonight.values()];
if (!runs.length || !bill.length) return posters;
const pr = rng(citySeed, 'posters', 0);
const PER_EDGE = 2;
let k = 0;
for (const e of runs) {
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);
if (len < 8) continue;
const ux = dx / len, uz = dz / len;
const off = poleOffset(e); // verge, just behind the kerb — never bitumen
for (let i = 0; i < PER_EDGE; i++) {
const t = 0.2 + 0.6 * pr(); // somewhere along the block, not on the corner
const side = pr() < 0.5 ? 1 : -1;
// outward normal (road → pole), exactly marchStrip's convention: a pole is a tiny lot, its front
// faces back across its own footpath, so ry = atan2(nx, nz).
const nx = side > 0 ? -uz : uz, nz = side > 0 ? ux : -ux;
const x = a.x + ux * len * t + nx * off, z = a.z + uz * len * t + nz * off;
add(x, z, Math.atan2(nx, nz), bill[k++ % bill.length].gigId);
}
}
return posters;
}
// Public: augment a finished plan with the gig layer. Mutates + returns `plan` (caller passes a fresh
// plan). Adds `plan.gigs`, `plan.posters`, and converts one shop into the `pub` venue in place.
// ── public: augment a finished plan with the gig layer ─────────────────────────────────
// Mutates + returns `plan` (caller passes a fresh plan). Adds `plan.gigs` + `plan.posters` and converts
// 24 shops into venues in place.
export function withGigs(plan, citySeed, opts = {}) {
citySeed = citySeed >>> 0;
const venue = pickVenue(plan, citySeed);
if (!venue) { plan.gigs = []; plan.posters = []; return plan; } // degenerate town (no plain shops)
const venues = pickVenues(plan, citySeed);
if (!venues.length) { plan.gigs = []; plan.posters = []; return plan; } // no plain shops at all
// Convert the chosen shop into the pub venue, in place (keeps its lot id + geometry ⇒ no overlap).
const reg = SHOP_TYPES.pub;
const vseed = seedFor(citySeed, 'venue', venue.id);
const vr = mulberry32(vseed);
venue.type = 'pub';
venue.facadeSkin = pick(vr, reg.facades);
const nm = shopName(vseed, 'pub');
venue.name = nm.name; venue.sign = nm.sign;
venue.storeys = Math.max(venue.storeys | 0, 2); // a corner hotel reads as ≥2 storeys
venue.hours = [reg.hours.open, reg.hours.close]; // [17,23] — open into NIGHT so the gig runs
venue.venue = true; // easy lookup alongside venueShopId
// Nightly gig schedule at the venue.
const bands = assignBands(citySeed, NIGHTS, opts.customBands);
plan.gigs = [];
for (let n = 0; n < NIGHTS; n++) {
const gr = mulberry32(seedFor(citySeed, 'gig', n));
const cover = gr() < 0.5 ? 0 : 2 + ((gr() * 9) | 0); // ~half free, else integer $2$10
plan.gigs.push({
gigId: n, venueShopId: venue.id, bandName: bands[n], genreKey: GENRE,
night: n, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover,
});
}
plan.posters = buildPosters(plan, citySeed, venue, plan.gigs[0]);
for (const v of venues) convertVenue(citySeed, v.shop, v.kind);
plan.gigs = buildSchedule(plan, citySeed, venues, opts.customBands);
plan.posters = buildPosters(plan, citySeed, venues, plan.gigs);
return plan;
}

View File

@ -10,7 +10,11 @@
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 } from './gigs.js';
export { withGigs, gigKeyFor } 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.
export { SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth, vergeBand, poleOffset } from '../core/registry.js';
// planSource selector (round 6; multi-town round 8). Default 'synthetic' → the byte-identical
// golden-hash generator; 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm (+ optional

View File

@ -80,6 +80,14 @@ const PATTERNS = {
pub: [ // v3 venue (?gigs=1): the town pub/hotel
'{Pub}', 'The {Suburb} Hotel', 'The {Family} Hotel', 'The {Adj} Arms', '{Pub}',
],
band_room: [ // v3 venue (ROUND13): the tin shed on the warehouse fringe
'The {Adj} Warehouse', '{Suburb} Band Room', 'The {Animal} Room', 'The {Word} Shed',
'The Tote-ish', 'The {Adj} Room', '{Word} Rehearsal Rooms',
],
rsl: [ // v3 venue (ROUND13): the returned & services league club — carpet, bistro, meat raffle
'{Suburb} RSL Club', '{Suburb} RSL', '{Suburb} Services Club', 'The {Suburb} Memorial Club',
'{Suburb} Workers Club', '{Suburb} Returned Servicemens Club',
],
};
// ── band-name patterns (v3 gigs, 90s Aussie pub-rock) ──────────────────────────────
@ -99,7 +107,9 @@ const SIGN_PATTERNS = {
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'],
pub: ['HOTEL', 'THE ROYAL', '{Suburb} HOTEL', 'PUB'],
pub: ['HOTEL', 'THE ROYAL', '{Suburb} HOTEL', 'PUB'],
band_room: ['BAND ROOM', 'LIVE MUSIC', '{Word} ROOM', 'THE SHED'],
rsl: ['RSL', '{Suburb} RSL', 'SERVICES CLUB', 'MEMORIAL CLUB'],
};
// Public: name a shop from its stable seed + type. Returns { name, sign }.

View File

@ -9,8 +9,8 @@ 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 } from './index.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
import { generatePlanFor, gigKeyFor } from './index.js';
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
const HERE = dirname(fileURLToPath(import.meta.url));
@ -143,23 +143,67 @@ function structuralSuite(plan, label) {
}
// The v3 GIG layer (?gigs=1). Runs the full structuralSuite (venue-aware) plus gig-specific
// invariants. Validates the interface every downstream lane (C/D/B/F) hangs off this round.
// invariants. Validates the DISTRICT interface every downstream lane (C/D/B/F) hangs off this round.
function gigSuite(plan, label) {
structuralSuite(plan, label); // full coverage; venue exempt from late-shop rule
const pubs = plan.shops.filter(s => s.type === 'pub');
ok(pubs.length === 1, `${label}: exactly one pub venue (${pubs.length})`);
const pub = pubs[0] || {};
ok(pub.venue === true && !pub.openLate, `${label}: pub tagged venue, not the openLate landmark`);
ok((pub.hours || [0, 0])[1] >= 22, `${label}: venue open into night so the gig can run`);
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a nightly gig schedule`);
ok(plan.gigs.every(g => g.venueShopId === pub.id), `${label}: every gig plays the venue`);
structuralSuite(plan, label); // full coverage; venues exempt from late-shop rule
const venues = plan.shops.filter(s => s.venue);
const byId = new Map(venues.map(v => [v.id, v]));
// ── the district: 24 venues, kinds from the registry, never the openLate landmark ──
ok(venues.length >= 2 && venues.length <= 4, `${label}: 24 venues (${venues.length})`);
ok(venues.every(v => VENUE_KINDS.includes(v.venueKind)), `${label}: every venue has a known venueKind`);
ok(venues.every(v => v.type === v.venueKind), `${label}: venue type ≡ venueKind (C keys its recipe off type)`);
ok(venues.some(v => v.venueKind === 'pub'), `${label}: the town has a pub`);
ok(venues.every(v => !v.openLate), `${label}: no venue displaced the openLate landmark`);
ok(venues.every(v => (v.hours || [0, 0])[1] >= 22), `${label}: every venue is open into night so its gig can run`);
ok(new Set(venues.map(v => v.lot)).size === venues.length, `${label}: venues occupy distinct lots`);
ok(venues.every(v => v.genreKey === genreForVenueKind(v.venueKind)), `${label}: genreKey matches the registry kind→genre map`);
// debt #1: the manifest key IS the gigKey. One key, zero mapping.
ok(venues.every(v => gigKeyFor(v.genreKey) === `gig-${v.genreKey}`), `${label}: gigKey ≡ 'gig-'+genreKey`);
// ── the week ──
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a gig schedule`);
ok(plan.gigs.every(g => byId.has(g.venueShopId)), `${label}: every gig plays a real venue`);
ok(plan.gigs.every(g => Number.isInteger(g.cover) && (g.cover === 0 || (g.cover >= 2 && g.cover <= 10))), `${label}: cover ∈ {0}[2,10]`);
ok(plan.gigs.every(g => typeof g.bandName === 'string' && g.bandName && !/[{}]|undefined/.test(g.bandName)), `${label}: band names resolved`);
ok(plan.gigs.every(g => Number.isInteger(g.startSeg) && Number.isInteger(g.endSeg) && g.startSeg >= 0 && g.endSeg <= 5 && g.startSeg <= g.endSeg), `${label}: gig segment window sane`);
ok(plan.gigs.every(g => g.genreKey === 'pubrock' && Number.isInteger(g.night)), `${label}: alpha genre + night index`);
ok(plan.gigs.every(g => Number.isInteger(g.night) && g.night >= 0 && g.night < 7), `${label}: night index in [0,7)`);
ok(plan.gigs.every(g => g.genreKey === byId.get(g.venueShopId).genreKey), `${label}: gig genre ≡ its venue's genre`);
ok(new Set(plan.gigs.map(g => g.gigId)).size === plan.gigs.length, `${label}: gigIds unique across town`);
// ~46 gigs per venue per week, and never two on the same night at one venue
for (const v of venues) {
const mine = plan.gigs.filter(g => g.venueShopId === v.id);
ok(mine.length >= 4 && mine.length <= 6, `${label}: venue ${v.id} (${v.venueKind}) plays 46 nights (${mine.length})`);
ok(new Set(mine.map(g => g.night)).size === mine.length, `${label}: venue ${v.id} plays ≤1 gig per night`);
}
// no band plays two venues at once (≤1 slot per name per night across town)
let clash = null;
for (let n = 0; n < 7 && !clash; n++) {
const names = plan.gigs.filter(g => g.night === n).map(g => g.bandName);
if (new Set(names).size !== names.length) clash = n;
}
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) ──
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`);
// every poster clears its nearest edge's carriageway — the R12 bug was posters mid-road on nodes.
let onRoad = null;
for (const p of plan.posters) {
let best = Infinity, bestEdge = null;
for (const e of plan.streets.edges) {
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(p.x, p.z, a.x, a.z, b.x, b.z);
const d = Math.hypot(p.x - nx, p.z - nz);
if (d < best) { best = d; bestEdge = e; }
}
if (bestEdge && best < roadWidth(bestEdge) / 2) { onRoad = [p.id, bestEdge.id, +best.toFixed(2)]; 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)` : ''));
}
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)');
@ -243,7 +287,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 = 0xa6ae5a5e;
const GIG_GOLDEN = 0x1f636349; // ROUND13 re-pin: multi-venue district changed the gig-plan output (was 0xa6ae5a5e, the one-pub 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')}`);

View File

@ -8,7 +8,15 @@
export const DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market'];
export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade'];
export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill'];
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall', 'pub'];
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall', 'pub', 'band_room', 'rsl'];
// ── v3 venue kinds (?gigs=1) ───────────────────────────────────────────────────────
// The three gig venues. All three are registry shop types with NO district weights, so the base
// generator never places them; the gig layer converts a chosen shop into one, in place.
// `genre` is the single source of truth for the kind→genre map (ROUND13 §A1). The audio bed key is
// ALWAYS `gig-<genre>` — see gigKeyFor() in citygen/gigs.js; the manifest key IS the gigKey, there is
// no mapping table anywhere (ROUND13 debt #1).
export const VENUE_KINDS = ['pub', 'band_room', 'rsl'];
// ── the registry ──────────────────────────────────────────────────────────────────
// Per type:
@ -125,12 +133,49 @@ export const SHOP_TYPES = {
label: 'Pub',
facades: ['facade-djsim-pub.jpg', 'facade-federation.jpg'], // corner hotel; both exist in web/assets/gen/
sign: { bg: '#3a2416', fg: '#f2d98a', accent: '#c0392b', style: 'gilt' },
interior: 'band_room', // Lane C pub archetype (stage + PA + bar)
// `interior` is the Lane C RECIPE key, which C resolves from shop.type (theme.js canonicalType) —
// so it must equal the type id. R12 shipped 'band_room' here, which was harmless only because C
// keys off shop.type and nothing reads this field; now that `band_room` is itself a venue type it
// would be an outright collision, so it's corrected to 'pub' (Lane A amendment, ROUND13).
interior: 'pub', // Lane C pub archetype (stage + PA + bar)
fittings: ['bar_counter', 'stage', 'pa_stack', 'pub_tables', 'stools'],
storeys: [1, 2],
hours: { open: 17, close: 23 }, // a NIGHT venue: open into NIGHT so gigs can run (gig layer sets these)
weights: {}, // NEVER auto-placed — the ?gigs=1 layer converts one shop into the venue
footprint: { fmin: 16, fmax: 24, dmin: 18, dmax: 26 },
venueKind: 'pub',
genre: 'pubrock', // ⇒ audio bed key 'gig-pubrock'
placement: 'spine_end', // a destination you follow the sound to (gigs.js pickVenues)
},
band_room: {
label: 'Band Room',
// A tin shed on the warehouse fringe: sticky floor, low ceiling, PA louder than the room.
facades: ['facade-warehouse-tin.jpg', 'facade-besser.jpg', 'facade-corrugated.jpg'],
sign: { bg: '#181818', fg: '#e8e6e1', accent: '#7ac74f', style: 'stencil' },
interior: 'band_room',
fittings: ['bar_counter', 'stage', 'pa_stack', 'stools'], // no tables — it's a standing room
storeys: [1, 1],
hours: { open: 18, close: 23 },
weights: {},
footprint: { fmin: 18, fmax: 28, dmin: 20, dmax: 30 },
venueKind: 'band_room',
genre: 'grunge', // ⇒ 'gig-grunge' (John may re-flavour — names only)
placement: 'warehouse_fringe',
},
rsl: {
label: 'RSL Club',
// Carpet, bistro, a stage at one end, and the biggest crowd cap in town.
facades: ['facade-brickveneer.jpg', 'facade-redbrick.jpg'],
sign: { bg: '#0f2d4a', fg: '#f6e7c1', accent: '#c9a227', style: 'club' },
interior: 'rsl',
fittings: ['bar_counter', 'stage', 'pa_stack', 'bistro_tables', 'stools'],
storeys: [1, 2],
hours: { open: 16, close: 23 }, // opens early — the bistro trades before the band
weights: {},
footprint: { fmin: 20, fmax: 30, dmin: 22, dmax: 32 },
venueKind: 'rsl',
genre: 'covers', // ⇒ 'gig-covers'
placement: 'off_spine',
},
};
@ -157,6 +202,43 @@ export function pickWeightedType(r, districtKind, exclude = null) {
return list[list.length - 1].type;
}
// The genre a venue kind plays (ROUND13 §A1 kind→genre map). Unknown kind ⇒ null.
export function genreForVenueKind(kind) {
const reg = SHOP_TYPES[kind];
return (reg && reg.genre) || null;
}
// ── street corridor law (Lane A publishes it as a FUNCTION, not plan data) ──────────
// `edge.width` is the full CORRIDOR — carriageway + verge/footpath, centreline ± width/2 — as
// CITY_SPEC states twice ("road corridor (road + verge, centreline ± width/2)") and as chunkIndex
// buckets. It is NOT the carriageway width. The plan never carried the split, so consumers had to
// guess it; these helpers are the split, published so nobody guesses again (ROUND13 debt #5).
// Pure functions over an existing edge ⇒ no plan fields change ⇒ base goldens stay frozen.
export function roadWidth(edge) {
const w = (edge && edge.width) || 0;
switch (edge && edge.kind) {
case 'main': return 10; // 28m corridor = 10m carriageway + two ~9m verandah'd footpaths
case 'side': return w >= 18 ? 8 : 6; // the two central rungs are second high streets (wider)
case 'lane': return w; // a service laneway is all carriageway — no footpath
case 'arcade': return 0; // covered pedestrian lane — no carriageway at all
default: return Math.min(10, w);
}
}
// The band, as distance from the centreline, where footpath furniture lives: [kerb, corridor edge].
// Street furniture, poles, verandah posts and gig posters belong in here — never inside `kerb`.
export function vergeBand(edge) {
const inner = roadWidth(edge) / 2;
const outer = ((edge && edge.width) || 0) / 2;
return [inner, Math.max(inner, outer)];
}
// Where a pole-mounted thing (poster, sign) seats: just behind the kerb, clamped into the verge.
export function poleOffset(edge, clear = 1) {
const [inner, outer] = vergeBand(edge);
return outer <= inner ? inner : Math.min(inner + clear, outer - 0.25);
}
// Every facade filename referenced anywhere (Lane A selfcheck asserts each exists on disk).
export function allFacadeSkins() {
const set = new Set();

View File

@ -22,9 +22,13 @@
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
// // room-tone), seeded per shop. musicKey may be null. gigKey set
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
// watchPoints: [ {x,z,ry,dance} ], // VENUE (R12, pub only, else []) — 6..8 audience poses facing the
// // stage; dance ~1/3 true. Same frame + ry convention as counter.stand.
// stage: { x,z,w,d,deckY,frontZ,bandPoses }, // VENUE (R12, else null) — the band stage; bandPoses[3] on the deck.
// 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.
// 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)
@ -91,8 +95,12 @@ const MUSIC_BY_TYPE = { // manifest.audio.music dedi
milkbar: 'milkbar', video: 'video-synth',
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
// The genre a venue kind plays — mirrors registry.SHOP_TYPES[kind].genre so C resolves the gigKey even
// standalone (the gig plan carries shop.genreKey; this is the fallback when a test builds a bare venue
// without A's conversion). ROUND13 §Lane C: pub → pubrock, band_room → grunge, rsl → covers.
const GENRE_BY_TYPE = { pub: 'pubrock', band_room: 'grunge', rsl: 'covers' };
function audioFor(type, ctx, opts) {
function audioFor(type, ctx, opts, shop) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
@ -100,10 +108,15 @@ function audioFor(type, ctx, opts) {
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
const audio = { musicKey, toneKey };
// Venue gig (R12): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
// while the gig is on. A quiet-night pub has no opts.gig → normal interior (room-tone + maybe radio).
// Venue gig (R12/13): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
// while the gig is on. A quiet-night venue has no opts.gig → normal interior (room-tone + maybe radio).
// The genre resolves gig.genreKey → the venue's own shop.genreKey (A sets it) → the kind default. The
// manifest key IS the gigKey, canonical form `gig-<genreKey>` — no mapping table (ROUND13 debt #1).
const gig = opts && opts.gig;
if (gig && (gig.on || gig.genreKey)) audio.gigKey = 'gig-' + (gig.genreKey || 'pubrock');
if (gig && (gig.on || gig.genreKey)) {
const genreKey = gig.genreKey || (shop && shop.genreKey) || GENRE_BY_TYPE[type] || 'pubrock';
audio.gigKey = 'gig-' + genreKey;
}
return audio;
}
@ -161,7 +174,7 @@ export function buildInterior(shop, THREE, opts) {
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
watchPoints: lay.watchPoints || [], // venue (R12): [ {x,z,ry,dance,slotIndex} ] 6..8 audience poses (else [])
stage: lay.stage || null, // venue (R12): { x,z,w,d,deckY,frontZ,bandPoses[3] } (else null)
audio: audioFor(recipe.key, ctx, opts), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
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 },
placement: lay.placement,

View File

@ -213,10 +213,11 @@ function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
// 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).
function placeStage(ctx, grid, W, D, roomGroup, r) {
const sw = Math.min(W - 1.4, Math.max(3.0, W * 0.58)), sd = 2.0;
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
const cx = 0, cz = -D / 2 + sd / 2 + 0.2; // back wall, centred
const f = buildFitting('stage', ctx, { w: sw, d: sd }, r);
const f = buildFitting('stage', ctx, { w: sw, d: sd, h: deckH }, r);
f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect);
@ -233,23 +234,32 @@ function placeStage(ctx, grid, W, D, roomGroup, r) {
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
}
const deckY = f.deckY || 0.32; // 12 amps on the deck behind the band
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 bandPoses = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({ // 3 across the deck front, facing +Z (audience)
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i],
// 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
// pose carries its own stand height `y` — the front line on the deck (deckY), the drummer on the drum
// riser (deckY+0.16, the fittings.js stage riser). D lifts each member to pose.y (was: stage.deckY).
const riserY = round(deckY + 0.16); // drum riser top surface (fittings.js: 0.16 m box)
const front = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i], y: round(deckY),
}));
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, frontZ: round(cz + sd / 2), bandPoses } };
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 } };
}
// ── WATCH POINTS (venue, round 12) — 68 seeded audience floor poses facing the stage ──
// ── WATCH POINTS (venue) — seeded audience floor poses facing the stage ──
// Same pure-data pattern as browse points: walkable free cells, reachable from spawn, clear of the keeper
// stand + browse points + stage, spaced for a crowd. `dance` seeded ~1/3 true (John: "a bit of both").
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r) {
// `watchMax` = the crowd cap per kind (round 13): pub/band_room 8, RSL 12 (the crowd-cap stress case).
// The room fills what geometry allows up to the cap; the count IS the crowd cap (F asserts crowd ≤ it).
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r, watchMax = 8) {
let start = spawnCell;
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
const reach = floodFill(grid, start);
@ -270,7 +280,7 @@ function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r)
return true;
};
for (const c of cands) {
if (pts.length >= 8) break;
if (pts.length >= watchMax) break;
if (!valid(c.x, c.z)) continue;
const dx = stage.x - c.x, dz = stage.z - c.z; // face the stage centre (counter.stand ry convention)
pts.push({ x: round(c.x), z: round(c.z), ry: round(Math.atan2(-dx, -dz)), dance: r() < 0.34, slotIndex: pts.length });
@ -341,7 +351,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
let stageInfo = null, watchPoints = [];
if (recipe.venue) {
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'));
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'), recipe.venueSpec);
stageInfo = st.stage;
for (const sp of st.placed) placed.push(sp);
}
@ -410,7 +420,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// 6b) WATCH POINTS (venue, round 12) — audience floor poses facing the stage, computed after the path
// guarantee + browse points (so they avoid both), on cells reachable from the door.
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'));
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'), (recipe.venueSpec && recipe.venueSpec.watchMax) || 8);
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({

View File

@ -213,6 +213,10 @@ const RECIPES = {
clutter: 0.45, // keep the floor open — the crowd fills it
counterPos: 'corner', // the bar, along the east wall (keeper = barkeep)
venue: true, // → stage + PA + watchPoints (layout.js)
// venueSpec (round 13): per-kind stage/crowd tuning read by layout.js placeStage/placeWatchPoints.
// watchMax = crowd cap (== max watchPoints); deckH = deck height; deckDepth/deckWidthFrac size the
// deck. pub values == the R12 constants, so the pub stage is byte-identical (bar the new drums pose).
venueSpec: { watchMax: 8, deckH: 0.32, deckDepth: 2.0, deckWidthFrac: 0.58 },
fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls
{ kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles)
@ -221,6 +225,48 @@ const RECIPES = {
stockKind: 'snacks',
signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'],
},
// ── BAND ROOM (venue, v3.0-beta · round 13, behind ?gigs=1) ──────────────────────────
// A tin shed on the warehouse fringe: small, scuzzy, low ceiling, the PA louder than the room and the
// stage barely a riser. No tables — you stand and you sweat. `genre: 'grunge'` ⇒ gigKey 'gig-grunge'.
// Reuses the pub venue machinery; only the dressing + venueSpec differ.
band_room: {
label: 'Band Room',
archetypeBias: [['cosy', 3], ['pokey', 2], ['wide', 1]], // small rooms; low ceilings come from storeys=1
wallpaperBias: ['woodchip-white', 'stripe-sage', 'damask-mauve'], // dim + dingy
floorBias: ['lino-cork', 'carpet-swirl', 'boards-polished'], // bare/sticky
clutter: 0.35, // near-empty standing room — the crowd IS the furniture
counterPos: 'corner', // a plank bar in the corner (keeper = barkeep)
venue: true,
venueSpec: { watchMax: 8, deckH: 0.2, deckDepth: 1.6, deckWidthFrac: 0.62 }, // barely a riser, wide + shallow
fittings: [
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf (tinnies)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['ALL AGES SUN', 'PAY THE BAND', 'NO STAGE DIVING', 'MERCH OUT BACK', '$5 ON THE DOOR'],
},
// ── RSL CLUB (venue, v3.0-beta · round 13, behind ?gigs=1) ───────────────────────────
// Carpet, flock wallpaper, a bistro round the edge, a proper stage at one end, and the biggest crowd
// cap in town — the round-13 crowd-cap stress case. `genre: 'covers'` ⇒ gigKey 'gig-covers'.
rsl: {
label: 'RSL Club',
archetypeBias: [['hall', 3], ['wide', 2]], // big floor for the biggest crowd
wallpaperBias: ['damask-mauve', 'floral-gold', 'trellis-blue'], // clubby flock
floorBias: ['carpet-mustard', 'carpet-swirl', 'carpet-greygreen'], // patterned club carpet
clutter: 0.5, // bistro tables round the edges, floor kept open for the crowd
counterPos: 'corner', // the members' bar
venue: true,
venueSpec: { watchMax: 12, deckH: 0.4, deckDepth: 2.4, deckWidthFrac: 0.5 }, // a raised stage at one end
fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 2, max: 4, priority: 2 }, // bistro tables round the edge
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['MEMBERS & GUESTS', 'BISTRO OPEN', 'BINGO THURS', 'MEAT RAFFLE 7PM', 'LEST WE FORGET'],
},
};
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.

View File

@ -14,6 +14,8 @@
// Self-contained: reads live state off window.PROCITY and self-ticks via rAF, so the shell only has
// to createAudioEngine(PROCITY) once. Exposes window.PROCITY.audio for Lane F's smokes.
import { gigKeyFor } from '../citygen/gigs.js'; // debt #1: the ONE genre→bed mapping (gig-<genreKey>)
const BASE = 'assets/';
// shop type → street-spill music key (manifest.music). Only these types leak a bed to the footpath;
@ -192,20 +194,33 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
const v = { lx: null, lz: null, stepAcc: 0, spillT: 0, tramX: null, tramZ: null, tramRang: false };
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22, GIG_SPILL_R = 26;
// the pub venue (round 12), if the gig layer is present (?gigs); its lot carries the coords.
const venueShop = (PROCITY.plan?.shops || []).find((s) => s.venue) || null;
const venueLot = venueShop && (PROCITY.plan?.lots || []).find((l) => l.id === venueShop.lot);
// gig state for the muffled spill: prefer Lane F's window.PROCITY.gigs state machine, else derive
// from plan.gigs + the clock. Returns null with no gig layer (flags-off ⇒ no gig spill, byte-identical).
function gigStateNow() {
// the district's venues (round 13), if the gig layer is present (?gigs); each lot carries the coords
// and each shop its genreKey ⇒ gigKeyFor(genreKey) is the bed. Empty flags-off ⇒ no gig spill.
const venues = (PROCITY.plan?.shops || [])
.filter((s) => s.venue)
.map((s) => ({ shop: s, lot: (PROCITY.plan?.lots || []).find((l) => l.id === s.lot), genreKey: s.genreKey }))
.filter((v) => v.lot);
if (!venues.length) { // R12 fallback: the pub is the sole venue if A didn't flag venues
const pub = (PROCITY.plan?.shops || []).find((s) => s.type === 'pub');
const lot = pub && (PROCITY.plan?.lots || []).find((l) => l.id === pub.lot);
if (lot) venues.push({ shop: pub, lot, genreKey: pub.genreKey || 'pubrock' });
}
// Per-venue gig state for the muffled spill, robust across Lane F's alpha→district transition:
// 1. F R13 per-venue map window.PROCITY.gigs.byVenue[id].state
// 2. alpha alias window.PROCITY.gigs is the single machine → its state for its venueShopId
// 3. clock fallback plan.gigs (night 0) vs the current segment (works before/without F)
// Returns null only when there is no gig layer at all (flags-off ⇒ byte-identical, no spill).
function venueGigState(shopId) {
const g = PROCITY.gigs;
if (g && typeof g.state === 'string') return g.state;
if (g && typeof g.on === 'boolean') return g.on ? 'on' : 'quiet';
if (g && g.byVenue) { const e = g.byVenue[shopId]; if (e) return typeof e === 'string' ? e : (e.state || 'quiet'); }
if (g && g.venueShopId === shopId && typeof g.state === 'string') return g.state;
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
const ton = gigs.find((x) => x.night === 0 && x.venueShopId === shopId);
if (!ton) return 'quiet'; // dark tonight — reads true
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
const s = gigs[0].startSeg ?? 5, e = gigs[0].endSeg ?? 5;
const s = ton.startSeg ?? 5, e = ton.endSeg ?? 5;
if (clk.seg >= s && clk.seg <= e) return 'on';
if (clk.seg === s - 1) return 'doors';
if (clk.seg === (s - 1 + 6) % 6) return 'doors';
return 'quiet';
}
@ -265,15 +280,23 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
ramp(L.spill.g.gain, 0, 0.4);
}
// ── muffled gig spill through the venue wall (round 12) — low-pass bed, reaches further than a
// door spill; only while the gig layer is on (?gigs) and a gig is at doors/on. Byte-identical off. ──
if (street && venueLot && P.plan?.gigs?.length) {
const gs = gigStateNow(), on = gs === 'on' || gs === 'doors';
const dist = Math.hypot(venueLot.x - _cam.x, venueLot.z - _cam.z);
const gg = (on && dist < GIG_SPILL_R)
? (AUDIO?.music?.['pubrock-live']?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R) : 0;
state.gig = gs;
swapBed(L.gigSpill, gg > 0 ? 'pubrock-live' : null, gg > 0 ? AUDIO?.music?.['pubrock-live'] : null, gg, 0.9);
// ── muffled gig spill through the nearest active venue's wall (round 13, the district) — a low-pass
// bed PER GENRE (gig-<genreKey>), reaching further than a door spill; the loudest (nearest, at
// doors/on) venue wins, so walking pub→band_room→RSL crossfades the genre. Byte-identical off. ──
if (street && venues.length && P.plan?.gigs?.length) {
let bestGain = 0, bestKey = null, bestState = null;
for (let i = 0; i < venues.length; i++) {
const vn = venues[i];
const gs = venueGigState(vn.shop.id);
if (gs !== 'on' && gs !== 'doors') continue;
const dist = Math.hypot(vn.lot.x - _cam.x, vn.lot.z - _cam.z);
if (dist >= GIG_SPILL_R) continue;
const key = gigKeyFor(vn.genreKey || 'pubrock');
const gg = (AUDIO?.music?.[key]?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R);
if (gg > bestGain) { bestGain = gg; bestKey = key; bestState = gs; }
}
state.gig = bestState;
swapBed(L.gigSpill, bestGain > 0 ? bestKey : null, bestGain > 0 ? AUDIO?.music?.[bestKey] : null, bestGain, 0.9);
} else if (L.gigSpill.key) { ramp(L.gigSpill.g.gain, 0, 0.6); }
// ── tram rumble (distance-gain) + bell (rings once as it settles at a stop nearby) ──

View File

@ -129,6 +129,14 @@ export function installDBG(deps) {
};
// nearest openLate (video) shop, for the night/rain money shots
const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22);
// the gig-layer venues (?gigs): stand out in front so the lit frontage + queue + a poster all frame.
const venueShops = () => (plan.shops || []).filter((s) => s.venue);
const poseForVenue = (kind, dist = 13) => {
const vs = venueShops();
const shop = (kind ? vs.find((s) => (s.venueKind || s.type) === kind) : null) || vs[0]
|| (plan.shops || []).find((s) => s.type === 'pub');
return shop ? poseForShop(shop, dist) : null;
};
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
@ -153,6 +161,9 @@ export function installDBG(deps) {
rain_street: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?weather=rain — wet strip, rain down the street
night_crowd: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 16) : streetViewPose(['main']); } }, // night video-shop draw (wider, shows the crowd)
shopfront_detail: { seg: 3, resolve: () => pickShop((s) => s.type === 'opshop' || s.type === 'record' || s.type === 'milkbar') }, // verandah + sign + window up close
// ── v3 gig district (round 13, ?gigs=1): the street money shots F scripts via tools/qa/gig_shot.py ──
venue_night: { seg: 5, resolve: () => poseForVenue('pub', 13) || streetViewPose(['main']) }, // ?gigs=1 — lit pub frontage + streetlamp pools + queue + posters at night
district_posters: { seg: 5, resolve: () => streetViewPose(['main']) }, // ?gigs=1 — the spine at night: poster poles + lit venue frontages receding
};
const DBG = {

View File

@ -1,45 +1,53 @@
// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 12 / v3.0-alpha]
// The Friday-night state machine: quiet → doors → on → done, driven off Lane B's day clock and Lane A's
// plan.gigs. F owns it because every other lane consumes it and none of them own the clock:
// • Lane B audio.js reads window.PROCITY.gigs.state → the muffled through-the-wall spill
// • Lane B venue.js update(state) → marquee/sign glow on the frontage
// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge
// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside)
// • Lane F the cover charge at the door + the smokes
// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 13 / v3.0-beta THE DISTRICT]
// The Friday-night state machine, now PER-VENUE. A town has 24 venues (pub / band_room / rsl); each runs
// its own latch quiet → doors → on → done off Lane B's day clock and Lane A's plan.gigs. F owns it because
// every other lane consumes it and none of them own the clock:
// • Lane B audio.js reads window.PROCITY.gigs.byVenue[id] → the muffled through-the-wall spill per venue
// • Lane B venue.js update(byVenue) → marquee/sign glow on each frontage
// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge, per venue
// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside)
// • Lane F the cover charge at each door + the smokes
//
// Alpha model (CITY_SPEC §"CityPlan v3 — gig layer"): tonight is plan.gigs[0]; startSeg=5 (NIGHT 22:00),
// endSeg=5, doors at startSeg1 (DUSK). One venue, one genre, seven nightly entries in the plan.
// Model (CITY_SPEC §"CityPlan v3 — gig layer"): plan.gigs is the whole week × every venue. TONIGHT for a
// venue is its night-0 entry (the same night-0 convention A's posters and B's frontage key off); a venue
// dark tonight has no latch gig → reads 'quiet' all session, which is true to the seeded schedule. Each
// venue's night rolls the same way (clock turns → next cover due), so a night at two venues is two covers.
//
// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and the pub closes at
// 23, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the wrap, DAWN.
// So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after it (DAWN =
// closing time — the band packs up and the crowd drains per Lane A's closing-time ruling), and clear the
// latch on any other segment → back to 'quiet'. The latch clears on any non-DAWN segment rather than only
// at MORNING because the player drives this clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must
// read doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly
// that). A night only *counts* as rolled when we leave 'done' FORWARDS — that's what separates a real
// DAWN→MORNING roll (new night, cover due again) from a rewind back out of the gig (same night, paid).
// ── THREE HARD-WON LAWS, unchanged from the alpha (they each cost a live-verify to learn): ──────────────
// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and a venue closes at
// closing time, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the
// wrap, DAWN. So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after
// it (DAWN = closing time — the band packs up, the crowd drains per Lane A's closing ruling), and clear on
// any other segment → back to 'quiet'. The latch clears on any non-DAWN segment (not only at MORNING)
// because the player drives the clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must read
// doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly that).
//
// WHY IT LISTENS RATHER THAN POLLS. The latch has to observe every segment, and a poll only sees the
// segments it happens to be sampled on: driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in
// between (player inside the pub, audio muted, so neither the street loop nor the audio rAF is reading)
// skipped the roll entirely — the night never advanced and the cover was never due again. Caught live in
// the R12 verify. lighting.js dispatches `procity:segment` on EVERY segment change (hand-driven or the
// automatic day cycle), so that is the signal the latch rides. Reads stay safe on their own: `state` is a
// getter that re-observes the current segment, and observing the same segment twice is idempotent.
// WHY THE NIGHT ROLLS ON WHERE THE CLOCK LANDS, not on watching 'done' go by. First cut incremented the
// night when LEAVING 'done' forwards — so a clock JUMP straight from NIGHT to MORNING (never touching DAWN)
// never rolled the night, and a broke punter walked in on last night's paid stamp. Now: the latch clears
// wherever it clears, and landing in the DAYTIME (MORNING…ARVO) is what rolls the night; landing on the
// doors means we rewound out of the gig — same night, stays paid. DBG/shots/soak all JUMP, so only this
// rule survives them.
//
// WHY IT LISTENS RATHER THAN POLLS. Each latch must observe EVERY segment; a poll only sees the segments it
// is sampled on. Driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside a
// venue, audio muted, so neither the street loop nor the audio rAF is reading) skipped the roll entirely —
// the night never advanced and the cover was never due again. lighting.js dispatches `procity:segment` on
// EVERY segment change (hand-driven or the auto day cycle), so that is the signal every latch rides. Reads
// stay safe on their own: state is a getter that re-observes the current segment, and observing the same
// segment twice is idempotent.
const SEGS = 6;
export function createGigState({ plan, lighting }) {
const gigs = (plan && plan.gigs) || [];
const gig = gigs[0] || null; // alpha: tonight is gigs[0] (CITY_SPEC v3)
// One venue's latch — the alpha machine, its hard-won internals verbatim. `gig` is this venue's tonight
// (night-0) entry, or null when the venue is dark tonight (→ always 'quiet').
function createVenueLatch(gig, lighting) {
const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5;
const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5;
const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open
const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in
// The daytime segments: everything from the morning after the gig up to (not including) the doors —
// MORNING/MIDDAY/ARVO for the alpha schedule. Landing here after a gig means THE DAY HAS TURNED, which
// is what rolls the night (and makes the next cover due). See the roll note below.
// The daytime segments: everything from the morning after the gig up to (not including) the doors.
// Landing here after a gig means THE DAY HAS TURNED, which rolls the night (next cover due).
const DAYTIME = (() => {
const set = new Set();
for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s);
@ -52,16 +60,14 @@ export function createGigState({ plan, lighting }) {
let paidNight = -1; // the night whose cover is paid; re-entry the same night is free
// The whole machine, for ONE segment. Idempotent per segment, so it is safe to call from both the
// event and any read.
// procity:segment event and any read.
function observe(seg) {
const on = seg >= startSeg && seg <= endSeg;
if (on) played = true;
else if (played && seg !== doneSeg) {
// The latch is clearing: the gig is behind us. Whether that means "a new night" (cover due again)
// or "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by having
// watched 'done' go by: keying the roll on leaving 'done' meant a clock jump straight from NIGHT to
// MORNING never rolled the night at all, and a broke punter walked in on last night's stamp. The
// smoke caught it; stepping ([/]) always passes through DAWN, so only jumps exposed it.
// The latch is clearing: the gig is behind us. Whether that's "a new night" (cover due again) or
// "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by watching
// 'done' go by (see the roll law in the header).
played = false;
if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due
} // (landed on the doors ⇒ rewound out of the gig ⇒ same night)
@ -69,30 +75,81 @@ export function createGigState({ plan, lighting }) {
return state;
}
// Every segment change, from [ / ] or the automatic cycle — this is what guarantees no segment is missed.
const onSegment = (e) => { if (gig && e && e.detail) observe(e.detail.seg); };
if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment);
function tick() {
if (!gig || !lighting || !lighting.getClock) return (state = 'quiet');
return observe(lighting.getClock().seg);
}
tick(); // settle on the boot segment
return {
gig, observe, tick,
get state() { return tick(); },
update: tick, // shell calls this once per street frame (same thing)
get on() { return tick() === 'on'; },
get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters
get gig() { return gig; },
get venueShopId() { return gig ? gig.venueShopId : null; },
get bandName() { return gig ? gig.bandName : null; },
get cover() { return gig ? gig.cover | 0 : 0; },
get night() { return night; },
// cover stamp — runtime only (like the wallet itself): paid once, free to pop out and back in until the
// night rolls. NOT persisted, NOT written into the plan (goldens never move).
get bandName() { return gig ? gig.bandName : null; },
get night() { tick(); return night; },
// cover stamp — runtime only (like the wallet): paid once, free to pop out and back in until the night
// rolls. NOT persisted, NOT written into the plan (goldens never move).
get paid() { tick(); return paidNight === night; },
markPaid() { tick(); paidNight = night; },
};
}
export function createGigState({ plan, lighting }) {
const gigs = (plan && plan.gigs) || [];
// The district's venues, in first-seen (gigId) order. Primary = gigs[0].venueShopId — the alpha's
// "tonight", kept as the compat alias so B's mid-round single-venue callers still light the right pub.
const venueIds = [...new Set(gigs.map((g) => g.venueShopId))];
const primaryId = gigs.length ? gigs[0].venueShopId : null;
// one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet')
const latch = new Map();
for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
latch.set(id, createVenueLatch(tonight, lighting));
}
// ONE listener drives every latch — the listens-not-polls law, now fanned out across the district.
const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); };
if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment);
function tickAll() { for (const l of latch.values()) l.tick(); }
tickAll(); // settle every latch on the boot segment
const L = (id) => latch.get(id) || null;
const P = () => L(primaryId);
return {
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
get venueShopIds() { return venueIds.slice(); },
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },
onOf(id) { const l = L(id); return l ? l.on : false; },
openOf(id) { const l = L(id); return l ? l.open : false; },
gigOf(id) { const l = L(id); return l ? l.gig : null; },
coverOf(id) { const l = L(id); return l ? l.cover : 0; },
bandNameOf(id) { const l = L(id); return l ? l.bandName : null; },
nightOf(id) { const l = L(id); return l ? l.night : 0; },
paidOf(id) { const l = L(id); return l ? l.paid : false; },
markPaidOf(id) { const l = L(id); if (l) l.markPaid(); },
// the map B's venue.update() and audio.js consume: a fresh { [venueShopId]: 'quiet'|'doors'|'on'|'done' }.
// Called per street frame over a handful of venues — cheap.
get byVenue() { const o = {}; for (const [id, l] of latch) o[id] = l.tick(); return o; },
// shell calls this once per street frame — ticks every latch (so B/D read fresh state) and returns the
// primary state for the alpha-compat caller.
update() { tickAll(); return P() ? P().state : 'quiet'; },
// ── alpha-shape alias → the PRIMARY venue (kept for one round; B reads it mid-round) ─────────────
get state() { const p = P(); return p ? p.state : 'quiet'; },
get on() { const p = P(); return p ? p.on : false; },
get open() { const p = P(); return p ? p.open : false; },
get gig() { const p = P(); return p ? p.gig : null; },
get venueShopId() { return primaryId; },
get bandName() { const p = P(); return p ? p.bandName : null; },
get cover() { const p = P(); return p ? p.cover : 0; },
get night() { const p = P(); return p ? p.night : 0; },
get paid() { const p = P(); return p ? p.paid : false; },
markPaid() { const p = P(); if (p) p.markPaid(); },
dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); },
};
}

View File

@ -27,16 +27,14 @@ const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
// [Lane F R12 — CROSS-LANE SEAM FIX] Lane C emits `room.audio.gigKey = 'gig-' + genreKey` and asked Lane
// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as
// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already
// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined
// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig).
// F owns the glue, so the key resolves here: exact map first, then a generic `gig-<genre>` → `<genre>-live`
// fallback so a second genre works without another edit. Reported to C/E for a real contract line in
// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12.
const GIG_BED = { 'gig-pubrock': 'pubrock-live' };
const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null);
// [Lane F R13 — debt #1 CLOSED] The gigKey contract is now canonical: a genre's live-bed manifest key IS
// `gig-<genreKey>` (Lane A exports gigKeyFor(); Lane C emits room.audio.gigKey; Lane E named the manifest
// beds gig-pubrock/gig-grunge/gig-covers to match). So the room's gigKey feeds the audio engine DIRECTLY,
// with NO mapping. The R12 `GIG_BED` bridge that rewrote gig-pubrock→pubrock-live is DELETED — and not a
// moment too soon: E renamed pubrock-live→gig-pubrock this round, so the old bridge had become a LIVE BUG
// (it pointed at a manifest key that no longer exists → the band would mime in a silent room). Contract line
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
@ -51,6 +49,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// most rooms never see one), disposed with the room like the keepers.
let crew = null;
let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this)
let applauded = false; // [R13] one-shot latch: the on→done applause sting has fired for this visit
let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
@ -191,23 +190,25 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null →
// Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null;
// [Lane F R12 gig layer] Is this the venue, mid-gig? Lane C only stamps room.audio.gigKey when we pass
// opts.gig, and it treats `{on:false}` as "a gig" if genreKey is set — so pass NULL unless it's really
// on. A quiet-night pub then builds as a normal interior (stage + watch points still there, just nobody
// on them), which is exactly the "quiet night at the pub" C specced.
const gigOn = !!(gigState && gigState.venueShopId != null && shop.id === gigState.venueShopId && gigState.on);
const tonight = gigOn ? gigState.gig : null;
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
// 24 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
const gigOn = !!(gigState && gigState.onOf(shop.id));
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop;
scene.add(current.group);
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
// [R12] Prefer the gig bed over the room's seeded radio while the band is on (LANE_C_PUB.md), through
// F's key resolver above — playInterior only knows musicKey/toneKey, so the gig bed rides in as musicKey.
// [R13] While the band is on, the LIVE BED plays over the room's radio: room.audio.gigKey is ALREADY the
// manifest key (gig-<genreKey> — debt #1 closed, no bridge), and the crowd WALLA rides the interior tone
// layer under it (you hear the room, not the fridge hum). Quiet night → the room's own seeded bed.
const ra = current.audio || {};
const bed = gigBedKey(ra.gigKey);
window.PROCITY.audio && window.PROCITY.audio.playInterior(bed ? { ...ra, musicKey: bed } : ra);
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
window.PROCITY.audio && window.PROCITY.audio.playInterior(gigAudio);
applauded = false; // [R13] re-arm the on→done applause sting for this visit
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) {
@ -281,6 +282,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
if (crew) crew.update(dt); // [R12] Lane D: band strum-sway + crowd stand/dance (seeded phase per member)
// [Lane F R13] applause sting on on→done: crewInfo is non-null only when a gig crew spawned (gigOn at
// enter); when the clock then rolls the gig off (NIGHT→DAWN — the band's last song), the crowd claps
// ONCE. Fail-soft via the house audio law (no-op muted / ?noassets / pre-gesture). applauded guards re-fire.
if (crewInfo && !applauded && gigState && currentShop && !gigState.onOf(currentShop.id)) {
applauded = true;
window.PROCITY.audio && window.PROCITY.audio.playSfx && window.PROCITY.audio.playSfx('applause', { gain: 0.9 });
}
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so
// renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)

View File

@ -1,90 +1,229 @@
// PROCITY Lane B — venue.js (round 12, v3.0-alpha gig layer, ?gigs=1)
// The street-side presentation of the pub venue: (1) gig POSTERS around town — E's poster skins with
// A's plan.posters placement and the band name overprinted like a shop sign; (2) the lit pub FRONTAGE
// at night while a gig is on — a warm marquee over the door + lit sign glow that F toggles from the
// gig state machine. Consumes A's plan.posters / plan.gigs / the venue shop. B owns the street side.
// PROCITY Lane B — venue.js (round 13, v3.0-beta THE DISTRICT, ?gigs=1)
// The street-side presentation of the town's 24 venues (pub / band_room / rsl). Round 13 generalises
// the R12 one-pub slice to the whole district and adds the night-readability polish:
// (1) town-wide gig POSTERS — E's poster skins at A's plan.posters positions, band name overprinted,
// instanced per gig so the whole town is a handful of draws;
// (2) a lit FRONTAGE per venue — warm marquee + bulb row over the door, ramped per-venue by F's gig
// state; window-light spill so the shopfront lifts out of the dark;
// (3) STREETLAMP POOLS on each venue block — warm pools of light on the footpath so the Friday-night
// walk reads (the money-shot polish). One instanced mesh, night-driven;
// (4) a QUEUE ZONE marker per venue, publishing `venue.queueZone = {x,z,ry,len}` for Lane D's line.
//
// Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js),
// so flags-off boot is byte-identical — the shell never builds it. Asset law: a missing poster JPEG
// falls back to a flat printed-poster canvas (still a poster). Deterministic from citySeed.
// Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js), so
// flags-off boot is byte-identical — the shell never builds it, and nothing here is on the always-on
// night path. Asset law: a missing poster JPEG (or ?noassets) falls back to a flat printed poster and
// fetches nothing. Deterministic from citySeed. B owns the street side of the gig seam.
//
// Orientation: a venue's facade/door is on the lot's local +Z (buildings.js canon: front = +Z), so the
// frontage, pools and queue seat on (sin ry, cos ry) — the STREET side. A's plan.posters carry their own
// ry under A's poster convention (front = local Z); posters are rendered at A's (x,z,ry) verbatim.
import * as THREE from 'three';
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox'];
const NAME_ZONE = [0.08, 0.04, 0.84, 0.23]; // manifest skins.poster.*.nameZone (band name band)
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; // E's poster pack (assets/gen/poster-*.jpg)
const NAME_ZONE = [0.08, 0.04, 0.84, 0.23]; // manifest skins.poster.*.nameZone (band-name band)
export function createVenuePresentation(plan, skins, scene, { assetBase = 'assets/gen/' } = {}) {
const group = new THREE.Group(); group.name = 'venue-presentation';
const disposables = [];
const params = (() => { try { return new URLSearchParams(location.search); } catch { return new URLSearchParams(); } })();
const NOASSETS = params.get('noassets') != null && params.get('noassets') !== '0'; // house law: zero fetch
const gigs = plan.gigs || [];
const posters = plan.posters || [];
const venueShop = plan.shops?.find((s) => s.venue) || plan.shops?.find((s) => s.type === 'pub') || null;
const venueLot = venueShop && plan.lots.find((l) => l.id === venueShop.lot);
const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
const gigById = new Map(gigs.map((g) => [g.gigId, g]));
// tonight = night 0 (matches gig_state.js / A's poster selection). Primary venue = the alpha machine's
// gigs[0] venue, so a legacy single-string update() still lights the right frontage mid-round.
const primaryId = gigs.length ? gigs[0].venueShopId : null;
// ── posters ──
for (const p of posters) {
const gig = gigs.find((g) => g.gigId === p.gigId) || gigs[0];
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(p.id + (plan.citySeed >>> 0)) % POSTER_SKINS.length]; // seeded template
const tex = posterTexture(assetBase, skin, band); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const mesh = new THREE.Mesh(geo, mat);
// A's venue poster sits at the lot CENTRE (inside the shell); nudge it out to the frontage.
let px = p.x, pz = p.z;
if (venueLot && Math.hypot(p.x - venueLot.x, p.z - venueLot.z) < 1.2) {
const ry = venueLot.ry || 0, off = (venueLot.d || 8) / 2 + 0.08;
px = venueLot.x + Math.sin(ry) * off; pz = venueLot.z + Math.cos(ry) * off;
}
mesh.position.set(px, 2.0, pz);
mesh.rotation.y = p.ry || 0;
mesh.receiveShadow = true;
group.add(mesh);
const venueShops = (plan.shops || []).filter((s) => s.venue);
// R12 fallback (no A gig-layer venue flag): treat the pub as the sole venue.
if (!venueShops.length) {
const pub = (plan.shops || []).find((s) => s.type === 'pub');
if (pub) venueShops.push(pub);
}
// ── lit venue frontage (marquee over the door; emissive ramped by gig state) ──
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
let marqueeMat = null, signGlowMat = null;
if (venueLot) {
const ry = venueLot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
const zf = (venueLot.d || 10) / 2 + 0.06;
const mx = venueLot.x + fx * zf, mz = venueLot.z + fz * zf;
// ── town-wide posters, instanced per gig (one draw + one texture per advertised gig) ──────────────
// A given gig's poster looks the same wherever it's pasted, so we group A's poster positions by gigId,
// pick ONE seeded skin per gig, and render each group as a single InstancedMesh. Town-wide the whole
// poster layer is ≤ (tonight's playing venues) draws instead of one mesh + one canvas per bill.
let posterDraws = 0;
{
const byGig = new Map();
for (const p of posters) {
const gid = p.gigId != null ? p.gigId : -1;
if (!byGig.has(gid)) byGig.set(gid, []);
byGig.get(gid).push(p);
}
const _m = new THREE.Matrix4(), _pos = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1), _up = new THREE.Vector3(0, 1, 0);
for (const [gid, list] of byGig) {
const gig = gigById.get(gid) || gigs[0] || null;
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(((gid | 0) + (plan.citySeed >>> 0)) % POSTER_SKINS.length + POSTER_SKINS.length) % POSTER_SKINS.length];
const tex = posterTexture(assetBase, skin, band, NOASSETS); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const inst = new THREE.InstancedMesh(geo, mat, list.length);
inst.name = 'venue-posters';
for (let i = 0; i < list.length; i++) {
const p = list[i];
// A's poster ry is under the poster convention (front = local Z ⇒ world facing (sin,cos)); a
// PlaneGeometry faces +Z, so rotate ry+π to point its printed face along A's outward normal.
_q.setFromAxisAngle(_up, (p.ry || 0) + Math.PI);
_pos.set(p.x, 2.0, p.z);
inst.setMatrixAt(i, _m.compose(_pos, _q, _s));
}
inst.instanceMatrix.needsUpdate = true;
inst.receiveShadow = true;
group.add(inst);
posterDraws++;
}
}
// ── per-venue frontage + window-spill + queue zone ────────────────────────────────────────────────
const venues = [];
const poolSpecs = []; // {x,z,diam} collected across all venues → one instanced pool mesh
for (const shop of venueShops) {
const lot = lotById.get(shop.lot);
if (!lot) continue;
const ry = lot.ry || 0;
const fx = Math.sin(ry), fz = Math.cos(ry); // facade OUTWARD normal (local +Z → street side)
const rx = Math.cos(ry), rz = -Math.sin(ry); // frontage tangent ("right" along the shopfront)
const w = lot.w || 8, d = lot.d || 10;
const zf = d / 2 + 0.06; // just proud of the facade plane
const cx = lot.x + fx * zf, cz = lot.z + fz * zf; // shopfront centre, on the street
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
// warm marquee slab above the entrance
marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
const mw = Math.min((venueLot.w || 8) * 0.92, 6.5);
const marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
const mw = Math.min(w * 0.92, 6.5);
const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35);
const marquee = new THREE.Mesh(mgeo, marqueeMat);
marquee.position.set(mx, 3.5, mz); marquee.rotation.y = ry;
marquee.position.set(cx, 3.5, cz); marquee.rotation.y = ry;
disposables.push(marqueeMat, mgeo);
// a row of marquee "bulbs" (emissive dots) along the front edge of the slab
const bulbMat = new THREE.MeshStandardMaterial({ color: 0x3a2a10, emissive: new THREE.Color(0xffe6b0), emissiveIntensity: 0 });
signGlowMat = bulbMat;
const bgeo = new THREE.SphereGeometry(0.06, 6, 5);
const nb = Math.max(4, Math.round(mw / 0.6));
const bulbs = new THREE.InstancedMesh(bgeo, bulbMat, nb);
const _m = new THREE.Matrix4(), _p = new THREE.Vector3();
const _bm = new THREE.Matrix4();
for (let i = 0; i < nb; i++) {
const t = (i / (nb - 1) - 0.5) * mw;
_p.set(mx + Math.cos(ry) * t, 3.25, mz - Math.sin(ry) * t);
bulbs.setMatrixAt(i, _m.makeTranslation(_p.x, _p.y, _p.z));
_bm.makeTranslation(cx + rx * t, 3.25, cz + rz * t);
bulbs.setMatrixAt(i, _bm);
}
bulbs.instanceMatrix.needsUpdate = true;
frontage.add(marquee, bulbs);
disposables.push(marqueeMat, mgeo, bulbMat, bgeo);
disposables.push(bulbMat, bgeo);
// window-light spill — a soft warm wash on the ground-floor shopfront so it lifts out of the dark.
// Additive so it reads as light, not paint; opacity ramps with the gig (and a faint night baseline).
const glowMat = new THREE.MeshBasicMaterial({ color: 0xffca82, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false, toneMapped: false });
const glowGeo = new THREE.PlaneGeometry(w * 0.94, 2.4);
const glow = new THREE.Mesh(glowGeo, glowMat);
glow.position.set(cx + fx * 0.05, 1.35, cz + fz * 0.05); glow.rotation.y = ry; // faces the street (+Z)
disposables.push(glowMat, glowGeo);
frontage.add(marquee, bulbs, glow);
group.add(frontage);
// streetlamp pools on this venue's block — one bright pool at the door, a couple spaced along the
// frontage footpath (pushed out onto the verge so they pool on the footpath, not under the awning).
const footOut = 1.9;
poolSpecs.push({ x: cx + fx * 0.6, z: cz + fz * 0.6, diam: 5.0 }); // door pool (bright, wide)
const along = Math.min(w * 0.42, 3.2);
poolSpecs.push({ x: cx + rx * along + fx * footOut, z: cz + rz * along + fz * footOut, diam: 3.6 });
poolSpecs.push({ x: cx - rx * along + fx * footOut, z: cz - rz * along + fz * footOut, diam: 3.6 });
// queue zone (Lane D): the line forms on the footpath, off to one side of the door so it doesn't
// block the entrance, and runs ALONG the frontage away from the door. Published for D's poses.
const qHeadX = cx + rx * (mw * 0.5 + 0.6) + fx * 1.1; // just past the marquee edge, out on the footpath
const qHeadZ = cz + rz * (mw * 0.5 + 0.6) + fz * 1.1;
const qRy = Math.atan2(rx, rz); // heading the queue EXTENDS (along +right); face door = this
const queueZone = { x: r2(qHeadX), z: r2(qHeadZ), ry: r4(qRy), len: 5.0 };
// a subtle painted marker on the ground (chalk-pale dashes) so the zone reads even before D's line —
// primitive, cheap, night-agnostic. Collected into the pool pass? No — keep it a thin flat quad strip.
const markMat = new THREE.MeshBasicMaterial({ color: 0xbfb9a6, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide, toneMapped: false });
const markGeo = new THREE.PlaneGeometry(0.5, queueZone.len);
markGeo.rotateX(-Math.PI / 2);
const mark = new THREE.Mesh(markGeo, markMat);
mark.position.set(qHeadX + rx * (queueZone.len / 2), 0.021, qHeadZ + rz * (queueZone.len / 2));
mark.rotation.y = qRy;
disposables.push(markMat, markGeo);
group.add(mark);
venues.push({
venueShopId: shop.id, kind: shop.venueKind || shop.type, genreKey: shop.genreKey || null,
lot, frontage, marqueeMat, bulbMat, glowMat, queueZone,
_glowCur: 0, _emisCur: 0,
});
}
group.add(frontage);
// ── one instanced streetlamp-pool mesh for the whole district (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 });
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);
poolMesh.name = 'venue-pools';
const _pm = new THREE.Matrix4(), _pp = new THREE.Vector3(), _pq = new THREE.Quaternion(), _ps = new THREE.Vector3();
for (let i = 0; i < poolSpecs.length; i++) {
const s = poolSpecs[i];
_pp.set(s.x, 0.02, s.z); _ps.set(s.diam, 1, s.diam);
poolMesh.setMatrixAt(i, _pm.compose(_pp, _pq, _ps));
}
poolMesh.instanceMatrix.needsUpdate = true;
group.add(poolMesh);
}
scene.add(group);
// gig state → frontage glow. 'on' = full, 'doors' = warm-up, else dark. Accepts F's state string
// ('quiet'|'doors'|'on'|'done'), a boolean, or {state}. Idempotent; call from the loop or on change.
function update(gigState) {
const s = (gigState && gigState.state) || gigState;
let e = 0;
if (s === 'on' || s === true) e = 1.7;
else if (s === 'doors') e = 0.7;
if (marqueeMat) marqueeMat.emissiveIntensity = e;
if (signGlowMat) signGlowMat.emissiveIntensity = e * 1.15;
// ── per-venue state → frontage glow. Accepts F's per-venue map { [venueShopId]: state }, a Map, an
// { byVenue } wrapper, OR a bare state string (alpha compat → the primary pub). Idempotent; the shell
// calls it every street frame, so pool/glow ramps and the night baseline lerp here. ────────────────
function stateGetter(gigStates) {
if (gigStates == null) return () => 'quiet';
if (typeof gigStates === 'string') return (id) => (id === primaryId ? gigStates : 'quiet');
const map = gigStates.byVenue || gigStates;
return (id) => {
const v = map instanceof Map ? map.get(id) : map[id];
if (v == null) return 'quiet';
return typeof v === 'string' ? v : (v.state || 'quiet');
};
}
function levelFor(s) { return s === 'on' ? 1 : s === 'doors' ? 0.55 : 0; }
function update(gigStates) {
const get = stateGetter(gigStates);
const night = !!(typeof window !== 'undefined' && window.PROCITY?.lighting?.getClock?.().night);
for (const v of venues) {
const lvl = levelFor(get(v.venueShopId));
// marquee/bulbs emissive follow the gig (they only matter visually near night anyway)
const emisT = lvl * 1.7;
v._emisCur += (emisT - v._emisCur) * 0.25;
if (Math.abs(emisT - v._emisCur) < 0.004) v._emisCur = emisT;
v.marqueeMat.emissiveIntensity = v._emisCur;
v.bulbMat.emissiveIntensity = v._emisCur * 1.15;
// window spill: a faint night baseline (someone's in) + the gig glow, off entirely by day
const glowT = night ? 0.14 + lvl * 0.4 : 0;
v._glowCur += (glowT - v._glowCur) * 0.15;
if (Math.abs(glowT - v._glowCur) < 0.004) v._glowCur = glowT;
v.glowMat.opacity = v._glowCur;
}
// streetlamp pools: on at night (streetlamps don't care about gigs), off by day
if (poolMat) {
const poolT = night ? 0.5 : 0;
poolMat.opacity += (poolT - poolMat.opacity) * 0.1;
if (Math.abs(poolT - poolMat.opacity) < 0.004) poolMat.opacity = poolT;
if (poolMesh) poolMesh.visible = poolMat.opacity > 0.01;
}
}
update('quiet');
@ -94,17 +233,44 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
scene.remove(group);
}
const queueZones = {};
for (const v of venues) queueZones[v.venueShopId] = v.queueZone;
return {
group, frontage, update, dispose,
venueShopId: venueShop ? venueShop.id : null,
group, venues, update, dispose,
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat
venueShopIds: venues.map((v) => v.venueShopId),
queueZones,
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
// R12 compat: the primary venue's frontage group (some callers referenced `.frontage`)
get frontage() { return venues[0] ? venues[0].frontage : null; },
get posterCount() { return posters.length; },
get posterDraws() { return posterDraws; },
};
}
// ── per-poster canvas: E's poster art with the band name overprinted in its nameZone. ──
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over
// it when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top.
function posterTexture(assetBase, skin, band) {
const r2 = (v) => Math.round(v * 100) / 100;
const r4 = (v) => Math.round(v * 10000) / 10000;
// ── a warm radial streetlamp-pool sprite (procedural, no fetch): bright warm centre → transparent edge.
function poolTexture() {
const S = 128;
const c = document.createElement('canvas'); c.width = S; c.height = S;
const x = c.getContext('2d');
const g = x.createRadialGradient(S / 2, S / 2, 2, S / 2, S / 2, S / 2);
g.addColorStop(0.00, 'rgba(255,226,170,0.85)');
g.addColorStop(0.45, 'rgba(255,205,130,0.35)');
g.addColorStop(1.00, 'rgba(255,190,110,0.0)');
x.fillStyle = g; x.fillRect(0, 0, S, S);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
// ── per-gig canvas: E's poster art with the band name overprinted in its nameZone. ──
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over it
// when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top. Under
// ?noassets we skip the fetch entirely (zero network) and keep the flat poster.
function posterTexture(assetBase, skin, band, noassets) {
const W = 240, H = 342;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d');
@ -112,13 +278,15 @@ function posterTexture(assetBase, skin, band) {
x.fillStyle = '#cbb98f'; x.fillRect(8, 8, W - 16, H - 16); // aged paper
x.fillStyle = '#7a2a24'; x.fillRect(8, H * 0.62, W - 16, 3);
x.fillStyle = '#1c1712'; x.font = 'bold 13px Arial'; x.textAlign = 'center';
x.fillText('LIVE · THE ROYAL', W / 2, H * 0.72);
x.fillText('LIVE · TONIGHT', W / 2, H * 0.72);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
drawBandName(x, W, H, band);
const img = new Image();
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
if (!noassets) {
const img = new Image();
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
}
return tex;
}