Compare commits
6 Commits
22a0741ad1
...
2498fdc892
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2498fdc892 | ||
|
|
f0a0a99207 | ||
|
|
888335d633 | ||
|
|
7795780336 | ||
|
|
05e020e3b8 | ||
|
|
acf734a1e5 |
@ -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
|
||||
|
||||
@ -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[]`** — 6–8 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.60–1.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.60–1.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
|
||||
|
||||
@ -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 (30–96k, 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) —
|
||||
|
||||
@ -4,6 +4,67 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 12 (v3.0-alpha — the gig layer, `?gigs=1`) — WIRED + VERIFIED
|
||||
|
||||
A–E 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 **42–50 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.68–1.77 m, crowd 1.60–1.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),
|
||||
|
||||
@ -1,9 +1,47 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 2–9 closed** (see below).
|
||||
`qa.sh --strict` GREEN.*
|
||||
*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 2–12 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.
|
||||
|
||||
@ -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 (startSeg−1 = 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 |
|
||||
|
||||
|
||||
@ -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 (startSeg−1 = 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)
|
||||
|
||||
@ -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
@ -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).
|
||||
@ -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 | **42–50 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.68–1.77 m (crown ≤2.09 m under a 4.0 m ceiling); crowd 1.60–1.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 |
|
||||
|
||||
BIN
docs/shots/laneD/r12_gig_night.jpg
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
docs/shots/laneF/r12_v3alpha_gig.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
@ -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 (30–96k 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).
|
||||
|
||||
@ -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 |
|
||||
|
||||
90
pipeline/_gig_results.json
Normal 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": []
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
@ -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", []):
|
||||
|
||||
@ -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")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"]]
|
||||
|
||||
@ -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
|
||||
|
||||
10
pipeline/gig_instruments.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
37
pipeline/gig_norm_batch.json
Normal 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
|
||||
}
|
||||
]
|
||||
@ -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
@ -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()
|
||||
BIN
web/assets/audio/ambience-crowd-walla.m4a
Normal file
BIN
web/assets/audio/ambience-crowd-walla.ogg
Normal file
BIN
web/assets/audio/music-pubrock-live.m4a
Normal file
BIN
web/assets/audio/music-pubrock-live.ogg
Normal file
BIN
web/assets/audio/sfx-applause.m4a
Normal file
BIN
web/assets/audio/sfx-applause.ogg
Normal file
15
web/assets/custom_bands.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
web/assets/gen/poster-grunge.jpg
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
web/assets/gen/poster-retro.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
web/assets/gen/poster-screenprint.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
web/assets/gen/poster-xerox.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
web/assets/thumbs/procity_fit_bass_guitar_01.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
web/assets/thumbs/procity_fit_drum_kit_01.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
web/assets/thumbs/procity_fit_electric_guitar_01.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/assets/thumbs/procity_fit_guitar_amp_01.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
web/assets/thumbs/procity_fit_mic_stand_01.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
@ -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 startSeg−1 (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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 0–24 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) {
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
@ -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) ─────────────────
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 1–2 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; // 1–2 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) — 6–8 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: 6–8 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
|
||||
|
||||
@ -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 5–6', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'],
|
||||
},
|
||||
};
|
||||
|
||||
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.
|
||||
|
||||
@ -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
@ -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 startSeg−1 (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); },
|
||||
};
|
||||
}
|
||||
@ -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
@ -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();
|
||||
}
|
||||
34
web/map.html
@ -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>
|
||||
|
||||