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

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**) |
| 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 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()
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 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
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:

View File

@ -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__':

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
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 foot0 staturecrown 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()

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 { 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

View File

@ -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 24 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 startSeg1 (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); },
};
}

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 EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
// [Lane F R12 — CROSS-LANE SEAM FIX] Lane C emits `room.audio.gigKey = 'gig-' + genreKey` and asked Lane
// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as
// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already
// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined
// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig).
// F owns the glue, so the key resolves here: exact map first, then a generic `gig-<genre>` → `<genre>-live`
// fallback so a second genre works without another edit. Reported to C/E for a real contract line in
// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12.
const GIG_BED = { 'gig-pubrock': 'pubrock-live' };
const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null);
// [Lane F R13 — debt #1 CLOSED] The gigKey contract is now canonical: a genre's live-bed manifest key IS
// `gig-<genreKey>` (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
// 24 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-<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 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)