Lane F R13 (v3.0-beta, the district): per-venue gig state + walla/applause + queue wiring; qa --strict 6/6

Wires A–E + D's district together and fires it. Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing
tonight (The Exchange Hotel pub gig-pubrock + an RSL gig-covers, one dark), Screaming Utes $10 on the door.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 00:22:03 +10:00
parent fefbb4b806
commit a358c5d9ef
11 changed files with 500 additions and 125 deletions

View File

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

View File

@ -257,6 +257,11 @@ gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover
venue's genre. venue's genre.
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors - `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors
(startSeg1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) (startSeg1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
**Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read
it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters
`stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. The R12
single-venue alias (`.state/.on/.open/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s)
is kept for **one round** so mid-round single-venue callers don't break; it retires next round.
- `cover` — integer in `{0} [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$210`, - `cover` — integer in `{0} [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$210`,
RSL 45% free / `$25` (cheap — the bistro pays the bills), band_room 70% free / `$26` (a hat on a RSL 45% free / `$25` (cheap — the bistro pays the bills), band_room 70% free / `$26` (a hat on a
milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers. milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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