Lane F R12: wire the v3.0-alpha gig layer (?gigs=1) — state machine, cover charge, band+crowd, gig audio

F last: five landed lanes → one Friday night. Seed 20261990 → The Thornbury Hotel,
tonight SCREAMING UTES, $10 on the door. qa.sh --strict GREEN 6/6.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-15 21:09:56 +10:00
parent f0a0a99207
commit 2498fdc892
8 changed files with 709 additions and 7 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

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

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

View File

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

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

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

View File

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