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:
parent
f0a0a99207
commit
2498fdc892
@ -4,6 +4,67 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 12 (v3.0-alpha — the gig layer, `?gigs=1`) — WIRED + VERIFIED
|
||||
|
||||
A–E all landed before me, so F did what F does: turned five parts into one Friday night. **Seed
|
||||
20261990 → "Boolarra Heads", venue = The Thornbury Hotel, tonight = SCREAMING UTES, $10 on the door.**
|
||||
Acceptance shot: `docs/shots/laneF/r12_v3alpha_gig.png`.
|
||||
|
||||
| F task (ROUND12 §Lane F) | result |
|
||||
|---|---|
|
||||
| **1 — gig state machine** | ✅ new F-owned `web/js/world/gig_state.js`. `quiet → doors (DUSK) → on (NIGHT) → done (DAWN) → quiet`, verified forwards AND backwards on the hand-driven clock. On `window.PROCITY.gigs`. |
|
||||
| **2 — `?gigs=1` seam + flags-off** | ✅ shell bootstrap fetches `custom_bands.json` (no fetch when flag-off or `?noassets=1`) → `generatePlanFor(seed, src, {gigs:true, customBands})`. Flags-off verified **wholly absent**: no state machine, no `plan.gigs`/`posters`, no pub, no venue geometry, zero band-file fetches, golden `0x3fa36874` unmoved. |
|
||||
| **3 — cover charge** | ✅ all three halves of John's ruling verified live: **$10 debited once** (191→181, `cover — Screaming Utes` in the wallet), **re-entry the same night free** (181→181), **broke on a new night → polite knockback, no entry**. Free nights (seed 1234) walk straight in, wallet untouched. Zero new UI — rides the R8 buy seam. |
|
||||
| **4 — audio wiring** | ✅ gig bed inside via C's `gigKey`, B's muffled spill outside reads `PROCITY.gigs.state`. **Found + bridged a real C↔E key mismatch — see below.** |
|
||||
| **5 — smokes** | ✅ `smoke_gigs` in `flags_check.py`: schedule determinism · band==3 · crowd ≤ watchPoints · dance mix · bed resolves · ≤350 draws · no-giants · `?noassets=1`+`?gigs=1`. Plus the v3 arm of the flags-off regression. |
|
||||
| **6 — shot + tag** | ✅ shot captured. Tag pending the harness run (below). |
|
||||
|
||||
**Numbers.** Gig-night interior **42–50 draws** with the rig fleet, **171 draws** on `?noassets=1`
|
||||
placeholders (worst; budget ≤350) · ~40k tris · street at the venue 69 draws/29.6k tris · **leak-free**
|
||||
(geo 66→66, tex 63→63 over 4 gig cycles) · band 3 + crowd 8/8 watch points, **3 dancing / 5 standing**
|
||||
· band stature 1.68–1.77 m, crowd 1.60–1.77 m, tallest crown 2.09 m under a 4.0 m ceiling.
|
||||
|
||||
**John's drop-in works:** 7 of your 10 hand-written names fill all 7 nights (Screaming Utes, The Dead
|
||||
Ringers, The Rissoles, The Flannel Shirts, Boganvillea, Chunderbolt, Amyl & The Servos). Covers rolled
|
||||
`10,0,0,3,2,0,9` — 3 free of 7. Under `?noassets=1` it falls back to the pure generator ("The Wombatts")
|
||||
with zero fetch, exactly as A specced.
|
||||
|
||||
### Three bugs F found in its own work (all caught by verification, fixed, re-verified)
|
||||
1. **The state machine only advanced when polled.** Driving NIGHT→DAWN→MORNING→NIGHT with nothing
|
||||
reading in between (player inside the pub, audio muted) **skipped the night roll** — the cover was
|
||||
never due again. Now it rides `procity:segment`, which `lighting.js` fires on every change.
|
||||
2. **Rewinding the clock left `done` stuck** on a midday town (the latch only cleared at MORNING).
|
||||
3. **The night only rolled if the clock passed through DAWN** — so a *jump* from NIGHT to MORNING left
|
||||
last night's paid stamp valid and **a broke punter walked straight in**. Caught by my own smoke, not
|
||||
by hand: stepping with `[`/`]` always crosses DAWN, so only jumps (DBG/shots/soak) exposed it. The
|
||||
roll now keys on landing in the daytime, not on having observed one particular segment.
|
||||
|
||||
Also worth recording, because it nearly shipped a wrong gate: **"no giants" must measure stature, not
|
||||
world crown.** The band stands on C's 0.32 m deck, so a perfectly human 1.73 m guitarist has a 2.05 m
|
||||
world crown and the naive R10 check **failed a correct band**. The smoke now measures `crown − base.y`
|
||||
and keeps the world-crown assert only for "never clips the ceiling" — the real R10 failure mode.
|
||||
|
||||
### Three findings for other lanes (detail + rulings in `LANE_F_NOTES.md` §12)
|
||||
1. **⚠ C ↔ E: the gig bed key never met.** C emits `gig-pubrock` (and asked E for that name); E shipped
|
||||
**`pubrock-live`**. Unresolved, the pub plays to a **silent room** and — because the audio law fails
|
||||
soft — *nothing errors*, you just get a band miming. **F bridged it at the glue** so alpha ships.
|
||||
This is the R1 facade-skin filename trap again. → **needs a named contract line in CITY_SPEC.**
|
||||
2. **⚠ D + E: E's 5 instrument GLBs landed; nothing wires them.** D holds primitives and published
|
||||
`instrumentFor` as the seam "when they land" — they've landed, nobody owns the join. Non-blocking
|
||||
(asset law; reads fine in the shot). F did **not** silently do it inside D's rig. → R13.
|
||||
3. **⚠ spec ↔ C: the band has no drummer.** ROUND12 §D.1 says "drummer seated bob" and E built a merged
|
||||
kit; C's `bandPoses` are guitar/**vocal**/bass and the drum riser is set-dressing. D flagged it and
|
||||
built to C's poses. A spec/implementation divergence **no gate can catch** → Fable/John to rule.
|
||||
|
||||
### Harness note
|
||||
This MacBook had **no Chromium/Chrome**, so the Playwright gates couldn't run locally; John authorised
|
||||
the install (done — 93.5 MB, Playwright CDN). `/Volumes/m3ultra/Documents/procity` is a **mount of
|
||||
m3ultra's `~/Documents/procity`** (same tree), so `ssh m3ultra@100.89.131.57` runs the real gates on the
|
||||
real files — but **node isn't on the non-interactive ssh PATH**: use
|
||||
`env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict` or every node gate reports a bogus FAIL.
|
||||
|
||||
---
|
||||
|
||||
## Round 11 (the audio round) — DONE ✅ (v2.1)
|
||||
|
||||
John greenlit "generate freely". E generated the pack (23 assets, `web/assets/audio/`, 8.7 MB ≤ 25),
|
||||
|
||||
@ -351,3 +351,99 @@ are the top Lane F item for R6.)*
|
||||
### Marshal
|
||||
Shared-tree arbiter this round: F owns `web/index.html` + `tools/`; if two lanes collide on a file,
|
||||
F arbitrates. Cross-lane asks → the owning lane's NOTES, not silent edits.
|
||||
|
||||
---
|
||||
|
||||
## 12. Round 12 — v3.0-alpha gig layer (`?gigs=1`). F wires it last; findings below.
|
||||
|
||||
**What F built.** `web/js/world/gig_state.js` (new, F-owned) — the Friday-night state machine; the
|
||||
`?gigs=1` seam + `custom_bands.json` bootstrap in `index.html`; the cover charge at the door; the band +
|
||||
crowd + gig-bed wiring in `interior_mode.js`; `smoke_gigs` in `tools/flags_check.py` (6 gates) + the
|
||||
v3 arm of the flags-off regression.
|
||||
|
||||
### The state machine (`gig_state.js`) — the two things that are load-bearing
|
||||
1. **It listens, it doesn't poll.** `lighting.js` dispatches `procity:segment` on every segment change;
|
||||
the latch rides that. A poll-only machine sees only the segments it is sampled on — driving
|
||||
NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside the pub, audio muted, so
|
||||
neither F's street loop nor B's audio rAF is reading) **skipped the night roll entirely**: the night
|
||||
never advanced and the cover was never due again. Caught live, fixed, re-verified.
|
||||
2. **`state` is a getter, not a field.** B's audio engine self-ticks on its own rAF and reads
|
||||
`PROCITY.gigs.state` while the player is *inside* a shop — i.e. when F's street loop is not running.
|
||||
A pushed field goes stale there and the spill lies about the gig.
|
||||
|
||||
3. **The night rolls on WHERE the clock lands, not on watching `done` go by.** First cut incremented the
|
||||
night when leaving `'done'` forwards — which meant a clock *jump* straight from NIGHT to MORNING (never
|
||||
touching DAWN) never rolled the night at all, and **a broke punter walked in on last night's paid
|
||||
stamp**. `smoke_gigs` caught it; stepping with `[`/`]` always passes through DAWN, so only a jump
|
||||
(DBG/shots/soak all jump) exposed it. Now: the latch clears wherever it clears, and landing in the
|
||||
**daytime** (MORNING…ARVO) is what rolls the night; landing on the **doors** means we rewound out of
|
||||
the gig — same night, stays paid.
|
||||
|
||||
`done` is real, not decorative: the segment→hour map tops out at NIGHT (22:00) so the gig owns the whole
|
||||
night segment, and "after the gig" can only be DAWN (closing time — band packing up, crowd draining per
|
||||
A's closing-time ruling). Latch clears on any non-DAWN segment so rewinding the clock (`[`) doesn't leave
|
||||
`done` stuck on a midday town — it did exactly that on the first verify.
|
||||
|
||||
### ⚠ Finding 1 (Lane C ↔ Lane E) — the gig bed key never met. **F bridged it; needs a contract line.**
|
||||
`LANE_C_PUB.md` asks E to name the live bed **`gig-pubrock`** (C emits `room.audio.gigKey = 'gig-' +
|
||||
genreKey`). E shipped it as **`pubrock-live`** (`gig:true`, `types:['pub']`) — which is also the key B's
|
||||
street spill hardcodes. Neither is wrong; they just never met. Unresolved, `AUDIO.music['gig-pubrock']`
|
||||
is `undefined` and **the pub plays to a silent room** — and because the audio law fails soft, nothing
|
||||
errors: you just get a band miming. F resolves it at the glue (`interior_mode.js GIG_BED` + a generic
|
||||
`gig-<genre>` → `<genre>-live` fallback). **This is the R1 facade-skin filename trap again** (registry
|
||||
stored filenames, `skins.js` expected bare keys). → **C/E/Fable: pick one and put it in CITY_SPEC as a
|
||||
named contract**, so genre #2 doesn't rediscover this. F's smoke now asserts `gigKey` resolves.
|
||||
|
||||
### ⚠ Finding 2 (Lane D + Lane E) — E's instrument GLBs landed; nothing wires them.
|
||||
E shipped 5 instrument GLBs this round (`electric_guitar`, `bass_guitar`, `drum_kit`, `guitar_amp`,
|
||||
`mic_stand`, all in `manifest.fittings` with footprints + thumbs). D's band holds **primitives** and
|
||||
published `spawn(..., { instrumentFor })` as the injection seam — D-progress says E's GLBs "drop in via
|
||||
`opts.instrumentFor(role)` **when they land**". They have landed. Nobody owns the join. Not blocking
|
||||
(asset law: a primitive band is still a gig, and it reads fine in the shot), but it is a dangling R12
|
||||
deliverable. F did **not** do it silently: picking manifest ids and orienting/scaling instruments inside
|
||||
D's rig is D's call, and the R12 no-drummer question below wants answering in the same pass. → **R13.**
|
||||
|
||||
### ⚠ Finding 3 (ROUND12 spec ↔ Lane C) — the band has no drummer.
|
||||
ROUND12 §Lane D.1 specifies "guitarist/bassist strum-sway, **drummer seated bob**", and E was asked for
|
||||
"drum kit (one merged kit)". C's `stage.bandPoses` are **guitar / vocal / bass** — a front-line trio; the
|
||||
drum riser is set-dressing with no pose. D built to C's poses and flagged it. So the shipped alpha band is
|
||||
3-across-the-front with a mic stand where the spec expected a kit. Cosmetic, non-blocking, deterministic —
|
||||
but it is a **spec/implementation divergence that no gate can catch**. → **Fable/John: rule for R13** (add
|
||||
a 4th `drums` pose on the riser, or amend the spec to a front-line trio).
|
||||
|
||||
### Gate lesson — "no giants" must measure STATURE, not world crown.
|
||||
The R10 gate asserts head-bone world Y ∈ [1.4, 2.0]. The band stands on C's **0.32 m stage deck**, so a
|
||||
perfectly human 1.73 m guitarist has a world crown of **2.05 m** and a naive world-Y check **fails a
|
||||
correct band** (it did, first run). `smoke_gigs` measures `crown − base.y` for the band/crowd and keeps
|
||||
the world-crown assert only for "never clips the ceiling", which is the real R10 failure mode.
|
||||
`tools/qa/interior_scale_check.py` is unchanged (keepers/browsers stand on the floor, base.y = 0) — but
|
||||
**if anyone ever puts a keeper on a platform, that gate inherits this bug.**
|
||||
|
||||
### → Lane B (small, R13): no venue bookmark for shots
|
||||
`dbg.js BOOKMARKS` has no venue pose, and `DBG.shot(name)` forces street mode **and re-poses/re-segments**
|
||||
— so there is no way to drive a scripted "lit pub frontage at night, gig on" street shot (the alpha's other
|
||||
money shot; your marquee + posters + spill are the whole street story of this round). F verified the
|
||||
frontage lights correctly (`emissiveIntensity` 1.7 at `on`, 71 draws / 29.6k tris at the venue) but shot it
|
||||
by hand. **Ask:** add a `venue_night` bookmark (pose at the pub frontage, `seg:5`) — then F's
|
||||
`tools/qa/gig_shot.py` can capture the street side too. This is the same "3 missing bookmark poses" ask
|
||||
from R3 §9, so it may be worth doing the set in one pass.
|
||||
|
||||
### Harness note — where the browser gates actually run
|
||||
This session's box had **no Chromium and no Chrome**, so every Playwright gate (flags harness + scale
|
||||
gate) is un-runnable locally until `tools/.venv/bin/python -m playwright install chromium`. The repo at
|
||||
`/Volumes/m3ultra/Documents/procity` is a **mount of m3ultra's `~/Documents/procity`** — same tree, so
|
||||
`ssh m3ultra@100.89.131.57` runs the real gates on the real files. **Gotcha:** node is not on the
|
||||
non-interactive ssh PATH — `env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict`, or every node gate
|
||||
reports a bogus FAIL (`node: command not found`).
|
||||
|
||||
### R12 numbers (measured, seed 20261990 → "Boolarra Heads", venue = The Thornbury Hotel)
|
||||
| thing | number |
|
||||
|---|---|
|
||||
| gig-night interior, rig fleet | **42–50 draws** · ~40k tris (budget **≤350**) |
|
||||
| gig-night interior, `?noassets=1` placeholders (**worst**) | **171 draws** (budget ≤350) |
|
||||
| gig-night street at the venue | 69 draws · 29.6k tris (budget 300 / 200k) |
|
||||
| band / crowd | **3** on the deck · **8** at 8 watch points · **3 dancing / 5 standing** |
|
||||
| figure scale | band stature 1.68–1.77 m (crown ≤2.09 m under a 4.0 m ceiling); crowd 1.60–1.77 m |
|
||||
| leak, 4 gig enter/exit cycles | geo 66→66 · tex 63→63 (**zero**) |
|
||||
| tonight's covers (7 nights) | `10, 0, 0, 3, 2, 0, 9` — 3 free of 7 (~half, as seeded) |
|
||||
| custom band names surfacing | **7 of John's 10** fill all 7 nights; `?noassets=1` → pure generator |
|
||||
|
||||
BIN
docs/shots/laneF/r12_v3alpha_gig.png
Normal file
BIN
docs/shots/laneF/r12_v3alpha_gig.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@ -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
100
tools/qa/gig_shot.py
Normal file
@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — v3.0-alpha acceptance shot (round 12).
|
||||
|
||||
The money shot the round asks for: gig ON, band on Lane C's deck, mixed standing/dancing crowd at the
|
||||
watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be
|
||||
re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo).
|
||||
|
||||
Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--out PATH]
|
||||
Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG.
|
||||
"""
|
||||
import sys, time, socket, subprocess, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = 8130
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
OUT = ROOT / 'docs' / 'shots' / 'laneF' / 'r12_v3alpha_gig.png'
|
||||
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||||
if '--out' in sys.argv: OUT = pathlib.Path(sys.argv[sys.argv.index('--out') + 1])
|
||||
|
||||
|
||||
def port_up(port):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.4)
|
||||
return s.connect_ex(('127.0.0.1', port)) == 0
|
||||
|
||||
|
||||
def ensure_server():
|
||||
if port_up(PORT):
|
||||
return None
|
||||
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(50):
|
||||
if port_up(PORT): return proc
|
||||
time.sleep(0.1)
|
||||
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
|
||||
|
||||
|
||||
# Stand back by the door at eye height and look at the stage over the crowd's heads. Forcing one explicit
|
||||
# render (rather than trusting rAF) keeps the shot deterministic in headless.
|
||||
POSE_JS = r"""
|
||||
async () => {
|
||||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
|
||||
D.setSegment(5); // NIGHT — the gig is on
|
||||
D.enterShop(P.gigs.venueShopId);
|
||||
await new Promise(r => setTimeout(r, 900)); // room build + crew spawn + rig clips settle
|
||||
const room = P.interiorMode.current;
|
||||
if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.state };
|
||||
const st = room.stage;
|
||||
P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2);
|
||||
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
|
||||
P.renderer.info.reset();
|
||||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||||
const crew = P.interiorMode.crew;
|
||||
return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName,
|
||||
state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
|
||||
dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0,
|
||||
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
|
||||
srv = ensure_server()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||||
errs = []
|
||||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1')
|
||||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000)
|
||||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||
try:
|
||||
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)',
|
||||
timeout=15000)
|
||||
except Exception:
|
||||
print(' ! fleet never readied — shooting the placeholder crew')
|
||||
info = pg.evaluate(POSE_JS)
|
||||
if not info.get('ok'):
|
||||
b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m')
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
pg.screenshot(path=str(OUT))
|
||||
b.close()
|
||||
finally:
|
||||
if srv: srv.terminate()
|
||||
|
||||
print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}")
|
||||
print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover")
|
||||
print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · "
|
||||
f"{info['draws']} draws · {info['tris']:,} tris")
|
||||
if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -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
98
web/js/world/gig_state.js
Normal file
@ -0,0 +1,98 @@
|
||||
// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 12 / v3.0-alpha]
|
||||
// The Friday-night state machine: quiet → doors → on → done, driven off Lane B's day clock and Lane A's
|
||||
// plan.gigs. F owns it because every other lane consumes it and none of them own the clock:
|
||||
// • Lane B audio.js reads window.PROCITY.gigs.state → the muffled through-the-wall spill
|
||||
// • Lane B venue.js update(state) → marquee/sign glow on the frontage
|
||||
// • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge
|
||||
// • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside)
|
||||
// • Lane F the cover charge at the door + the smokes
|
||||
//
|
||||
// Alpha model (CITY_SPEC §"CityPlan v3 — gig layer"): tonight is plan.gigs[0]; startSeg=5 (NIGHT 22:00),
|
||||
// endSeg=5, doors at startSeg−1 (DUSK). One venue, one genre, seven nightly entries in the plan.
|
||||
//
|
||||
// WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and the pub closes at
|
||||
// 23, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the wrap, DAWN.
|
||||
// So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after it (DAWN =
|
||||
// closing time — the band packs up and the crowd drains per Lane A's closing-time ruling), and clear the
|
||||
// latch on any other segment → back to 'quiet'. The latch clears on any non-DAWN segment rather than only
|
||||
// at MORNING because the player drives this clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must
|
||||
// read doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly
|
||||
// that). A night only *counts* as rolled when we leave 'done' FORWARDS — that's what separates a real
|
||||
// DAWN→MORNING roll (new night, cover due again) from a rewind back out of the gig (same night, paid).
|
||||
//
|
||||
// WHY IT LISTENS RATHER THAN POLLS. The latch has to observe every segment, and a poll only sees the
|
||||
// segments it happens to be sampled on: driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in
|
||||
// between (player inside the pub, audio muted, so neither the street loop nor the audio rAF is reading)
|
||||
// skipped the roll entirely — the night never advanced and the cover was never due again. Caught live in
|
||||
// the R12 verify. lighting.js dispatches `procity:segment` on EVERY segment change (hand-driven or the
|
||||
// automatic day cycle), so that is the signal the latch rides. Reads stay safe on their own: `state` is a
|
||||
// getter that re-observes the current segment, and observing the same segment twice is idempotent.
|
||||
|
||||
const SEGS = 6;
|
||||
|
||||
export function createGigState({ plan, lighting }) {
|
||||
const gigs = (plan && plan.gigs) || [];
|
||||
const gig = gigs[0] || null; // alpha: tonight is gigs[0] (CITY_SPEC v3)
|
||||
const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5;
|
||||
const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5;
|
||||
const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open
|
||||
const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in
|
||||
// The daytime segments: everything from the morning after the gig up to (not including) the doors —
|
||||
// MORNING/MIDDAY/ARVO for the alpha schedule. Landing here after a gig means THE DAY HAS TURNED, which
|
||||
// is what rolls the night (and makes the next cover due). See the roll note below.
|
||||
const DAYTIME = (() => {
|
||||
const set = new Set();
|
||||
for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s);
|
||||
return set;
|
||||
})();
|
||||
|
||||
let state = 'quiet';
|
||||
let played = false; // latch: tonight's gig has run (set while inside the on-window)
|
||||
let night = 0; // rolls when the day turns after a gig — keys the cover stamp (runtime only)
|
||||
let paidNight = -1; // the night whose cover is paid; re-entry the same night is free
|
||||
|
||||
// The whole machine, for ONE segment. Idempotent per segment, so it is safe to call from both the
|
||||
// event and any read.
|
||||
function observe(seg) {
|
||||
const on = seg >= startSeg && seg <= endSeg;
|
||||
if (on) played = true;
|
||||
else if (played && seg !== doneSeg) {
|
||||
// The latch is clearing: the gig is behind us. Whether that means "a new night" (cover due again)
|
||||
// or "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by having
|
||||
// watched 'done' go by: keying the roll on leaving 'done' meant a clock jump straight from NIGHT to
|
||||
// MORNING never rolled the night at all, and a broke punter walked in on last night's stamp. The
|
||||
// smoke caught it; stepping ([/]) always passes through DAWN, so only jumps exposed it.
|
||||
played = false;
|
||||
if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due
|
||||
} // (landed on the doors ⇒ rewound out of the gig ⇒ same night)
|
||||
state = on ? 'on' : seg === doorsSeg ? 'doors' : played ? 'done' : 'quiet';
|
||||
return state;
|
||||
}
|
||||
|
||||
// Every segment change, from [ / ] or the automatic cycle — this is what guarantees no segment is missed.
|
||||
const onSegment = (e) => { if (gig && e && e.detail) observe(e.detail.seg); };
|
||||
if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment);
|
||||
|
||||
function tick() {
|
||||
if (!gig || !lighting || !lighting.getClock) return (state = 'quiet');
|
||||
return observe(lighting.getClock().seg);
|
||||
}
|
||||
tick(); // settle on the boot segment
|
||||
|
||||
return {
|
||||
get state() { return tick(); },
|
||||
update: tick, // shell calls this once per street frame (same thing)
|
||||
get on() { return tick() === 'on'; },
|
||||
get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters
|
||||
get gig() { return gig; },
|
||||
get venueShopId() { return gig ? gig.venueShopId : null; },
|
||||
get bandName() { return gig ? gig.bandName : null; },
|
||||
get cover() { return gig ? gig.cover | 0 : 0; },
|
||||
get night() { return night; },
|
||||
// cover stamp — runtime only (like the wallet itself): paid once, free to pop out and back in until the
|
||||
// night rolls. NOT persisted, NOT written into the plan (goldens never move).
|
||||
get paid() { tick(); return paidNight === night; },
|
||||
markPaid() { tick(); paidNight = night; },
|
||||
dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); },
|
||||
};
|
||||
}
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/interiors.js';
|
||||
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
||||
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
||||
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
||||
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
||||
|
||||
@ -26,15 +27,30 @@ const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
|
||||
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
|
||||
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
|
||||
|
||||
// [Lane F R12 — CROSS-LANE SEAM FIX] Lane C emits `room.audio.gigKey = 'gig-' + genreKey` and asked Lane
|
||||
// E (LANE_C_PUB.md) to name the live bed `gig-pubrock` in manifest.audio; Lane E shipped it as
|
||||
// `pubrock-live` (tagged `gig:true`, types:['pub']) — which is also the key Lane B's street spill already
|
||||
// hardcodes. Neither is wrong, they just never met: unresolved, `AUDIO.music['gig-pubrock']` is undefined
|
||||
// and the pub plays to a SILENT room (audio law fails soft, so nothing errors — it just isn't a gig).
|
||||
// F owns the glue, so the key resolves here: exact map first, then a generic `gig-<genre>` → `<genre>-live`
|
||||
// fallback so a second genre works without another edit. Reported to C/E for a real contract line in
|
||||
// CITY_SPEC (same class as the R1 facade-skin filename trap). See LANE_F_NOTES §12.
|
||||
const GIG_BED = { 'gig-pubrock': 'pubrock-live' };
|
||||
const gigBedKey = (k) => (k ? GIG_BED[k] || k.replace(/^gig-(.+)$/, '$1-live') : null);
|
||||
|
||||
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
||||
dig: digEnabled = false, stockReal = false, wallet = null,
|
||||
occupancyOf = null }) {
|
||||
occupancyOf = null, gigState = null }) {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#0e0b07');
|
||||
|
||||
// Lane D shopkeeper — one per interior, at the counter. With a rig `fleet` (§3.4) the keeper is a
|
||||
// shared GLB actor; without one it's an asset-free placeholder. Either way it greets the player.
|
||||
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed, fleet });
|
||||
// [Lane F R12] Lane D's band trio + audience. Built lazily on the first gig (a town has ONE venue and
|
||||
// most rooms never see one), disposed with the room like the keepers.
|
||||
let crew = null;
|
||||
let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this)
|
||||
|
||||
let current = null; // active Lane C interior handle
|
||||
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
|
||||
@ -175,12 +191,23 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null →
|
||||
// Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
|
||||
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type)) : null;
|
||||
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter }); // static room sleeves
|
||||
// [Lane F R12 gig layer] Is this the venue, mid-gig? Lane C only stamps room.audio.gigKey when we pass
|
||||
// opts.gig, and it treats `{on:false}` as "a gig" if genreKey is set — so pass NULL unless it's really
|
||||
// on. A quiet-night pub then builds as a normal interior (stage + watch points still there, just nobody
|
||||
// on them), which is exactly the "quiet night at the pub" C specced.
|
||||
const gigOn = !!(gigState && gigState.venueShopId != null && shop.id === gigState.venueShopId && gigState.on);
|
||||
const tonight = gigOn ? gigState.gig : null;
|
||||
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||||
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
||||
currentShop = shop;
|
||||
scene.add(current.group);
|
||||
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
|
||||
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
|
||||
window.PROCITY.audio && window.PROCITY.audio.playInterior(current.audio);
|
||||
// [R12] Prefer the gig bed over the room's seeded radio while the band is on (LANE_C_PUB.md), through
|
||||
// F's key resolver above — playInterior only knows musicKey/toneKey, so the gig bed rides in as musicKey.
|
||||
const ra = current.audio || {};
|
||||
const bed = gigBedKey(ra.gigKey);
|
||||
window.PROCITY.audio && window.PROCITY.audio.playInterior(bed ? { ...ra, musicKey: bed } : ra);
|
||||
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
|
||||
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
|
||||
if (counter) {
|
||||
@ -200,6 +227,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
seedKey: `${shop.id}#${i}`,
|
||||
}));
|
||||
}
|
||||
// [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: 3 on the deck, up to 8 at the watch
|
||||
// points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at watchPoints.length
|
||||
// and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these rigs too.
|
||||
// Instruments are D's primitives this round — E's instrument GLBs landed but nothing wires them yet
|
||||
// (D's `opts.instrumentFor` seam is open); asset law says a primitive band is still a gig. → LANE_F_NOTES §12.
|
||||
crewInfo = null;
|
||||
if (gigOn && current.stage && current.watchPoints && current.watchPoints.length) {
|
||||
if (!crew) crew = new GigCrew({ citySeed: plan.citySeed, fleet });
|
||||
crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, gig: tonight });
|
||||
}
|
||||
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
|
||||
camera.position.set(current.spawn.x, EYE, current.spawn.z);
|
||||
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
|
||||
@ -207,6 +244,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
let hasBins = false;
|
||||
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
|
||||
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
||||
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
||||
+ (hasBins ? ' · E: riffle a record bin' : '');
|
||||
banner.style.display = 'block';
|
||||
return current;
|
||||
@ -242,6 +280,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
}
|
||||
|
||||
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
|
||||
if (crew) crew.update(dt); // [R12] Lane D: band strum-sway + crowd stand/dance (seeded phase per member)
|
||||
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
|
||||
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so
|
||||
// renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)
|
||||
@ -258,6 +297,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
|
||||
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
|
||||
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
|
||||
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
|
||||
current.dispose();
|
||||
current = null;
|
||||
currentShop = null;
|
||||
@ -278,6 +318,8 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
keepers,
|
||||
get active() { return !!current; },
|
||||
get current() { return current; },
|
||||
get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered)
|
||||
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
|
||||
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user