diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 6393599..0eba8e9 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -272,9 +272,8 @@ gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover (startSeg−1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) **Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters - `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. The R12 - single-venue alias (`.state/.on/.open/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s) - is kept for **one round** so mid-round single-venue callers don't break; it retires next round. + `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/nightOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. + Every read is keyed by `venueShopId` — there is no scalar alias (the R12 single-venue alias retired in R15). - `cover` — integer in `{0} ∪ [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$2–10`, RSL 45% free / `$2–5` (cheap — the bistro pays the bills), band_room 70% free / `$2–6` (a hat on a milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers. diff --git a/tools/flags_check.py b/tools/flags_check.py index 6d5ea54..a3425e6 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -630,9 +630,10 @@ def smoke_gigs(p): 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(); + const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id D.setSegment(5); // NIGHT — the gig is on - const state = P.gigs.state; - D.enterShop(P.gigs.venueShopId); + const state = P.gigs.stateOf(id); + D.enterShop(id); await new Promise(r => setTimeout(r, 500)); const room = P.interiorMode.current; if (!room) return { entered: false, state }; @@ -777,7 +778,7 @@ def smoke_gigs(p): q = pg.evaluate("""async () => { const P = window.PROCITY, D = window.DBG; if (typeof P.queueCountOf !== 'function') return { wired: false }; - const id = P.gigs.venueShopId; + const id = P.gigs.venueShopIds[0]; D.setSegment(4); await new Promise(r => setTimeout(r, 700)); // DUSK — doors open, the line forms const atDoors = P.queueCountOf(id) | 0; D.setSegment(0); await new Promise(r => setTimeout(r, 700)); // DAWN — closing time ('done'), line gone @@ -806,7 +807,7 @@ def smoke_gigs(p): st = pg.evaluate("""async () => { const P = window.PROCITY, D = window.DBG; D.setSegment(5); // NIGHT — frontage lit, posters up - const id = P.gigs.venueShopId, shop = (P.plan.shops || []).find(s => s.id === id); + const id = P.gigs.venueShopIds[0], shop = (P.plan.shops || []).find(s => s.id === id); const lot = shop && (P.plan.lots || []).find(l => l.id === shop.lot); if (lot) { const fx = Math.sin(lot.ry || 0), fz = Math.cos(lot.ry || 0); @@ -834,15 +835,16 @@ def smoke_gigs(p): boot(pg, 'gigs=1') paid = pg.evaluate("""async () => { const P = window.PROCITY, D = window.DBG; + const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id D.setSegment(5); - const cover = P.gigs.cover; + const cover = P.gigs.coverOf(id); if (!cover) return { skip: true, cover }; const before = P.wallet.cash(); - D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350)); + D.enterShop(id); 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)); + D.enterShop(id); 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 @@ -850,7 +852,7 @@ def smoke_gigs(p): 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)); + D.enterShop(id); 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) }; @@ -882,10 +884,11 @@ def smoke_gigs(p): 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; + const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id 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 }; + const cover = P.gigs.coverOf(id), before = P.wallet.cash(); + D.enterShop(id); await new Promise(r => setTimeout(r, 350)); + return { cover, before, after: P.wallet.cash(), inRoom: !!P.interiorMode.current, band: P.gigs.bandNameOf(id) }; }""") 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']})") @@ -906,12 +909,13 @@ def smoke_gigs(p): pg.keyboard.press('Space') na = pg.evaluate("""async () => { const P = window.PROCITY, D = window.DBG; + const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id D.setSegment(5); - D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 450)); + D.enterShop(id); 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, + return { state: P.gigs.stateOf(id), 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 }; + bandName: P.gigs.bandNameOf(id), audio: P.audio && P.audio.state.manifest }; }""") if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0: OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)") diff --git a/tools/qa/gig_shot.py b/tools/qa/gig_shot.py index 50971e9..64c1e72 100644 --- a/tools/qa/gig_shot.py +++ b/tools/qa/gig_shot.py @@ -46,11 +46,12 @@ def ensure_server(): INTERIOR_JS = r""" async () => { const P = window.PROCITY, D = window.DBG, THREE = P.THREE; + const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — read the primary venue by id D.setSegment(5); // NIGHT — the gig is on - D.enterShop(P.gigs.venueShopId); + D.enterShop(id); 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 }; + if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.stateOf(id) }; 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)); @@ -58,8 +59,8 @@ async () => { P.renderer.render(P.interiorMode.scene, P.camera); const crew = P.interiorMode.crew, mem = crew ? crew.members : []; const band = mem.filter(m => m.part === 'band'); - return { ok: true, venue: (P.plan.shops.find(s => s.id === P.gigs.venueShopId) || {}).name, - band: P.gigs.bandName, state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo, + return { ok: true, venue: (P.plan.shops.find(s => s.id === id) || {}).name, + band: P.gigs.bandNameOf(id), state: P.gigs.stateOf(id), cover: P.gigs.coverOf(id), crew: P.interiorMode.crewInfo, roles: band.map(m => m.role), dancers: mem.filter(m => m.part === 'crowd' && m.dance).length, draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; @@ -76,9 +77,9 @@ async () => { if (P.mode === 'interior') { D.exitShop(); await new Promise(r => setTimeout(r, 350)); } D.shot('venue_night'); // B's bookmark: seg 5 + pose at the pub frontage await new Promise(r => setTimeout(r, 1600)); // frontage ramp + queue spawn + chunk/poster stream settle - const id = P.gigs.venueShopId; + const id = P.gigs.venueShopIds[0]; return { ok: P.mode === 'street', venue: (P.plan.shops.find(s => s.id === id) || {}).name, - state: P.gigs.stateOf ? P.gigs.stateOf(id) : P.gigs.state, + state: P.gigs.stateOf(id), queue: P.queueCountOf ? P.queueCountOf(id) : null, posters: (P.plan.posters || []).length, venues: (P.gigs.venueShopIds || []).length, draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; diff --git a/web/js/world/gig_state.js b/web/js/world/gig_state.js index 52c4e49..80a638c 100644 --- a/web/js/world/gig_state.js +++ b/web/js/world/gig_state.js @@ -97,10 +97,9 @@ function createVenueLatch(gig, lighting) { export function createGigState({ plan, lighting }) { const gigs = (plan && plan.gigs) || []; - // The district's venues, in first-seen (gigId) order. Primary = gigs[0].venueShopId — the alpha's - // "tonight", kept as the compat alias so B's mid-round single-venue callers still light the right pub. + // The district's venues, in first-seen (gigId) order. Every read is keyed by venueShopId — the R12 + // single-venue alias retired in R15 (B swept its cross-lane readers in R14; F migrated its own tools). const venueIds = [...new Set(gigs.map((g) => g.venueShopId))]; - const primaryId = gigs.length ? gigs[0].venueShopId : null; // one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet') const latch = new Map(); @@ -117,7 +116,6 @@ export function createGigState({ plan, lighting }) { tickAll(); // settle every latch on the boot segment const L = (id) => latch.get(id) || null; - const P = () => L(primaryId); return { // ── per-venue API (R13 district) ────────────────────────────────────────────────────────────── @@ -134,21 +132,8 @@ export function createGigState({ plan, lighting }) { // the map B's venue.update() and audio.js consume: a fresh { [venueShopId]: 'quiet'|'doors'|'on'|'done' }. // Called per street frame over a handful of venues — cheap. get byVenue() { const o = {}; for (const [id, l] of latch) o[id] = l.tick(); return o; }, - // shell calls this once per street frame — ticks every latch (so B/D read fresh state) and returns the - // primary state for the alpha-compat caller. - update() { tickAll(); return P() ? P().state : 'quiet'; }, - - // ── alpha-shape alias → the PRIMARY venue (kept for one round; B reads it mid-round) ───────────── - get state() { const p = P(); return p ? p.state : 'quiet'; }, - get on() { const p = P(); return p ? p.on : false; }, - get open() { const p = P(); return p ? p.open : false; }, - get gig() { const p = P(); return p ? p.gig : null; }, - get venueShopId() { return primaryId; }, - get bandName() { const p = P(); return p ? p.bandName : null; }, - get cover() { const p = P(); return p ? p.cover : 0; }, - get night() { const p = P(); return p ? p.night : 0; }, - get paid() { const p = P(); return p ? p.paid : false; }, - markPaid() { const p = P(); if (p) p.markPaid(); }, + // shell calls this once per street frame — ticks every latch so B/D read fresh per-venue state. + update() { tickAll(); }, dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); }, };