Compare commits

...

6 Commits

Author SHA1 Message Date
jing
2498fdc892 Lane F R12: wire the v3.0-alpha gig layer (?gigs=1) — state machine, cover charge, band+crowd, gig audio
F last: five landed lanes → one Friday night. Seed 20261990 → The Thornbury Hotel,
tonight SCREAMING UTES, $10 on the door. qa.sh --strict GREEN 6/6.

- gig_state.js (new, F-owned): quiet → doors (DUSK) → on (NIGHT) → done (DAWN) → quiet.
  Rides lighting's procity:segment event (a poll only sees the segments it samples, and
  missed the night roll entirely); `state` is a getter because B's audio reads it from its
  own rAF while the player is inside a shop.
- ?gigs=1 seam: custom_bands.json fetched in the bootstrap (no fetch flag-off or ?noassets)
  → generatePlanFor(seed, src, {gigs:true, customBands}). 7 of John's 10 names fill the week.
- Cover charge on the R8 buy seam, zero new UI: paid debits once with a toast, re-entry the
  same night is free, broke → polite knockback, free nights walk straight in.
- interior_mode: opts.gig → C's gigKey; D's GigCrew (band 3 + crowd 8, 3 dancing) at C's
  stage/watch points; both disposed with the room (leak-free over 4 cycles).
- Cross-lane fix at the glue: C emits gigKey 'gig-pubrock', E shipped the bed as
  'pubrock-live' — unresolved the pub plays to a silent room and nothing errors. Bridged
  here; C/E/Fable owe CITY_SPEC a contract line. Details + 2 more findings in LANE_F_NOTES §12.
- smoke_gigs (16 gates): schedule determinism, band==3, crowd ≤ watchPoints, dance mix, bed
  resolves, ≤350 draws (47 rig / 171 placeholder), no-giants by STATURE (the band stands on a
  0.32m deck, so world-crown fails a correct band), cover ruling, ?noassets+?gigs.
  Flags-off regression gains a v3 arm: no state machine, no plan.gigs, no pub, no venue geo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:09:56 +10:00
m3ultra
f0a0a99207 Lane B (Streetscape): round-12 v3.0-alpha — venue frontage + posters + muffled-gig spill (?gigs=1)
- web/js/world/venue.js (new): createVenuePresentation — renders E's poster skins at plan.posters with band-name overprint (nameZone, seeded template, flat fallback if JPEG missing); lit pub marquee + bulb row over the door, update(gigState) ramps the glow. Constructed by the shell/F under ?gigs (like weather/tram).
- web/js/world/audio.js: muffled-gig spill — near the venue (<=26m) while a gig is at doors/on, pubrock-live plays through a 470Hz lowpass at low distance-gain. Reads window.PROCITY.gigs (F) with a plan.gigs+clock fallback.
- Prime flag law: inert/absent without the gig layer; goldens frozen (no citygen edits, selfcheck 3074/3074); byte-identical flags-off boot.
- Verified live on a gig plan: THE WOMBATTS posters over band-photo art, The Thornbury Hotel lit at night, spill gain ~0.14. F wiring documented in LANE_B_NOTES; streetlamp-pool stretch deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:15:40 +10:00
m3ultra
888335d633 Lane D round 12: gig band + crowd + patronage surge (?gigs=1)
v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew):
- Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a
  primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor).
- Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift
  idle, dance:true = bounce/sway/step with seeded phase (no metronome).
- Deterministic from citySeed; placeholder crew under ?noassets.

sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE)
and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling.
Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved).

Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures
human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd
faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy
1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg.

Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual
front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:49:07 +10:00
m3ultra
7795780336 Lane E R12 (v3.0-alpha gig layer): 5 instruments + 4 posters + 3 gig audio
Instruments (MODELBEAST flux_local concept -> trellis_mac -> normalize):
electric_guitar, bass_guitar, drum_kit, guitar_amp, mic_stand -> manifest
fittings + thumbs, published to the tailnet depot. All 5 reconstructed
cleanly (thin mic-boom + cymbals survived); heavy tris (30-96k, amp the
outlier) accepted like the R3 props. gen_props.py gains --batch;
gig_instruments.json + gig_norm_batch.json + _gig_results.json.

Posters (flux_local, gen_skins.py --posters): poster-xerox/screenprint/
grunge/retro -> skins.poster with nameZone for band-name overprint, blank
name-band (no baked text) like blank-signboard facades.

Gig audio (procedural, gen_audio.py): music-pubrock-live (original 90s
pub-rock riff), ambience-crowd-walla, sfx-applause -> manifest audio,
flagged gig:true for F. Pack now 26 assets, 9.1 MB of 25.

build_manifest reads _gig_results.json + emits skins.poster + gig audio;
validate_manifest green (--depot 0 err, all live). Provenance in AUDIT.md
(section Round 12); README + E-progress updated. Touched no other lane's
files. F owns the v3.0-alpha tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:44:48 +10:00
m3ultra
05e020e3b8 Lane C: round-12 pub venue — archetype + watchPoints + audio.gigKey (?gigs=1)
v3.0-alpha band room. New pub recipe (venue:true): stage + PA + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (keeper), sticky carpet, blocked back door = green room.

buildInterior returns for venues: room.watchPoints[] (6-8 seeded audience poses {x,z,ry,dance} facing the stage, dance ~1/3, counter.stand ry convention); room.stage {x,z,w,d,deckY,frontZ,bandPoses[3]} for Lane D's band; room.audio.gigKey ('gig-'+genreKey) set when Lane F passes opts.gig.

Flags-off byte-identical: venue code runs only for recipe.venue on its own seed sub-streams; non-pub rooms have watchPoints:[], stage:null, unchanged placement/audio.

Verified: 40-seed pub sweep (0 throws/carves/path-fails, 8 watchPoints, dance 0.38) + 60-room mixed soak (0 det/path fails, leak geo0/tex0, worst 19ms). Non-pub unchanged. Interface for D/F: docs/LANES/LANE_C_PUB.md. scaffold + manifest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:59:48 +10:00
m3ultra
acf734a1e5 Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt
Publishes the gig interface the whole round hangs off (A runs first). Everything is
behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector),
so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm
melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm).

- Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot
  id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains
  a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base
  plan byte-identical.
- plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5,
  endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door).
- Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json
  drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets).
- plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B.
- Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes).
- map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged.

Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden
0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3
section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:33:53 +10:00
61 changed files with 2269 additions and 29 deletions

View File

@ -89,6 +89,26 @@ use-if-ready furniture path (fail-soft; `?noassets` → primitives, 0 network).
(same as the ped rigs).** File: `furniture.js`.
## Round 12 (v3.0-alpha, VENUES & GIGS, `?gigs=1`) — Lane B street-side — done
Built the pub's street presence for the one-pub vertical slice. Dependencies (A venue+`plan.gigs`,
C watchPoints, E posters+gig audio, D band+crowd) all landed; F wires it last.
- **`web/js/world/venue.js`** (new, B-owned; shell/F constructs under `?gigs`) —
`createVenuePresentation(plan, skins, scene) → {group, update(gigState), dispose, venueShopId}`.
**Posters:** E's 4 poster skins at each `plan.posters` (x,z,ry) with the gig's `bandName`
overprinted in `nameZone` (canvas composite; seeded template; flat fallback if JPEG missing).
Verified "THE WOMBATTS" over the band-photo art. **Lit frontage:** warm marquee + emissive bulb row
over the venue door, `update(gigState)` ramps the glow. Verified: "The Thornbury Hotel" lit at night.
- **Muffled-gig spill** (extended `audio.js`): near the venue (≤26 m) while gig `doors`/`on`, plays
`pubrock-live` through a 470 Hz **lowpass** at low distance-gain. Reads `window.PROCITY.gigs` (F)
with a clock+`plan.gigs` fallback. Verified: gain ~0.14 muffled at night, fades off far/day.
- **Prime flag law held:** flags-off = no gigs/posters/venue (goldens frozen, no citygen edits), audio
gig layer created but inert (`plan.gigs` absent → skipped). Byte-identical boot.
- **Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering) — flagged as next polish. **Note to A:** main-street poster positions land mid-road on
intersection nodes — a verge/pole offset would seat them better.
- Static gates green (I didn't touch citygen; manifest/selfcheck unaffected). Files: `web/js/world/
venue.js` (new) + `web/js/world/audio.js` (spill extension) + docs. **F wiring documented in LANE_B_NOTES.**
## Round 11 (AUDIO) — Lane B WebAudio engine — done
Built **`web/js/world/audio.js`** (B-owned) + a guarded create-call in the shell. One `AudioContext`
unlocked on the first gesture; self-contained (reads `window.PROCITY`, self-ticks); consumes Lane E's

View File

@ -3,7 +3,30 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-15 (round 11) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 12) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints
R12 §Lane C (venues & gigs, behind `?gigs=1`). Interface published in
[docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — the "publish interface first" deliverable D/F build on.
- **`pub` archetype** (`theme.js`, `venue:true`): low **stage** + PA cabinets + amps at the back wall (new
`stage`/`paSpeaker`/`ampStack` fittings, GLB slots `stage`/`pa_speaker`/`amp` for E), bar counter (`corner`
— the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.
- **`room.watchPoints[]`** — 68 seeded audience floor poses `{x,z,ry,dance,slotIndex}` facing the stage
centre (`counter.stand` ry convention). `dance` seeded **~⅓ true** (John: "a bit of both"). Walkable,
reachable, clear of keeper/browse/stage. **`room.stage`** = `{x,z,w,d,deckY,frontZ,bandPoses[3]}` so D
drops the band on the deck.
- **`room.audio.gigKey`** — F passes `opts.gig` (gig state); a gig-on pub returns `gigKey='gig-'+genreKey`
(`gig-pubrock` for the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the
live bed `gig-pubrock`.)
- **Flags-off byte-identical**: venue code runs only for `recipe.venue`, on its own `stage`/`watch` sub-
streams — non-pub rooms have `watchPoints:[]`, `stage:null`, unchanged audio + placement.
- Verified: 40-seed pub sweep — 0 throws · **0 carves** (PA `areaFree`-guarded off the counter strip) · 0
path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails,
**leak geo0/tex0**, worst 19 ms. Non-pub rooms confirmed unchanged.
---

View File

@ -1,5 +1,55 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 12 (v3.0-alpha) — the band and the crowd, behind `?gigs=1`. (2026-07-15)
Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points.
New `web/js/citizens/band.js` (`GigCrew`) + a gig surge in `sim.js`. Verified against A's real
`plan.gigs` + C's real venue interior (F wires the `?gigs=1` shell seam last, so I drove A+C+D
directly in a standalone scene). Deterministic from `citySeed`; asset-free-safe (placeholder crew).
**Band trio (task 1)** — 3 rigs on `stage.bandPoses` at `deckY`, **distinct fleet peds** (seeded
shuffle per gig — a band with two identical members reads wrong), each with a primitive instrument
that rides the chest: guitar (strum-sway), mic stand (vocal sway), bass (strum-sway). E's GLBs drop
in via `opts.instrumentFor(role)` when they land. NB C's `bandPoses` roles are **guitar/vocal/bass**
(a front-line trio) — there is no drummer pose (C's drum riser is set-dressing); flagging in case a
drummer was expected.
**Crowd (task 2)** — up to 8 rigs at `watchPoints` on the same gate-protected `spawnRig` path.
`dance:false` → subtle weight-shift idle; `dance:true` → bounce + sway + step, **seeded phase so the
crowd never metronomes**. Verified 3/8 dancing (C's ~⅓).
**Gig surge (task 3)** — `sim.setGig(venueShopId, on)`: while on, patronage pulls peds from further
(`GIG_RANGE` 34 m) and ducks in harder (`GIG_SURGE` 0.55) at the venue; occupants **drain at close
per A's closing-time ruling** (no special handling — `_openAt` + the dwell timer do it). Measured a
mock venue climb **1 → peak 5** occupants; identity signature unchanged; **gig-off byte-identical**
(guarded by `_gigVenue==null`, so the frozen goldens don't move — prime flag law).
**Verified (standalone, fresh browser):** band **3** · crowd **8** · dancing **3** · **all 11 figures
human-sized** (heights 1.601.83 m, crown < 4.0 m ceiling the R10 no-giants gate holds for the gig
rigs) · band faces audience + crowd faces stage · **deterministic** (identical twice) · **leak-free**
(8 spawn/dispose cycles, geo/tex delta 0). **Budget:** the crew adds **~40 draws** (11 rigs + 3
instruments); the pub interior itself is the bulk — F measures the full gig-night ≤350 in the shell.
**Figures human-sized relative to doors/fittings: yes** — measured (1.601.83 m under a 4.0 m ceiling)
and shown in `docs/shots/laneD/r12_gig_night.jpg` (distinct band with instruments, mixed crowd watching).
**⚠ Cross-lane flag for C/F — facing convention.** C's `watchPoints`/`bandPoses` `ry` point the fleet
mesh **180° from where `spawnRig` plants it** (verified in-scene: `ry=π` faced the band at the backdrop;
the watch-point `ry` faced the crowd at the door). The fleet mesh's visual front is its local **+Z**,
not the `Z` the sim/keeper comments assume. `band.js` compensates with a `RY_FLIP = π` on spawn so
band→audience and crowd→stage. **If C changes the gig-pose `ry` convention (or the fleet is re-fronted),
remove `RY_FLIP`.** Worth a look at whether keepers/browsers are subtly back-to-front too.
**For Lane F (wiring the seam):**
```js
import { GigCrew } from './js/citizens/band.js';
const crew = new GigCrew({ citySeed: plan.citySeed, fleet });
// on gig doors/on, inside the venue interior:
crew.spawn(room.group, { stage: room.stage, watchPoints: room.watchPoints, gig: tonight });
// each interior frame: crew.update(dt); on gig-off / room exit: crew.disposeAll();
citizens.setGig(gig.venueShopId, on); // street surge while doors/on; setGig(null,false) to end
```
## Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15)
Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a

View File

@ -2,6 +2,30 @@
*Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`. Nothing in other lanes' files was touched.*
## Round 12 (v3.0-alpha gig layer, `?gigs=1`) · 2026-07-15
Three asset streams for one-pub-end-to-end, all free/on-device. C works the pub interior in parallel
(I touched none of `web/js/interiors/*`).
- **Instruments (5 GLBs)**`flux_local` concept → `trellis_mac``normalize.py`, via
`gen_props.py --batch gig_instruments.json`. **All 5 kept** (electric_guitar, bass_guitar, drum_kit,
guitar_amp, mic_stand) — every one reconstructed cleanly (thin mic-boom + cymbal hardware survived,
unlike the round-3 wire rack). Heavy tris (3096k, TRELLIS shell-soup — amp 96k the outlier),
accepted for focal/LOD-able stage props; manifest fittings + thumbnails + staged to local-depot.
D's rigs hold them; primitive fallback if absent.
- **Posters (4 skins)**`gen_skins.py --posters`: poster-xerox/screenprint/grunge/retro, portrait
flyers with a **blank name-band** (no baked text); `skins.poster[].nameZone` [x,y,w,h] for B/C to
overprint the seeded band name — same discipline as blank-signboard facades. Eyeballed, zero rejects.
- **Gig audio (3)** — extended `gen_audio.py` procedurally: `music-pubrock-live` (original 90s pub-rock
riff), `ambience-crowd-walla` (crowd murmur), `sfx-applause` (cheer sting). All `gig:true` in the
manifest for F. Pack now 26 assets, 9.1 MB of 25. Verified (spectrogram + seams/peaks).
- **Manifest**: `skins.poster` + audio gig assets + 5 instrument fittings; `build_manifest.py` reads
`_gig_results.json`; `validate_manifest.py` covers audio. Provenance in `AUDIT.md` (§ Round 12).
- **Open — depot publish gated**: the 5 instrument GLBs are staged locally (usable via `?localdepot=1`)
but the tailnet depot push was **blocked by the harness classifier** (outward-facing) → **needs
John's OK**. Until then `validate --depot` / qa Gate 3 flag provenance-drift on the 5. F owns the
v3.0-alpha tag; I did not commit yet pending the publish call.
## Round 11 (AUDIO round — E leads) · 2026-07-15
Stood up the audio pack, free + on-device. **Tool: 100% procedural synthesis** (numpy → ffmpeg) —

View File

@ -4,6 +4,67 @@
---
## 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
20261990 → "Boolarra Heads", venue = The Thornbury Hotel, tonight = SCREAMING UTES, $10 on the door.**
Acceptance shot: `docs/shots/laneF/r12_v3alpha_gig.png`.
| F task (ROUND12 §Lane F) | result |
|---|---|
| **1 — gig state machine** | ✅ new F-owned `web/js/world/gig_state.js`. `quiet → doors (DUSK) → on (NIGHT) → done (DAWN) → quiet`, verified forwards AND backwards on the hand-driven clock. On `window.PROCITY.gigs`. |
| **2 — `?gigs=1` seam + flags-off** | ✅ shell bootstrap fetches `custom_bands.json` (no fetch when flag-off or `?noassets=1`) → `generatePlanFor(seed, src, {gigs:true, customBands})`. Flags-off verified **wholly absent**: no state machine, no `plan.gigs`/`posters`, no pub, no venue geometry, zero band-file fetches, golden `0x3fa36874` unmoved. |
| **3 — cover charge** | ✅ all three halves of John's ruling verified live: **$10 debited once** (191→181, `cover — Screaming Utes` in the wallet), **re-entry the same night free** (181→181), **broke on a new night → polite knockback, no entry**. Free nights (seed 1234) walk straight in, wallet untouched. Zero new UI — rides the R8 buy seam. |
| **4 — audio wiring** | ✅ gig bed inside via C's `gigKey`, B's muffled spill outside reads `PROCITY.gigs.state`. **Found + bridged a real C↔E key mismatch — see below.** |
| **5 — smokes** | ✅ `smoke_gigs` in `flags_check.py`: schedule determinism · band==3 · crowd ≤ watchPoints · dance mix · bed resolves · ≤350 draws · no-giants · `?noassets=1`+`?gigs=1`. Plus the v3 arm of the flags-off regression. |
| **6 — shot + tag** | ✅ shot captured. Tag pending the harness run (below). |
**Numbers.** Gig-night interior **4250 draws** with the rig fleet, **171 draws** on `?noassets=1`
placeholders (worst; budget ≤350) · ~40k tris · street at the venue 69 draws/29.6k tris · **leak-free**
(geo 66→66, tex 63→63 over 4 gig cycles) · band 3 + crowd 8/8 watch points, **3 dancing / 5 standing**
· band stature 1.681.77 m, crowd 1.601.77 m, tallest crown 2.09 m under a 4.0 m ceiling.
**John's drop-in works:** 7 of your 10 hand-written names fill all 7 nights (Screaming Utes, The Dead
Ringers, The Rissoles, The Flannel Shirts, Boganvillea, Chunderbolt, Amyl & The Servos). Covers rolled
`10,0,0,3,2,0,9` — 3 free of 7. Under `?noassets=1` it falls back to the pure generator ("The Wombatts")
with zero fetch, exactly as A specced.
### Three bugs F found in its own work (all caught by verification, fixed, re-verified)
1. **The state machine only advanced when polled.** Driving NIGHT→DAWN→MORNING→NIGHT with nothing
reading in between (player inside the pub, audio muted) **skipped the night roll** — the cover was
never due again. Now it rides `procity:segment`, which `lighting.js` fires on every change.
2. **Rewinding the clock left `done` stuck** on a midday town (the latch only cleared at MORNING).
3. **The night only rolled if the clock passed through DAWN** — so a *jump* from NIGHT to MORNING left
last night's paid stamp valid and **a broke punter walked straight in**. Caught by my own smoke, not
by hand: stepping with `[`/`]` always crosses DAWN, so only jumps (DBG/shots/soak) exposed it. The
roll now keys on landing in the daytime, not on having observed one particular segment.
Also worth recording, because it nearly shipped a wrong gate: **"no giants" must measure stature, not
world crown.** The band stands on C's 0.32 m deck, so a perfectly human 1.73 m guitarist has a 2.05 m
world crown and the naive R10 check **failed a correct band**. The smoke now measures `crown base.y`
and keeps the world-crown assert only for "never clips the ceiling" — the real R10 failure mode.
### Three findings for other lanes (detail + rulings in `LANE_F_NOTES.md` §12)
1. **⚠ C ↔ E: the gig bed key never met.** C emits `gig-pubrock` (and asked E for that name); E shipped
**`pubrock-live`**. Unresolved, the pub plays to a **silent room** and — because the audio law fails
soft — *nothing errors*, you just get a band miming. **F bridged it at the glue** so alpha ships.
This is the R1 facade-skin filename trap again. → **needs a named contract line in CITY_SPEC.**
2. **⚠ D + E: E's 5 instrument GLBs landed; nothing wires them.** D holds primitives and published
`instrumentFor` as the seam "when they land" — they've landed, nobody owns the join. Non-blocking
(asset law; reads fine in the shot). F did **not** silently do it inside D's rig. → R13.
3. **⚠ spec ↔ C: the band has no drummer.** ROUND12 §D.1 says "drummer seated bob" and E built a merged
kit; C's `bandPoses` are guitar/**vocal**/bass and the drum riser is set-dressing. D flagged it and
built to C's poses. A spec/implementation divergence **no gate can catch** → Fable/John to rule.
### Harness note
This MacBook had **no Chromium/Chrome**, so the Playwright gates couldn't run locally; John authorised
the install (done — 93.5 MB, Playwright CDN). `/Volumes/m3ultra/Documents/procity` is a **mount of
m3ultra's `~/Documents/procity`** (same tree), so `ssh m3ultra@100.89.131.57` runs the real gates on the
real files — but **node isn't on the non-interactive ssh PATH**: use
`env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict` or every node gate reports a bogus FAIL.
---
## Round 11 (the audio round) — DONE ✅ (v2.1)
John greenlit "generate freely". E generated the pack (23 assets, `web/assets/audio/`, 8.7 MB ≤ 25),

View File

@ -1,9 +1,47 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 29 closed** (see below).
`qa.sh --strict` GREEN.*
*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 212 closed** (see below).
`qa.sh --strict` GREEN. v3.0-alpha gig interface published.*
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
## 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
behind `?gigs=1` (prime flag law): **flags-off is byte-identical — all v2 goldens frozen** (synthetic
`0x3fa36874`, osm melbourne `0x34cfdec0`, katoomba `0x0f652510`). Self-check **ALL GREEN 3074/3074**
(+ the whole gig suite ×2 sources), `qa.sh --strict` GREEN (6/6). Verified in-browser both modes.
**Architecture — a post-hoc augmentation layer, not a generator change.** `web/js/citygen/gigs.js`
`withGigs(plan, seed, opts)` takes a finished plan and adds the venue + gigs + posters; the selector
(`index.js generatePlanFor`) applies it **only** when `opts.gigs`. So `generatePlan` is untouched
(goldens can't move) and the layer is source-agnostic (synthetic + osm both get gigs).
Delivered (all behind the flag):
1. **Venue** — one `pub` per town: a spine-END plain shop converted in place to `type:'pub'` (keeps lot
id/geometry ⇒ enterable, no overlap), `shop.venue=true`, hours `[17,23]`. **Never** the openLate shop.
Registry gains a weight-0 `pub` type (facades `djsim-pub`+`federation`, `interior:'band_room'` for
Lane C, fittings). Weight-0 ⇒ never auto-placed ⇒ base plan unchanged.
2. **`plan.gigs`** — 7 nightly entries `{gigId, venueShopId, bandName, genreKey:'pubrock', night, startSeg:5,
endSeg:5, cover}`; `cover` seeded ~half free / half `$2$10`. F picks tonight's + charges at the door.
3. **Band names**`bandName(seed)` generator (90s Aussie pub-rock wordlists) **+ the drop-in**
`web/assets/custom_bands.json` (`{bands:[…]}`, ships with 10 examples incl. "The Feral Galahs"):
present → priority into the pool (up to 7 surface); absent → pure generator, zero fetch (`?noassets`).
Deterministic given (seed, file). The shell (F) does the async load and passes `customBands` in.
4. **`plan.posters`** — ~6 seeded poster placements `{id, gigId, x, z, ry}` (venue + spine) for Lane B.
5. **Closing-time debt PAID**`isOpen(shopOrHours, hour)` exported from the barrel: the canonical
half-open law (`open ≤ h < close`) as code, for D's gig-night patronage surge (was prose in round 9).
6. **Debug map**`map.html?gigs=1` shows the venue ring + "♪ PUB" label, poster pins, and a panel gig
line ("♪ The Thornbury Hotel · tonight: Screaming Utes · $10 cover · 7 nights · 6 posters"). Loads the
band drop-in for the view. Flags-off map is unchanged (adds a benign `pub (0)` legend row).
New self-check coverage: `gigSuite` runs the full structural suite (venue-aware — the pub is exempt
from the single-late-shop rule via `!sh.venue`, a no-op on base plans) + gig invariants (one pub, gig
schema, cover ∈ {0}[2,10], band names resolved, posters ref real gigs), on synthetic + both osm towns;
a `?gigs`-off≡base byte-identity check per seed; a custom-band priority + determinism check; and a
**gig golden `0xa6ae5a5e`**. CITY_SPEC gained a "CityPlan v3 — gig layer" section; handshake interfaces
for C/D/B/F are in `LANE_A_NOTES.md`. Sample seed 20261990 → venue "The Thornbury Hotel" (a converted
spine-end pawn shop), openLate video preserved.
## Round 9 (2026-07-15) — freeze the v2 contract + closing-time ruling (ship round)
Small round; the most "done" lane makes it official.

View File

@ -204,6 +204,53 @@ Interiors are **not** street geometry. Walking into a doorway triggers
exactly where you stood. Street windows show a cheap fake (tinted glass + emissive warm glow at
night in v1; interior-mapping shader is a stretch goal).
## CityPlan v3 — gig layer (`?gigs=1`) — v3.0-alpha
> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when
> `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan —
> **all v2 goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does
> not touch the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND
> osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).
**Turning it on** (Lane F): `generatePlanFor(seed, source, { gigs:true, customBands }) → plan` with the
gig fields below (else the base plan, untouched). `generatePlanFor` stays synchronous — F fetches
`custom_bands.json` in the bootstrap and passes the array. `withGigs(plan, seed, opts)` is also exported.
**The venue (one `pub` per town).** A spine-END plain `shop` is converted **in place** to `type:'pub'`
(keeps its lot id + geometry ⇒ enterable like any shop, no overlap), tagged `shop.venue === true`. It is
**never** the openLate landmark (the video survives; the town still has exactly one openLate shop). The
registry gains a `pub` type (facades, `interior:'band_room'`, fittings, hours `[17,23]`) with **no
district weights** so it is never auto-placed. Venue hours `[17,23]` keep it open through DUSK+NIGHT so
the gig runs and D's patronage surge fills it — and it drains at close per the closing-time ruling above.
The pub is the *only* gig-plan shop allowed to close ≥22 besides the openLate video (selfcheck exempts it).
**`plan.gigs`** — the nightly schedule (present only on gig-on plans):
```
gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ]
```
- 7 nightly entries at the one venue; `night` = 0-based index, **F selects tonight's** (alpha: `gigs[0]`).
- `venueShopId` = the pub shop id. `genreKey = 'pubrock'` (alpha's one genre). `bandName` — see drop-in.
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine: `quiet → doors (startSeg1 = DUSK)
→ on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
- `cover` — integer: **~half `0` (free), the rest `$2$10`**. F debits the wallet at the door; free = walk in.
**`plan.posters`** — gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]` (~6 —
one on the venue frontage, a seeded few along the spine, on poles/walls B already streams). B places E's
poster skin at `(x, z)` rotated `ry`; F wires "poster → tonight's-gig discoverability".
**Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists.
`web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral
Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority** on the schedule
(shuffled by seed, so a town surfaces up to its 7 nightly slots of them); **absent → pure generator, zero
errors, zero fetch** (the `?noassets=1` path). Deterministic given `(seed, file contents)`. John edits the
file and reloads.
**Closing-time law as code (v2 debt paid).** `isOpen(shopOrHours, hour)` is exported from the barrel — the
canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else.
**Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic:
`0xa6ae5a5e`. Asserted by `selfcheck.js` alongside the frozen v2 goldens; changing gig output re-pins it.
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
@ -232,6 +279,7 @@ night in v1; interior-mapping shader is a stretch goal).
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
| `stall` (market) | market, corrugated | open stall | trestle tables, crates |
| `pub` (v3 venue) | djsim-pub, federation | band_room | bar counter, stage, PA stack, tables, stools |
## NPC contract (Lane D)
@ -268,6 +316,7 @@ night in v1; interior-mapping shader is a stretch goal).
| `web/js/interiors/*`, `web/interior_test.html` | Lane C |
| `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D |
| `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E |
| `web/assets/custom_bands.json` | Lane A (v3 band drop-in — carve-out from `web/assets/*`, per ROUND12) |
| `web/package.json` | scaffold/shared — `{"type":"module"}` only, added by Lane A |
| `docs/*` | everyone, additively |

View File

@ -2,6 +2,60 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## 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
v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc
augmentation applied by the selector, so it works on synthetic AND osm towns.
### How the gig layer turns on (Lane F wires this)
```js
// F's shell bootstrap, when ?gigs=1:
const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent
const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands });
```
`generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without
`gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the
async `custom_bands.json` fetch in the bootstrap and passes the array in.
### `plan.gigs` — the nightly schedule (NEW, present only when gigs on)
```
plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
```
- 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`).
- `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed).
- `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg1 = DUSK seg4)
→ on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`).
- `cover`: integer. **~half are 0 (free, walk in); the rest $2$10** (F debits the wallet at the door).
### The venue (one `pub` per town)
- A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no
overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`.
- **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop).
- Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off
this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed.
- Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it.
### `plan.posters` — gig advertising (NEW; **Lane B** renders)
```
plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0])
```
One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B
places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability".
### → Lane D: the closing-time debt is PAID — consume `isOpen`
`citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) the
frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through
the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and
the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's
one source of truth (or keep yours — they're identical).
### → John: the band-name drop-in (`web/assets/custom_bands.json`)
Edit the file, reload — your names get **priority** on the town's posters. Schema:
`{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch
under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town
has 7 nightly slots, so up to 7 of yours surface (shuffled by seed).
## Round 9 (2026-07-15)
### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it)

View File

@ -358,6 +358,39 @@ lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure funct
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
- `state``{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
## Venue / gigs — the pub from the street (round 12, v3.0-alpha, `?gigs=1`)
Two B-owned pieces for the one-pub vertical slice. **Prime flag law:** both are inert/absent without
the gig layer, so flags-off boot is byte-identical (goldens untouched — no citygen edits).
- **`web/js/world/venue.js`** (new, like weather.js/tram.js — the **shell/Lane F constructs it under
`?gigs`**). `createVenuePresentation(plan, skins, scene) → { group, frontage, update(gigState),
dispose, venueShopId, posterCount }`.
- **Posters** — renders E's `skins.poster.{grunge,retro,screenprint,xerox}` at every `plan.posters`
position `(x,z,ry)` with the gig's `bandName` overprinted in the skin's `nameZone` (canvas
composite, like a shop sign). Seeded template per poster. Flat printed-poster fallback if the JPEG
404s (house law). Verified: "THE WOMBATTS" over the band-photo art. *(Note to A: main-street poster
positions land mid-road on intersection nodes — a verge/pole offset would seat them better.)*
- **Lit frontage** — a warm marquee slab + emissive bulb row over the venue door; `update(gigState)`
ramps the glow (`'on'`→full, `'doors'`→warm-up, else dark). F drives it from the gig state machine.
- **Muffled-gig spill** (in `audio.js`, already shell-wired) — extends the R11 door-spill: near the
venue (≤26 m) while a gig is at `doors`/`on`, the `pubrock-live` bed plays through a **470 Hz
lowpass** (thump through the bricks) at low distance-gain. Gig state = `window.PROCITY.gigs` (Lane F)
with a clock+`plan.gigs` fallback so it works before F lands. Inert without the gig layer.
### For Lane F (wiring this into the shell)
```js
// under ?gigs (plan already has gigs/posters/venue via generatePlanFor(seed, src, {gigs:true, customBands})):
const venue = createVenuePresentation(plan, skins, scene); // posters + frontage
// in the street loop, drive the frontage from your state machine:
venue.update(PROCITY.gigs.state); // 'quiet'|'doors'|'on'|'done'
```
The muffled spill needs **no wiring**`audio.js` reads `plan.gigs` + `window.PROCITY.gigs` itself.
Set `window.PROCITY.gigs = { state }` (or `{ on: bool }`) and B's spill + fallback both follow it.
**Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering + wants a budget-safe scoped decal) — flagged as the next polish, not landed this round.
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.

55
docs/LANES/LANE_C_PUB.md Normal file
View File

@ -0,0 +1,55 @@
# LANE C — pub venue interface (round 12, v3.0-alpha) → for Lane D + Lane F
*PROCITY-C, 2026-07-15. R12 §Lane C. The band room ships behind `?gigs=1` (A only emits `pub` shops
when the flag is on). Interface published here + in this commit's code; non-pub rooms are byte-identical
(the venue code only runs for `recipe.venue`, on its own seed sub-streams). Verified: 40-seed pub sweep +
60-room mixed soak — 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst 19 ms.*
## What `buildInterior` returns for a pub (type `'pub'`)
Everything below is **pure data on the return** (room-local coords, `counter.stand` ry convention:
rig-front = local Z), so Lane D drops rigs straight in and Lane F reads state — no new lifecycle.
### `room.stage` — the band stage (null for non-venues)
```js
room.stage = {
x, z, // deck centre (against the back wall)
w, d, // deck size (m)
deckY, // top surface height (m) — band rigs stand at y = deckY
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)
}
```
**Lane D:** put the 3-piece band at `bandPoses` (lift to `y = deckY`); `role` hints instrument. Drummer
can sit up-stage on the riser (there's a raised riser at deck-centre-back) — your call, C just gives 3 front poses.
Instruments = E's GLBs when present, primitives otherwise (asset law).
### `room.watchPoints` — the audience (empty [] for non-venues)
```js
room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // 6..8 seeded floor poses, all facing the stage centre
```
- `dance` is seeded **~⅓ true** ("a bit of both"). **Lane D:** `dance:false` → stand-and-watch idle
(subtle weight-shift); `dance:true` → bob/sway/step loop with a **seeded phase offset** so the crowd
doesn't metronome (use `slotIndex` or the pose coords to derive the phase).
- Every point is walkable, reachable from the door, and clear of the keeper stand, browse points, and the
stage/PA footprint. Deterministic per shop.seed. Crowd cap = `watchPoints.length` (F's smoke asserts ≤).
### `room.counter` / keeper — unchanged
The bar is a normal counter; the **barkeep is just a keeper** at `room.counter.stand` (existing R9 seam).
### `room.audio.gigKey` — the live bed
`room.audio = { musicKey, toneKey, gigKey? }`. **Lane F** passes gig state via `opts.gig` to `buildInterior`:
```js
buildInterior(pubShop, THREE, { gig: { on: true, genreKey: 'pubrock' } })
// → 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
(`toneKey` room-tone + maybe the seeded radio `musicKey`).
- **Prefer `gigKey` over `musicKey`** while the gig plays: `play(room.audio.gigKey || room.audio.musicKey)`.
- **Lane E:** please name the pub-rock live bed **`gig-pubrock`** in `manifest.audio` (music or a `gig`
section) so the key resolves; missing key → silence (audio law), band still visible.
## The archetype (C-owned, FYI)
`pub` recipe (`theme.js`, `venue:true`): stage + PA + amps at the back wall, bar counter (`corner`),
sticky-carpet floor, pub tables + bar-back shelf against the walls, the blocked back doorway doubles as
the **green room**. Primitive stage/PA/amp fittings today with **GLB slots for Lane E**: `stage`,
`pa_speaker`, `amp` (house GLB law; primitive fallback holds — a gig with no GLBs is still a gig).

View File

@ -351,3 +351,99 @@ are the top Lane F item for R6.)*
### Marshal
Shared-tree arbiter this round: F owns `web/index.html` + `tools/`; if two lanes collide on a file,
F arbitrates. Cross-lane asks → the owning lane's NOTES, not silent edits.
---
## 12. Round 12 — v3.0-alpha gig layer (`?gigs=1`). F wires it last; findings below.
**What F built.** `web/js/world/gig_state.js` (new, F-owned) — the Friday-night state machine; the
`?gigs=1` seam + `custom_bands.json` bootstrap in `index.html`; the cover charge at the door; the band +
crowd + gig-bed wiring in `interior_mode.js`; `smoke_gigs` in `tools/flags_check.py` (6 gates) + the
v3 arm of the flags-off regression.
### The state machine (`gig_state.js`) — the two things that are load-bearing
1. **It listens, it doesn't poll.** `lighting.js` dispatches `procity:segment` on every segment change;
the latch rides that. A poll-only machine sees only the segments it is sampled on — driving
NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside the pub, audio muted, so
neither F's street loop nor B's audio rAF is reading) **skipped the night roll entirely**: the night
never advanced and the cover was never due again. Caught live, fixed, re-verified.
2. **`state` is a getter, not a field.** B's audio engine self-ticks on its own rAF and reads
`PROCITY.gigs.state` while the player is *inside* a shop — i.e. when F's street loop is not running.
A pushed field goes stale there and the spill lies about the gig.
3. **The night rolls on WHERE the clock lands, not on watching `done` go by.** First cut incremented the
night when leaving `'done'` forwards — which meant a clock *jump* straight from NIGHT to MORNING (never
touching DAWN) never rolled the night at all, and **a broke punter walked in on last night's paid
stamp**. `smoke_gigs` caught it; stepping with `[`/`]` always passes through DAWN, so only a jump
(DBG/shots/soak all jump) exposed it. Now: the latch clears wherever it clears, and landing in the
**daytime** (MORNING…ARVO) is what rolls the night; landing on the **doors** means we rewound out of
the gig — same night, stays paid.
`done` is real, not decorative: the segment→hour map tops out at NIGHT (22:00) so the gig owns the whole
night segment, and "after the gig" can only be DAWN (closing time — band packing up, crowd draining per
A's closing-time ruling). Latch clears on any non-DAWN segment so rewinding the clock (`[`) doesn't leave
`done` stuck on a midday town — it did exactly that on the first verify.
### ⚠ Finding 1 (Lane C ↔ Lane E) — the gig bed key never met. **F bridged it; needs a contract line.**
`LANE_C_PUB.md` asks E to name the live bed **`gig-pubrock`** (C emits `room.audio.gigKey = 'gig-' +
genreKey`). E shipped it as **`pubrock-live`** (`gig:true`, `types:['pub']`) — which is also the key B's
street spill hardcodes. Neither is wrong; they just never met. Unresolved, `AUDIO.music['gig-pubrock']`
is `undefined` and **the pub plays to a silent room** — and because the audio law fails soft, nothing
errors: you just get a band miming. F resolves it at the glue (`interior_mode.js GIG_BED` + a generic
`gig-<genre>``<genre>-live` fallback). **This is the R1 facade-skin filename trap again** (registry
stored filenames, `skins.js` expected bare keys). → **C/E/Fable: pick one and put it in CITY_SPEC as a
named contract**, so genre #2 doesn't rediscover this. F's smoke now asserts `gigKey` resolves.
### ⚠ Finding 2 (Lane D + Lane E) — E's instrument GLBs landed; nothing wires them.
E shipped 5 instrument GLBs this round (`electric_guitar`, `bass_guitar`, `drum_kit`, `guitar_amp`,
`mic_stand`, all in `manifest.fittings` with footprints + thumbs). D's band holds **primitives** and
published `spawn(..., { instrumentFor })` as the injection seam — D-progress says E's GLBs "drop in via
`opts.instrumentFor(role)` **when they land**". They have landed. Nobody owns the join. Not blocking
(asset law: a primitive band is still a gig, and it reads fine in the shot), but it is a dangling R12
deliverable. F did **not** do it silently: picking manifest ids and orienting/scaling instruments inside
D's rig is D's call, and the R12 no-drummer question below wants answering in the same pass. → **R13.**
### ⚠ Finding 3 (ROUND12 spec ↔ Lane C) — the band has no drummer.
ROUND12 §Lane D.1 specifies "guitarist/bassist strum-sway, **drummer seated bob**", and E was asked for
"drum kit (one merged kit)". C's `stage.bandPoses` are **guitar / vocal / bass** — a front-line trio; the
drum riser is set-dressing with no pose. D built to C's poses and flagged it. So the shipped alpha band is
3-across-the-front with a mic stand where the spec expected a kit. Cosmetic, non-blocking, deterministic —
but it is a **spec/implementation divergence that no gate can catch**. → **Fable/John: rule for R13** (add
a 4th `drums` pose on the riser, or amend the spec to a front-line trio).
### Gate lesson — "no giants" must measure STATURE, not world crown.
The R10 gate asserts head-bone world Y ∈ [1.4, 2.0]. The band stands on C's **0.32 m stage deck**, so a
perfectly human 1.73 m guitarist has a world crown of **2.05 m** and a naive world-Y check **fails a
correct band** (it did, first run). `smoke_gigs` measures `crown base.y` for the band/crowd and keeps
the world-crown assert only for "never clips the ceiling", which is the real R10 failure mode.
`tools/qa/interior_scale_check.py` is unchanged (keepers/browsers stand on the floor, base.y = 0) — but
**if anyone ever puts a keeper on a platform, that gate inherits this bug.**
### → Lane B (small, R13): no venue bookmark for shots
`dbg.js BOOKMARKS` has no venue pose, and `DBG.shot(name)` forces street mode **and re-poses/re-segments**
— so there is no way to drive a scripted "lit pub frontage at night, gig on" street shot (the alpha's other
money shot; your marquee + posters + spill are the whole street story of this round). F verified the
frontage lights correctly (`emissiveIntensity` 1.7 at `on`, 71 draws / 29.6k tris at the venue) but shot it
by hand. **Ask:** add a `venue_night` bookmark (pose at the pub frontage, `seg:5`) — then F's
`tools/qa/gig_shot.py` can capture the street side too. This is the same "3 missing bookmark poses" ask
from R3 §9, so it may be worth doing the set in one pass.
### Harness note — where the browser gates actually run
This session's box had **no Chromium and no Chrome**, so every Playwright gate (flags harness + scale
gate) is un-runnable locally until `tools/.venv/bin/python -m playwright install chromium`. The repo at
`/Volumes/m3ultra/Documents/procity` is a **mount of m3ultra's `~/Documents/procity`** — same tree, so
`ssh m3ultra@100.89.131.57` runs the real gates on the real files. **Gotcha:** node is not on the
non-interactive ssh PATH — `env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict`, or every node gate
reports a bogus FAIL (`node: command not found`).
### R12 numbers (measured, seed 20261990 → "Boolarra Heads", venue = The Thornbury Hotel)
| thing | number |
|---|---|
| gig-night interior, rig fleet | **4250 draws** · ~40k tris (budget **≤350**) |
| gig-night interior, `?noassets=1` placeholders (**worst**) | **171 draws** (budget ≤350) |
| gig-night street at the venue | 69 draws · 29.6k tris (budget 300 / 200k) |
| band / crowd | **3** on the deck · **8** at 8 watch points · **3 dancing / 5 standing** |
| figure scale | band stature 1.681.77 m (crown ≤2.09 m under a 4.0 m ceiling); crowd 1.601.77 m |
| leak, 4 gig enter/exit cycles | geo 66→66 · tex 63→63 (**zero**) |
| tonight's covers (7 nights) | `10, 0, 0, 3, 2, 0, 9` — 3 free of 7 (~half, as seeded) |
| custom band names surfacing | **7 of John's 10** fill all 7 nights; `?noassets=1` → pure generator |

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -417,3 +417,36 @@ deterministic/seeded, perfectly loopable, and licence-clean.
- **Budget**: 8.1 MB shipped (2.8 MB ogg + 5.3 MB m4a) vs the 25 MB cap; lazy-loaded per manifest.
- Files ship in `web/assets/audio/` (committed, like `gen/` skins); WAV scratch `pipeline/.genaudio/`
is git-ignored. Contract in `manifest.json``audio:{ambience,sfx,music}`.
## Round 12 — gig layer provenance (v3.0-alpha, `?gigs=1`)
**Gig audio (3, procedural — same discipline as R11):** `music-pubrock-live` (original 90s pub-rock
riff — overdriven power chords + bass + rock kit), `ambience-crowd-walla` (synth crowd murmur,
decorrelated L/R), `sfx-applause` (400 filtered-noise claps + cheer swell). 🟢 100% original synthesis,
$0, seamless loops, parody-safe. Flagged `gig:true` in the manifest; F plays them when a gig is on.
Pack now 26 assets, 9.1 MB of 25 MB.
**Gig posters (4 skins, `skins.poster`):** poster-xerox/screenprint/grunge/retro — 90s pub-rock flyer
templates generated by MODELBEAST `flux_local`. **Blank-name-band discipline** (like blank-signboard
facades): each has a clean top banner and *no baked text*; B/C overprint the seeded band name into
`nameZone` [x,y,w,h]. Eyeballed (contact sheet in the round note) — all 4 have the blank banner, zero
legible text. 🟢 original generation, portrait 768×1024 JPEG in `web/assets/gen/`.
**Gig instruments (5 GLBs, MODELBEAST `flux_local` concept → `trellis_mac``normalize.py`):**
🟢 100% original synthesis, $0. **All 5 kept** — every one reconstructed cleanly (better than the
round-3 wire rack; even the mic-stand's thin boom pole and the drum cymbal hardware survived). Held
by D's band rigs; primitive fallback if a GLB is absent (house law).
| instrument | tris | footprint (m) | h (m) | note |
|---|---|---|---|---|
| electric_guitar | 43k | 0.38×0.08 | 1.0 | sunburst solid-body, clean |
| bass_guitar | 36k | 0.34×0.12 | 1.15 | black bass, clean |
| drum_kit | 30k | 1.50×1.24 | 1.3 | full kit, cymbals survived |
| guitar_amp | **96k** | 0.51×0.29 | 0.6 | combo amp — heaviest (grille cloth → shell-soup) |
| mic_stand | 34k | 0.57×0.57 | 1.7 | chrome boom stand, thin pole held |
- Same TRELLIS tri-weight tradeoff as round-3 props (3096k tris vs the ≤5k library law) — accepted
for focal/optional/LOD-able stage props seen at mid-distance; texture-bake-to-lowpoly is the
follow-up. Amp (96k) is the outlier — a candidate for that pass first. Eyeballed (contact sheet).
- Staged to `web/assets/models/` for local validation (`?localdepot=1`). **Depot publish PENDING
John's OK** (the classifier gates the outward-facing push; provenance recorded here first).

View File

@ -115,6 +115,24 @@ loops. Loudness: beds/music `loudnorm` -16 LUFS, sfx peak-normalised. **Ear-ball
final ear-check is John's/F's). Add assets by editing `PACK` in `gen_audio.py` + the `AUDIO_*` maps in
`build_manifest.py`. WAV scratch `pipeline/.genaudio/` is git-ignored; OGG/M4A ship committed.
### 9. Gig layer — round-12 (v3.0-alpha, `?gigs=1`) *(DONE, free, on-device)*
Three asset streams for one-pub-end-to-end, all $0:
```bash
# instruments (5 GLBs): flux concept -> trellis -> normalize, via the --batch override
python3 pipeline/gen_props.py --batch pipeline/gig_instruments.json --concepts # eyeball first
python3 pipeline/gen_props.py --batch pipeline/gig_instruments.json --mesh # ~3-4 min each
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/normalize.py -- pipeline/gig_norm_batch.json pipeline/_normalized web/assets/thumbs
# posters (4 skins): portrait flyers with a blank name-band (B/C overprint like signboards)
python3 pipeline/gen_skins.py --posters && python3 pipeline/gen_skins.py --harvest
# gig audio (3): pub-rock live bed + crowd-walla + applause — already in gen_audio.py PACK
~/Documents/MODELBEAST/venvs/mflux/bin/python pipeline/gen_audio.py --only music # etc.
python3 pipeline/build_manifest.py # catalogues skins.poster + audio gig assets + fittings
```
Instruments: amp/guitar/bass reconstruct well; thin objects (mic-stand pole, drum hardware) may fall
to D's primitive (round-3 thin-wire lesson — spec allows "primitives when absent"). Posters carry
`nameZone` [x,y,w,h] in `skins.poster`. Gig audio is flagged `gig:true` in `manifest.audio`.
## Cost summary
| step | cost |

View File

@ -0,0 +1,90 @@
{
"results": [
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/electric-guitar.glb",
"file": "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",
"tri_before": 181716,
"tri_after": 43484,
"size_m": [
0.38,
1.0,
0.077
],
"footprint": [
0.38,
0.08
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bass-guitar.glb",
"file": "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",
"tri_before": 192238,
"tri_after": 36048,
"size_m": [
0.337,
1.15,
0.124
],
"footprint": [
0.34,
0.12
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drum-kit.glb",
"file": "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",
"tri_before": 197990,
"tri_after": 29890,
"size_m": [
1.498,
1.3,
1.242
],
"footprint": [
1.5,
1.24
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/guitar-amp.glb",
"file": "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",
"tri_before": 180813,
"tri_after": 96592,
"size_m": [
0.511,
0.6,
0.285
],
"footprint": [
0.51,
0.29
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/mic-stand.glb",
"file": "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",
"tri_before": 162256,
"tri_after": 34124,
"size_m": [
0.571,
1.7,
0.573
],
"footprint": [
0.57,
0.57
]
}
],
"errors": []
}

View File

@ -1,5 +1,6 @@
[
"procity_fit_arcade_cabinet_01.glb",
"procity_fit_bass_guitar_01.glb",
"procity_fit_bookshelf_01.glb",
"procity_fit_box_crate_01.glb",
"procity_fit_clothes_rack_01.glb",
@ -9,9 +10,13 @@
"procity_fit_crate_stack_01.glb",
"procity_fit_cube_shelf_wide_01.glb",
"procity_fit_drinks_fridge_01.glb",
"procity_fit_drum_kit_01.glb",
"procity_fit_electric_guitar_01.glb",
"procity_fit_glass_case_01.glb",
"procity_fit_guitar_amp_01.glb",
"procity_fit_listening_booth_01.glb",
"procity_fit_magazine_rack_01.glb",
"procity_fit_mic_stand_01.glb",
"procity_fit_milkshake_mixer_01.glb",
"procity_fit_record_crate_01.glb",
"procity_fit_wire_shelf_01.glb",
@ -25,5 +30,6 @@
"procity_street_longbench_01.glb",
"procity_street_novelty_record_01.glb",
"procity_street_park_bench_01.glb",
"procity_street_streetlight_01.glb"
"procity_street_streetlight_01.glb",
"vintage-cash-register.glb"
]

View File

@ -15,6 +15,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "web", "assets", "gen")
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") # library GLBs
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
GIG_RESULTS = os.path.join(ROOT, "pipeline", "_gig_results.json") # round-12 gig instruments
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
DEPOT = "https://digalot.fyi/3god"
@ -32,9 +33,11 @@ AUDIO_MUSIC = { # interior music key -> (gain, shop types, note)
"milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
"video-synth": (0.50, ["video"], "video store synth hum"),
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"),
"pubrock-live":(0.60, ["pub"], "gig live bed — F plays via room.audio gigKey when a gig is on"),
}
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
"toast": 0.5, "tram-bell": 0.7}
"toast": 0.5, "tram-bell": 0.7, "applause": 0.7} # applause = R12 gig
AUDIO_SFX_LOOP = {"tram-rumble": 0.5}
AUDIO_FOOTSTEPS = {"pavement": 3, "timber": 3} # surface -> variants
@ -118,6 +121,10 @@ def build_audio():
r = ref("roomtone-" + k)
if r:
ambience["roomtone-" + k] = {**r, "loop": True, "gain": 0.35, "scope": "interior", "types": types}
for k, (g, note) in AUDIO_INTERIOR_AMB.items(): # R12 gig crowd
r = ref("ambience-" + k)
if r:
ambience[k] = {**r, "loop": True, "gain": g, "scope": "interior", "gig": True, "note": note}
music = {}
for k, (g, types, note) in AUDIO_MUSIC.items():
r = ref("music-" + k)
@ -140,6 +147,10 @@ def build_audio():
footsteps[surf] = vs
if footsteps:
sfx["footstep"] = footsteps
if "pubrock-live" in music: # R12: flag gig-only assets for F
music["pubrock-live"]["gig"] = True
if "applause" in sfx:
sfx["applause"]["gig"] = True
return {"ambience": ambience, "sfx": sfx, "music": music}
@ -193,6 +204,15 @@ def build():
for k, t in SURFACE_TEX.items() if f("tex", k)]
awning = [{"id": k, "file": f("tex", k)} for k in AWNING_TEX if f("tex", k)]
# ---- gig posters (round 12): blank-name-band flyers. B/C overprint the seeded band name in
# nameZone = [x,y,w,h] fractions of the image (the top banner) — like blank-signboard facades ----
POSTER_NAME_ZONE = [0.08, 0.04, 0.84, 0.23]
poster = {}
for name in sorted(have):
if name.startswith("poster-"):
poster[name[len("poster-"):]] = {"file": "gen/" + name + ".jpg",
"nameZone": POSTER_NAME_ZONE, "signboard": "blank"}
skins = {
"facade": facade,
"sky": sky,
@ -200,13 +220,14 @@ def build():
"wall": wall,
"interior": {"floor": floor, "surface": surface},
"awning": awning,
"poster": poster,
}
# ---- fittings / furniture from the normalizer results (library + round-3 hero props) ----
fittings, furniture = {}, {}
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
DROP = {"procity_fit_cube_shelf_01.glb"}
for results_file in (RESULTS, PROPS_RESULTS):
for results_file in (RESULTS, PROPS_RESULTS, GIG_RESULTS):
if not os.path.exists(results_file):
continue
for r in json.load(open(results_file)).get("results", []):

View File

@ -389,6 +389,28 @@ def amb_roomtone_video(name):
extra=lambda r, d: osc(15734, d, "sine")*0.012)
def _walla_chan(r, dur):
base = bandpass(noise(dur, r), 300, 2600)
env = np.full(int(dur * SR), 0.5) # crowd "waves" = sum of slow random LFOs
for _ in range(4):
env = env + 0.22 * np.sin(2 * np.pi * r.uniform(0.1, 0.5) * tarr(dur) + r.random() * 6)
walla = base * np.clip(env, 0.1, 1.2) * 0.5
walla = 0.6 * walla + 0.4 * biquad_lp(hp1(walla, 400), 1600, 3.0) # vocal-ish formant colour
for _ in range(int(dur * 0.8)): # sparse laughs/calls
at = r.uniform(0, dur - 0.3)
blip = bandpass(noise(r.uniform(0.1, 0.3), r), 500, 2500)
blip *= np.hanning(len(blip)) * r.uniform(0.1, 0.25)
p = int(at * SR); walla[p:p+len(blip)] += blip
return walla
def amb_crowd_walla(name):
# pub crowd murmur — decorrelated L/R for width, seamless loop (gig interior layer)
r = rng(name); dur = 20.0 + XF
body = stereo(_walla_chan(r, dur), _walla_chan(r, dur))
return seamless(peaknorm(body, -6), XF)
# ─────────────────────────────────────────────────────────────────────────────
# SFX generators (short, dry, peak-normalized)
# ─────────────────────────────────────────────────────────────────────────────
@ -466,6 +488,22 @@ def sfx_tram_rumble(name):
return seamless(peaknorm(body, -6), XF)
def sfx_applause(name):
# applause + cheer sting (one-shot): a swell of ~400 filtered-noise claps over a cheer bed
r = rng(name); dur = 2.6
out = np.zeros(int(dur * SR))
for _ in range(400):
at = min(r.uniform(0, dur) * r.uniform(0.5, 1.0), dur - 0.02) # bias toward a swell
clap = bandpass(noise(0.015, r), 900, 5000) * np.exp(-np.linspace(0, 40, int(0.015*SR)))
p = int(at * SR); out[p:p+len(clap)] += clap * r.uniform(0.2, 0.6)
cheer = bandpass(noise(dur, r), 400, 2000)
swell = np.concatenate([np.linspace(0, 1, int(0.4*SR)),
np.ones(max(int(dur*SR) - int(1.2*SR), 0)),
np.linspace(1, 0, int(0.8*SR))])
cheer = cheer * np.resize(swell, len(cheer)) * 0.3
return peaknorm(fade(mix(out, cheer), 0.005, 0.15), -3)
def _footstep(name, bright, res_f):
r = rng(name)
thump = osc(np.linspace(res_f*1.6, res_f, int(0.05*SR)), 0.05, "sine")
@ -557,6 +595,28 @@ def music_arcade(name):
return loop_music(body, 0.03)
def powerchord(root, dur, drive=3.5):
x = osc(midi_hz(root), dur, "saw") + osc(midi_hz(root+7), dur, "saw") + osc(midi_hz(root+12), dur, "saw")
x = softclip(x * 0.4, drive) # overdriven pub-rock crunch
x = biquad_lp(x, 3200, 0.9)
return x * adsr(dur, 0.003, 0.05, 0.7, 0.04)
def music_pubrock(name):
# the gig live bed — 90s Aussie pub-rock: driving power chords, punchy bass, rock kit. Louder
# + rawer than the shop beds. Original I-bVII-IV-I riff in E.
r = rng(name); bpm = 144; bars = 8
prog = [40, 38, 45, 40] * 2 # E D A E, ×2 bars = 8 bars
guitar = render_line([(root, 0.5) for root in prog for _ in range(8)], # palm-mute 8ths
bpm, lambda m, d: powerchord(m, d), gap=0.02)
bass = render_line([(root - 12, 0.5) for root in prog for _ in range(8)],
bpm, lambda m, d: synth_note(m, d, "saw", 0.003, 0.05, 0.6, 0.03, cutoff=650))
drums = render_drums({"k": [0, 3, 6, 8, 11, 14], "s": [4, 12],
"h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [15]}, bpm, bars, r)
body = mix(pan(guitar, -0.12)*0.5, pan(bass, 0.1)*0.7, pan(drums, 0)*0.7)
return loop_music(body, 0.06)
# ─────────────────────────────────────────────────────────────────────────────
# the pack: name -> (category, generator, {loop, gain, note})
# ─────────────────────────────────────────────────────────────────────────────
@ -588,6 +648,10 @@ PACK = {
"music-milkbar": ("music", music_milkbar, dict(loop=True, gain=0.5, note="milk bar radio")),
"music-video-synth": ("music", music_video_synth, dict(loop=True, gain=0.5, note="video store")),
"music-arcade": ("music", music_arcade, dict(loop=True, gain=0.5, note="arcade")),
# ── round-12 gig layer (?gigs=1 consumers: F plays these when a gig is on) ────────────
"music-pubrock-live": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock; F plays via room.audio gigKey")),
"ambience-crowd-walla":("ambience", amb_crowd_walla, dict(loop=True, gain=0.4, note="gig crowd layer (interior)")),
"sfx-applause": ("sfx", sfx_applause, dict(loop=False, gain=0.7, note="between-sets cheer")),
}

View File

@ -106,6 +106,8 @@ def gen_mesh(a, draft=False):
if __name__ == "__main__":
if "--batch" in sys.argv: # override the asset list (e.g. gig instruments)
BATCH = os.path.abspath(sys.argv[sys.argv.index("--batch") + 1])
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
draft = "--draft" in sys.argv
todo = [a for a in props() if not only or only in a["name"]]

View File

@ -54,6 +54,18 @@ HOUSE = ("Straight-on photograph of a modest Australian suburban house front, {v
"a bit of fence, no text anywhere, overcast daylight, flat front elevation filling the "
"entire frame edge to edge, nothing else visible")
# ── round-12 gig posters: blank name-band like the blank-signboard facades (B/C overprint the band
# name). Portrait A4 flyer, no baked text — same overprint discipline as shop signs. ──────────────
POSTER = ("A 1990s Australian pub-rock gig poster flyer, {v}, a large BLANK empty rectangular banner "
"panel across the top reserved for the band name, no text no lettering no words anywhere, "
"portrait A4 flyer filling the entire frame edge to edge, high contrast, photocopied paper texture")
POSTERS = {
"poster-xerox": "black-and-white photocopied punk flyer, high-contrast halftone silhouette of a band, torn paper edges and staple marks",
"poster-screenprint":"bold two-colour screenprinted poster, crimson and black, a graphic electric-guitar motif and sound-wave bars",
"poster-grunge": "grungy spray-paint stencil flyer, distressed mustard-yellow and black, splatter and photocopier streaks",
"poster-retro": "colourful retro flyer, orange and teal, geometric 70s-revival shapes and a starburst",
}
PROMPTS = {
# ── shop-type facades that the registry is thin on ──────────────────────────────────
**{f"facade-{k}": FACADE.format(v=v) for k, v in {
@ -182,6 +194,23 @@ def harvest():
if __name__ == "__main__":
if "--harvest" in sys.argv:
harvest(); sys.exit(0)
if "--posters" in sys.argv: # round-12 gig posters (portrait, local only)
todo = {s: POSTER.format(v=v) for s, v in POSTERS.items() if not have(s)}
print(f"{len(todo)} gig posters (MODELBEAST flux2-klein-4b, local·free)")
if "--dry-run" in sys.argv:
for s in sorted(todo):
print(f" {s}")
sys.exit(0)
if not local_available():
print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN})."); sys.exit(2)
for i, (slug, prompt) in enumerate(sorted(todo.items()), 1):
try:
fn = gen_local(slug, prompt, w=768, h=1024, seed=11) # portrait A4
print(f"[{i}/{len(todo)}] {slug} ({os.path.getsize(fn)//1024}KB)")
except Exception as e:
print(f"[{i}/{len(todo)}] {slug} FAILED: {str(e)[:90]}")
print("done. Review .genraw/poster-*.png, then --harvest → web/assets/gen/poster-*.jpg.")
sys.exit(0)
LOCAL = "--local" in sys.argv # MODELBEAST flux_local: on-device, free, ungated
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
# local = one GPU job at a time (MPS won't share cleanly); cloud fans out

View File

@ -0,0 +1,10 @@
{
"_comment": "PROCITY round-12 gig instruments — E slot GLBs for the pub stage (D's band rigs hold them; primitives when absent). MODELBEAST flux_local concept -> trellis_mac -> normalize.py. Thin objects (mic-stand pole, drum cymbal stands) may reconstruct badly (round-3 thin-wire lesson) -> primitive fallback. height_m = longest real dimension; D orients when placing.",
"assets": [
{"name": "electric-guitar", "prompt": "a solid-body electric guitar, sunburst finish, full neck with headstock and six strings, pickups and bridge, 1990s pub-rock", "height_m": 1.0, "tris": 5000, "for": "stage"},
{"name": "bass-guitar", "prompt": "a solid-body electric bass guitar, black finish, long neck with headstock and four thick strings, 1990s pub-rock", "height_m": 1.15, "tris": 5000, "for": "stage"},
{"name": "drum-kit", "prompt": "a compact rock drum kit as one assembly, bass drum with pedal, snare, two mounted toms, hi-hat and a crash cymbal on stands", "height_m": 1.3, "tris": 8000, "for": "stage"},
{"name": "guitar-amp", "prompt": "a guitar amplifier combo, rectangular black tolex speaker cabinet with a cloth grille and a top control panel with knobs, 1990s", "height_m": 0.6, "tris": 4000, "for": "stage"},
{"name": "mic-stand", "prompt": "a chrome microphone on a tall round-base boom stand, single vertical pole with a clip and a dynamic mic", "height_m": 1.7, "tris": 3000, "for": "stage"}
]
}

View File

@ -0,0 +1,37 @@
[
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/electric-guitar.glb",
"id": "electric_guitar",
"category": "fit",
"height_m": 1.0,
"tris": 5000
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bass-guitar.glb",
"id": "bass_guitar",
"category": "fit",
"height_m": 1.15,
"tris": 5000
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drum-kit.glb",
"id": "drum_kit",
"category": "fit",
"height_m": 1.3,
"tris": 8000
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/guitar-amp.glb",
"id": "guitar_amp",
"category": "fit",
"height_m": 0.6,
"tris": 4000
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/mic-stand.glb",
"id": "mic_stand",
"category": "fit",
"height_m": 1.7,
"tris": 3000
}
]

View File

@ -38,6 +38,13 @@ GOLDEN_HASH = 0x3fa36874 # frozen determinism anchor (Lane A sel
BASE_DRAWS, BASE_TRIS = 164, 66000
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
KNOWN_FLAGS = ['winmap=1', 'dig=1', 'roster=stream'] # plansrc=osm auto-appended if Lane A landed it
# [R12] gig-night interior budget (ROUND12 law: ≤350 draws incl. the crowd — a room with a band and an
# audience in it is allowed more than a shop, which is what the ≤300 street/room numbers were tuned for).
GIG_DRAWS_MAX = 350
# `cover` is seeded per gig, so which door-charge path the default seed exercises is luck. Pin one seed
# per half of John's "free AND paid" ruling: 20261990's tonight is $10 (Screaming Utes at The Thornbury
# Hotel), 1234's is free (Dazza & The Dropbears at The Imperial). Re-pin if Lane A re-seeds the gig stream.
FREE_GIG_SEED = 1234
fails, warns = [], []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
@ -78,6 +85,17 @@ def boot(pg, query):
except Exception:
pass
def gigs_landed(p):
"""Boot ?gigs=1; landed iff Lane A's augmentation produced a schedule (else the flag is inert)."""
b, pg, _ = new_page(p)
try:
boot(pg, 'gigs=1')
return bool(pg.evaluate("() => !!(window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"))
except Exception:
return False
finally:
b.close()
def plansrc_landed(p):
"""Boot ?plansrc=osm; landed iff the shell exposes an 'osm' plan source (else the flag is ignored)."""
b, pg, _ = new_page(p)
@ -105,7 +123,12 @@ def flags_off_regression(p):
return { draws:i.drawCalls, tris:i.tris, mode:i.mode,
winmap: !!(P.winmap && P.winmap.active),
digActive: !!(P.interiorMode && P.interiorMode.digActive),
streamMode: !!c.streamMode }; // R7: real roster-mode flag (was reading a nonexistent prop)
streamMode: !!c.streamMode, // R7: real roster-mode flag (was reading a nonexistent prop)
// [R12] the gig layer must be wholly absent flags-off: no state machine, no schedule
// in the plan, no pub converted, and none of Lane B's venue geometry in the scene.
gigState: !!P.gigs, planGigs: (P.plan.gigs||[]).length, posters: (P.plan.posters||[]).length,
pub: !!(P.plan.shops||[]).find(s => s.venue || s.type === 'pub'),
venueGeo: !!P.scene.getObjectByName('venue-presentation') };
}""")
# determinism anchor — run Lane A's selfcheck in-process (golden fingerprint)
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT),
@ -124,6 +147,14 @@ def flags_off_regression(p):
OK('R7 roster flip in effect — streamed roster ACTIVE on default boot')
else: FAIL('roster flip REGRESSED — default boot is not streaming (expected streamMode:true)')
# [R12] gig layer absent flags-off (prime flag law for v3). Any of these leaking means ?gigs=1
# has escaped its flag and the frozen v2 goldens are living on borrowed time.
gig_leaks = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
if not gig_leaks:
OK('v3 gig layer wholly absent flags-off (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
else:
FAIL(f"v3 gig layer LEAKED into the default boot: {[(k, state[k]) for k in gig_leaks]}")
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
OK(f"flags-off street view within budget (draws {state['draws']}{STREET_DRAWS_MAX}, tris {state['tris']}{STREET_TRIS_MAX})")
else: FAIL(f"flags-off street view OVER budget: draws {state['draws']}, tris {state['tris']}")
@ -560,6 +591,220 @@ def smoke_audio(p):
finally:
b.close()
def smoke_gigs(p):
"""R12 v3.0-alpha gig layer (Lane F). The six gates from ROUND12 §Lane F.5 — schedule determinism ·
band always 3 · crowd watchPoints · gig-night interior draw budget · no-giants on the stage/crowd
rigs (crowns re-asserted here so the gig path can't ship a giant even if qa runs without the scale
gate) · ?noassets=1 still silent-and-fine with ?gigs=1 plus John's cover ruling (paid · free ·
broke-knockback · no re-charge on re-entry).
Seeds are pinned because `cover` is seeded: 20261990's tonight is a $10 night (Screaming Utes at The
Thornbury Hotel) and 1234's is free (Dazza & The Dropbears at The Imperial), so BOTH halves of "free
AND paid" are covered deterministically rather than whichever the default seed happened to roll."""
head('SMOKE: gigs (R12 v3.0-alpha — schedule · band · crowd · budget · cover · no-giants · noassets)')
if not gigs_landed(p):
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
return
# 1) schedule determinism — two independent boots (fresh contexts) → byte-equal gigs + posters
sigs = []
for _ in range(2):
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
sigs.append(pg.evaluate("() => JSON.stringify({g: window.PROCITY.plan.gigs, p: window.PROCITY.plan.posters})"))
if errs: FAIL(f"gigs boot: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
if sigs[0] == sigs[1]:
n = len(json.loads(sigs[0])['g'])
OK(f"gig schedule deterministic: two runs byte-equal ({n} nights + posters)")
else:
FAIL('gig schedule NOT deterministic across two runs (byte-diff in plan.gigs/plan.posters)')
# 2) the gig night itself: band · crowd · dance mix · budget · the live bed · no giants
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
pg.keyboard.press('Space') # gesture → AudioContext unlock (headless-safe, no pointer-lock)
night = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
D.setSegment(5); // NIGHT the gig is on
const state = P.gigs.state;
D.enterShop(P.gigs.venueShopId);
await new Promise(r => setTimeout(r, 500));
const room = P.interiorMode.current;
if (!room) return { entered: false, state };
const crew = P.interiorMode.crew, info = P.interiorMode.crewInfo;
const mem = crew ? crew.members : [];
// STATURE, not world crown: the band stands on Lane C's stage deck (deckY ~0.32 m), so a
// perfectly human 1.73 m guitarist has a world crown of 2.05 m and a naive [1.4,2.0] world-Y
// check fails a correct band (it did, on the first R12 verify run). Measure crown MINUS the
// figure's own base y — that is what "human-sized" actually means — and keep the separate
// "never clips the ceiling" assert on the world crown, which is the real R10 failure mode.
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, dance: !!m.dance,
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3) };
});
// Force one clean interior frame rather than trusting whatever the last rAF left in
// renderer.info a throttled/backgrounded tab otherwise reports the last STREET frame
// (composer + bloom), which reads as a wildly over-budget room. Bit us on the first verify.
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
return {
entered: true, state, H: +room.dims.H.toFixed(2),
watchPoints: room.watchPoints.length, stage: !!room.stage,
band: info ? info.band : 0, crowd: info ? info.crowd : 0,
dancers: mem.filter(m => m.part === 'crowd' && m.dance).length,
gigKey: room.audio.gigKey || null, musicKey: room.audio.musicKey || null,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles, figs,
};
}""")
if not night.get('entered'):
FAIL(f"gig night: could not enter the venue at NIGHT ({night})")
else:
if night['state'] == 'on': OK(f"gig state machine: NIGHT → 'on' (venue {night['H']}m room)")
else: FAIL(f"gig state machine: NIGHT should be 'on', got '{night['state']}'")
# band always 3 (D's contract: 3 poses on C's deck)
if night['band'] == 3: OK('band: exactly 3 on the stage')
else: FAIL(f"band: expected 3 on the stage, got {night['band']}")
# crowd cap ≤ watchPoints
if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0:
OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)")
else:
FAIL(f"crowd {night['crowd']} vs {night['watchPoints']} watch points (must be >0 and ≤ cap)")
# John's "a bit of both": the crowd must not be all-standing or all-dancing
d, c = night['dancers'], night['crowd']
if c and 0 < d < c: OK(f"crowd mix: {d} dancing / {c - d} standing — 'a bit of both'")
else: FAIL(f"crowd is not mixed: {d} dancing of {c} (John's ruling: some of both)")
# the live bed actually resolves (the C↔E gigKey seam F bridges — see LANE_F_NOTES §12)
if night['gigKey']: OK(f"live bed: room.audio.gigKey = {night['gigKey']} (F resolves → manifest key)")
else: FAIL('live bed: no room.audio.gigKey on a gig-on venue (Lane C opts.gig seam)')
# gig-night interior draw budget (ROUND12: ≤350 incl. the crowd)
if night['draws'] <= GIG_DRAWS_MAX:
OK(f"gig-night interior budget: {night['draws']} draws ≤ {GIG_DRAWS_MAX} (tris {night['tris']:,})")
else:
FAIL(f"gig-night interior budget: {night['draws']} draws > {GIG_DRAWS_MAX} (tris {night['tris']:,})")
# no giants on the stage/crowd rigs (R10 gate, inherited by the gig path)
rigs = [f for f in night['figs'] if f['kind'] == 'rig']
bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0) or f['crown'] >= night['H']]
if not rigs:
WARN(f"no-giants: crew spawned as placeholders (no fleet) — rig check n/a ({len(night['figs'])} figs)")
elif bad:
FAIL(f"no-giants: {len(bad)} gig rig(s) out of [1.4,2.0]m stature or through the {night['H']}m ceiling: {bad[:3]}")
else:
lo = min(f['stature'] for f in rigs); hi = max(f['stature'] for f in rigs)
hc = max(f['crown'] for f in rigs)
OK(f"no-giants: {len(rigs)} band+crowd rigs human-sized ({lo:.2f}{hi:.2f} m stature; "
f"tallest crown {hc:.2f} m under the {night['H']} m ceiling)")
if errs: FAIL(f"gig night: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('gig night: 0 console errors')
finally:
b.close()
# 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
paid = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5);
const cover = P.gigs.cover;
if (!cover) return { skip: true, cover };
const before = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
const inRoom1 = !!P.interiorMode.current, afterPay = P.wallet.cash();
D.exitShop(); await new Promise(r => setTimeout(r, 250));
const mid = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
const inRoom2 = !!P.interiorMode.current, afterReentry = P.wallet.cash();
D.exitShop(); await new Promise(r => setTimeout(r, 250));
// now go broke and try again on the SAME night (stamp already paid still free), then roll the
// night over (MORNING clears the stamp) and try broke must be knocked back at the door.
P.wallet.buy({ title: 'qa: drain', price: P.wallet.cash() });
D.setSegment(1); D.setSegment(5); // NIGHT MORNING NIGHT = a new night, cover due
const brokeCash = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
const inRoomBroke = !!P.interiorMode.current;
return { cover, before, afterPay, inRoom1, mid, afterReentry, inRoom2, brokeCash, inRoomBroke,
inv: P.wallet.inventory().map(i => i.title) };
}""")
if paid.get('skip'):
WARN(f"cover: seed {SEED} tonight is free (cover 0) — paid path not exercised here")
else:
if paid['inRoom1'] and paid['afterPay'] == paid['before'] - paid['cover']:
OK(f"cover: ${paid['cover']} debited once at the door (${paid['before']} → ${paid['afterPay']}), entry granted")
else:
FAIL(f"cover: wrong debit/entry on a paid night: {paid}")
if paid['inRoom2'] and paid['afterReentry'] == paid['mid']:
OK(f"cover: re-entry the same night is FREE (no re-charge, still ${paid['afterReentry']})")
else:
FAIL(f"cover: re-entry re-charged or was refused: {paid}")
if not paid['inRoomBroke'] and paid['brokeCash'] < paid['cover']:
OK(f"cover: broke (${paid['brokeCash']}) on a NEW night → polite knockback, no entry")
else:
FAIL(f"cover: broke player got in (or the night never rolled): {paid}")
if errs: FAIL(f"cover: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
# 4) cover ruling, FREE night (seed 1234 → cover 0): walk straight in, wallet untouched
b, pg, errs = new_page(p)
try:
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1&gigs=1')
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
free = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5);
const cover = P.gigs.cover, before = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
return { cover, before, after: P.wallet.cash(), inRoom: !!P.interiorMode.current, band: P.gigs.bandName };
}""")
if free['cover'] == 0 and free['inRoom'] and free['after'] == free['before']:
OK(f"cover: free night (seed {FREE_GIG_SEED}, {free['band']}) → walked straight in, wallet untouched (${free['after']})")
else:
FAIL(f"cover: free-night entry wrong: {free}")
if errs: FAIL(f"free night: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
# 5) ?noassets=1 + ?gigs=1 — still silent-and-fine: zero audio/band-file fetches, gig still happens
b, pg, errs = new_page(p)
reqs = []
pg.on('request', lambda r: reqs.append(r.url)
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')
or 'custom_bands' in r.url or r.url.endswith('.glb')) else None)
try:
boot(pg, 'gigs=1&noassets=1')
pg.keyboard.press('Space')
na = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5);
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 450));
const info = P.interiorMode.crewInfo;
return { state: P.gigs.state, inRoom: !!P.interiorMode.current, band: info && info.band,
crowd: info && info.crowd, gigs: (P.plan.gigs || []).length,
bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest };
}""")
if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0:
OK(f"?noassets=1&gigs=1: the gig still happens (band {na['band']}, crowd {na['crowd']}, placeholders)")
else:
FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}")
if len(reqs) == 0:
OK(f"?noassets=1&gigs=1: 0 audio/GLB/band-file fetches (manifest={na['audio']}, bands from the generator)")
else:
FAIL(f"?noassets=1&gigs=1: {len(reqs)} asset fetch(es) leaked; first {reqs[0]}")
if errs: FAIL(f"?noassets=1&gigs=1: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('?noassets=1&gigs=1: 0 console errors (silent-and-happy)')
finally:
b.close()
def main():
srv = ensure_server()
try:
@ -584,6 +829,7 @@ def main():
smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves)
smoke_tram(p) # new (warn) — tram (auto-skips if not landed)
smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak)
smoke_gigs(p) # R12 — v3.0-alpha gig layer (schedule · band · crowd · budget · cover · noassets)
finally:
if srv: srv.terminate()

100
tools/qa/gig_shot.py Normal file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""PROCITY Lane F — v3.0-alpha acceptance shot (round 12).
The money shot the round asks for: gig ON, band on Lane C's deck, mixed standing/dancing crowd at the
watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be
re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo).
Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--out PATH]
Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG.
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUT = ROOT / 'docs' / 'shots' / 'laneF' / 'r12_v3alpha_gig.png'
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])
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
# Stand back by the door at eye height and look at the stage over the crowd's heads. Forcing one explicit
# render (rather than trusting rAF) keeps the shot deterministic in headless.
POSE_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
D.setSegment(5); // NIGHT the gig is on
D.enterShop(P.gigs.venueShopId);
await new Promise(r => setTimeout(r, 900)); // room build + crew spawn + rig clips settle
const room = P.interiorMode.current;
if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.state };
const st = room.stage;
P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2);
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
const crew = P.interiorMode.crew;
return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName,
state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
}
"""
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
srv = ensure_server()
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)',
timeout=15000)
except Exception:
print(' ! fleet never readied — shooting the placeholder crew')
info = pg.evaluate(POSE_JS)
if not info.get('ok'):
b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m')
OUT.parent.mkdir(parents=True, exist_ok=True)
pg.screenshot(path=str(OUT))
b.close()
finally:
if srv: srv.terminate()
print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}")
print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover")
print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · "
f"{info['draws']} draws · {info['tris']:,} tris")
if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
if __name__ == '__main__':
main()

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

@ -0,0 +1,15 @@
{
"_comment": "OPTIONAL band-name drop-in (v3 gigs). Edit the list and reload — these names get PRIORITY on the town's gig posters. Delete this file and the generator takes over (no errors). Schema: { \"bands\": [ \"name\", ... ] }. Owned by Lane A per ROUND12.",
"bands": [
"The Feral Galahs",
"Chunderbolt",
"Screaming Utes",
"The Rissoles",
"Dazza & The Dropbears",
"Sunburnt Country",
"The Flannel Shirts",
"Amyl & The Servos",
"Boganvillea",
"The Dead Ringers"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View File

@ -565,7 +565,49 @@
"id": "awning-blue",
"file": "gen/tex-awning-blue.jpg"
}
]
],
"poster": {
"grunge": {
"file": "gen/poster-grunge.jpg",
"nameZone": [
0.08,
0.04,
0.84,
0.23
],
"signboard": "blank"
},
"retro": {
"file": "gen/poster-retro.jpg",
"nameZone": [
0.08,
0.04,
0.84,
0.23
],
"signboard": "blank"
},
"screenprint": {
"file": "gen/poster-screenprint.jpg",
"nameZone": [
0.08,
0.04,
0.84,
0.23
],
"signboard": "blank"
},
"xerox": {
"file": "gen/poster-xerox.jpg",
"nameZone": [
0.08,
0.04,
0.84,
0.23
],
"signboard": "blank"
}
}
},
"fittings": {
"record_crate": {
@ -721,6 +763,51 @@
"height": 0.9,
"thumb": "thumbs/procity_fit_crate_stack_01.png"
},
"electric_guitar": {
"file": "procity_fit_electric_guitar_01.glb",
"footprint": [
0.38,
0.08
],
"height": 1.0,
"thumb": "thumbs/procity_fit_electric_guitar_01.png"
},
"bass_guitar": {
"file": "procity_fit_bass_guitar_01.glb",
"footprint": [
0.34,
0.12
],
"height": 1.15,
"thumb": "thumbs/procity_fit_bass_guitar_01.png"
},
"drum_kit": {
"file": "procity_fit_drum_kit_01.glb",
"footprint": [
1.5,
1.24
],
"height": 1.3,
"thumb": "thumbs/procity_fit_drum_kit_01.png"
},
"guitar_amp": {
"file": "procity_fit_guitar_amp_01.glb",
"footprint": [
0.51,
0.29
],
"height": 0.6,
"thumb": "thumbs/procity_fit_guitar_amp_01.png"
},
"mic_stand": {
"file": "procity_fit_mic_stand_01.glb",
"footprint": [
0.57,
0.57
],
"height": 1.7,
"thumb": "thumbs/procity_fit_mic_stand_01.png"
},
"cash_register": {
"file": "vintage-cash-register.glb",
"footprint": [
@ -900,6 +987,15 @@
"dept",
"arcade"
]
},
"crowd-walla": {
"file": "audio/ambience-crowd-walla.ogg",
"fallback": "audio/ambience-crowd-walla.m4a",
"loop": true,
"gain": 0.4,
"scope": "interior",
"gig": true,
"note": "gig crowd layer (interior; F plays when gig on)"
}
},
"sfx": {
@ -933,6 +1029,12 @@
"fallback": "audio/sfx-tram-bell.m4a",
"gain": 0.7
},
"applause": {
"file": "audio/sfx-applause.ogg",
"fallback": "audio/sfx-applause.m4a",
"gain": 0.7,
"gig": true
},
"tram-rumble": {
"file": "audio/sfx-tram-rumble.ogg",
"fallback": "audio/sfx-tram-rumble.m4a",
@ -1012,6 +1114,17 @@
"dept"
],
"note": "arcade cabinet"
},
"pubrock-live": {
"file": "audio/music-pubrock-live.ogg",
"fallback": "audio/music-pubrock-live.m4a",
"loop": true,
"gain": 0.6,
"types": [
"pub"
],
"note": "gig live bed \u2014 F plays via room.audio gigKey when a gig is on",
"gig": true
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -57,6 +57,8 @@ import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1)
import { createTram } from './js/world/tram.js'; // [Lane F R9] Lane B tram (?tram=1, non-blocking)
import { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1)
import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1)
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
@ -79,14 +81,36 @@ const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
// LANE_A_NOTES F2). Zero network either way. Guarded: falls back to synthetic generatePlan, then fixture.
const PLANSRC = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = params.get('town') || undefined; // [Lane F R8] &town=<key> selects an OSM town (A: melbourne|katoomba)
// [Lane F R12 — v3.0-alpha gig layer] ?gigs=1 turns on Lane A's post-hoc gig augmentation (the pub venue,
// plan.gigs, plan.posters) and everything F wires off it: the state machine, B's lit frontage + muffled
// spill, C's band room, D's band + crowd, the cover charge at the door. Default OFF ⇒ generatePlanFor
// returns the byte-identical v2 plan and NONE of the gig modules are ever constructed (prime flag law —
// the flags-off regression gate in tools/flags_check.py enforces exactly that).
const GIGS_ON = params.has('gigs') && params.get('gigs') !== '0';
// John's OPTIONAL band-name drop-in (web/assets/custom_bands.json — Lane A owns the file + schema; edit it
// and reload, the names get priority on the town's posters). Absent file → pure generator, zero errors.
// ZERO fetch when the flag is off or under ?noassets=1 (asset law). `no-cache` on purpose: this file exists
// to be hand-edited between reloads, so a stale 304 would make John's edit look broken.
async function loadCustomBands() {
if (!GIGS_ON || NOASSETS) return [];
try {
const r = await fetch('assets/custom_bands.json', { cache: 'no-cache' });
if (!r.ok) return []; // 404 = John deleted the file = generator takes over. Not an error.
const j = await r.json();
return Array.isArray(j && j.bands) ? j.bands : [];
} catch { return []; } // malformed / blocked → generator. Never breaks the boot.
}
const customBands = await loadCustomBands();
let plan;
try {
const citygen = await import('./js/citygen/index.js');
const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN }) // A's selector (synthetic | osm[+town])
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)
plan = gen ? gen(seed) : fixturePlan(seed);
console.log(`[procity] plan source: ${PLANSRC}${TOWN ? '/' + TOWN : ''} — ${plan.name}, ${plan.shops.length} shops`);
console.log(`[procity] plan source: ${PLANSRC}${TOWN ? '/' + TOWN : ''} — ${plan.name}, ${plan.shops.length} shops`
+ (GIGS_ON ? ` · gigs: ${(plan.gigs || []).length} nights, ${(plan.posters || []).length} posters`
+ `, ${customBands.length} custom band name(s)` : ''));
} catch (e) {
plan = fixturePlan(seed);
console.log('[procity] Lane A citygen absent — running on fixture_plan');
@ -155,10 +179,15 @@ if (STOCK_REAL) { preloadStockPack('record'); preloadStockPack('book'); preloadS
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
const wallet = createWallet(seed);
// [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id) });
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id), gigState });
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
@ -209,6 +238,10 @@ const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ scene, plan, camera, lighting }) : null;
// [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name
// overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state.
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null;
// spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2);
@ -238,6 +271,21 @@ function enterShop(shopId, name) {
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
// [Lane F §3.5] closed shops are locked; at night the openLate video shop is the one place still open
if (!isOpen(shop)) { hud.showToast(`🔒 ${name} — CLOSED · opens ${shop.hours[0]}:00`); return; }
// [Lane F R12 — cover charge at the door] John's ruling: free AND paid nights. A's `cover` is seeded per
// gig (~half 0). Paid → debit the R8 session wallet on the way in with a toast; too skint → a polite
// knockback and NO entry (the wallet stays honest); free → walk straight in. Re-entry the same night is
// free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. Zero new
// UI — it all rides the round-8 buy seam. The venue is a normal shop in every other respect.
if (gigState && shopId === gigState.venueShopId && gigState.open && gigState.cover > 0 && !gigState.paid) {
const cover = gigState.cover, BAND = String(gigState.bandName || 'live').toUpperCase();
if (!wallet.canBuy(cover)) {
hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`);
return;
}
wallet.buy({ title: `cover — ${gigState.bandName}`, price: cover });
gigState.markPaid();
hud.showToast(`$${cover} cover — ${BAND}`);
}
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
const lot = plan.lots.find((l) => l.id === shop.lot);
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name);
@ -311,6 +359,14 @@ function frame() {
lighting.update(dt, player.position);
const clk = lighting.getClock();
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
// [Lane F R12] Friday night, off the clock: tick the machine, ramp Lane B's marquee/sign glow, and point
// Lane D's patronage surge at the venue while the doors are open (setGig(id,false) ends it → normal day
// patronage). Lane B's audio engine reads window.PROCITY.gigs.state on its own rAF for the muffled spill.
if (gigState) {
const gs = gigState.update();
if (venuePresentation) venuePresentation.update(gs);
citizens.setGig(gigState.venueShopId, gs === 'doors' || gs === 'on');
}
// [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.
if (weather) weather.update(dt); // [Lane F R8] Lane B rain particles + wet ground
@ -337,6 +393,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so

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

@ -0,0 +1,158 @@
// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1).
//
// A Friday night at the pub: three musicians on Lane C's stage deck and a mixed crowd at C's
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig).
//
// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on,
// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs); E's instrument GLBs replace
// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId).
import * as THREE from 'three';
import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints
const MIC = 0x222226, POLE = 0x9a9a9e;
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.)
const RY_FLIP = Math.PI;
// ---- primitive instruments (E's GLBs replace these; footprint-compatible, held at the chest) ----
// Each returns an Object3D positioned in the actor's OUTER-fig space (origin at the feet, +Z front),
// so it rides the performer's procedural sway. Cheap shared-nothing meshes (3 instruments per gig).
function guitarPrim(bodyCol, big = false) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
const neckMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
const bw = big ? 0.30 : 0.34, bh = big ? 0.44 : 0.40;
const body = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, 0.06), bodyMat);
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat);
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
g.add(body, neck);
// the fleet mesh faces its local +Z (verified in-scene), so the chest — and the instrument — is +Z.
g.position.set(0.02, 1.02, 0.19); // across the chest, toward the audience
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd
return g;
}
function micStandPrim() {
const g = new THREE.Group();
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 1.42, 6),
new THREE.MeshStandardMaterial({ color: POLE, roughness: 0.4, metalness: 0.6 }));
pole.position.y = 0.71;
const head = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6),
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
head.position.y = 1.44;
g.add(pole, head);
g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side)
return g;
}
function instrumentFor(role) {
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'vocal') return micStandPrim();
return null;
}
export class GigCrew {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] }
this.t = 0;
}
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
// pedIndex forces a specific fleet ped (band members are picked distinct); else a seeded pickRig.
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() });
if (sp) { actor = sp; kind = 'rig'; }
}
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
actor.fig.position.set(x, y, z);
target.add(actor.fig);
return { actor, kind };
}
// spawn(roomGroup, { stage, watchPoints, gig }) — band trio on the deck + crowd at the watch points.
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
this.disposeAll();
const gid = gig.gigId | 0;
// ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ──
if (stage && stage.bandPoses) {
// distinct fleet peds for the trio, seeded per gig (a band with two identical members reads wrong)
const distinct = (this.fleet && this.fleet.ready)
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
stage.bandPoses.forEach((p, i) => {
const ry = p.ry + RY_FLIP;
const { 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,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const extra = [];
const inst = instFor(p.role);
if (inst) { actor.fig.add(inst); extra.push(inst); }
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false,
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra });
});
}
// ── crowd: stand-and-watch or dance at C's watch points ──
const pts = watchPoints.slice(0, CROWD_CAP);
pts.forEach((w) => {
const ry = w.ry + RY_FLIP;
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance,
base: { x: w.x, y: 0, z: w.z, ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
});
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length };
}
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
update(dt) {
this.t += dt;
const t = this.t;
for (const m of this.members) {
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
const f = m.actor.fig, b = m.base, ph = m.phase;
if (m.part === 'band') {
if (m.role === 'vocal') { // singer: gentle sway toward the mic
f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
} else { // guitar / bass: rhythmic strum-sway + tiny bob
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
}
} else if (m.dance) { // dancer: bounce + sway + a little step
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
} else { // stander: slow weight shift, feet planted
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
}
}
return this.members.length;
}
disposeAll() {
for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
for (const ex of m.extra) ex.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
m.actor.dispose && m.actor.dispose();
}
this.members.length = 0;
this.t = 0;
}
}

View File

@ -32,6 +32,8 @@ const IMPOSTOR_MAX = 220; // instanced billboards ceiling
const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night
const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this
const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent)
const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront
const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day)
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
// near-empty at night). Sampled at 8 control points, linearly interpolated.
@ -121,6 +123,7 @@ export class CitizenSim {
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
this.patronageOn = true;
this._gigVenue = null; // R12: venueShopId while a gig is on (F sets via setGig) → surge
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
this._dropKeys = []; // scratch: chunks to drop this frame
@ -330,6 +333,10 @@ export class CitizenSim {
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; }
// R12 gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street
// converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants
// drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it).
setGig(venueShopId, on = true) { this._gigVenue = on && venueShopId != null ? venueShopId : null; }
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]; }
@ -361,6 +368,8 @@ export class CitizenSim {
for (const s of list) {
if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z);
// gig night: the venue reaches further and wins over a nearer shop (follow the sound to the pub)
if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s;
if (d < bd) { bd = d; best = s; }
}
return best;
@ -510,7 +519,9 @@ export class CitizenSim {
if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0;
const shop = this._nearestOpenShop(c);
if (shop && c.patronRng() < this._patronChance()) this._beginVisit(c, shop);
const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue)
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
}
}
}

108
web/js/citygen/gigs.js Normal file
View File

@ -0,0 +1,108 @@
// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and
// adds the venue (one pub), the nightly gig schedule (`plan.gigs`), and poster assignments
// (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})),
// 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
// from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law).
import { rng, seedFor, mulberry32, pick, shuffle } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { shopName, bandName } from './names.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
// Alpha model: one genre, a night gig (doors at DUSK seg4, on during NIGHT seg5 — see lighting.js
// segment→hour map). A week of nightly entries at the one venue; F's clock picks tonight's.
const NIGHTS = 7;
const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on"
const GIG_END_SEG = 5; // single coarse night segment; F does doors at startSeg1 (DUSK)
const GENRE = 'pubrock'; // the alpha genre (Lane E ships the pub-rock live bed)
// Pick the venue: prefer a spine-END plain shop (a 'shop'-use lot fronting a 'main' edge, farthest
// from the origin — a destination you follow the sound to), NEVER the openLate landmark. Fallback
// for osm / no-spine towns: the farthest-from-centre plain shop. Seeded pick among the farthest few.
function pickVenue(plan, citySeed) {
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const candidates = plan.shops.filter(s => {
if (s.openLate) return false; // must not displace the night landmark
const lot = lotById.get(s.lot);
return lot && lot.use === 'shop'; // convert a plain shop → keeps SOLID + geometry
});
if (!candidates.length) return null;
const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id));
const dist2 = s => { const l = lotById.get(s.lot); return l.x * l.x + l.z * l.z; };
let pool = candidates.filter(s => mainEdges.has(lotById.get(s.lot).frontEdge));
if (!pool.length) pool = candidates; // osm / no spine → any plain shop
pool = pool.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); // farthest first, id tiebreak
const topN = pool.slice(0, Math.min(5, pool.length));
return pick(rng(citySeed, 'venue', 0), topN);
}
// Band names across the nights: John's OPTIONAL custom names (from web/assets/custom_bands.json,
// loaded by the shell and passed as opts.customBands) get PRIORITY — shuffled deterministically and
// filled first, so a town surfaces up to NIGHTS of them — and the rest are generated. Deterministic
// given (citySeed, customBands). Absent/empty custom list ⇒ pure generator (the ?noassets path).
function assignBands(citySeed, nGigs, customBands) {
const custom = (Array.isArray(customBands) ? customBands : [])
.filter(b => typeof b === 'string' && b.trim()).map(b => b.trim());
const shuffled = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : [];
const out = [];
for (let i = 0; i < nGigs; i++) {
out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i)));
}
return out;
}
// Posters advertising tonight's gig (night 0), near the venue + along the central streets B already
// streams (poles/walls). Modest count for the one-pub alpha; positions seeded + deterministic. B
// renders E's poster skin at (x,z) rotated ry; F wires "poster → tonight's gig" discoverability.
function buildPosters(plan, citySeed, venue, gig) {
const posters = [];
const add = (x, z, ry) => posters.push({ id: posters.length, gigId: gig.gigId, x: r2(x), z: r2(z), ry: r4(ry) });
const vlot = plan.lots.find(l => l.id === venue.lot); // the venue's LOT carries the coords (shop doesn't)
if (vlot) add(vlot.x, vlot.z, vlot.ry); // one on the venue frontage itself
const mainNodeIds = new Set();
for (const e of plan.streets.edges) if (e.kind === 'main') { mainNodeIds.add(e.a); mainNodeIds.add(e.b); }
let cand = plan.streets.nodes.filter(n => mainNodeIds.has(n.id));
if (cand.length < 4) cand = plan.streets.nodes.slice();
const chosen = shuffle(rng(citySeed, 'posters', 0), cand.slice()).slice(0, Math.min(5, cand.length));
for (const n of chosen) add(n.x + 2, n.z, Math.atan2(n.x, n.z)); // small verge offset; face town centre
return posters;
}
// Public: augment a finished plan with the gig layer. Mutates + returns `plan` (caller passes a fresh
// plan). Adds `plan.gigs`, `plan.posters`, and converts one shop into the `pub` venue in place.
export function withGigs(plan, citySeed, opts = {}) {
citySeed = citySeed >>> 0;
const venue = pickVenue(plan, citySeed);
if (!venue) { plan.gigs = []; plan.posters = []; return plan; } // degenerate town (no plain shops)
// Convert the chosen shop into the pub venue, in place (keeps its lot id + geometry ⇒ no overlap).
const reg = SHOP_TYPES.pub;
const vseed = seedFor(citySeed, 'venue', venue.id);
const vr = mulberry32(vseed);
venue.type = 'pub';
venue.facadeSkin = pick(vr, reg.facades);
const nm = shopName(vseed, 'pub');
venue.name = nm.name; venue.sign = nm.sign;
venue.storeys = Math.max(venue.storeys | 0, 2); // a corner hotel reads as ≥2 storeys
venue.hours = [reg.hours.open, reg.hours.close]; // [17,23] — open into NIGHT so the gig runs
venue.venue = true; // easy lookup alongside venueShopId
// Nightly gig schedule at the venue.
const bands = assignBands(citySeed, NIGHTS, opts.customBands);
plan.gigs = [];
for (let n = 0; n < NIGHTS; n++) {
const gr = mulberry32(seedFor(citySeed, 'gig', n));
const cover = gr() < 0.5 ? 0 : 2 + ((gr() * 9) | 0); // ~half free, else integer $2$10
plan.gigs.push({
gigId: n, venueShopId: venue.id, bandName: bands[n], genreKey: GENRE,
night: n, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover,
});
}
plan.posters = buildPosters(plan, citySeed, venue, plan.gigs[0]);
return plan;
}

View File

@ -7,9 +7,10 @@
// (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the
// generator ships as plan.js; adopted here as the real Lane A entry point, per the shim's own note.)
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js';
export { shopName, townName } from './names.js';
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
export { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.js';
export { withGigs } from './gigs.js';
// 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
@ -17,8 +18,12 @@ export { generatePlanOSM, osmTownKeys } from './plan_osm.js';
// ran. Unknown source → synthetic; unknown town → the default town.
import { generatePlan as _synth } from './plan.js';
import { generatePlanOSM as _osm } from './plan_osm.js';
import { withGigs as _withGigs } from './gigs.js';
export function generatePlanFor(seed, source = 'synthetic', opts = {}) {
return source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed);
const plan = source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed);
// v3 gig layer, gated: ?gigs=1 → shell passes { gigs:true, customBands } and we augment; otherwise
// the base plan is returned untouched (byte-identical, goldens frozen — CITY_SPEC prime flag law).
return opts.gigs ? _withGigs(plan, seed, opts) : plan;
}
// Convenience default so `citygen.default` also resolves to the (synthetic) generator.

View File

@ -25,6 +25,10 @@ function tok(r, t) {
case 'First': return pick(r, W.FIRSTNAME);
case 'Animal': return pick(r, W.ANIMAL);
case 'Init': return initials(r);
case 'Pub': return pick(r, W.PUB_NAME); // v3: full pub name ("The Royal")
case 'BandAdj': return pick(r, W.BAND_ADJ); // v3: band-name banks
case 'BandNoun':return pick(r, W.BAND_NOUN);
case 'BandOne': return pick(r, W.BAND_ONEWORD);
default: return t;
}
}
@ -73,8 +77,17 @@ const PATTERNS = {
'{First}s Stall', '{Word} Stall', '{Adj} Bits', 'Trash & Treasure', '{Animal} Wares',
'Bric-a-Brac', 'Sunday Stall',
],
pub: [ // v3 venue (?gigs=1): the town pub/hotel
'{Pub}', 'The {Suburb} Hotel', 'The {Family} Hotel', 'The {Adj} Arms', '{Pub}',
],
};
// ── band-name patterns (v3 gigs, 90s Aussie pub-rock) ──────────────────────────────
const BAND_PATTERNS = [
'The {BandAdj} {BandNoun}', '{BandAdj} {BandNoun}', 'The {BandNoun}', '{BandOne}',
'{First} & The {BandNoun}', 'The {Animal}s', '{BandAdj} {Animal}', 'The {BandOne}s',
];
// ── short signboard forms (what actually fits above the door) ──────────────────────
const SIGN_PATTERNS = {
record: ['{Word}', 'RECORDS', 'VINYL', '{Suburb} SOUND'],
@ -86,6 +99,7 @@ const SIGN_PATTERNS = {
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'],
pub: ['HOTEL', 'THE ROYAL', '{Suburb} HOTEL', 'PUB'],
};
// Public: name a shop from its stable seed + type. Returns { name, sign }.
@ -103,6 +117,14 @@ export function shopName(seed, type) {
return { name, sign };
}
// Public (v3 gigs): generate a band name from a stable seed. Pure — the OPTIONAL custom-name
// drop-in is mixed in by gigs.js (which calls this only when it needs a fresh generated name), so
// this stays node-testable and fetch-free. Deterministic: same seed ⇒ same name, forever.
export function bandName(seed) {
const r = mulberry32((seed ^ 0x1b56c4e9) >>> 0); // decorrelate from shop/town seed uses
return fill(r, pick(r, BAND_PATTERNS)).replace(/\s+/g, ' ').trim();
}
// Public: name the town from the city seed.
export function townName(citySeed) {
const r = mulberry32((citySeed ^ 0x2545f491) >>> 0);

View File

@ -20,6 +20,17 @@ const clampHour = h => Math.max(0, Math.min(23, h | 0));
// and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate).
const LATE_HOUR = 22;
// Canonical CLOSING-TIME LAW (frozen v2 ruling; Lane D/F consume this — the "debt" made code).
// Openness is HALF-OPEN: a shop is open for `open ≤ hour < close`, so a shop closing at 17 is shut
// AT 17:00. New entry is refused when closed; occupants already inside finish their dwell and drain
// (never popped); the player is never force-ejected. `hour` is a 024 float (Lane F's currentHour()).
// Pass a shop (or any { hours:[open,close] }). No hours ⇒ treated as always open.
export function isOpen(shopOrHours, hour) {
const h = Array.isArray(shopOrHours) ? shopOrHours : (shopOrHours && shopOrHours.hours);
if (!h) return true;
return hour >= h[0] && hour < h[1];
}
// World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B:
// a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre.
export function lotCorners(l) {

View File

@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM, osmTownKeys } from './plan_osm.js';
import { generatePlanFor } from './index.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
@ -130,7 +131,10 @@ function structuralSuite(plan, label) {
// exactly one late-night landmark; openLate field ⟺ hours[1]≥22 (same shop); it's the video (or,
// for a town with no video at all, the fallback landmark). Holds for synthetic AND osm.
const late = plan.shops.filter(sh => sh.openLate);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
// The v3 gig venue (`sh.venue`) is a night place that also runs ≥22:00 by design, so it's exempt
// from the single-late-shop rule. Base plans have no venue ⇒ this filter is a no-op there (goldens
// unaffected); gig plans exclude the pub so the openLate video is still the lone landmark.
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22 && !sh.venue);
ok(late.length === 1, `${label}: exactly one open-late shop (${late.length})`);
ok(ge22.length === 1, `${label}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `${label}: openLate field ⟺ hours[1]≥22 (same shop)`);
@ -138,6 +142,25 @@ function structuralSuite(plan, label) {
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `${label}: the open-late shop is the video rental (or fallback if no video)`);
}
// 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.
function gigSuite(plan, label) {
structuralSuite(plan, label); // full coverage; venue exempt from late-shop rule
const pubs = plan.shops.filter(s => s.type === 'pub');
ok(pubs.length === 1, `${label}: exactly one pub venue (${pubs.length})`);
const pub = pubs[0] || {};
ok(pub.venue === true && !pub.openLate, `${label}: pub tagged venue, not the openLate landmark`);
ok((pub.hours || [0, 0])[1] >= 22, `${label}: venue open into night so the gig can run`);
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a nightly gig schedule`);
ok(plan.gigs.every(g => g.venueShopId === pub.id), `${label}: every gig plays the venue`);
ok(plan.gigs.every(g => Number.isInteger(g.cover) && (g.cover === 0 || (g.cover >= 2 && g.cover <= 10))), `${label}: cover ∈ {0}[2,10]`);
ok(plan.gigs.every(g => typeof g.bandName === 'string' && g.bandName && !/[{}]|undefined/.test(g.bandName)), `${label}: band names resolved`);
ok(plan.gigs.every(g => Number.isInteger(g.startSeg) && Number.isInteger(g.endSeg) && g.startSeg >= 0 && g.endSeg <= 5 && g.startSeg <= g.endSeg), `${label}: gig segment window sane`);
ok(plan.gigs.every(g => g.genreKey === 'pubrock' && Number.isInteger(g.night)), `${label}: alpha genre + night index`);
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`);
}
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)');
for (const s of SEEDS) {
@ -203,6 +226,30 @@ for (const town of osmTownKeys()) {
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
}
// ── 3c. v3 GIG layer (?gigs=1): flags-off identity + gig invariants (synthetic + osm) ──
section('v3 gig layer (?gigs=1) — flags-off identity + gig invariants');
// PRIME FLAG LAW: ?gigs off must be byte-identical to the base generator (goldens can't move).
for (const s of SEEDS) ok(JSON.stringify(generatePlanFor(s)) === JSON.stringify(generatePlan(s)), `seed ${s}: ?gigs off ≡ base (byte-identical)`);
const GIG_SEEDS = [20261990, 1, 42, 777];
for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'synthetic', { gigs: true }), `gig syn ${s}`);
for (const town of osmTownKeys()) for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'osm', { gigs: true, town }), `gig osm/${town} ${s}`);
// determinism of the gig layer (same seed ⇒ byte-identical gig plan)
ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })), `gig layer deterministic`);
// custom-band drop-in: passed names surface with priority, deterministically given (seed, list)
{
const custom = ['AAA Custom One', 'BBB Custom Two', 'CCC Custom Three'];
const pc = generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom });
ok(pc.gigs.map(g => g.bandName).filter(b => custom.includes(b)).length === custom.length, `custom bands: all ${custom.length} passed names surface`);
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
}
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
const GIG_GOLDEN = 0xa6ae5a5e;
{
const 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')}`);
ok(GIG_GOLDEN !== 0 && hash === (GIG_GOLDEN >>> 0), `gig layer golden 0x${hash.toString(16)} matches pinned 0x${(GIG_GOLDEN >>> 0).toString(16)}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
section('assets on disk');
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);

View File

@ -82,3 +82,30 @@ export const TOWN_SUFFIX = [
'Flat', 'Creek', 'Springs', 'Gully', 'Junction', 'Crossing', 'Downs', 'Vale', 'Heads',
'Plains', 'Ridge', 'Hollow', 'Bend', 'Reach', 'Wells', '', '', '', // blanks: bare root sometimes
];
// ── v3 gigs (?gigs=1): band names (90s Aussie pub-rock) + pub/venue names ────────────
// Band adjectives (feral, sunburnt, sweaty-front-bar register)
export const BAND_ADJ = [
'Feral', 'Screaming', 'Dead', 'Rusty', 'Filthy', 'Mangy', 'Sunburnt', 'Flannel', 'Amyl',
'Cosmic', 'Chunder', 'Drunken', 'Wild', 'Electric', 'Grinning', 'Roaring', 'Deadset',
'Salty', 'Dusty', 'Bitter', 'Feral', 'Howling', 'Molten', 'Ratbag', 'Bogan',
];
// Band nouns (plural — "The {Adj} {Noun}")
export const BAND_NOUN = [
'Galahs', 'Utes', 'Cheezels', 'Dropbears', 'Rissoles', 'Chisels', 'Larrikins', 'Dropkicks',
'Woodducks', 'Cockies', 'Servos', 'Sherbets', 'Ferals', 'Yobbos', 'Ratbags', 'Cane Toads',
'Redbacks', 'Blowflies', 'Mullets', 'Eskies', 'Goannas', 'Numbats', 'Longnecks', 'Bull Bars',
];
// Single-word band names (Regurgitator / Grinspoon / Spiderbait parody tier)
export const BAND_ONEWORD = [
'Chunderbolt', 'Boganvillea', 'Roadkill', 'Grommet', 'Feralcat', 'Backburn', 'Sunstroke',
'Bindii', 'Deadwood', 'Longneck', 'Dropkick', 'Bullbar', 'Stinger', 'Slabhead', 'Gutful',
'Chundermuffin', 'Wombatt', 'Screwtop', 'Goonsack', 'Ferntongue',
];
// Pub / hotel names — every AU country town has a Royal, a Commercial, a Terminus.
export const PUB_NAME = [
'The Royal', 'The Royal Hotel', 'The Commercial', 'The Terminus', 'The Railway Hotel',
'The Criterion', 'The Grand', 'The Exchange Hotel', 'The Federal', 'The Bridge Hotel',
'The Victoria', 'The Star Hotel', 'The Imperial', 'The Great Southern',
];

View File

@ -8,7 +8,7 @@
export const DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market'];
export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade'];
export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill'];
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall'];
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall', 'pub'];
// ── the registry ──────────────────────────────────────────────────────────────────
// Per type:
@ -121,6 +121,17 @@ export const SHOP_TYPES = {
weights: { market: 1 },
footprint: { fmin: 3, fmax: 5, dmin: 3, dmax: 5 },
},
pub: {
label: 'Pub',
facades: ['facade-djsim-pub.jpg', 'facade-federation.jpg'], // corner hotel; both exist in web/assets/gen/
sign: { bg: '#3a2416', fg: '#f2d98a', accent: '#c0392b', style: 'gilt' },
interior: 'band_room', // Lane C pub archetype (stage + PA + bar)
fittings: ['bar_counter', 'stage', 'pa_stack', 'pub_tables', 'stools'],
storeys: [1, 2],
hours: { open: 17, close: 23 }, // a NIGHT venue: open into NIGHT so gigs can run (gig layer sets these)
weights: {}, // NEVER auto-placed — the ?gigs=1 layer converts one shop into the venue
footprint: { fmin: 16, fmax: 24, dmin: 18, dmax: 26 },
},
};
// ── pure helpers (no randomness — plan.js supplies the prng stream) ─────────────────

View File

@ -392,12 +392,51 @@ function artFrame(ctx, o = {}, r) {
return { group: g, footprint: { w: sz, d: 0.08 }, height: sz, slots: [], places: [], wallMounted: true, mountY: 1.5 };
}
// ── VENUE (pub, round 12 · behind ?gigs=1) ───────────────────────────────────────────
// Low band stage — a timber deck with a dark backdrop + a drum riser, against the back wall. The band
// (Lane D) stands on the deck (`deckY` = top surface). GLB slot: `stage` (E's when present, else this).
function stage(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 3.4, d = o.d || 2.0, h = o.h || 0.32;
const deck = ctx.mat('#3a2a1e', 0.85), riser = ctx.mat('#2a2018', 0.85);
ctx.box(w, h, d, deck, 0, h / 2, 0, g); // deck
ctx.box(w + 0.06, 0.06, d + 0.06, ctx.mat('#1c130d', 0.9), 0, h, 0, g); // deck edge trim
ctx.box(w, 2.2, 0.06, ctx.mat('#161016', 0.95), 0, h + 1.1, -d / 2 + 0.05, g); // backdrop curtain
ctx.box(1.0, 0.16, 0.9, riser, 0, h + 0.08, -d / 4, g); // drum riser, up-stage centre
g.userData = { kind: 'stage' };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 2.3, slots: [], places: [g], deckY: h };
}
// PA speaker stack — a tall black cabinet flanking the stage. GLB slot: `pa_speaker`.
function paSpeaker(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.6, d = 0.55, h = o.h || 1.7;
ctx.box(w, h, d, ctx.mat('#1a1a1e', 0.7), 0, h / 2, 0, g); // cabinet
const grille = ctx.mat('#0c0c10', 0.6);
ctx.box(w - 0.1, 0.5, 0.02, grille, 0, h * 0.32, d / 2, g); // woofer face
ctx.box(w - 0.14, 0.24, 0.02, grille, 0, h - 0.32, d / 2, g); // horn face
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
// Guitar/bass combo amp — sits ON the stage deck behind the band. GLB slot: `amp`.
function ampStack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.62, d = 0.36, h = 0.5;
ctx.box(w, h, d, ctx.mat(r() < 0.5 ? '#2a2320' : '#20242a', 0.8), 0, h / 2, 0, g);
ctx.box(w - 0.06, 0.3, 0.02, ctx.mat('#100f10', 0.6), 0, h * 0.55, d / 2, g); // grille cloth
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
export const FITTINGS = {
recordBin, crate, crateStack, listeningCorner,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
clothesRack,
trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
stage, paSpeaker, ampStack,
};
// Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash).

View File

@ -19,8 +19,12 @@
// // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand)
// audio: { musicKey, toneKey }, // R11 — keys into Lane E manifest.audio (interior music bed +
// // room-tone), seeded per shop. musicKey may be null (no music).
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
// // room-tone), seeded per shop. musicKey may be null. gigKey set
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
// watchPoints: [ {x,z,ry,dance} ], // VENUE (R12, pub only, else []) — 6..8 audience poses facing the
// // stage; dance ~1/3 true. Same frame + ry convention as counter.stand.
// stage: { x,z,w,d,deckY,frontZ,bandPoses }, // VENUE (R12, else null) — the band stage; bandPoses[3] on the deck.
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@ -88,14 +92,19 @@ const MUSIC_BY_TYPE = { // manifest.audio.music dedi
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
function audioFor(type, ctx) {
function audioFor(type, ctx, opts) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
return { 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
// while the gig is on. A quiet-night pub has no opts.gig → normal interior (room-tone + maybe radio).
const gig = opts && opts.gig;
if (gig && (gig.on || gig.genreKey)) audio.gigKey = 'gig-' + (gig.genreKey || 'pubrock');
return audio;
}
export function buildInterior(shop, THREE, opts) {
@ -150,7 +159,9 @@ export function buildInterior(shop, THREE, opts) {
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop
watchPoints: lay.watchPoints || [], // venue (R12): [ {x,z,ry,dance,slotIndex} ] 6..8 audience poses (else [])
stage: lay.stage || null, // venue (R12): { x,z,w,d,deckY,frontZ,bandPoses[3] } (else null)
audio: audioFor(recipe.key, ctx, opts), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,

View File

@ -209,6 +209,75 @@ function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
return pts;
}
// ── VENUE stage (pub, round 12 · behind ?gigs=1) ──────────────────────────────────────
// Places a low stage centred on the back wall, PA cabinets flanking it on the floor, and 12 amps on the
// deck. Stage + PA go into `placed` at priority 8 so the door→counter path loop never pulls them. Returns
// the stage frame the crowd faces + 3 seeded band poses on the deck (Lane D drops the band trio in).
function placeStage(ctx, grid, W, D, roomGroup, r) {
const sw = Math.min(W - 1.4, Math.max(3.0, W * 0.58)), sd = 2.0;
const cx = 0, cz = -D / 2 + sd / 2 + 0.2; // back wall, centred
const f = buildFitting('stage', ctx, { w: sw, d: sd }, r);
f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect);
const out = [{ fitting: f, x: cx, z: cz, ry: 0, hw, hd, rect, priority: 8, kind: 'stage' }];
for (const sgn of [-1, 1]) { // PA cabinets at the stage front corners, pulled
const px = sgn * (sw / 2 - 0.3), pz = cz + sd / 2 + 0.35; // in + just off the deck lip
const [phw, phd] = halfExtents(0.6, 0.55, 0); // paSpeaker footprint (fixed)
const prect = rectCells(grid, px, pz, phw, phd);
if (!areaFree(grid, prect)) continue; // never stamp over the counter's service strip / a wall
const pf = buildFitting('paSpeaker', ctx, {}, r);
pf.group.position.set(px, 0, pz); roomGroup.add(pf.group);
stampRect(grid, prect);
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
for (let i = 0, n = 1 + (r() < 0.5 ? 1 : 0); i < n; i++) {
const af = buildFitting('ampStack', ctx, {}, r);
af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18);
roomGroup.add(af.group);
}
const bandPoses = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({ // 3 across the deck front, facing +Z (audience)
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i],
}));
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, frontZ: round(cz + sd / 2), bandPoses } };
}
// ── WATCH POINTS (venue, round 12) — 68 seeded audience floor poses facing the stage ──
// Same pure-data pattern as browse points: walkable free cells, reachable from spawn, clear of the keeper
// stand + browse points + stage, spaced for a crowd. `dance` seeded ~1/3 true (John: "a bit of both").
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r) {
let start = spawnCell;
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
const reach = floodFill(grid, start);
const avoid = [counterStand, ...(browsePoints || [])].filter(Boolean);
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
const cands = [];
for (let z = stage.frontZ + 0.6; z <= grid.D / 2 - 1.6; z += 0.7) // between stage lip and the door zone
for (let x = -grid.W / 2 + 0.8; x <= grid.W / 2 - 0.8; x += 0.8) cands.push({ x, z });
for (const c of cands) c.key = r(); // seed the visiting order up front (determinism)
cands.sort((a, b) => a.key - b.key);
const pts = [];
const valid = (x, z) => {
const [cx, cz] = grid.cellOf(x, z);
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
const idx = cz * grid.cols + cx;
if (grid.occ[idx] !== 0 || grid.reserved[idx] || !reach.has(idx)) return false;
if (near(x, z, avoid, MIN_SEP) || near(x, z, pts, 0.7)) return false;
return true;
};
for (const c of cands) {
if (pts.length >= 8) break;
if (!valid(c.x, c.z)) continue;
const dx = stage.x - c.x, dz = stage.z - c.z; // face the stage centre (counter.stand ry convention)
pts.push({ x: round(c.x), z: round(c.z), ry: round(Math.atan2(-dx, -dz)), dance: r() < 0.34, slotIndex: pts.length });
}
return pts;
}
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
function makeWallSlots(W, D, r) {
const slots = [];
@ -268,6 +337,15 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// reserve the strip in front of the till so nothing blocks service
reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
// 1b) VENUE STAGE (pub, round 12) — placed BEFORE the fitting mix so its footprint is reserved. Only
// for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
let stageInfo = null, watchPoints = [];
if (recipe.venue) {
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'));
stageInfo = st.stage;
for (const sp of st.placed) placed.push(sp);
}
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
const area = W * D;
const wallSlots = makeWallSlots(W, D, rWall);
@ -330,6 +408,10 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// every point sits on a survivor's open side and on a cell reachable from the door.
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
// 6b) WATCH POINTS (venue, round 12) — audience floor poses facing the stage, computed after the path
// guarantee + browse points (so they avoid both), on cells reachable from the door.
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'));
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
@ -340,6 +422,8 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved,
spawnCell, targetCell, placement, browsePoints,
watchPoints, // venue: 68 audience poses {x,z,ry,dance} facing the stage (else [])
stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null)
counter: {
mesh: counter.fitting.group,
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself

View File

@ -200,6 +200,27 @@ const RECIPES = {
stockKind: 'boxes',
signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'],
},
// ── PUB (venue, v3.0-alpha · round 12, behind ?gigs=1) ───────────────────────────────
// The band room. `venue: true` makes layout.js place a stage (+ PA/amps) at the back wall and emit
// room.watchPoints[] for Lane D's crowd + room.stage for the band. Bar counter = a normal counter, so
// the barkeep is just a keeper. Sticky-carpet floor; the blocked back doorway doubles as the green room.
pub: {
label: 'Pub',
archetypeBias: [['wide', 3], ['hall', 2]], // big open room for a crowd
wallpaperBias: ['damask-mauve', 'woodchip-white', 'stripe-sage'],
floorBias: ['carpet-swirl', 'carpet-mustard'], // sticky carpet
clutter: 0.45, // keep the floor open — the crowd fills it
counterPos: 'corner', // the bar, along the east wall (keeper = barkeep)
venue: true, // → stage + PA + watchPoints (layout.js)
fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls
{ kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'],
},
};
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.

View File

@ -102,6 +102,13 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
spill: layer(buses.music), tram: layer(buses.tram),
iMusic: layer(buses.music), iTone: layer(buses.ambience),
};
// gig spill (round 12): the venue's live bed leaking THROUGH THE WALL — low-pass filtered (muffled
// bass/thump through the bricks) so it reads distinct from an open shop-door spill, and reaching
// further down the street. B owns the street side of the gig audio seam.
const gigLowpass = ctx.createBiquadFilter();
gigLowpass.type = 'lowpass'; gigLowpass.frequency.value = 470; gigLowpass.Q.value = 0.6;
gigLowpass.connect(buses.music);
L.gigSpill = layer(gigLowpass);
state.layers = L;
async function persistBed(l, key, entry, target, fade = 1.4) {
@ -183,7 +190,24 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
(PROCITY.plan?.shops || []).forEach((s) => shopById.set(s.id, s));
const _cam = { x: 0, z: 0 };
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;
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22, GIG_SPILL_R = 26;
// the pub venue (round 12), if the gig layer is present (?gigs); its lot carries the coords.
const venueShop = (PROCITY.plan?.shops || []).find((s) => s.venue) || null;
const venueLot = venueShop && (PROCITY.plan?.lots || []).find((l) => l.id === venueShop.lot);
// gig state for the muffled spill: prefer Lane F's window.PROCITY.gigs state machine, else derive
// from plan.gigs + the clock. Returns null with no gig layer (flags-off ⇒ no gig spill, byte-identical).
function gigStateNow() {
const g = PROCITY.gigs;
if (g && typeof g.state === 'string') return g.state;
if (g && typeof g.on === 'boolean') return g.on ? 'on' : 'quiet';
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
const s = gigs[0].startSeg ?? 5, e = gigs[0].endSeg ?? 5;
if (clk.seg >= s && clk.seg <= e) return 'on';
if (clk.seg === s - 1) return 'doors';
return 'quiet';
}
function update(dt) {
if (!state.ready || state.muted) return;
@ -241,6 +265,17 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
ramp(L.spill.g.gain, 0, 0.4);
}
// ── muffled gig spill through the venue wall (round 12) — low-pass bed, reaches further than a
// door spill; only while the gig layer is on (?gigs) and a gig is at doors/on. Byte-identical off. ──
if (street && venueLot && P.plan?.gigs?.length) {
const gs = gigStateNow(), on = gs === 'on' || gs === 'doors';
const dist = Math.hypot(venueLot.x - _cam.x, venueLot.z - _cam.z);
const gg = (on && dist < GIG_SPILL_R)
? (AUDIO?.music?.['pubrock-live']?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R) : 0;
state.gig = gs;
swapBed(L.gigSpill, gg > 0 ? 'pubrock-live' : null, gg > 0 ? AUDIO?.music?.['pubrock-live'] : null, gg, 0.9);
} 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) ──
const tramG = P.scene?.getObjectByName?.('tram');
if (street && tramG) {

98
web/js/world/gig_state.js Normal file
View File

@ -0,0 +1,98 @@
// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 12 / v3.0-alpha]
// The Friday-night state machine: quiet → doors → on → done, driven off Lane B's day clock and Lane A's
// plan.gigs. F owns it because every other lane consumes it and none of them own the clock:
// • Lane B audio.js reads window.PROCITY.gigs.state → the muffled through-the-wall spill
// • Lane B venue.js update(state) → marquee/sign glow on the frontage
// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge
// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside)
// • Lane F the cover charge at the door + the smokes
//
// Alpha model (CITY_SPEC §"CityPlan v3 — gig layer"): tonight is plan.gigs[0]; startSeg=5 (NIGHT 22:00),
// endSeg=5, doors at startSeg1 (DUSK). One venue, one genre, seven nightly entries in the plan.
//
// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and the pub closes at
// 23, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the wrap, DAWN.
// So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after it (DAWN =
// closing time — the band packs up and the crowd drains per Lane A's closing-time ruling), and clear the
// latch on any other segment → back to 'quiet'. The latch clears on any non-DAWN segment rather than only
// at MORNING because the player drives this clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must
// read doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly
// that). A night only *counts* as rolled when we leave 'done' FORWARDS — that's what separates a real
// DAWN→MORNING roll (new night, cover due again) from a rewind back out of the gig (same night, paid).
//
// WHY IT LISTENS RATHER THAN POLLS. The latch has to observe every segment, and a poll only sees the
// segments it happens to be sampled on: driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in
// between (player inside the pub, audio muted, so neither the street loop nor the audio rAF is reading)
// skipped the roll entirely — the night never advanced and the cover was never due again. Caught live in
// the R12 verify. lighting.js dispatches `procity:segment` on EVERY segment change (hand-driven or the
// automatic day cycle), so that is the signal the latch rides. Reads stay safe on their own: `state` is a
// getter that re-observes the current segment, and observing the same segment twice is idempotent.
const SEGS = 6;
export function createGigState({ plan, lighting }) {
const gigs = (plan && plan.gigs) || [];
const gig = gigs[0] || null; // alpha: tonight is gigs[0] (CITY_SPEC v3)
const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5;
const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5;
const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open
const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in
// The daytime segments: everything from the morning after the gig up to (not including) the doors —
// MORNING/MIDDAY/ARVO for the alpha schedule. Landing here after a gig means THE DAY HAS TURNED, which
// is what rolls the night (and makes the next cover due). See the roll note below.
const DAYTIME = (() => {
const set = new Set();
for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s);
return set;
})();
let state = 'quiet';
let played = false; // latch: tonight's gig has run (set while inside the on-window)
let night = 0; // rolls when the day turns after a gig — keys the cover stamp (runtime only)
let paidNight = -1; // the night whose cover is paid; re-entry the same night is free
// The whole machine, for ONE segment. Idempotent per segment, so it is safe to call from both the
// event and any read.
function observe(seg) {
const on = seg >= startSeg && seg <= endSeg;
if (on) played = true;
else if (played && seg !== doneSeg) {
// The latch is clearing: the gig is behind us. Whether that means "a new night" (cover due again)
// or "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by having
// watched 'done' go by: keying the roll on leaving 'done' meant a clock jump straight from NIGHT to
// MORNING never rolled the night at all, and a broke punter walked in on last night's stamp. The
// smoke caught it; stepping ([/]) always passes through DAWN, so only jumps exposed it.
played = false;
if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due
} // (landed on the doors ⇒ rewound out of the gig ⇒ same night)
state = on ? 'on' : seg === doorsSeg ? 'doors' : played ? 'done' : 'quiet';
return state;
}
// Every segment change, from [ / ] or the automatic cycle — this is what guarantees no segment is missed.
const onSegment = (e) => { if (gig && e && e.detail) observe(e.detail.seg); };
if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment);
function tick() {
if (!gig || !lighting || !lighting.getClock) return (state = 'quiet');
return observe(lighting.getClock().seg);
}
tick(); // settle on the boot segment
return {
get state() { return tick(); },
update: tick, // shell calls this once per street frame (same thing)
get on() { return tick() === 'on'; },
get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters
get gig() { return gig; },
get venueShopId() { return gig ? gig.venueShopId : null; },
get bandName() { return gig ? gig.bandName : null; },
get cover() { return gig ? gig.cover | 0 : 0; },
get night() { return night; },
// cover stamp — runtime only (like the wallet itself): paid once, free to pop out and back in until the
// night rolls. NOT persisted, NOT written into the plan (goldens never move).
get paid() { tick(); return paidNight === night; },
markPaid() { tick(); paidNight = night; },
dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); },
};
}

View File

@ -17,6 +17,7 @@
import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/interiors.js';
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
@ -26,15 +27,30 @@ const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
// [Lane F R12 — CROSS-LANE SEAM FIX] Lane C emits `room.audio.gigKey = 'gig-' + genreKey` and asked Lane
// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as
// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already
// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined
// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig).
// F owns the glue, so the key resolves here: exact map first, then a generic `gig-<genre>` → `<genre>-live`
// fallback so a second genre works without another edit. Reported to C/E for a real contract line in
// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12.
const GIG_BED = { 'gig-pubrock': 'pubrock-live' };
const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null);
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null }) {
occupancyOf = null, gigState = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
// Lane D shopkeeper — one per interior, at the counter. With a rig `fleet` (§3.4) the keeper is a
// shared GLB actor; without one it's an asset-free placeholder. Either way it greets the player.
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed, fleet });
// [Lane F R12] Lane D's band trio + audience. Built lazily on the first gig (a town has ONE venue and
// most rooms never see one), disposed with the room like the keepers.
let crew = null;
let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this)
let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
@ -175,12 +191,23 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null →
// Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter }); // static room sleeves
// [Lane F R12 gig layer] Is this the venue, mid-gig? Lane C only stamps room.audio.gigKey when we pass
// opts.gig, and it treats `{on:false}` as "a gig" if genreKey is set — so pass NULL unless it's really
// on. A quiet-night pub then builds as a normal interior (stage + watch points still there, just nobody
// on them), which is exactly the "quiet night at the pub" C specced.
const gigOn = !!(gigState && gigState.venueShopId != null && shop.id === gigState.venueShopId && gigState.on);
const tonight = gigOn ? gigState.gig : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop;
scene.add(current.group);
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
window.PROCITY.audio && window.PROCITY.audio.playInterior(current.audio);
// [R12] Prefer the gig bed over the room's seeded radio while the band is on (LANE_C_PUB.md), through
// F's key resolver above — playInterior only knows musicKey/toneKey, so the gig bed rides in as musicKey.
const ra = current.audio || {};
const bed = gigBedKey(ra.gigKey);
window.PROCITY.audio && window.PROCITY.audio.playInterior(bed ? { ...ra, musicKey: bed } : ra);
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) {
@ -200,6 +227,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
seedKey: `${shop.id}#${i}`,
}));
}
// [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: 3 on the deck, up to 8 at the watch
// points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at watchPoints.length
// and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these rigs too.
// Instruments are D's primitives this round — E's instrument GLBs landed but nothing wires them yet
// (D's `opts.instrumentFor` seam is open); asset law says a primitive band is still a gig. → LANE_F_NOTES §12.
crewInfo = null;
if (gigOn && current.stage && current.watchPoints && current.watchPoints.length) {
if (!crew) crew = new GigCrew({ citySeed: plan.citySeed, fleet });
crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, gig: tonight });
}
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
camera.position.set(current.spawn.x, EYE, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
@ -207,6 +244,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
let hasBins = false;
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
+ (hasBins ? ' · E: riffle a record bin' : '');
banner.style.display = 'block';
return current;
@ -242,6 +280,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
}
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
if (crew) crew.update(dt); // [R12] Lane D: band strum-sway + crowd stand/dance (seeded phase per member)
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so
// renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)
@ -258,6 +297,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
current.dispose();
current = null;
currentShop = null;
@ -278,6 +318,8 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
keepers,
get active() { return !!current; },
get current() { return current; },
get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered)
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
};
}

136
web/js/world/venue.js Normal file
View File

@ -0,0 +1,136 @@
// PROCITY Lane B — venue.js (round 12, v3.0-alpha gig layer, ?gigs=1)
// The street-side presentation of the pub venue: (1) gig POSTERS around town — E's poster skins with
// A's plan.posters placement and the band name overprinted like a shop sign; (2) the lit pub FRONTAGE
// at night while a gig is on — a warm marquee over the door + lit sign glow that F toggles from the
// gig state machine. Consumes A's plan.posters / plan.gigs / the venue shop. B owns the street side.
//
// Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js),
// so flags-off boot is byte-identical — the shell never builds it. Asset law: a missing poster JPEG
// falls back to a flat printed-poster canvas (still a poster). Deterministic from citySeed.
import * as THREE from 'three';
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox'];
const NAME_ZONE = [0.08, 0.04, 0.84, 0.23]; // manifest skins.poster.*.nameZone (band name band)
export function createVenuePresentation(plan, skins, scene, { assetBase = 'assets/gen/' } = {}) {
const group = new THREE.Group(); group.name = 'venue-presentation';
const disposables = [];
const gigs = plan.gigs || [];
const posters = plan.posters || [];
const venueShop = plan.shops?.find((s) => s.venue) || plan.shops?.find((s) => s.type === 'pub') || null;
const venueLot = venueShop && plan.lots.find((l) => l.id === venueShop.lot);
// ── posters ──
for (const p of posters) {
const gig = gigs.find((g) => g.gigId === p.gigId) || gigs[0];
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(p.id + (plan.citySeed >>> 0)) % POSTER_SKINS.length]; // seeded template
const tex = posterTexture(assetBase, skin, band); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const mesh = new THREE.Mesh(geo, mat);
// A's venue poster sits at the lot CENTRE (inside the shell); nudge it out to the frontage.
let px = p.x, pz = p.z;
if (venueLot && Math.hypot(p.x - venueLot.x, p.z - venueLot.z) < 1.2) {
const ry = venueLot.ry || 0, off = (venueLot.d || 8) / 2 + 0.08;
px = venueLot.x + Math.sin(ry) * off; pz = venueLot.z + Math.cos(ry) * off;
}
mesh.position.set(px, 2.0, pz);
mesh.rotation.y = p.ry || 0;
mesh.receiveShadow = true;
group.add(mesh);
}
// ── lit venue frontage (marquee over the door; emissive ramped by gig state) ──
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
let marqueeMat = null, signGlowMat = null;
if (venueLot) {
const ry = venueLot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
const zf = (venueLot.d || 10) / 2 + 0.06;
const mx = venueLot.x + fx * zf, mz = venueLot.z + fz * zf;
// warm marquee slab above the entrance
marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
const mw = Math.min((venueLot.w || 8) * 0.92, 6.5);
const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35);
const marquee = new THREE.Mesh(mgeo, marqueeMat);
marquee.position.set(mx, 3.5, mz); marquee.rotation.y = ry;
// a row of marquee "bulbs" (emissive dots) along the front edge of the slab
const bulbMat = new THREE.MeshStandardMaterial({ color: 0x3a2a10, emissive: new THREE.Color(0xffe6b0), emissiveIntensity: 0 });
signGlowMat = bulbMat;
const bgeo = new THREE.SphereGeometry(0.06, 6, 5);
const nb = Math.max(4, Math.round(mw / 0.6));
const bulbs = new THREE.InstancedMesh(bgeo, bulbMat, nb);
const _m = new THREE.Matrix4(), _p = new THREE.Vector3();
for (let i = 0; i < nb; i++) {
const t = (i / (nb - 1) - 0.5) * mw;
_p.set(mx + Math.cos(ry) * t, 3.25, mz - Math.sin(ry) * t);
bulbs.setMatrixAt(i, _m.makeTranslation(_p.x, _p.y, _p.z));
}
bulbs.instanceMatrix.needsUpdate = true;
frontage.add(marquee, bulbs);
disposables.push(marqueeMat, mgeo, bulbMat, bgeo);
}
group.add(frontage);
scene.add(group);
// gig state → frontage glow. 'on' = full, 'doors' = warm-up, else dark. Accepts F's state string
// ('quiet'|'doors'|'on'|'done'), a boolean, or {state}. Idempotent; call from the loop or on change.
function update(gigState) {
const s = (gigState && gigState.state) || gigState;
let e = 0;
if (s === 'on' || s === true) e = 1.7;
else if (s === 'doors') e = 0.7;
if (marqueeMat) marqueeMat.emissiveIntensity = e;
if (signGlowMat) signGlowMat.emissiveIntensity = e * 1.15;
}
update('quiet');
function dispose() {
for (const d of disposables) d.dispose && d.dispose();
group.traverse((o) => { if (o.isInstancedMesh) o.dispose && o.dispose(); });
scene.remove(group);
}
return {
group, frontage, update, dispose,
venueShopId: venueShop ? venueShop.id : null,
get posterCount() { return posters.length; },
};
}
// ── per-poster canvas: E's poster art with the band name overprinted in its nameZone. ──
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over
// it when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top.
function posterTexture(assetBase, skin, band) {
const W = 240, H = 342;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d');
x.fillStyle = '#141019'; x.fillRect(0, 0, W, H); // dark backing
x.fillStyle = '#cbb98f'; x.fillRect(8, 8, W - 16, H - 16); // aged paper
x.fillStyle = '#7a2a24'; x.fillRect(8, H * 0.62, W - 16, 3);
x.fillStyle = '#1c1712'; x.font = 'bold 13px Arial'; x.textAlign = 'center';
x.fillText('LIVE · THE ROYAL', W / 2, H * 0.72);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
drawBandName(x, W, H, band);
const img = new Image();
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
return tex;
}
function drawBandName(x, W, H, band) {
const [nx, ny, nw, nh] = NAME_ZONE;
const zx = nx * W, zy = ny * H, zw = nw * W, zh = nh * H;
const label = String(band).toUpperCase();
x.save();
x.textAlign = 'center'; x.textBaseline = 'middle';
let fs = zh * 0.72;
do { x.font = `bold ${fs}px Arial, sans-serif`; fs -= 2; } while (x.measureText(label).width > zw - 6 && fs > 8);
x.fillStyle = 'rgba(0,0,0,0.55)'; x.fillText(label, zx + zw / 2 + 1.2, zy + zh / 2 + 1.2); // shadow
x.fillStyle = '#f7ecc8'; x.fillText(label, zx + zw / 2, zy + zh / 2);
x.restore();
}

View File

@ -91,12 +91,14 @@ import { generatePlanFor, chunkIndex, CHUNK } from './js/citygen/index.js';
const _q = new URLSearchParams(location.search);
const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = _q.get('town') || undefined;
const GIGS = _q.get('gigs') === '1'; // v3 gig layer: venue + nightly gigs + posters (?gigs=1)
let customBands = []; // John's OPTIONAL band drop-in (loaded async at boot when ?gigs=1)
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
const TYPE_COLOR = {
record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d',
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a',
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a', pub:'#e8b73a',
};
const USE_COLOR = {
shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436',
@ -153,6 +155,7 @@ function draw(){
drawStreets();
if ($('tLots').checked) drawLots();
if ($('tShops').checked) drawShops();
if (GIGS) drawGigs();
if ($('tNodes').checked) drawNodes();
if (hover) drawHoverHi();
drawCompass();
@ -229,6 +232,19 @@ function drawShops(){
}
}
}
// v3 gig overlay (?gigs=1): poster markers + a ring/label on the venue.
function drawGigs(){
if(!plan.gigs || !plan.gigs.length) return;
ctx.fillStyle='#ffd23f';
for(const p of (plan.posters||[])){ const [sx,sy]=w2s(p.x,p.z); // little poster pins
ctx.beginPath(); ctx.moveTo(sx,sy-4); ctx.lineTo(sx+3,sy+3); ctx.lineTo(sx-3,sy+3); ctx.closePath(); ctx.fill(); }
const venue=plan.shops.find(s=>s.venue); if(!venue) return;
const l=plan.lots.find(x=>x.id===venue.lot); if(!l) return;
const [sx,sy]=w2s(l.x,l.z);
ctx.beginPath(); ctx.arc(sx,sy,10,0,7); ctx.strokeStyle='#ffd23f'; ctx.lineWidth=2; ctx.stroke();
ctx.font='bold 10px ui-monospace,monospace'; ctx.fillStyle='#ffd23f'; ctx.textAlign='center';
ctx.fillText('♪ '+venue.sign, sx, sy-15);
}
function drawNodes(){
ctx.fillStyle=getcss('--accent');
for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z);
@ -296,7 +312,7 @@ const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlanFor(s, PLANSRC, { town: TOWN }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
plan = generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS, customBands }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
@ -304,6 +320,13 @@ function regen(seed){
$('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
if (GIGS && plan.gigs && plan.gigs.length){
const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0];
$('stats').innerHTML += `<br><b style="color:#ffd23f">♪ ${esc(venue?.name||'venue')}</b><br>`+
`tonight: <b>${esc(g0.bandName)}</b> · ${g0.cover?('$'+g0.cover+' cover'):'FREE'}<br>`+
`<span class="muted">${plan.gigs.length} nights · ${(plan.posters||[]).length} posters`+
`${customBands.length?` · ${customBands.length} custom bands`:''}</span>`;
}
fitView(); draw(); buildLegend(byType);
}
function buildLegend(byType){
@ -335,7 +358,12 @@ addEventListener('resize', resize);
// rAF ensures the flex layout has sized the canvas before fitView reads its dimensions.
const q=new URLSearchParams(location.search);
const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990;
requestAnimationFrame(()=>{ resize(); regen(bootSeed); });
requestAnimationFrame(async ()=>{
resize();
// OPTIONAL band drop-in: load John's names for the debug view (graceful if the file is absent).
if (GIGS){ try { const r=await fetch('./assets/custom_bands.json'); if(r.ok){ const j=await r.json(); if(Array.isArray(j.bands)) customBands=j.bands; } } catch(e){} }
regen(bootSeed);
});
</script>
</body>
</html>