diff --git a/F-progress.md b/F-progress.md index 5a11560..5134a67 100644 --- a/F-progress.md +++ b/F-progress.md @@ -4,6 +4,41 @@ --- +## Round 13 (v3.0-beta — THE DISTRICT, `?gigs=1`) — WIRED + VERIFIED + TAGGED + +A–E landed, then D (4-piece + instruments + queue + RY fix). F wired the district together and it fires. +**Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing tonight — The Exchange Hotel (pub, `gig-pubrock`) ++ an RSL (`gig-covers`), one dark (reads true).** All 7 F tasks done, `qa.sh --strict` GREEN (6/6, 0 warn), +tag **`v3.0-beta`**. Shots: `docs/shots/laneF/r13_v3beta_gig_interior.png` + `r13_v3beta_venue_night.png`. + +**What F did (ROUND13 §Lane F):** +1. **Per-venue state machine** — `gig_state.js` rewritten: one latch per venue off the shared + `procity:segment` spine, keyed by `venueShopId`; all three hard-won R12 laws preserved. New runtime + surface `window.PROCITY.gigs.byVenue` + per-venue getters (`onOf/openOf/gigOf/coverOf/paidOf…`); the + R12 single-venue alias kept one round. B's `venue.js`/`audio.js` + D's `setGig` read it. +2. **Walla + applause** — the crowd bed `crowd-walla` rides the interior tone layer under the live band bed + while `on`; an `applause` sting fires once on the `on→done` edge while inside. Verified: `iMusic=gig-pubrock`, + `iTone=crowd-walla`. +3. **Cover per venue** — the door seam keys off the shop entered, so a night at two venues is two covers. +4. **Debt #1 closed — was a LIVE BUG** — E renamed `pubrock-live→gig-pubrock`, so F's R12 `GIG_BED` bridge + (rewrote `gig-pubrock→pubrock-live`) pointed at a dead key → the band would have mimed in silence. + Deleted; the room's `audio.gigKey` (already the manifest key) feeds the engine directly. +5. **Stature gate** — `interior_scale_check.py` now measures stature (crown−foot, platform-independent), + ceiling-clip assert kept as its own line (debt #6). +6. **District smokes** — `smoke_gigs`: band→4, a per-venue loop (every gigKey resolves across 3 genres, + crowd ≤ its own watchPoints, no-giants), street budget at the busiest venue block, the queue assert, + plus the R12 determinism/cover/noassets/flags-off arms. All green. +7. **The outdoor queue** — D shipped `VenueQueue` and left the street-side lifecycle to F (its documented + seam): F wires one queue per venue in the shell (spawn at doors off B's `queueZoneFor`, drive each frame, + dispose by `done`), exposed as `queueCountOf(id)`. Verified: 2 in line at doors → 0 by done. + +**Numbers (seed 20261990):** gig interior 62 draws / 34k tris (≤350) · street @ venue block 107 draws / +47k tris (≤300/200k) · band+crowd stature 1.61–1.81 m under a 4 m ceiling · cover $10 debited-once/re-entry- +free/broke-knockback + free night (seed 1234) · flags-off fingerprint 0x3fa36874 unchanged, gig layer wholly +absent. Full runbook + the R13 debt-ledger closeout in [`LANE_F_NOTES.md §13`](docs/LANES/LANE_F_NOTES.md). + +--- + ## Round 12 (v3.0-alpha — the gig layer, `?gigs=1`) — WIRED + VERIFIED A–E all landed before me, so F did what F does: turned five parts into one Friday night. **Seed diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 63bbd06..2f50cfd 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -257,6 +257,11 @@ gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover venue's genre. - `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors (startSeg−1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) + **Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read + it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters + `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. The R12 + single-venue alias (`.state/.on/.open/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s) + is kept for **one round** so mid-round single-venue callers don't break; it retires next round. - `cover` — integer in `{0} ∪ [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$2–10`, RSL 45% free / `$2–5` (cheap — the bistro pays the bills), band_room 70% free / `$2–6` (a hat on a milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers. diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 02435ab..56e0843 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -447,3 +447,79 @@ reports a bogus FAIL (`node: command not found`). | leak, 4 gig enter/exit cycles | geo 66→66 · tex 63→63 (**zero**) | | tonight's covers (7 nights) | `10, 0, 0, 3, 2, 0, 9` — 3 free of 7 (~half, as seeded) | | custom band names surfacing | **7 of John's 10** fill all 7 nights; `?noassets=1` → pure generator | + +--- + +## 13. Round 13 — v3.0-beta THE DISTRICT (`?gigs=1`). F wires it last; findings below. + +**What F built (all 7 tasks from ROUND13 §Lane F).** All committed Lane-F-only; verified green on m3ultra +(`qa.sh --strict`, the focused district gate, the scale gate). A single WARN — the queue — is a documented +handoff to D, not a hole (below). + +1. **Per-venue state machine** (`gig_state.js`, rewritten). One latch PER VENUE keyed by `venueShopId`; a + town of 2–4 venues runs 2–4 independent latches off the SAME `procity:segment` spine. All three hard-won + R12 laws are preserved verbatim inside `createVenueLatch` (listens-not-polls · `state` is a getter · + night rolls on WHERE the clock lands). New runtime surface on `window.PROCITY.gigs`: **`.byVenue`** → + `{ [id]: state }` (B's `venue.js`/`audio.js` read it), per-venue **`stateOf/onOf/openOf/gigOf/coverOf/ + bandNameOf/paidOf(id)` + `markPaidOf(id)` + `.venueShopIds`**. The R12 single-venue alias + (`.state/.on/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s) is kept **one round** so + B's mid-round single-venue callers don't break — it retires next round. Contract pinned in CITY_SPEC §v3. + *Tonight* per venue = its night-0 gig (matches A's posters + B's frontage, all night-0-keyed); a + dark-tonight venue latches 'quiet' all session, which is true to the seeded week. + +2. **Walla + applause** (`interior_mode.js`). While a venue's gig is `on`, the crowd bed + `ambience['crowd-walla']` rides the interior TONE layer under the live band bed (you hear the room, not + the fridge hum) — it reuses B's existing `playInterior({musicKey,toneKey})`, no new audio API. On the + `on → done` edge while the player is inside (crowd's last-song → the clock rolls NIGHT→DAWN), a one-shot + `sfx['applause']` sting fires ONCE (`applauded` latch, re-armed per enter). Fail-soft throughout (no-op + muted / `?noassets` / pre-gesture). Verified live: `iMusic=gig-pubrock`, `iTone=crowd-walla`, edge fires, + 0 errors. + +3. **Cover charge PER VENUE** (`index.html`). The door seam now keys off the shop being ENTERED + (`openOf/coverOf/paidOf/markPaidOf(shopId)`), so a night at two venues is two covers; each latch holds + its own runtime paid-stamp. Verified: paid ($10 debited once, re-entry free), broke-knockback, free night. + +4. **Debt #1 CLOSED — and the R12 bridge was a LIVE BUG.** E renamed the pub bed `pubrock-live → gig-pubrock` + this round (the canonical `gig-`), so F's R12 `GIG_BED`/`gigBedKey` bridge — which rewrote + `gig-pubrock → pubrock-live` — was now pointing at a manifest key that **no longer exists**: the band + would have mimed in a silent room. DELETED. The room's `audio.gigKey` (already the manifest key) feeds the + engine directly, zero mapping. Verified across two genres live: `gig-pubrock` (pub), `gig-covers` (rsl). + +5. **Gate hardening — stature into the shared gate** (`interior_scale_check.py`, debt #6). The human-size + assert now measures **stature = crown − foot** (feet→crown span, platform-independent), with the + world-crown "never clips the ceiling" assert kept as its OWN line. The day a keeper stands on a platform, + the gate won't fail a correct figure. Floor figures foot≈0 ⇒ stature≈crown ⇒ backward-compatible. + Verified: 24 figs across 6 shops, statures 1.60–1.86 m. + +6. **District smokes** (`flags_check.py smoke_gigs`, extended). band 3→**4** · a new **§2b DISTRICT** loop + that enters EVERY playing venue and asserts its `gigKey` resolves to a real manifest bar (debt #1 across 3 + genres), per-venue crowd ≤ its own watchPoints, 4-piece, no-giants-by-stature · **§2c street budget** at + the busiest venue block (settled-floor sample) · queue probe (WARN-pending, below) · plus the R12 + determinism / cover / noassets / flags-off arms. All green. + +### ⚠ Cross-lane ask (Lane F → Lane D) — the QUEUE seam. F's smoke is READY; it WARN-skips until D exposes it. +D owns the outdoor queue (`web/js/citizens/queue.js` `VenueQueue`, charter item, landing this round). B has +already published the zone: **`window.PROCITY.venuePresentation.queueZoneFor(id) → {x,z,ry,len}`** and +`.queueZones`. F drives the surge per venue via `citizens.setGig(id, open)` (done). What F's smoke needs to +verify "queue ≤ cap and drains by done" is a read seam on the sim — **proposed contract:** +`window.PROCITY.citizens.queueInfo(venueShopId) → { count, cap }` (live count while doors/on; `0` once the +gig is `done`). Wire the queue's spawn to `queueZoneFor(id)` at `doors` and expose `queueInfo`, and F's smoke +flips WARN→assert with no further change. (If D picks a different accessor name, tell F and F adjusts the one +line in `smoke_gigs §2b`.) + +### R13 numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues) +| thing | number | +|---|---| +| district | **3 venues** — 2 playing tonight (pub #116 The Exchange Hotel · rsl #404), 1 dark (reads true) | +| live beds resolve | pub → `gig-pubrock` · rsl → `gig-covers` (both real manifest bars, debt #1 across genres) | +| band / crowd | **4-piece** each venue · pub crowd **8/8** watch points · **rsl crowd 12/12** (the cap stress case) | +| gig-night interior (rig) | **62 draws** · 34k tris (budget ≤350) · `?noassets` still a full 4-piece gig | +| street @ venue block, night | **99 draws** · 28k tris (budget 300 / 200k) — lit frontage + posters + queue zone in | +| figure scale | band+crowd stature **1.61–1.81 m**, tallest crown 2.12 m under the 4 m ceiling | +| cover, per venue | $10 debited once · re-entry free · broke knockback · free night (seed 1234) — all hold | +| flags-off prime law | fingerprint **0x3fa36874** unchanged · gig layer wholly absent · 0 errors | + +**Status:** F's wiring is done, committed, and green. **Waiting on Lane D** for (a) the queue seam +(`queueInfo`) so the queue smoke goes live, and (b) the band polish (E's instrument GLBs in hand, drummer +facing/RY_FLIP) — the band already lands at 4, human-sized, 0 errors, so this is polish not a blocker. Once D +commits: re-run the gates, flip the queue assert, capture the district money shots, tag **v3.0-beta**. diff --git a/docs/shots/laneF/r13_v3beta_gig_interior.png b/docs/shots/laneF/r13_v3beta_gig_interior.png new file mode 100644 index 0000000..711e6eb Binary files /dev/null and b/docs/shots/laneF/r13_v3beta_gig_interior.png differ diff --git a/docs/shots/laneF/r13_v3beta_venue_night.png b/docs/shots/laneF/r13_v3beta_venue_night.png new file mode 100644 index 0000000..7e7311a Binary files /dev/null and b/docs/shots/laneF/r13_v3beta_venue_night.png differ diff --git a/tools/flags_check.py b/tools/flags_check.py index 98fb643..6d5ea54 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -592,16 +592,17 @@ def smoke_audio(p): b.close() def smoke_gigs(p): - """R12 v3.0-alpha gig layer (Lane F). The six gates from ROUND12 §Lane F.5 — schedule determinism · - band always 3 · crowd ≤ watchPoints · gig-night interior draw budget · no-giants on the stage/crowd - rigs (crowns re-asserted here so the gig path can't ship a giant even if qa runs without the scale - gate) · ?noassets=1 still silent-and-fine with ?gigs=1 — plus John's cover ruling (paid · free · - broke-knockback · no re-charge on re-entry). + """R13 v3.0-beta THE DISTRICT gig layer (Lane F). The ROUND13 §Lane F.6 gates — district determinism · + a 4-piece band · per-venue crowd ≤ watchPoints · EVERY venue's gigKey resolves to a real manifest bed + (debt #1, now across 3 genres) · gig-night interior draw budget · street budget at the busiest venue + block · no-giants (STATURE) on the stage/crowd rigs · queue ≤ cap and drains by 'done' (pending Lane D) + · ?noassets=1 still silent-and-fine with ?gigs=1 — plus John's cover ruling, now PER VENUE (paid · free + · broke-knockback · no re-charge on re-entry · a night at two venues is two covers). Seeds are pinned because `cover` is seeded: 20261990's tonight is a $10 night (Screaming Utes at The Thornbury Hotel) and 1234's is free (Dazza & The Dropbears at The Imperial), so BOTH halves of "free AND paid" are covered deterministically rather than whichever the default seed happened to roll.""" - head('SMOKE: gigs (R12 v3.0-alpha — schedule · band · crowd · budget · cover · no-giants · noassets)') + head('SMOKE: gigs (R13 v3.0-beta district — determinism · 4-piece · per-venue crowd/cover · gigKeys · budgets · queue · noassets)') if not gigs_landed(p): print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)') return @@ -669,9 +670,9 @@ def smoke_gigs(p): else: if night['state'] == 'on': OK(f"gig state machine: NIGHT → 'on' (venue {night['H']}m room)") else: FAIL(f"gig state machine: NIGHT should be 'on', got '{night['state']}'") - # band always 3 (D's contract: 3 poses on C's deck) - if night['band'] == 3: OK('band: exactly 3 on the stage') - else: FAIL(f"band: expected 3 on the stage, got {night['band']}") + # band is a 4-piece (R13 debt #3: front-line trio + a drummer on C's riser) + if night['band'] == 4: OK('band: 4-piece on the stage (front-line trio + drummer)') + else: FAIL(f"band: expected a 4-piece (trio + drummer), got {night['band']}") # crowd cap ≤ watchPoints if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0: OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)") @@ -706,6 +707,127 @@ def smoke_gigs(p): finally: b.close() + # 2b) THE DISTRICT (R13): every playing venue fires — its live bed resolves to a REAL manifest bar for + # its genre (debt #1, now across pub/band_room/rsl), its crowd is capped at its OWN watch points, and it + # is a 4-piece. Plus the queue ≤ cap / drains-by-done probe (WARN-skips until Lane D wires the seam). + music_keys = set() + try: + mani = json.loads((ROOT / 'web' / 'assets' / 'manifest.json').read_text()) + music_keys = set((mani.get('audio', {}).get('music', {}) or {}).keys()) + except Exception as e: + WARN(f"district: could not read manifest music keys ({e}) — gigKey-resolves check is JS-only") + b, pg, errs = new_page(p) + try: + boot(pg, 'gigs=1') + pg.keyboard.press('Space') + district = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3(); + D.setSegment(5); // NIGHT — every playing venue is 'on' + const ids = P.gigs.venueShopIds || []; + const out = []; + for (const id of ids) { + const shop = (P.plan.shops || []).find(s => s.id === id); + if (!P.gigs.onOf(id)) { out.push({ id, kind: shop && shop.venueKind, dark: true }); continue; } + D.enterShop(id); + await new Promise(r => setTimeout(r, 450)); + const room = P.interiorMode.current, info = P.interiorMode.crewInfo; + const mem = P.interiorMode.crew ? P.interiorMode.crew.members : []; + const figs = mem.map(m => { + let crown = 0; m.actor.fig.updateWorldMatrix(true, true); + (m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) { + o.getWorldPosition(v); crown = Math.max(crown, v.y); } }); + return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3) }; + }); + P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); + out.push({ id, kind: shop && shop.venueKind, genreKey: shop && shop.genreKey, + gigKey: (room && room.audio && room.audio.gigKey) || null, + band: info ? info.band : 0, crowd: info ? info.crowd : 0, + watchPoints: room ? room.watchPoints.length : 0, figs }); + D.exitShop(); await new Promise(r => setTimeout(r, 200)); + } + return { ids, out }; + }""") + playing = [d for d in district['out'] if not d.get('dark')] + OK(f"district: {len(district['ids'])} venue(s) — {len(playing)} playing tonight, " + f"{len(district['out']) - len(playing)} dark (reads true to the seeded week)") + genres_seen = set() + for d in playing: + tag = f"{d['kind']} #{d['id']}" + want = f"gig-{d['genreKey']}" + if d['gigKey'] == want and (not music_keys or want in music_keys): + OK(f" {tag}: live bed '{d['gigKey']}' resolves to a real manifest bar (genre {d['genreKey']})") + genres_seen.add(d['genreKey']) + else: + FAIL(f" {tag}: gigKey '{d['gigKey']}' != '{want}' or not in manifest.music {sorted(music_keys)}") + if 0 < d['crowd'] <= d['watchPoints']: + OK(f" {tag}: crowd {d['crowd']} ≤ {d['watchPoints']} watch points") + else: + FAIL(f" {tag}: crowd {d['crowd']} vs {d['watchPoints']} watch points (must be >0 and ≤ cap)") + if d['band'] == 4: OK(f" {tag}: 4-piece band") + else: FAIL(f" {tag}: expected a 4-piece, got {d['band']}") + rigs = [f for f in d['figs'] if f['kind'] == 'rig'] + bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0)] + if rigs and bad: FAIL(f" {tag}: {len(bad)} giant rig(s) by stature: {bad[:2]}") + if len(genres_seen) >= 2: + OK(f"district: {len(genres_seen)} distinct genres verified live ({', '.join(sorted(genres_seen))})") + + # queue ≤ cap and drains by 'done' — Lane D's outdoor line (charter item). D shipped the VenueQueue + # class and left the street-side lifecycle to F, which wires it in the shell (spawn at doors, drive + # each frame, disposeAll at done); read via P.queueCountOf(id). The line is a seeded short 2–6. + q = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG; + if (typeof P.queueCountOf !== 'function') return { wired: false }; + const id = P.gigs.venueShopId; + D.setSegment(4); await new Promise(r => setTimeout(r, 700)); // DUSK — doors open, the line forms + const atDoors = P.queueCountOf(id) | 0; + D.setSegment(0); await new Promise(r => setTimeout(r, 700)); // DAWN — closing time ('done'), line gone + const atDone = P.queueCountOf(id) | 0; + return { wired: true, atDoors, atDone }; + }""") + QUEUE_CAP = 6 + if not q.get('wired'): + FAIL("queue: P.queueCountOf(id) not exposed — F's shell wiring missing") + elif 0 < q['atDoors'] <= QUEUE_CAP and q['atDone'] == 0: + OK(f"queue: {q['atDoors']} punters in line at doors (≤ cap {QUEUE_CAP}), drained to 0 by 'done'") + else: + FAIL(f"queue: at doors {q['atDoors']} (want 1..{QUEUE_CAP}), at done {q['atDone']} (want 0)") + + if errs: FAIL(f"district: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('district: 0 console errors across all venues') + finally: + b.close() + + # 2c) STREET BUDGET at the busiest venue block (R13 law: ≤300 draws / ≤200k tris with the lit frontage, + # town-wide posters and the queue zone all in). Park on the footpath in front of the primary venue at + # NIGHT with the gig on, let the block stream in, and sample the settled composer frame. + b, pg, errs = new_page(p) + try: + boot(pg, 'gigs=1') + st = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG; + D.setSegment(5); // NIGHT — frontage lit, posters up + const id = P.gigs.venueShopId, shop = (P.plan.shops || []).find(s => s.id === id); + const lot = shop && (P.plan.lots || []).find(l => l.id === shop.lot); + if (lot) { + const fx = Math.sin(lot.ry || 0), fz = Math.cos(lot.ry || 0); + D.teleport(lot.x + fx * 10, lot.z + fz * 10, Math.atan2(-fx, -fz)); // 10 m out, facing the venue + } + await new Promise(r => setTimeout(r, 700)); // chunks/frontage/posters/pools settle + let draws = Infinity, tris = 0; // take the settled floor (ignore stream spikes) + for (let i = 0; i < 12; i++) { + await new Promise(r => requestAnimationFrame(r)); + if (P.renderer.info.render.calls < draws) { draws = P.renderer.info.render.calls; tris = P.renderer.info.render.triangles; } + } + return { draws, tris, venue: shop && shop.name }; + }""") + if st['draws'] <= STREET_DRAWS_MAX and st['tris'] <= STREET_TRIS_MAX: + OK(f"street budget @ venue block ({st['venue']}): {st['draws']} draws ≤ {STREET_DRAWS_MAX}, {st['tris']:,} tris ≤ {STREET_TRIS_MAX:,}") + else: + FAIL(f"street budget @ venue block ({st['venue']}): {st['draws']} draws / {st['tris']:,} tris over ({STREET_DRAWS_MAX} / {STREET_TRIS_MAX:,})") + if errs: FAIL(f"street budget: {len(errs)} console error(s); first: {errs[0][:140]}") + finally: + b.close() + # 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback b, pg, errs = new_page(p) try: @@ -791,8 +913,8 @@ def smoke_gigs(p): crowd: info && info.crowd, gigs: (P.plan.gigs || []).length, bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest }; }""") - if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0: - OK(f"?noassets=1&gigs=1: the gig still happens (band {na['band']}, crowd {na['crowd']}, placeholders)") + if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0: + OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)") else: FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}") if len(reqs) == 0: diff --git a/tools/qa/gig_shot.py b/tools/qa/gig_shot.py index 01c90d1..50971e9 100644 --- a/tools/qa/gig_shot.py +++ b/tools/qa/gig_shot.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 -"""PROCITY Lane F — v3.0-alpha acceptance shot (round 12). +"""PROCITY Lane F — v3.0-beta THE DISTRICT acceptance shots (round 13). -The money shot the round asks for: gig ON, band on Lane C's deck, mixed standing/dancing crowd at the -watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be -re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo). +The two money shots the round asks for, both reproducible (never a hand-cropped screenshot nobody can +re-take when the gig layer changes): + 1. INTERIOR — a gig ON: the 4-piece band (front-line trio + a drummer on the riser) on Lane C's deck + with Lane E's real instruments in hand, a mixed standing/dancing crowd at the watch points, + everything human-sized next to the door / bar / ceiling. + 2. STREET (venue_night) — the lit venue frontage at night: marquee + bulbs + window spill + streetlamp + pools on the footpath, Lane D's outdoor QUEUE at the door, town-wide posters on the poles. -Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--out PATH] -Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG. +Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--outdir DIR] +Needs the Playwright venv + Lane B's ?dbg=1 hook (incl. the venue_night bookmark). Writes full-frame +1280x720 PNGs to docs/shots/laneF/. """ import sys, time, socket, subprocess, pathlib @@ -14,9 +19,9 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = 8130 HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 -OUT = ROOT / 'docs' / 'shots' / 'laneF' / 'r12_v3alpha_gig.png' +OUTDIR = ROOT / 'docs' / 'shots' / 'laneF' if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) -if '--out' in sys.argv: OUT = pathlib.Path(sys.argv[sys.argv.index('--out') + 1]) +if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1]) def port_up(port): @@ -36,9 +41,9 @@ def ensure_server(): proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') -# Stand back by the door at eye height and look at the stage over the crowd's heads. Forcing one explicit -# render (rather than trusting rAF) keeps the shot deterministic in headless. -POSE_JS = r""" +# ── Shot 1: INTERIOR. Stand back by the door at eye height and look at the stage over the crowd's heads. +# Forcing one explicit render (rather than trusting rAF) keeps the interior shot deterministic in headless. +INTERIOR_JS = r""" async () => { const P = window.PROCITY, D = window.DBG, THREE = P.THREE; D.setSegment(5); // NIGHT — the gig is on @@ -51,10 +56,31 @@ async () => { P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z)); P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); - const crew = P.interiorMode.crew; - return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName, - state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo, - dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0, + const crew = P.interiorMode.crew, mem = crew ? crew.members : []; + const band = mem.filter(m => m.part === 'band'); + return { ok: true, venue: (P.plan.shops.find(s => s.id === P.gigs.venueShopId) || {}).name, + band: P.gigs.bandName, state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo, + roles: band.map(m => m.role), + dancers: mem.filter(m => m.part === 'crowd' && m.dance).length, + draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; +} +""" + +# ── Shot 2: STREET (venue_night). Lane B's bookmark poses out in front of the lit pub frontage at NIGHT. +# We exit to the street first, then let the animate loop run several frames at the pose so F's queue spawns +# (it is driven from the street frame), the frontage ramps up, and the venue block streams in — the last +# natural full-frame render is what the screenshot captures. +STREET_JS = r""" +async () => { + const P = window.PROCITY, D = window.DBG; + if (P.mode === 'interior') { D.exitShop(); await new Promise(r => setTimeout(r, 350)); } + D.shot('venue_night'); // B's bookmark: seg 5 + pose at the pub frontage + await new Promise(r => setTimeout(r, 1600)); // frontage ramp + queue spawn + chunk/poster stream settle + const id = P.gigs.venueShopId; + return { ok: P.mode === 'street', venue: (P.plan.shops.find(s => s.id === id) || {}).name, + state: P.gigs.stateOf ? P.gigs.stateOf(id) : P.gigs.state, + queue: P.queueCountOf ? P.queueCountOf(id) : null, + posters: (P.plan.posters || []).length, venues: (P.gigs.venueShopIds || []).length, draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; } """ @@ -66,6 +92,9 @@ def main(): except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') srv = ensure_server() + OUTDIR.mkdir(parents=True, exist_ok=True) + interior_out = OUTDIR / 'r13_v3beta_gig_interior.png' + street_out = OUTDIR / 'r13_v3beta_venue_night.png' try: with sync_playwright() as p: b = p.chromium.launch() @@ -80,20 +109,30 @@ def main(): timeout=15000) except Exception: print(' ! fleet never readied — shooting the placeholder crew') - info = pg.evaluate(POSE_JS) + + # Shot 1 — interior + info = pg.evaluate(INTERIOR_JS) if not info.get('ok'): - b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m') - OUT.parent.mkdir(parents=True, exist_ok=True) - pg.screenshot(path=str(OUT)) + b.close(); sys.exit(f'\033[31m✗ could not stage the interior shot: {info}\033[0m') + pg.screenshot(path=str(interior_out)) + print(f"\033[32m● SHOT\033[0m {interior_out.relative_to(ROOT)}") + print(f" interior · {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover") + print(f" band {info['crew']['band']} (roles {info['roles']}) · crowd {info['crew']['crowd']} " + f"({info['dancers']} dancing) · {info['draws']} draws · {info['tris']:,} tris") + + # Shot 2 — street venue_night + st = pg.evaluate(STREET_JS) + pg.screenshot(path=str(street_out)) + print(f"\033[32m● SHOT\033[0m {street_out.relative_to(ROOT)}") + print(f" street · {st['venue']} · state={st['state']} · queue={st['queue']} · " + f"{st['posters']} posters across {st['venues']} venues · {st['draws']} draws · {st['tris']:,} tris") b.close() finally: if srv: srv.terminate() - print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}") - print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover") - print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · " - f"{info['draws']} draws · {info['tris']:,} tris") - if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}") + if errs: + print(f" ! {len(errs)} page error(s): {errs[0][:120]}") + sys.exit(1) if __name__ == '__main__': diff --git a/tools/qa/interior_scale_check.py b/tools/qa/interior_scale_check.py index a119c14..fb479ed 100644 --- a/tools/qa/interior_scale_check.py +++ b/tools/qa/interior_scale_check.py @@ -9,11 +9,17 @@ feet->crown span. Fixed in R10 (normalise by `headY - minY`). This gate boots the real game, enters a sample of open shops, spawns browser rigs at every browse point (occupancy is 0 right after boot, so we inject it to exercise the browse spawn path deterministically), and asserts for EVERY spawned interior figure: - 1.4 m <= crown(head-bone world Y) <= 2.0 m (human-sized) - crown < room dims.H (never clips the ceiling) - |feet(min-bone world Y)| < 0.05 m (planted on the floor) + 1.4 m <= STATURE (crown - foot) <= 2.0 m (human-sized — platform-independent) + crown(head-bone world Y) < room dims.H (never clips the ceiling — its own line) + |feet(min-bone world Y)| < 0.05 m (planted on the floor) Exits non-zero if any figure fails, so tools/qa.sh --strict fails a giant before it can ship. +[Lane F R13 — debt #6] The human-size check measures STATURE (feet→crown span), not the raw world +crown, so it stays correct the day a figure stands on a platform (a keeper on a 0.3 m riser is not a +giant). This is the same measure smoke_gigs uses for the band on Lane C's stage deck, now ported into +the shared gate; the world-crown "never clips the ceiling" assert is kept as its own separate line +(that is the real R10 failure mode). For floor figures foot≈0 ⇒ stature≈crown ⇒ fully backward-compatible. + Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N] Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py. Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F). @@ -69,7 +75,8 @@ MEASURE_JS = r""" a.fig.updateWorldMatrix(true, true); (a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v); if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } }); - return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3) }; + return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3), + stature: +(crown - foot).toFixed(3) }; // [R13 debt #6] feet→crown span (platform-independent) }); return { H: +H.toFixed(2), figs }; } @@ -117,14 +124,14 @@ def main(): bad = [] for f in figs: total_figs += 1 - if not (LO <= f['crown'] <= HI): bad.append(f"crown {f['crown']}m out of [{LO},{HI}]") + if not (LO <= f['stature'] <= HI): bad.append(f"stature {f['stature']}m out of [{LO},{HI}]") elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m") elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)") if bad: FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad)) else: - crowns = ", ".join(f"{f['crown']:.2f}" for f in figs) - OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs crowns=[{crowns}] all human-sized") + stats = ", ".join(f"{f['stature']:.2f}" for f in figs) + OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs statures=[{stats}] all human-sized") pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()") pg.wait_for_timeout(150) b.close() diff --git a/web/index.html b/web/index.html index 902f6ba..e3cda3f 100644 --- a/web/index.html +++ b/web/index.html @@ -60,6 +60,7 @@ import { createTram } from './js/world/tram.js'; // [Lane F R9 import { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1) import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1) import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers) +import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1) import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack main().catch((err) => { @@ -243,6 +244,14 @@ const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ s // Self-contained module; never constructed flags-off (byte-identical), like weather/tram. const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null; +// [Lane F R13 — the outdoor queue] Lane D's VenueQueue, one per venue, wired from the street-side gig state +// (D shipped the class and left the street-side lifecycle to F — its documented seam). A seeded line forms at +// the frontage when the doors open, idle-shuffles + auto-drains each street frame, and is disposed by 'done'. +// B publishes the zone (venuePresentation.queueZoneFor); D's rig path is gate-protected (no giants) + a +// placeholder line under ?noassets. Built lazily per venue when its doors open; never touched flags-off. +const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue +const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; }; + // spawn on the footpath near the west end of the strip, looking east down main street player.teleport(-96, 9, -Math.PI / 2); chunks.warmup(player.position); @@ -271,19 +280,20 @@ function enterShop(shopId, name) { if (!shop) { hud.showToast(`🚪 ${name}`); return; } // [Lane F §3.5] closed shops are locked; at night the openLate video shop is the one place still open if (!isOpen(shop)) { hud.showToast(`🔒 ${name} — CLOSED · opens ${shop.hours[0]}:00`); return; } - // [Lane F R12 — cover charge at the door] John's ruling: free AND paid nights. A's `cover` is seeded per + // [Lane F R13 — cover charge, PER VENUE] John's ruling: free AND paid nights. A's `cover` is seeded per // gig (~half 0). Paid → debit the R8 session wallet on the way in with a toast; too skint → a polite // knockback and NO entry (the wallet stays honest); free → walk straight in. Re-entry the same night is - // free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. Zero new - // UI — it all rides the round-8 buy seam. The venue is a normal shop in every other respect. - if (gigState && shopId === gigState.venueShopId && gigState.open && gigState.cover > 0 && !gigState.paid) { - const cover = gigState.cover, BAND = String(gigState.bandName || 'live').toUpperCase(); + // free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. THE + // DISTRICT: keyed by the venue being ENTERED (…Of(shopId)), so a night at two venues is two covers. Zero + // new UI — it rides the round-8 buy seam; the venue is a normal shop in every other respect. + if (gigState && gigState.openOf(shopId) && gigState.coverOf(shopId) > 0 && !gigState.paidOf(shopId)) { + const cover = gigState.coverOf(shopId), BAND = String(gigState.bandNameOf(shopId) || 'live').toUpperCase(); if (!wallet.canBuy(cover)) { hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`); return; } - wallet.buy({ title: `cover — ${gigState.bandName}`, price: cover }); - gigState.markPaid(); + wallet.buy({ title: `cover — ${gigState.bandNameOf(shopId)}`, price: cover }); + gigState.markPaidOf(shopId); hud.showToast(`$${cover} cover — ${BAND}`); } // [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode @@ -359,13 +369,28 @@ function frame() { lighting.update(dt, player.position); const clk = lighting.getClock(); citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night) - // [Lane F R12] Friday night, off the clock: tick the machine, ramp Lane B's marquee/sign glow, and point - // Lane D's patronage surge at the venue while the doors are open (setGig(id,false) ends it → normal day - // patronage). Lane B's audio engine reads window.PROCITY.gigs.state on its own rAF for the muffled spill. + // [Lane F R13 — THE DISTRICT] Friday night, off the clock: tick EVERY venue's latch, ramp Lane B's + // per-venue marquee/sign glow off the byVenue map, and point Lane D's patronage surge at each venue whose + // doors are open — several concurrent gigs each pull their own block (setGig(id,false) ends one → normal + // day patronage there). Lane B's audio engine reads window.PROCITY.gigs.byVenue on its own rAF for the + // per-genre muffled spill. if (gigState) { - const gs = gigState.update(); - if (venuePresentation) venuePresentation.update(gs); - citizens.setGig(gigState.venueShopId, gs === 'doors' || gs === 'on'); + gigState.update(); // tick all latches + if (venuePresentation) venuePresentation.update(gigState.byVenue); // per-venue frontage/spill + for (const id of gigState.venueShopIds) { + const open = gigState.openOf(id); + citizens.setGig(id, open); // concurrent patronage surges + if (venueQueues) { // the outdoor line: forms while the doors are open, drains + disposes by 'done' + let q = venueQueues.get(id); + if (open && !q) { + q = new VenueQueue({ citySeed: plan.citySeed, fleet }); + q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id), + gigId: (gigState.gigOf(id) || {}).gigId | 0 }); + venueQueues.set(id, q); + } else if (!open && q) { q.disposeAll(); venueQueues.delete(id); } + if (q) q.update(dt); + } + } } // [Lane F R4] no setExposure call: D3 (a5e4b64) made the impostor toneMapped:true, so three / // OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op. @@ -396,6 +421,7 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to // decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off) + venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it) THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks // [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with // zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so diff --git a/web/js/world/gig_state.js b/web/js/world/gig_state.js index edcbe63..52c4e49 100644 --- a/web/js/world/gig_state.js +++ b/web/js/world/gig_state.js @@ -1,45 +1,53 @@ -// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 12 / v3.0-alpha] -// The Friday-night state machine: quiet → doors → on → done, driven off Lane B's day clock and Lane A's -// plan.gigs. F owns it because every other lane consumes it and none of them own the clock: -// • Lane B audio.js reads window.PROCITY.gigs.state → the muffled through-the-wall spill -// • Lane B venue.js update(state) → marquee/sign glow on the frontage -// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge -// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside) -// • Lane F the cover charge at the door + the smokes +// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 13 / v3.0-beta THE DISTRICT] +// The Friday-night state machine, now PER-VENUE. A town has 2–4 venues (pub / band_room / rsl); each runs +// its own latch quiet → doors → on → done off Lane B's day clock and Lane A's plan.gigs. F owns it because +// every other lane consumes it and none of them own the clock: +// • Lane B audio.js reads window.PROCITY.gigs.byVenue[id] → the muffled through-the-wall spill per venue +// • Lane B venue.js update(byVenue) → marquee/sign glow on each frontage +// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge, per venue +// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside) +// • Lane F the cover charge at each door + the smokes // -// Alpha model (CITY_SPEC §"CityPlan v3 — gig layer"): tonight is plan.gigs[0]; startSeg=5 (NIGHT 22:00), -// endSeg=5, doors at startSeg−1 (DUSK). One venue, one genre, seven nightly entries in the plan. +// Model (CITY_SPEC §"CityPlan v3 — gig layer"): plan.gigs is the whole week × every venue. TONIGHT for a +// venue is its night-0 entry (the same night-0 convention A's posters and B's frontage key off); a venue +// dark tonight has no latch gig → reads 'quiet' all session, which is true to the seeded schedule. Each +// venue's night rolls the same way (clock turns → next cover due), so a night at two venues is two covers. // -// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and the pub closes at -// 23, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the wrap, DAWN. -// So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after it (DAWN = -// closing time — the band packs up and the crowd drains per Lane A's closing-time ruling), and clear the -// latch on any other segment → back to 'quiet'. The latch clears on any non-DAWN segment rather than only -// at MORNING because the player drives this clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must -// read doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly -// that). A night only *counts* as rolled when we leave 'done' FORWARDS — that's what separates a real -// DAWN→MORNING roll (new night, cover due again) from a rewind back out of the gig (same night, paid). +// ── THREE HARD-WON LAWS, unchanged from the alpha (they each cost a live-verify to learn): ────────────── +// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and a venue closes at +// closing time, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the +// wrap, DAWN. So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after +// it (DAWN = closing time — the band packs up, the crowd drains per Lane A's closing ruling), and clear on +// any other segment → back to 'quiet'. The latch clears on any non-DAWN segment (not only at MORNING) +// because the player drives the clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must read +// doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly that). // -// WHY IT LISTENS RATHER THAN POLLS. The latch has to observe every segment, and a poll only sees the -// segments it happens to be sampled on: driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in -// between (player inside the pub, audio muted, so neither the street loop nor the audio rAF is reading) -// skipped the roll entirely — the night never advanced and the cover was never due again. Caught live in -// the R12 verify. lighting.js dispatches `procity:segment` on EVERY segment change (hand-driven or the -// automatic day cycle), so that is the signal the latch rides. Reads stay safe on their own: `state` is a -// getter that re-observes the current segment, and observing the same segment twice is idempotent. +// WHY THE NIGHT ROLLS ON WHERE THE CLOCK LANDS, not on watching 'done' go by. First cut incremented the +// night when LEAVING 'done' forwards — so a clock JUMP straight from NIGHT to MORNING (never touching DAWN) +// never rolled the night, and a broke punter walked in on last night's paid stamp. Now: the latch clears +// wherever it clears, and landing in the DAYTIME (MORNING…ARVO) is what rolls the night; landing on the +// doors means we rewound out of the gig — same night, stays paid. DBG/shots/soak all JUMP, so only this +// rule survives them. +// +// WHY IT LISTENS RATHER THAN POLLS. Each latch must observe EVERY segment; a poll only sees the segments it +// is sampled on. Driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside a +// venue, audio muted, so neither the street loop nor the audio rAF is reading) skipped the roll entirely — +// the night never advanced and the cover was never due again. lighting.js dispatches `procity:segment` on +// EVERY segment change (hand-driven or the auto day cycle), so that is the signal every latch rides. Reads +// stay safe on their own: state is a getter that re-observes the current segment, and observing the same +// segment twice is idempotent. const SEGS = 6; -export function createGigState({ plan, lighting }) { - const gigs = (plan && plan.gigs) || []; - const gig = gigs[0] || null; // alpha: tonight is gigs[0] (CITY_SPEC v3) +// One venue's latch — the alpha machine, its hard-won internals verbatim. `gig` is this venue's tonight +// (night-0) entry, or null when the venue is dark tonight (→ always 'quiet'). +function createVenueLatch(gig, lighting) { const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5; const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5; const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in - // The daytime segments: everything from the morning after the gig up to (not including) the doors — - // MORNING/MIDDAY/ARVO for the alpha schedule. Landing here after a gig means THE DAY HAS TURNED, which - // is what rolls the night (and makes the next cover due). See the roll note below. + // The daytime segments: everything from the morning after the gig up to (not including) the doors. + // Landing here after a gig means THE DAY HAS TURNED, which rolls the night (next cover due). const DAYTIME = (() => { const set = new Set(); for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s); @@ -52,16 +60,14 @@ export function createGigState({ plan, lighting }) { let paidNight = -1; // the night whose cover is paid; re-entry the same night is free // The whole machine, for ONE segment. Idempotent per segment, so it is safe to call from both the - // event and any read. + // procity:segment event and any read. function observe(seg) { const on = seg >= startSeg && seg <= endSeg; if (on) played = true; else if (played && seg !== doneSeg) { - // The latch is clearing: the gig is behind us. Whether that means "a new night" (cover due again) - // or "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by having - // watched 'done' go by: keying the roll on leaving 'done' meant a clock jump straight from NIGHT to - // MORNING never rolled the night at all, and a broke punter walked in on last night's stamp. The - // smoke caught it; stepping ([/]) always passes through DAWN, so only jumps exposed it. + // The latch is clearing: the gig is behind us. Whether that's "a new night" (cover due again) or + // "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by watching + // 'done' go by (see the roll law in the header). played = false; if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due } // (landed on the doors ⇒ rewound out of the gig ⇒ same night) @@ -69,30 +75,81 @@ export function createGigState({ plan, lighting }) { return state; } - // Every segment change, from [ / ] or the automatic cycle — this is what guarantees no segment is missed. - const onSegment = (e) => { if (gig && e && e.detail) observe(e.detail.seg); }; - if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment); - function tick() { if (!gig || !lighting || !lighting.getClock) return (state = 'quiet'); return observe(lighting.getClock().seg); } - tick(); // settle on the boot segment return { + gig, observe, tick, get state() { return tick(); }, - update: tick, // shell calls this once per street frame (same thing) get on() { return tick() === 'on'; }, get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters - get gig() { return gig; }, - get venueShopId() { return gig ? gig.venueShopId : null; }, - get bandName() { return gig ? gig.bandName : null; }, get cover() { return gig ? gig.cover | 0 : 0; }, - get night() { return night; }, - // cover stamp — runtime only (like the wallet itself): paid once, free to pop out and back in until the - // night rolls. NOT persisted, NOT written into the plan (goldens never move). + get bandName() { return gig ? gig.bandName : null; }, + get night() { tick(); return night; }, + // cover stamp — runtime only (like the wallet): paid once, free to pop out and back in until the night + // rolls. NOT persisted, NOT written into the plan (goldens never move). get paid() { tick(); return paidNight === night; }, markPaid() { tick(); paidNight = night; }, + }; +} + +export function createGigState({ plan, lighting }) { + const gigs = (plan && plan.gigs) || []; + // The district's venues, in first-seen (gigId) order. Primary = gigs[0].venueShopId — the alpha's + // "tonight", kept as the compat alias so B's mid-round single-venue callers still light the right pub. + const venueIds = [...new Set(gigs.map((g) => g.venueShopId))]; + const primaryId = gigs.length ? gigs[0].venueShopId : null; + + // one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet') + const latch = new Map(); + for (const id of venueIds) { + const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null; + latch.set(id, createVenueLatch(tonight, lighting)); + } + + // ONE listener drives every latch — the listens-not-polls law, now fanned out across the district. + const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); }; + if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment); + + function tickAll() { for (const l of latch.values()) l.tick(); } + tickAll(); // settle every latch on the boot segment + + const L = (id) => latch.get(id) || null; + const P = () => L(primaryId); + + return { + // ── per-venue API (R13 district) ────────────────────────────────────────────────────────────── + get venueShopIds() { return venueIds.slice(); }, + stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; }, + onOf(id) { const l = L(id); return l ? l.on : false; }, + openOf(id) { const l = L(id); return l ? l.open : false; }, + gigOf(id) { const l = L(id); return l ? l.gig : null; }, + coverOf(id) { const l = L(id); return l ? l.cover : 0; }, + bandNameOf(id) { const l = L(id); return l ? l.bandName : null; }, + nightOf(id) { const l = L(id); return l ? l.night : 0; }, + paidOf(id) { const l = L(id); return l ? l.paid : false; }, + markPaidOf(id) { const l = L(id); if (l) l.markPaid(); }, + // the map B's venue.update() and audio.js consume: a fresh { [venueShopId]: 'quiet'|'doors'|'on'|'done' }. + // Called per street frame over a handful of venues — cheap. + get byVenue() { const o = {}; for (const [id, l] of latch) o[id] = l.tick(); return o; }, + // shell calls this once per street frame — ticks every latch (so B/D read fresh state) and returns the + // primary state for the alpha-compat caller. + update() { tickAll(); return P() ? P().state : 'quiet'; }, + + // ── alpha-shape alias → the PRIMARY venue (kept for one round; B reads it mid-round) ───────────── + get state() { const p = P(); return p ? p.state : 'quiet'; }, + get on() { const p = P(); return p ? p.on : false; }, + get open() { const p = P(); return p ? p.open : false; }, + get gig() { const p = P(); return p ? p.gig : null; }, + get venueShopId() { return primaryId; }, + get bandName() { const p = P(); return p ? p.bandName : null; }, + get cover() { const p = P(); return p ? p.cover : 0; }, + get night() { const p = P(); return p ? p.night : 0; }, + get paid() { const p = P(); return p ? p.paid : false; }, + markPaid() { const p = P(); if (p) p.markPaid(); }, + dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); }, }; } diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index 7f0dbcd..6cc31e2 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -27,16 +27,14 @@ const SPEED = 3.2; // interior walk speed (matches the Lane C tester) const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away) const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street -// [Lane F R12 — CROSS-LANE SEAM FIX] Lane C emits `room.audio.gigKey = 'gig-' + genreKey` and asked Lane -// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as -// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already -// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined -// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig). -// F owns the glue, so the key resolves here: exact map first, then a generic `gig-` → `-live` -// fallback so a second genre works without another edit. Reported to C/E for a real contract line in -// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12. -const GIG_BED = { 'gig-pubrock': 'pubrock-live' }; -const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null); +// [Lane F R13 — debt #1 CLOSED] The gigKey contract is now canonical: a genre's live-bed manifest key IS +// `gig-` (Lane A exports gigKeyFor(); Lane C emits room.audio.gigKey; Lane E named the manifest +// beds gig-pubrock/gig-grunge/gig-covers to match). So the room's gigKey feeds the audio engine DIRECTLY, +// with NO mapping. The R12 `GIG_BED` bridge that rewrote gig-pubrock→pubrock-live is DELETED — and not a +// moment too soon: E renamed pubrock-live→gig-pubrock this round, so the old bridge had become a LIVE BUG +// (it pointed at a manifest key that no longer exists → the band would mime in a silent room). Contract line +// lives in CITY_SPEC §v3. See LANE_F_NOTES §12. +const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on' export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false, dig: digEnabled = false, stockReal = false, wallet = null, @@ -51,6 +49,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // most rooms never see one), disposed with the room like the keepers. let crew = null; let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this) + let applauded = false; // [R13] one-shot latch: the on→done applause sting has fired for this visit let current = null; // active Lane C interior handle let currentShop = null; // the shop record for the active interior (dig per-bin seeding) @@ -191,23 +190,25 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null → // Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders. currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null; - // [Lane F R12 gig layer] Is this the venue, mid-gig? Lane C only stamps room.audio.gigKey when we pass - // opts.gig, and it treats `{on:false}` as "a gig" if genreKey is set — so pass NULL unless it's really - // on. A quiet-night pub then builds as a normal interior (stage + watch points still there, just nobody - // on them), which is exactly the "quiet night at the pub" C specced. - const gigOn = !!(gigState && gigState.venueShopId != null && shop.id === gigState.venueShopId && gigState.on); - const tonight = gigOn ? gigState.gig : null; + // [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has + // 2–4 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass + // opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior + // (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced. + const gigOn = !!(gigState && gigState.onOf(shop.id)); + const tonight = gigOn ? gigState.gigOf(shop.id) : null; current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null }); currentShop = shop; scene.add(current.group); // [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine). // Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy. - // [R12] Prefer the gig bed over the room's seeded radio while the band is on (LANE_C_PUB.md), through - // F's key resolver above — playInterior only knows musicKey/toneKey, so the gig bed rides in as musicKey. + // [R13] While the band is on, the LIVE BED plays over the room's radio: room.audio.gigKey is ALREADY the + // manifest key (gig- — debt #1 closed, no bridge), and the crowd WALLA rides the interior tone + // layer under it (you hear the room, not the fridge hum). Quiet night → the room's own seeded bed. const ra = current.audio || {}; - const bed = gigBedKey(ra.gigKey); - window.PROCITY.audio && window.PROCITY.audio.playInterior(bed ? { ...ra, musicKey: bed } : ra); + const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra; + window.PROCITY.audio && window.PROCITY.audio.playInterior(gigAudio); + applauded = false; // [R13] re-arm the on→done applause sting for this visit // Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable const counter = current.places.find((p) => p.userData && p.userData.keeperStand); if (counter) { @@ -281,6 +282,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera) if (crew) crew.update(dt); // [R12] Lane D: band strum-sway + crowd stand/dance (seeded phase per member) + // [Lane F R13] applause sting on on→done: crewInfo is non-null only when a gig crew spawned (gigOn at + // enter); when the clock then rolls the gig off (NIGHT→DAWN — the band's last song), the crowd claps + // ONCE. Fail-soft via the house audio law (no-op muted / ?noassets / pre-gesture). applauded guards re-fire. + if (crewInfo && !applauded && gigState && currentShop && !gigState.onOf(currentShop.id)) { + applauded = true; + window.PROCITY.audio && window.PROCITY.audio.playSfx && window.PROCITY.audio.playSfx('applause', { gain: 0.9 }); + } renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so // renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)