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/_records.tsv
pipeline/_books.tsv pipeline/_books.tsv
pipeline/_toys.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 *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.* 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 ## 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 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) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 212 closed** (see below). *Status: **all deliverables landed; self-check all-green (3273/3273); rounds 213 closed** (see below).
`qa.sh --strict` GREEN. v3.0-alpha gig interface published.* 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)* *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 ## 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 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 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). 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 > **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 — > `?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 > **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 > 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). > 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 **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 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. `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'` **The venues (24 per town — a seeded count).** Three registry venue kinds — **`pub`**, **`band_room`**,
(keeps its lot id + geometry ⇒ enterable like any shop, no overlap), tagged `shop.venue === true`. It is **`rsl`** — each with **no district weights** (never auto-placed). The layer converts that many plain
**never** the openLate landmark (the video survives; the town still has exactly one openLate shop). The `shop`s **in place** (keeps each lot id + geometry ⇒ enterable, no overlap), tags them
registry gains a `pub` type (facades, `interior:'band_room'`, fittings, hours `[17,23]`) with **no `shop.venue === true`, `shop.venueKind`, `shop.genreKey`, and **never** takes the openLate landmark (the
district weights** so it is never auto-placed. Venue hours `[17,23]` keep it open through DUSK+NIGHT so town keeps exactly one openLate shop). Kinds are added in the order `pub, band_room, rsl, pub` (a 4-venue
the gig runs and D's patronage surge fills it — and it drains at close per the closing-time ruling above. town gets a second pub at the far end of the spine). Placement rules (each degrades to "any remaining
The pub is the *only* gig-plan shop allowed to close ≥22 besides the openLate video (selfcheck exempts it). 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 } ] 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]`). - **7 nights × every venue, but not every venue plays every night**: each venue takes a seeded **46**
- `venueShopId` = the pub shop id. `genreKey = 'pubrock'` (alpha's one genre). `bandName` — see drop-in. of the 7 and goes dark the rest (dark nights read true). `gigId` unique town-wide; `night` = 0-based,
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine: `quiet → doors (startSeg1 = DUSK) **F selects tonight's** (night `0`) per venue. `venueShopId` → the venue shop id; `genreKey` ≡ that
→ on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) venue's genre.
- `cover` — integer: **~half `0` (free), the rest `$2$10`**. F debits the wallet at the door; free = walk in. - `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 — **`plan.posters`** — town-wide gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]`.
one on the venue frontage, a seeded few along the spine, on poles/walls B already streams). B places E's Only **tonight's** (night 0) playing venues are advertised. Each such venue gets one poster on **its own
poster skin at `(x, z)` rotated `ry`; F wires "poster → tonight's-gig discoverability". 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. **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 `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 Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority**, deduped and shuffled
(shuffled by seed, so a town surfaces up to its 7 nightly slots of them); **absent → pure generator, zero by seed, spread **night-major across the whole week's slots town-wide** (not one venue's residency);
errors, zero fetch** (the `?noassets=1` path). Deterministic given `(seed, file contents)`. John edits the **absent → pure generator, zero errors, zero fetch** (the `?noassets=1` path). Deterministic given
file and reloads. `(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 **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. 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: **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) ## 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 | | `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 | | `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
| `stall` (market) | market, corrugated | open stall | trestle tables, crates | | `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) ## 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`. 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) ## 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 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) # 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`) ## 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): 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 *PROCITY-C, 2026-07-15. R13 §Lane C. The district ships **three** venue archetypes behind `?gigs=1`
when the flag is on). Interface published here + in this commit's code; non-pub rooms are byte-identical `pub` (R12), and new this round **`band_room`** and **`rsl`**. A converts a chosen shop to a venue kind in
(the venue code only runs for `recipe.venue`, on its own seed sub-streams). Verified: 40-seed pub sweep + place (`shop.type = kind`); C keys the interior recipe off `shop.type`. All three return the SAME shape
60-room mixed soak — 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst 19 ms.* (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'`) *Verified (fresh context, seeds 20261990/1990/7/42/101 + 90-build venue soak): 0 throws · 0 carves ·
Everything below is **pure data on the return** (room-local coords, `counter.stand` ry convention: 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst build 8.3 ms. Draws below.*
rig-front = local Z), so Lane D drops rigs straight in and Lane F reads state — no new lifecycle.
### `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 ```js
room.stage = { room.stage = {
x, z, // deck centre (against the back wall) x, z, // deck centre (against the back wall)
w, d, // deck size (m) w, d, // deck size (m) — varies by kind (table below)
deckY, // top surface height (m) — band rigs stand at y = deckY 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) 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 - `bandPoses[0..2]` = the **front line**: `role``guitar` / `vocal` / `bass`, across the deck lip, `y = deckY`.
can sit up-stage on the riser (there's a raised riser at deck-centre-back) — your call, C just gives 3 front poses. - `bandPoses[3]` = the **drummer**: `role: 'drums'`, `seated: true`, up-stage centre on the drum riser,
Instruments = E's GLBs when present, primitives otherwise (asset law). `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 ```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 - **Crowd cap by kind: `pub` 8 · `band_room` 8 · `rsl` 12** (the RSL is the crowd-cap stress case). The room
(subtle weight-shift); `dance:true` → bob/sway/step loop with a **seeded phase offset** so the crowd fills what geometry allows up to the cap; **the count IS the crowd cap** (F asserts crowd ≤ `watchPoints.length`).
doesn't metronome (use `slotIndex` or the pose coords to derive the phase). - `dance` seeded **~⅓ true** ("a bit of both"). D: `dance:false` → stand-and-watch idle; `dance:true` → bob/sway
- Every point is walkable, reachable from the door, and clear of the keeper stand, browse points, and the with a **seeded phase offset** (use `slotIndex`/coords). Every point walkable, reachable, clear of the keeper
stage/PA footprint. Deterministic per shop.seed. Crowd cap = `watchPoints.length` (F's smoke asserts ≤). stand + browse points + stage/PA. Deterministic per `shop.seed`.
### `room.counter` / keeper — unchanged ### `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.gigKey` — the live bed (ROUND13 debt #1)
`room.audio = { musicKey, toneKey, gigKey? }`. **Lane F** passes gig state via `opts.gig` to `buildInterior`: `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 ```js
buildInterior(pubShop, THREE, { gig: { on: true, genreKey: 'pubrock' } }) buildInterior(rslShop, THREE, { gig: { on: true } }) // → room.audio.gigKey === 'gig-covers'
// → room.audio.gigKey === 'gig-pubrock' (gigKey = 'gig-' + genreKey)
``` ```
- `gigKey` is present **only when a gig is on**. A quiet-night pub has no `opts.gig` → normal interior - Genre resolves `opts.gig.genreKey` → the venue's own `shop.genreKey` (A sets it) → a kind default
(`toneKey` room-tone + maybe the seeded radio `musicKey`). (`pub`→pubrock, `band_room`→grunge, `rsl`→covers). Present **only when a gig is on**.
- **Prefer `gigKey` over `musicKey`** while the gig plays: `play(room.audio.gigKey || room.audio.musicKey)`. - **Prefer `gigKey` over `musicKey`** while the gig plays. **Lane E** names the beds `gig-pubrock`,
- **Lane E:** please name the pub-rock live bed **`gig-pubrock`** in `manifest.audio` (music or a `gig` `gig-grunge`, `gig-covers`; a missing key → silence (audio law), band still visible.
section) so the key resolves; missing key → silence (audio law), band still visible.
## The archetype (C-owned, FYI) ### The venue table (C-owned tuning, FYI)
`pub` recipe (`theme.js`, `venue:true`): stage + PA + amps at the back wall, bar counter (`corner`), | kind | archetype bias | ceiling | deck `w × d`, `deckY` | crowd cap | genre → gigKey | dressing |
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`, | `pub` | wide / hall | 3.6 m | `0.58·W × 2.0`, 0.32 | 8 | pubrock → `gig-pubrock` | pub tables, sticky carpet, bar |
`pa_speaker`, `amp` (house GLB law; primitive fallback holds — a gig with no GLBs is still a gig). | `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) ## 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; **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.** data task, not new rig work. **Verify each exists before requesting; never re-download.**
Wanted for richer loiter/keeper behaviour, in priority order: 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). 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. 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). 4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).

View File

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

View File

@ -1,6 +1,7 @@
[ [
"procity_fit_arcade_cabinet_01.glb", "procity_fit_arcade_cabinet_01.glb",
"procity_fit_bass_guitar_01.glb", "procity_fit_bass_guitar_01.glb",
"procity_fit_bass_guitar_01_hi.glb",
"procity_fit_bookshelf_01.glb", "procity_fit_bookshelf_01.glb",
"procity_fit_box_crate_01.glb", "procity_fit_box_crate_01.glb",
"procity_fit_clothes_rack_01.glb", "procity_fit_clothes_rack_01.glb",
@ -11,12 +12,16 @@
"procity_fit_cube_shelf_wide_01.glb", "procity_fit_cube_shelf_wide_01.glb",
"procity_fit_drinks_fridge_01.glb", "procity_fit_drinks_fridge_01.glb",
"procity_fit_drum_kit_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.glb",
"procity_fit_electric_guitar_01_hi.glb",
"procity_fit_glass_case_01.glb", "procity_fit_glass_case_01.glb",
"procity_fit_guitar_amp_01.glb", "procity_fit_guitar_amp_01.glb",
"procity_fit_guitar_amp_01_hi.glb",
"procity_fit_listening_booth_01.glb", "procity_fit_listening_booth_01.glb",
"procity_fit_magazine_rack_01.glb", "procity_fit_magazine_rack_01.glb",
"procity_fit_mic_stand_01.glb", "procity_fit_mic_stand_01.glb",
"procity_fit_mic_stand_01_hi.glb",
"procity_fit_milkshake_mixer_01.glb", "procity_fit_milkshake_mixer_01.glb",
"procity_fit_record_crate_01.glb", "procity_fit_record_crate_01.glb",
"procity_fit_wire_shelf_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"), "milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
"video-synth": (0.50, ["video"], "video store synth hum"), "video-synth": (0.50, ["video"], "video store synth hum"),
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"), "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_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 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 footsteps[surf] = vs
if footsteps: if footsteps:
sfx["footstep"] = footsteps sfx["footstep"] = footsteps
if "pubrock-live" in music: # R12: flag gig-only assets for F for k in music: # flag gig-only beds for F (any 'gig-*' key)
music["pubrock-live"]["gig"] = True if k.startswith("gig-"):
music[k]["gig"] = True
if "applause" in sfx: if "applause" in sfx:
sfx["applause"]["gig"] = True sfx["applause"]["gig"] = True
return {"ambience": ambience, "sfx": sfx, "music": music} 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) 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): def kick(dur=0.28):
n = int(dur * SR) n = int(dur * SR)
f = np.linspace(120, 45, n) 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) 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): def music_pubrock(name):
# the gig live bed — 90s Aussie pub-rock: driving power chords, punchy bass, rock kit. Louder # 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. # + 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) 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}) # 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-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-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")), "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) ──────────── # ── gig layer (?gigs=1 consumers: F plays these when a gig is on) — the manifest key IS the
"music-pubrock-live": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock; F plays via room.audio gigKey")), # 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)")), "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")), "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() b.close()
def smoke_gigs(p): def smoke_gigs(p):
"""R12 v3.0-alpha gig layer (Lane F). The six gates from ROUND12 §Lane F.5 — schedule determinism · """R13 v3.0-beta THE DISTRICT gig layer (Lane F). The ROUND13 §Lane F.6 gates — district determinism ·
band always 3 · crowd watchPoints · gig-night interior draw budget · no-giants on the stage/crowd a 4-piece band · per-venue crowd watchPoints · EVERY venue's gigKey resolves to a real manifest bed
rigs (crowns re-asserted here so the gig path can't ship a giant even if qa runs without the scale (debt #1, now across 3 genres) · gig-night interior draw budget · street budget at the busiest venue
gate) · ?noassets=1 still silent-and-fine with ?gigs=1 plus John's cover ruling (paid · free · block · no-giants (STATURE) on the stage/crowd rigs · queue cap and drains by 'done' (pending Lane D)
broke-knockback · no re-charge on re-entry). · ?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 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 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.""" 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): if not gigs_landed(p):
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)') print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
return return
@ -669,9 +670,9 @@ def smoke_gigs(p):
else: else:
if night['state'] == 'on': OK(f"gig state machine: NIGHT → 'on' (venue {night['H']}m room)") 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']}'") 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) # band is a 4-piece (R13 debt #3: front-line trio + a drummer on C's riser)
if night['band'] == 3: OK('band: exactly 3 on the stage') if night['band'] == 4: OK('band: 4-piece on the stage (front-line trio + drummer)')
else: FAIL(f"band: expected 3 on the stage, got {night['band']}") else: FAIL(f"band: expected a 4-piece (trio + drummer), got {night['band']}")
# crowd cap ≤ watchPoints # crowd cap ≤ watchPoints
if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0: if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0:
OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)") OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)")
@ -706,6 +707,127 @@ def smoke_gigs(p):
finally: finally:
b.close() 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 # 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback
b, pg, errs = new_page(p) b, pg, errs = new_page(p)
try: try:
@ -791,8 +913,8 @@ def smoke_gigs(p):
crowd: info && info.crowd, gigs: (P.plan.gigs || []).length, crowd: info && info.crowd, gigs: (P.plan.gigs || []).length,
bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest }; bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest };
}""") }""")
if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0: if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0:
OK(f"?noassets=1&gigs=1: the gig still happens (band {na['band']}, crowd {na['crowd']}, placeholders)") OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)")
else: else:
FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}") FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}")
if len(reqs) == 0: if len(reqs) == 0:

View File

@ -1,12 +1,17 @@
#!/usr/bin/env python3 #!/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 The two money shots the round asks for, both reproducible (never a hand-cropped screenshot nobody can
watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be re-take when the gig layer changes):
re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo). 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] Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--outdir DIR]
Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG. 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 import sys, time, socket, subprocess, pathlib
@ -14,9 +19,9 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130 PORT = 8130
HOST = f'http://127.0.0.1:{PORT}' HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990 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 '--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): def port_up(port):
@ -36,9 +41,9 @@ def ensure_server():
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') 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 # ── Shot 1: INTERIOR. Stand back by the door at eye height and look at the stage over the crowd's heads.
# render (rather than trusting rAF) keeps the shot deterministic in headless. # Forcing one explicit render (rather than trusting rAF) keeps the interior shot deterministic in headless.
POSE_JS = r""" INTERIOR_JS = r"""
async () => { async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE; const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
D.setSegment(5); // NIGHT the gig is on 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.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
P.renderer.info.reset(); P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera); P.renderer.render(P.interiorMode.scene, P.camera);
const crew = P.interiorMode.crew; const crew = P.interiorMode.crew, mem = crew ? crew.members : [];
return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName, const band = mem.filter(m => m.part === 'band');
state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo, return { ok: true, venue: (P.plan.shops.find(s => s.id === P.gigs.venueShopId) || {}).name,
dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0, 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 }; draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
} }
""" """
@ -66,6 +92,9 @@ def main():
except ImportError: except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
srv = ensure_server() 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: try:
with sync_playwright() as p: with sync_playwright() as p:
b = p.chromium.launch() b = p.chromium.launch()
@ -80,20 +109,30 @@ def main():
timeout=15000) timeout=15000)
except Exception: except Exception:
print(' ! fleet never readied — shooting the placeholder crew') 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'): if not info.get('ok'):
b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m') b.close(); sys.exit(f'\033[31m✗ could not stage the interior shot: {info}\033[0m')
OUT.parent.mkdir(parents=True, exist_ok=True) pg.screenshot(path=str(interior_out))
pg.screenshot(path=str(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() b.close()
finally: finally:
if srv: srv.terminate() if srv: srv.terminate()
print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}") if errs:
print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover") print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · " sys.exit(1)
f"{info['draws']} draws · {info['tris']:,} tris")
if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
if __name__ == '__main__': 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 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 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: deterministically), and asserts for EVERY spawned interior figure:
1.4 m <= crown(head-bone world Y) <= 2.0 m (human-sized) 1.4 m <= STATURE (crown - foot) <= 2.0 m (human-sized platform-independent)
crown < room dims.H (never clips the ceiling) 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) |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. 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] 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. 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). 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.fig.updateWorldMatrix(true, true);
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v); (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); } }); 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 }; return { H: +H.toFixed(2), figs };
} }
@ -117,14 +124,14 @@ def main():
bad = [] bad = []
for f in figs: for f in figs:
total_figs += 1 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 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)") elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
if bad: if bad:
FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad)) FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad))
else: else:
crowns = ", ".join(f"{f['crown']:.2f}" for f in figs) stats = ", ".join(f"{f['stature']:.2f}" for f in figs)
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs crowns=[{crowns}] all human-sized") 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.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150) pg.wait_for_timeout(150)
b.close() 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", "file": "procity_fit_bass_guitar_01.glb",
"footprint": [ "footprint": [
0.34, 0.34,
0.12 0.13
], ],
"height": 1.15, "height": 1.15,
"thumb": "thumbs/procity_fit_bass_guitar_01.png" "thumb": "thumbs/procity_fit_bass_guitar_01.png"
@ -793,10 +793,10 @@
"guitar_amp": { "guitar_amp": {
"file": "procity_fit_guitar_amp_01.glb", "file": "procity_fit_guitar_amp_01.glb",
"footprint": [ "footprint": [
0.51, 0.52,
0.29 0.29
], ],
"height": 0.6, "height": 0.607,
"thumb": "thumbs/procity_fit_guitar_amp_01.png" "thumb": "thumbs/procity_fit_guitar_amp_01.png"
}, },
"mic_stand": { "mic_stand": {
@ -805,7 +805,7 @@
0.57, 0.57,
0.57 0.57
], ],
"height": 1.7, "height": 1.702,
"thumb": "thumbs/procity_fit_mic_stand_01.png" "thumb": "thumbs/procity_fit_mic_stand_01.png"
}, },
"cash_register": { "cash_register": {
@ -1115,15 +1115,37 @@
], ],
"note": "arcade cabinet" "note": "arcade cabinet"
}, },
"pubrock-live": { "gig-pubrock": {
"file": "audio/music-pubrock-live.ogg", "file": "audio/music-gig-pubrock.ogg",
"fallback": "audio/music-pubrock-live.m4a", "fallback": "audio/music-gig-pubrock.m4a",
"loop": true, "loop": true,
"gain": 0.6, "gain": 0.6,
"types": [ "types": [
"pub" "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 "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 { 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 { 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 { 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 import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
main().catch((err) => { 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. // Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null; 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 // spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2); player.teleport(-96, 9, -Math.PI / 2);
chunks.warmup(player.position); chunks.warmup(player.position);
@ -271,19 +280,20 @@ function enterShop(shopId, name) {
if (!shop) { hud.showToast(`🚪 ${name}`); return; } 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 // [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; } 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 // 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 // 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 // free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. THE
// UI — it all rides the round-8 buy seam. The venue is a normal shop in every other respect. // DISTRICT: keyed by the venue being ENTERED (…Of(shopId)), so a night at two venues is two covers. Zero
if (gigState && shopId === gigState.venueShopId && gigState.open && gigState.cover > 0 && !gigState.paid) { // new UI — it rides the round-8 buy seam; the venue is a normal shop in every other respect.
const cover = gigState.cover, BAND = String(gigState.bandName || 'live').toUpperCase(); 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)) { if (!wallet.canBuy(cover)) {
hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`); hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`);
return; return;
} }
wallet.buy({ title: `cover — ${gigState.bandName}`, price: cover }); wallet.buy({ title: `cover — ${gigState.bandNameOf(shopId)}`, price: cover });
gigState.markPaid(); gigState.markPaidOf(shopId);
hud.showToast(`$${cover} cover — ${BAND}`); hud.showToast(`$${cover} cover — ${BAND}`);
} }
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode // [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); lighting.update(dt, player.position);
const clk = lighting.getClock(); const clk = lighting.getClock();
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night) 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 F R13 — THE DISTRICT] Friday night, off the clock: tick EVERY venue's latch, ramp Lane B's
// Lane D's patronage surge at the venue while the doors are open (setGig(id,false) ends it → normal day // per-venue marquee/sign glow off the byVenue map, and point Lane D's patronage surge at each venue whose
// patronage). Lane B's audio engine reads window.PROCITY.gigs.state on its own rAF for the muffled spill. // 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) { if (gigState) {
const gs = gigState.update(); gigState.update(); // tick all latches
if (venuePresentation) venuePresentation.update(gs); if (venuePresentation) venuePresentation.update(gigState.byVenue); // per-venue frontage/spill
citizens.setGig(gigState.venueShopId, gs === 'doors' || gs === 'on'); 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 / // [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. // 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 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 // 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) 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 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 // [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 // 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 // A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the // front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a // standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig). // (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, // 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 // 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 // points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId).
import * as THREE from 'three'; import * as THREE from 'three';
import { rng, shuffle } from '../core/prng.js'; import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js'; import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js'; import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.js';
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
const MIC = 0x222226, POLE = 0x9a9a9e; // holds — F's smoke asserts it). RSL fields the most (812 watch points).
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.) // Reads seated from the front (the crowd POV / money shot); a true
const RY_FLIP = Math.PI; // 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) { function guitarPrim(bodyCol, big = false) {
const g = new THREE.Group(); const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 }); 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); 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); neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
g.add(body, neck); 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 (fig-local -Z = the audience-facing front)
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
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd return tagOwn(g);
return g;
} }
function micStandPrim() { function micStandPrim() {
const g = new THREE.Group(); const g = new THREE.Group();
@ -48,26 +56,75 @@ function micStandPrim() {
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 })); new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
head.position.y = 1.44; head.position.y = 1.44;
g.add(pole, head); g.add(pole, head);
g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side) g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
return g; 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) { function instrumentFor(role) {
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'vocal') return micStandPrim(); if (role === 'vocal') return micStandPrim();
if (role === 'drums') return drumKitPrim();
return null; 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 { export class GigCrew {
constructor({ citySeed = 20261990, fleet = null } = {}) { constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0; this.citySeed = citySeed >>> 0;
this.fleet = fleet; 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; this.t = 0;
} }
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. // 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 }) { _make(target, key, { x, y, z, ry, height, pedIndex = null }) {
const r = rng(this.citySeed, 'gig', key); const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3; const h = height != null ? height : 1.6 + r() * 0.3;
@ -84,40 +141,91 @@ export class GigCrew {
return { actor, kind }; 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 } = {}) { spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
this.disposeAll(); this.disposeAll();
const gid = gig.gigId | 0; 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) { 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) const distinct = (this.fleet && this.fleet.ready)
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : []; ? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
stage.bandPoses.forEach((p, 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}`, 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 }); 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); const inst = instFor(p.role);
if (inst) { actor.fig.add(inst); extra.push(inst); } if (inst) { actor.fig.add(inst); member.extra.push(inst); }
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false, this.members.push(member);
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra }); 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 ── // ── 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) => { 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: w.ry });
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, seated: false,
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, 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: [] });
base: { x: w.x, y: 0, z: w.z, 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 }; 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). // procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
update(dt) { update(dt) {
this.t += dt; this.t += dt;
@ -127,7 +235,10 @@ export class GigCrew {
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
const f = m.actor.fig, b = m.base, ph = m.phase; const f = m.actor.fig, b = m.base, ph = m.phase;
if (m.part === 'band') { 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.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02; f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
} else { // guitar / bass: rhythmic strum-sway + tiny bob } else { // guitar / bass: rhythmic strum-sway + tiny bob
@ -149,10 +260,21 @@ export class GigCrew {
disposeAll() { disposeAll() {
for (const m of this.members) { for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig); 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(); 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.members.length = 0;
this.props.length = 0;
this._gen++; // invalidate any GLB load still in flight
this.t = 0; 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) { export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene); const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } }); 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(); const fig = new THREE.Group();
fig.add(inner); fig.add(inner);
fig.updateWorldMatrix(true, true); 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. // 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.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
this.patronageOn = true; 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.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._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
this._dropKeys = []; // scratch: chunks to drop this frame 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. // map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; } setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; } 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 // 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). // 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 }; } 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]; } _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)); const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z));
if (!list || !list.length) return null; if (!list || !list.length) return null;
let best = null, bd = PATRON_RANGE; 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; if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z); 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._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; }
if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s;
if (d < bd) { bd = d; best = s; } if (d < bd) { bd = d; best = s; }
} }
return best; return gigBest || best;
} }
_beginVisit(c, shop) { _beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out 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) { if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0; c._patronDist = 0;
const shop = this._nearestOpenShop(c); 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(); ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
if (shop && c.patronRng() < chance) this._beginVisit(c, shop); 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 // PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and adds
// adds the venue (one pub), the nightly gig schedule (`plan.gigs`), and poster assignments // the town's VENUES (24: pub / band_room / rsl), the week's gig schedule (`plan.gigs`), and poster
// (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})), // 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). // 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). // 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 { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js'; import { SHOP_TYPES, poleOffset } from '../core/registry.js';
import { shopName, bandName } from './names.js'; import { shopName, bandName } from './names.js';
const r2 = v => Math.round(v * 100) / 100; const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000; 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 // A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map.
// segment→hour map). A week of nightly entries at the one venue; F's clock picks tonight's.
const NIGHTS = 7; const NIGHTS = 7;
const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on" 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 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 const VENUES_PER_TOWN = [2, 4]; // seeded count (charter: 24 venues per town)
// from the origin — a destination you follow the sound to), NEVER the openLate landmark. Fallback const GIGS_PER_VENUE = [4, 6]; // per week — the rest are dark nights, which read true
// for osm / no-spine towns: the farthest-from-centre plain shop. Seeded pick among the farthest few. // Kind order as venues are added. A town of 4 gets a second pub at the far end of the spine —
function pickVenue(plan, citySeed) { // 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 lotById = new Map(plan.lots.map(l => [l.id, l]));
const candidates = plan.shops.filter(s => { const blockById = new Map(plan.blocks.map(b => [b.id, b]));
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 mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id)); 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, const lotOf = s => lotById.get(s.lot);
// loaded by the shell and passed as opts.customBands) get PRIORITY — shuffled deterministically and const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; };
// filled first, so a town surfaces up to NIGHTS of them — and the rest are generated. Deterministic const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); };
// given (citySeed, customBands). Absent/empty custom list ⇒ pure generator (the ?noassets path). const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; };
function assignBands(citySeed, nGigs, customBands) { const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id);
const custom = (Array.isArray(customBands) ? customBands : [])
.filter(b => typeof b === 'string' && b.trim()).map(b => b.trim()); const base = plan.shops.filter(s => {
const shuffled = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : []; 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 = []; const out = [];
for (let i = 0; i < nGigs; i++) { const n = irange(rng(citySeed, 'venueN', 0), VENUES_PER_TOWN[0], VENUES_PER_TOWN[1]);
out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i)));
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; return out;
} }
// Posters advertising tonight's gig (night 0), near the venue + along the central streets B already // Convert a chosen shop into a venue of `kind`, in place.
// streams (poles/walls). Modest count for the one-pub alpha; positions seeded + deterministic. B function convertVenue(citySeed, shop, kind) {
// renders E's poster skin at (x,z) rotated ry; F wires "poster → tonight's gig" discoverability. const reg = SHOP_TYPES[kind];
function buildPosters(plan, citySeed, venue, gig) { 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 posters = [];
const add = (x, z, ry) => posters.push({ id: posters.length, gigId: gig.gigId, x: r2(x), z: r2(z), ry: r4(ry) }); const add = (x, z, ry, gigId) => posters.push({ id: posters.length, 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) const lotById = new Map(plan.lots.map(l => [l.id, l]));
if (vlot) add(vlot.x, vlot.z, vlot.ry); // one on the venue frontage itself const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const mainNodeIds = new Set();
for (const e of plan.streets.edges) if (e.kind === 'main') { mainNodeIds.add(e.a); mainNodeIds.add(e.b); } const tonight = new Map(); // venueShopId → tonight's gig
let cand = plan.streets.nodes.filter(n => mainNodeIds.has(n.id)); for (const g of gigs) if (g.night === 0 && !tonight.has(g.venueShopId)) tonight.set(g.venueShopId, g);
if (cand.length < 4) cand = plan.streets.nodes.slice(); if (!tonight.size) return posters; // every venue dark tonight — no posters, reads true
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 // 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; return posters;
} }
// Public: augment a finished plan with the gig layer. Mutates + returns `plan` (caller passes a fresh // ── public: augment a finished plan with the gig layer ─────────────────────────────────
// plan). Adds `plan.gigs`, `plan.posters`, and converts one shop into the `pub` venue in place. // 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 = {}) { export function withGigs(plan, citySeed, opts = {}) {
citySeed = citySeed >>> 0; citySeed = citySeed >>> 0;
const venue = pickVenue(plan, citySeed); const venues = pickVenues(plan, citySeed);
if (!venue) { plan.gigs = []; plan.posters = []; return plan; } // degenerate town (no plain shops) 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). for (const v of venues) convertVenue(citySeed, v.shop, v.kind);
const reg = SHOP_TYPES.pub; plan.gigs = buildSchedule(plan, citySeed, venues, opts.customBands);
const vseed = seedFor(citySeed, 'venue', venue.id); plan.posters = buildPosters(plan, citySeed, venues, plan.gigs);
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]);
return plan; return plan;
} }

View File

@ -10,7 +10,11 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
export { shopName, townName, bandName } from './names.js'; export { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.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 // 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 // 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: [ // v3 venue (?gigs=1): the town pub/hotel
'{Pub}', 'The {Suburb} Hotel', 'The {Family} Hotel', 'The {Adj} Arms', '{Pub}', '{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) ────────────────────────────── // ── band-name patterns (v3 gigs, 90s Aussie pub-rock) ──────────────────────────────
@ -99,7 +107,9 @@ const SIGN_PATTERNS = {
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'], milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'], dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'], 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 }. // 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 { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM, osmTownKeys } from './plan_osm.js'; import { generatePlanOSM, osmTownKeys } from './plan_osm.js';
import { generatePlanFor } from './index.js'; import { generatePlanFor, gigKeyFor } from './index.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js'; import { xmur3 } from '../core/prng.js';
const HERE = dirname(fileURLToPath(import.meta.url)); 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 // 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) { function gigSuite(plan, label) {
structuralSuite(plan, label); // full coverage; venue exempt from late-shop rule structuralSuite(plan, label); // full coverage; venues exempt from late-shop rule
const pubs = plan.shops.filter(s => s.type === 'pub'); const venues = plan.shops.filter(s => s.venue);
ok(pubs.length === 1, `${label}: exactly one pub venue (${pubs.length})`); const byId = new Map(venues.map(v => [v.id, v]));
const pub = pubs[0] || {};
ok(pub.venue === true && !pub.openLate, `${label}: pub tagged venue, not the openLate landmark`); // ── the district: 24 venues, kinds from the registry, never the openLate landmark ──
ok((pub.hours || [0, 0])[1] >= 22, `${label}: venue open into night so the gig can run`); ok(venues.length >= 2 && venues.length <= 4, `${label}: 24 venues (${venues.length})`);
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a nightly gig schedule`); ok(venues.every(v => VENUE_KINDS.includes(v.venueKind)), `${label}: every venue has a known venueKind`);
ok(plan.gigs.every(g => g.venueShopId === pub.id), `${label}: every gig plays the venue`); 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 => 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 => 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 => 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)); 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(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 ───────────────────────────────────────── // ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)'); 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)`); 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) // 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; 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')}`); 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 DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market'];
export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade']; export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade'];
export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill']; 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 ────────────────────────────────────────────────────────────────── // ── the registry ──────────────────────────────────────────────────────────────────
// Per type: // Per type:
@ -125,12 +133,49 @@ export const SHOP_TYPES = {
label: 'Pub', label: 'Pub',
facades: ['facade-djsim-pub.jpg', 'facade-federation.jpg'], // corner hotel; both exist in web/assets/gen/ 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' }, 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'], fittings: ['bar_counter', 'stage', 'pa_stack', 'pub_tables', 'stools'],
storeys: [1, 2], storeys: [1, 2],
hours: { open: 17, close: 23 }, // a NIGHT venue: open into NIGHT so gigs can run (gig layer sets these) 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 weights: {}, // NEVER auto-placed — the ?gigs=1 layer converts one shop into the venue
footprint: { fmin: 16, fmax: 24, dmin: 18, dmax: 26 }, 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; 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). // Every facade filename referenced anywhere (Lane A selfcheck asserts each exists on disk).
export function allFacadeSkins() { export function allFacadeSkins() {
const set = new Set(); const set = new Set();

View File

@ -22,9 +22,13 @@
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed + // 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 // // room-tone), seeded per shop. musicKey may be null. gigKey set
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey. // // 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 // watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
// // stage; dance ~1/3 true. Same frame + ry convention as counter.stand. // // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
// stage: { x,z,w,d,deckY,frontZ,bandPoses }, // VENUE (R12, else null) — the band stage; bandPoses[3] on the deck. // // 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 // dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed) // placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed) // 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', milkbar: 'milkbar', video: 'video-synth',
dept: 'arcade', arcade: 'arcade', dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed) }; // 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'; const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null; 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 // 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. // revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar'; if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
const audio = { musicKey, toneKey }; const audio = { musicKey, toneKey };
// Venue gig (R12): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey // 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 pub has no opts.gig → normal interior (room-tone + maybe radio). // 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; 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; 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) 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 []) 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) 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 }, dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter }, recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement, 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 // 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 // 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). // 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) { function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
const sw = Math.min(W - 1.4, Math.max(3.0, W * 0.58)), sd = 2.0; 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 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); f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0); const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect); 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' }); 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++) { for (let i = 0, n = 1 + (r() < 0.5 ? 1 : 0); i < n; i++) {
const af = buildFitting('ampStack', ctx, {}, r); const af = buildFitting('ampStack', ctx, {}, r);
af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18); af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18);
roomGroup.add(af.group); roomGroup.add(af.group);
} }
const bandPoses = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({ // 3 across the deck front, facing +Z (audience) // 4-PIECE BAND (round 13, debt #3): front-line trio across the deck lip + a drummer up-stage on the
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i], // 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 // 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"). // 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; let start = spawnCell;
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; } if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
const reach = floodFill(grid, start); const reach = floodFill(grid, start);
@ -270,7 +280,7 @@ function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r)
return true; return true;
}; };
for (const c of cands) { for (const c of cands) {
if (pts.length >= 8) break; if (pts.length >= watchMax) break;
if (!valid(c.x, c.z)) continue; 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) 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 }); 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. // for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
let stageInfo = null, watchPoints = []; let stageInfo = null, watchPoints = [];
if (recipe.venue) { 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; stageInfo = st.stage;
for (const sp of st.placed) placed.push(sp); 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 // 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. // 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 // placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({ 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 clutter: 0.45, // keep the floor open — the crowd fills it
counterPos: 'corner', // the bar, along the east wall (keeper = barkeep) counterPos: 'corner', // the bar, along the east wall (keeper = barkeep)
venue: true, // → stage + PA + watchPoints (layout.js) 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: [ fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls { 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) { kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles)
@ -221,6 +225,48 @@ const RECIPES = {
stockKind: 'snacks', stockKind: 'snacks',
signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'], 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. // 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 // 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. // 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/'; const BASE = 'assets/';
// shop type → street-spill music key (manifest.music). Only these types leak a bed to the footpath; // 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 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; 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. // the district's venues (round 13), if the gig layer is present (?gigs); each lot carries the coords
const venueShop = (PROCITY.plan?.shops || []).find((s) => s.venue) || null; // and each shop its genreKey ⇒ gigKeyFor(genreKey) is the bed. Empty flags-off ⇒ no gig spill.
const venueLot = venueShop && (PROCITY.plan?.lots || []).find((l) => l.id === venueShop.lot); const venues = (PROCITY.plan?.shops || [])
// gig state for the muffled spill: prefer Lane F's window.PROCITY.gigs state machine, else derive .filter((s) => s.venue)
// from plan.gigs + the clock. Returns null with no gig layer (flags-off ⇒ no gig spill, byte-identical). .map((s) => ({ shop: s, lot: (PROCITY.plan?.lots || []).find((l) => l.id === s.lot), genreKey: s.genreKey }))
function gigStateNow() { .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; const g = PROCITY.gigs;
if (g && typeof g.state === 'string') return g.state; if (g && g.byVenue) { const e = g.byVenue[shopId]; if (e) return typeof e === 'string' ? e : (e.state || 'quiet'); }
if (g && typeof g.on === 'boolean') return g.on ? 'on' : '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 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 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 && clk.seg <= e) return 'on';
if (clk.seg === s - 1) return 'doors'; if (clk.seg === (s - 1 + 6) % 6) return 'doors';
return 'quiet'; return 'quiet';
} }
@ -265,15 +280,23 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
ramp(L.spill.g.gain, 0, 0.4); ramp(L.spill.g.gain, 0, 0.4);
} }
// ── muffled gig spill through the venue wall (round 12) — low-pass bed, reaches further than a // ── muffled gig spill through the nearest active venue's wall (round 13, the district) — a low-pass
// door spill; only while the gig layer is on (?gigs) and a gig is at doors/on. Byte-identical off. ── // bed PER GENRE (gig-<genreKey>), reaching further than a door spill; the loudest (nearest, at
if (street && venueLot && P.plan?.gigs?.length) { // doors/on) venue wins, so walking pub→band_room→RSL crossfades the genre. Byte-identical off. ──
const gs = gigStateNow(), on = gs === 'on' || gs === 'doors'; if (street && venues.length && P.plan?.gigs?.length) {
const dist = Math.hypot(venueLot.x - _cam.x, venueLot.z - _cam.z); let bestGain = 0, bestKey = null, bestState = null;
const gg = (on && dist < GIG_SPILL_R) for (let i = 0; i < venues.length; i++) {
? (AUDIO?.music?.['pubrock-live']?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R) : 0; const vn = venues[i];
state.gig = gs; const gs = venueGigState(vn.shop.id);
swapBed(L.gigSpill, gg > 0 ? 'pubrock-live' : null, gg > 0 ? AUDIO?.music?.['pubrock-live'] : null, gg, 0.9); 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); } } 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) ── // ── 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 // nearest openLate (video) shop, for the night/rain money shots
const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22); 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). // 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 // 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 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) 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 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 = { const DBG = {

View File

@ -1,45 +1,53 @@
// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 12 / v3.0-alpha] // PROCITY Lane F — gig_state.js [integration glue, F-owned, round 13 / v3.0-beta THE DISTRICT]
// The Friday-night state machine: quiet → doors → on → done, driven off Lane B's day clock and Lane A's // The Friday-night state machine, now PER-VENUE. A town has 24 venues (pub / band_room / rsl); each runs
// plan.gigs. F owns it because every other lane consumes it and none of them own the clock: // its own latch quiet → doors → on → done off Lane B's day clock and Lane A's plan.gigs. F owns it because
// • Lane B audio.js reads window.PROCITY.gigs.state → the muffled through-the-wall spill // every other lane consumes it and none of them own the clock:
// • Lane B venue.js update(state) → marquee/sign glow on the frontage // • Lane B audio.js reads window.PROCITY.gigs.byVenue[id] → the muffled through-the-wall spill per venue
// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge // • Lane B venue.js update(byVenue) → marquee/sign glow on each frontage
// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside) // • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge, per venue
// • Lane F the cover charge at the door + the smokes // • 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), // Model (CITY_SPEC §"CityPlan v3 — gig layer"): plan.gigs is the whole week × every venue. TONIGHT for a
// endSeg=5, doors at startSeg1 (DUSK). One venue, one genre, seven nightly entries in the plan. // 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 // ── THREE HARD-WON LAWS, unchanged from the alpha (they each cost a live-verify to learn): ──────────────
// 23, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the wrap, DAWN. // WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and a venue closes at
// So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after it (DAWN = // closing time, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the
// closing time — the band packs up and the crowd drains per Lane A's closing-time ruling), and clear the // wrap, DAWN. So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after
// latch on any other segment → back to 'quiet'. The latch clears on any non-DAWN segment rather than only // it (DAWN = closing time — the band packs up, the crowd drains per Lane A's closing ruling), and clear on
// at MORNING because the player drives this clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must // any other segment → back to 'quiet'. The latch clears on any non-DAWN segment (not only at MORNING)
// read doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly // because the player drives the clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must read
// that). A night only *counts* as rolled when we leave 'done' FORWARDS — that's what separates a real // doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly that).
// DAWN→MORNING roll (new night, cover due again) from a rewind back out of the gig (same night, paid).
// //
// WHY IT LISTENS RATHER THAN POLLS. The latch has to observe every segment, and a poll only sees the // WHY THE NIGHT ROLLS ON WHERE THE CLOCK LANDS, not on watching 'done' go by. First cut incremented the
// segments it happens to be sampled on: driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in // night when LEAVING 'done' forwards — so a clock JUMP straight from NIGHT to MORNING (never touching DAWN)
// between (player inside the pub, audio muted, so neither the street loop nor the audio rAF is reading) // never rolled the night, and a broke punter walked in on last night's paid stamp. Now: the latch clears
// skipped the roll entirely — the night never advanced and the cover was never due again. Caught live in // wherever it clears, and landing in the DAYTIME (MORNING…ARVO) is what rolls the night; landing on the
// the R12 verify. lighting.js dispatches `procity:segment` on EVERY segment change (hand-driven or the // doors means we rewound out of the gig — same night, stays paid. DBG/shots/soak all JUMP, so only this
// automatic day cycle), so that is the signal the latch rides. Reads stay safe on their own: `state` is a // rule survives them.
// getter that re-observes the current segment, and observing the same segment twice is idempotent. //
// 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; const SEGS = 6;
export function createGigState({ plan, lighting }) { // One venue's latch — the alpha machine, its hard-won internals verbatim. `gig` is this venue's tonight
const gigs = (plan && plan.gigs) || []; // (night-0) entry, or null when the venue is dark tonight (→ always 'quiet').
const gig = gigs[0] || null; // alpha: tonight is gigs[0] (CITY_SPEC v3) function createVenueLatch(gig, lighting) {
const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5; const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5;
const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5; const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5;
const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open
const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in 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 — // 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 // Landing here after a gig means THE DAY HAS TURNED, which rolls the night (next cover due).
// is what rolls the night (and makes the next cover due). See the roll note below.
const DAYTIME = (() => { const DAYTIME = (() => {
const set = new Set(); const set = new Set();
for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s); 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 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 // 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) { function observe(seg) {
const on = seg >= startSeg && seg <= endSeg; const on = seg >= startSeg && seg <= endSeg;
if (on) played = true; if (on) played = true;
else if (played && seg !== doneSeg) { else if (played && seg !== doneSeg) {
// The latch is clearing: the gig is behind us. Whether that means "a new night" (cover due again) // The latch is clearing: the gig is behind us. Whether that's "a new night" (cover due again) or
// or "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by having // "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by watching
// watched 'done' go by: keying the roll on leaving 'done' meant a clock jump straight from NIGHT to // 'done' go by (see the roll law in the header).
// 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.
played = false; played = false;
if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due 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) } // (landed on the doors ⇒ rewound out of the gig ⇒ same night)
@ -69,30 +75,81 @@ export function createGigState({ plan, lighting }) {
return state; 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() { function tick() {
if (!gig || !lighting || !lighting.getClock) return (state = 'quiet'); if (!gig || !lighting || !lighting.getClock) return (state = 'quiet');
return observe(lighting.getClock().seg); return observe(lighting.getClock().seg);
} }
tick(); // settle on the boot segment
return { return {
gig, observe, tick,
get state() { return tick(); }, get state() { return tick(); },
update: tick, // shell calls this once per street frame (same thing)
get on() { return tick() === 'on'; }, get on() { return tick() === 'on'; },
get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters 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 cover() { return gig ? gig.cover | 0 : 0; },
get night() { return night; }, get bandName() { return gig ? gig.bandName : null; },
// cover stamp — runtime only (like the wallet itself): paid once, free to pop out and back in until the get night() { tick(); return night; },
// night rolls. NOT persisted, NOT written into the plan (goldens never move). // 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; }, get paid() { tick(); return paidNight === night; },
markPaid() { tick(); 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); }, 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 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 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 // [Lane F R13 — debt #1 CLOSED] The gigKey contract is now canonical: a genre's live-bed manifest key IS
// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as // `gig-<genreKey>` (Lane A exports gigKeyFor(); Lane C emits room.audio.gigKey; Lane E named the manifest
// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already // beds gig-pubrock/gig-grunge/gig-covers to match). So the room's gigKey feeds the audio engine DIRECTLY,
// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined // with NO mapping. The R12 `GIG_BED` bridge that rewrote gig-pubrock→pubrock-live is DELETED — and not a
// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig). // moment too soon: E renamed pubrock-live→gig-pubrock this round, so the old bridge had become a LIVE BUG
// F owns the glue, so the key resolves here: exact map first, then a generic `gig-<genre>` → `<genre>-live` // (it pointed at a manifest key that no longer exists → the band would mime in a silent room). Contract line
// fallback so a second genre works without another edit. Reported to C/E for a real contract line in // lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12. const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
const GIG_BED = { 'gig-pubrock': 'pubrock-live' };
const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null);
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false, export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null, 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. // most rooms never see one), disposed with the room like the keepers.
let crew = null; let crew = null;
let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this) 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 current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding) 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 → // [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. // Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null; 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 // [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
// opts.gig, and it treats `{on:false}` as "a gig" if genreKey is set — so pass NULL unless it's really // 24 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
// on. A quiet-night pub then builds as a normal interior (stage + watch points still there, just nobody // opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
// on them), which is exactly the "quiet night at the pub" C specced. // (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
const gigOn = !!(gigState && gigState.venueShopId != null && shop.id === gigState.venueShopId && gigState.on); const gigOn = !!(gigState && gigState.onOf(shop.id));
const tonight = gigOn ? gigState.gig : null; const tonight = gigOn ? gigState.gigOf(shop.id) : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null }); gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop; currentShop = shop;
scene.add(current.group); scene.add(current.group);
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine). // [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. // 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 // [R13] While the band is on, the LIVE BED plays over the room's radio: room.audio.gigKey is ALREADY the
// F's key resolver above — playInterior only knows musicKey/toneKey, so the gig bed rides in as musicKey. // 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 ra = current.audio || {};
const bed = gigBedKey(ra.gigKey); const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
window.PROCITY.audio && window.PROCITY.audio.playInterior(bed ? { ...ra, musicKey: bed } : 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 // 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); const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) { 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) 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) 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.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.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) // 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) // PROCITY Lane B — venue.js (round 13, v3.0-beta THE DISTRICT, ?gigs=1)
// The street-side presentation of the pub venue: (1) gig POSTERS around town — E's poster skins with // The street-side presentation of the town's 24 venues (pub / band_room / rsl). Round 13 generalises
// A's plan.posters placement and the band name overprinted like a shop sign; (2) the lit pub FRONTAGE // the R12 one-pub slice to the whole district and adds the night-readability polish:
// at night while a gig is on — a warm marquee over the door + lit sign glow that F toggles from the // (1) town-wide gig POSTERS — E's poster skins at A's plan.posters positions, band name overprinted,
// gig state machine. Consumes A's plan.posters / plan.gigs / the venue shop. B owns the street side. // 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), // Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js), so
// so flags-off boot is byte-identical — the shell never builds it. Asset law: a missing poster JPEG // flags-off boot is byte-identical — the shell never builds it, and nothing here is on the always-on
// falls back to a flat printed-poster canvas (still a poster). Deterministic from citySeed. // 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'; import * as THREE from 'three';
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; 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) 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/' } = {}) { export function createVenuePresentation(plan, skins, scene, { assetBase = 'assets/gen/' } = {}) {
const group = new THREE.Group(); group.name = 'venue-presentation'; const group = new THREE.Group(); group.name = 'venue-presentation';
const disposables = []; 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 gigs = plan.gigs || [];
const posters = plan.posters || []; const posters = plan.posters || [];
const venueShop = plan.shops?.find((s) => s.venue) || plan.shops?.find((s) => s.type === 'pub') || null; const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
const venueLot = venueShop && plan.lots.find((l) => l.id === venueShop.lot); 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 ── const venueShops = (plan.shops || []).filter((s) => s.venue);
for (const p of posters) { // R12 fallback (no A gig-layer venue flag): treat the pub as the sole venue.
const gig = gigs.find((g) => g.gigId === p.gigId) || gigs[0]; if (!venueShops.length) {
const band = gig ? gig.bandName : 'LIVE TONIGHT'; const pub = (plan.shops || []).find((s) => s.type === 'pub');
const skin = POSTER_SKINS[(p.id + (plan.citySeed >>> 0)) % POSTER_SKINS.length]; // seeded template if (pub) venueShops.push(pub);
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);
} }
// ── lit venue frontage (marquee over the door; emissive ramped by gig state) ── // ── town-wide posters, instanced per gig (one draw + one texture per advertised gig) ──────────────
const frontage = new THREE.Group(); frontage.name = 'venue-frontage'; // A given gig's poster looks the same wherever it's pasted, so we group A's poster positions by gigId,
let marqueeMat = null, signGlowMat = null; // pick ONE seeded skin per gig, and render each group as a single InstancedMesh. Town-wide the whole
if (venueLot) { // poster layer is ≤ (tonight's playing venues) draws instead of one mesh + one canvas per bill.
const ry = venueLot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); let posterDraws = 0;
const zf = (venueLot.d || 10) / 2 + 0.06; {
const mx = venueLot.x + fx * zf, mz = venueLot.z + fz * zf; 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 // warm marquee slab above the entrance
marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 }); const 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 mw = Math.min(w * 0.92, 6.5);
const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35); const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35);
const marquee = new THREE.Mesh(mgeo, marqueeMat); 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 // 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 }); 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 bgeo = new THREE.SphereGeometry(0.06, 6, 5);
const nb = Math.max(4, Math.round(mw / 0.6)); const nb = Math.max(4, Math.round(mw / 0.6));
const bulbs = new THREE.InstancedMesh(bgeo, bulbMat, nb); 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++) { for (let i = 0; i < nb; i++) {
const t = (i / (nb - 1) - 0.5) * mw; const t = (i / (nb - 1) - 0.5) * mw;
_p.set(mx + Math.cos(ry) * t, 3.25, mz - Math.sin(ry) * t); _bm.makeTranslation(cx + rx * t, 3.25, cz + rz * t);
bulbs.setMatrixAt(i, _m.makeTranslation(_p.x, _p.y, _p.z)); bulbs.setMatrixAt(i, _bm);
} }
bulbs.instanceMatrix.needsUpdate = true; bulbs.instanceMatrix.needsUpdate = true;
frontage.add(marquee, bulbs); disposables.push(bulbMat, bgeo);
disposables.push(marqueeMat, mgeo, 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); scene.add(group);
// gig state → frontage glow. 'on' = full, 'doors' = warm-up, else dark. Accepts F's state string // ── per-venue state → frontage glow. Accepts F's per-venue map { [venueShopId]: state }, a Map, an
// ('quiet'|'doors'|'on'|'done'), a boolean, or {state}. Idempotent; call from the loop or on change. // { byVenue } wrapper, OR a bare state string (alpha compat → the primary pub). Idempotent; the shell
function update(gigState) { // calls it every street frame, so pool/glow ramps and the night baseline lerp here. ────────────────
const s = (gigState && gigState.state) || gigState; function stateGetter(gigStates) {
let e = 0; if (gigStates == null) return () => 'quiet';
if (s === 'on' || s === true) e = 1.7; if (typeof gigStates === 'string') return (id) => (id === primaryId ? gigStates : 'quiet');
else if (s === 'doors') e = 0.7; const map = gigStates.byVenue || gigStates;
if (marqueeMat) marqueeMat.emissiveIntensity = e; return (id) => {
if (signGlowMat) signGlowMat.emissiveIntensity = e * 1.15; 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'); update('quiet');
@ -94,17 +233,44 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
scene.remove(group); scene.remove(group);
} }
const queueZones = {};
for (const v of venues) queueZones[v.venueShopId] = v.queueZone;
return { return {
group, frontage, update, dispose, group, venues, update, dispose,
venueShopId: venueShop ? venueShop.id : null, 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 posterCount() { return posters.length; },
get posterDraws() { return posterDraws; },
}; };
} }
// ── per-poster canvas: E's poster art with the band name overprinted in its nameZone. ── const r2 = (v) => Math.round(v * 100) / 100;
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over const r4 = (v) => Math.round(v * 10000) / 10000;
// it when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top.
function posterTexture(assetBase, skin, band) { // ── 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 W = 240, H = 342;
const c = document.createElement('canvas'); c.width = W; c.height = H; const c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d'); 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 = '#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 = '#7a2a24'; x.fillRect(8, H * 0.62, W - 16, 3);
x.fillStyle = '#1c1712'; x.font = 'bold 13px Arial'; x.textAlign = 'center'; 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; const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
drawBandName(x, W, H, band); drawBandName(x, W, H, band);
const img = new Image(); if (!noassets) {
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; }; const img = new Image();
img.onerror = () => {}; // fallback poster already drawn img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.src = `${assetBase}poster-${skin}.jpg`; img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
}
return tex; return tex;
} }