diff --git a/F-progress.md b/F-progress.md index d67e4a5..5a11560 100644 --- a/F-progress.md +++ b/F-progress.md @@ -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), diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 7d2595f..02435ab 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -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-` → `-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 | diff --git a/docs/shots/laneF/r12_v3alpha_gig.png b/docs/shots/laneF/r12_v3alpha_gig.png new file mode 100644 index 0000000..79ef28a Binary files /dev/null and b/docs/shots/laneF/r12_v3alpha_gig.png differ diff --git a/tools/flags_check.py b/tools/flags_check.py index 562146c..98fb643 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -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() diff --git a/tools/qa/gig_shot.py b/tools/qa/gig_shot.py new file mode 100644 index 0000000..01c90d1 --- /dev/null +++ b/tools/qa/gig_shot.py @@ -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() diff --git a/web/index.html b/web/index.html index 7016115..902f6ba 100644 --- a/web/index.html +++ b/web/index.html @@ -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= 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 diff --git a/web/js/world/gig_state.js b/web/js/world/gig_state.js new file mode 100644 index 0000000..edcbe63 --- /dev/null +++ b/web/js/world/gig_state.js @@ -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); }, + }; +} diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index d8eed7d..7f0dbcd 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -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-` → `-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 }; }