// PROCITY Lane F — gig_state.js [integration glue, F-owned, round 13 / v3.0-beta THE DISTRICT] // The Friday-night state machine, now PER-VENUE. A town has 2–4 venues (pub / band_room / rsl); each runs // its own latch quiet → doors → on → done off Lane B's day clock and Lane A's plan.gigs. F owns it because // every other lane consumes it and none of them own the clock: // • Lane B audio.js reads window.PROCITY.gigs.byVenue[id] → the muffled through-the-wall spill per venue // • Lane B venue.js update(byVenue) → marquee/sign glow on each frontage // • Lane D sim.js setGig(venueShopId, on) → the gig-night patronage surge, per venue // • Lane C buildInterior(shop, THREE, { gig }) → room.audio.gigKey (the live bed inside) // • Lane F the cover charge at each door + the smokes // // Model (CITY_SPEC §"CityPlan v3 — gig layer"): plan.gigs is the whole week × every venue. TONIGHT for a // venue is its night-0 entry (the same night-0 convention A's posters and B's frontage key off); a venue // dark tonight has no latch gig → reads 'quiet' all session, which is true to the seeded schedule. Each // venue's night rolls the same way (clock turns → next cover due), so a night at two venues is two covers. // // ── THREE HARD-WON LAWS, unchanged from the alpha (they each cost a live-verify to learn): ────────────── // WHY 'done' NEEDS A LATCH. lighting.js's segment→hour map tops out at NIGHT (22:00) and a venue closes at // closing time, so the gig owns the WHOLE night segment — "after the gig" can only be the far side of the // wrap, DAWN. So: latch while the clock is inside the on-window, hold 'done' for exactly the segment after // it (DAWN = closing time — the band packs up, the crowd drains per Lane A's closing ruling), and clear on // any other segment → back to 'quiet'. The latch clears on any non-DAWN segment (not only at MORNING) // because the player drives the clock by hand ([ and ]): rewinding NIGHT → DUSK → ARVO must read // doors → quiet, not leave 'done' stuck on a midday town (the R12 live verify caught it doing exactly that). // // WHY THE NIGHT ROLLS ON WHERE THE CLOCK LANDS, not on watching 'done' go by. First cut incremented the // night when LEAVING 'done' forwards — so a clock JUMP straight from NIGHT to MORNING (never touching DAWN) // never rolled the night, and a broke punter walked in on last night's paid stamp. Now: the latch clears // wherever it clears, and landing in the DAYTIME (MORNING…ARVO) is what rolls the night; landing on the // doors means we rewound out of the gig — same night, stays paid. DBG/shots/soak all JUMP, so only this // rule survives them. // // WHY IT LISTENS RATHER THAN POLLS. Each latch must observe EVERY segment; a poll only sees the segments it // is sampled on. Driving the clock NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside a // venue, audio muted, so neither the street loop nor the audio rAF is reading) skipped the roll entirely — // the night never advanced and the cover was never due again. lighting.js dispatches `procity:segment` on // EVERY segment change (hand-driven or the auto day cycle), so that is the signal every latch rides. Reads // stay safe on their own: state is a getter that re-observes the current segment, and observing the same // segment twice is idempotent. const SEGS = 6; // One venue's latch — the alpha machine, its hard-won internals verbatim. `gig` is this venue's tonight // (night-0) entry, or null when the venue is dark tonight (→ always 'quiet'). function createVenueLatch(gig, lighting) { const startSeg = gig && gig.startSeg != null ? gig.startSeg : 5; const endSeg = gig && gig.endSeg != null ? gig.endSeg : 5; const doorsSeg = (startSeg - 1 + SEGS) % SEGS; // DUSK — the doors open const doneSeg = (endSeg + 1) % SEGS; // DAWN — closing time; the only segment 'done' lives in // The daytime segments: everything from the morning after the gig up to (not including) the doors. // Landing here after a gig means THE DAY HAS TURNED, which rolls the night (next cover due). const DAYTIME = (() => { const set = new Set(); for (let s = (doneSeg + 1) % SEGS; s !== doorsSeg; s = (s + 1) % SEGS) set.add(s); 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 // procity:segment event and any read. function observe(seg) { const on = seg >= startSeg && seg <= endSeg; if (on) played = true; else if (played && seg !== doneSeg) { // The latch is clearing: the gig is behind us. Whether that's "a new night" (cover due again) or // "we rewound out of it" (same night, stays paid) is decided by WHERE we land, not by watching // 'done' go by (see the roll law in the header). played = false; if (DAYTIME.has(seg)) night++; // landed in the daytime ⇒ the day turned ⇒ next cover is due } // (landed on the doors ⇒ rewound out of the gig ⇒ same night) state = on ? 'on' : seg === doorsSeg ? 'doors' : played ? 'done' : 'quiet'; return state; } function tick() { if (!gig || !lighting || !lighting.getClock) return (state = 'quiet'); return observe(lighting.getClock().seg); } return { gig, observe, tick, get state() { return tick(); }, get on() { return tick() === 'on'; }, get open() { const s = tick(); return s === 'doors' || s === 'on'; }, // the door is taking punters get cover() { return gig ? gig.cover | 0 : 0; }, get bandName() { return gig ? gig.bandName : null; }, get night() { tick(); return night; }, // cover stamp — runtime only (like the wallet): paid once, free to pop out and back in until the night // rolls. NOT persisted, NOT written into the plan (goldens never move). get paid() { tick(); return paidNight === night; }, markPaid() { tick(); paidNight = night; }, }; } export function createGigState({ plan, lighting }) { const gigs = (plan && plan.gigs) || []; // The district's venues, in first-seen (gigId) order. Primary = gigs[0].venueShopId — the alpha's // "tonight", kept as the compat alias so B's mid-round single-venue callers still light the right pub. const venueIds = [...new Set(gigs.map((g) => g.venueShopId))]; const primaryId = gigs.length ? gigs[0].venueShopId : null; // one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet') const latch = new Map(); for (const id of venueIds) { const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null; latch.set(id, createVenueLatch(tonight, lighting)); } // ONE listener drives every latch — the listens-not-polls law, now fanned out across the district. const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); }; if (typeof window !== 'undefined') window.addEventListener('procity:segment', onSegment); function tickAll() { for (const l of latch.values()) l.tick(); } tickAll(); // settle every latch on the boot segment const L = (id) => latch.get(id) || null; const P = () => L(primaryId); return { // ── per-venue API (R13 district) ────────────────────────────────────────────────────────────── get venueShopIds() { return venueIds.slice(); }, stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; }, onOf(id) { const l = L(id); return l ? l.on : false; }, openOf(id) { const l = L(id); return l ? l.open : false; }, gigOf(id) { const l = L(id); return l ? l.gig : null; }, coverOf(id) { const l = L(id); return l ? l.cover : 0; }, bandNameOf(id) { const l = L(id); return l ? l.bandName : null; }, nightOf(id) { const l = L(id); return l ? l.night : 0; }, paidOf(id) { const l = L(id); return l ? l.paid : false; }, markPaidOf(id) { const l = L(id); if (l) l.markPaid(); }, // the map B's venue.update() and audio.js consume: a fresh { [venueShopId]: 'quiet'|'doors'|'on'|'done' }. // Called per street frame over a handful of venues — cheap. get byVenue() { const o = {}; for (const [id, l] of latch) o[id] = l.tick(); return o; }, // shell calls this once per street frame — ticks every latch (so B/D read fresh state) and returns the // primary state for the alpha-compat caller. update() { tickAll(); return P() ? P().state : 'quiet'; }, // ── alpha-shape alias → the PRIMARY venue (kept for one round; B reads it mid-round) ───────────── get state() { const p = P(); return p ? p.state : 'quiet'; }, get on() { const p = P(); return p ? p.on : false; }, get open() { const p = P(); return p ? p.open : false; }, get gig() { const p = P(); return p ? p.gig : null; }, get venueShopId() { return primaryId; }, get bandName() { const p = P(); return p ? p.bandName : null; }, get cover() { const p = P(); return p ? p.cover : 0; }, get night() { const p = P(); return p ? p.night : 0; }, get paid() { const p = P(); return p ? p.paid : false; }, markPaid() { const p = P(); if (p) p.markPaid(); }, dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); }, }; }