PROCITY/web/js/world/gig_state.js
m3ultra a358c5d9ef Lane F R13 (v3.0-beta, the district): per-venue gig state + walla/applause + queue wiring; qa --strict 6/6
Wires A–E + D's district together and fires it. Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing
tonight (The Exchange Hotel pub gig-pubrock + an RSL gig-covers, one dark), Screaming Utes $10 on the door.

- gig_state.js → PER-VENUE latches off the shared procity:segment spine (one per venueShopId); all three
  hard-won R12 laws kept. New runtime surface window.PROCITY.gigs.byVenue + onOf/openOf/gigOf/coverOf/
  bandNameOf/paidOf(id) + markPaidOf/venueShopIds; the R12 single-venue alias kept for one round.
- interior_mode.js: crowd-walla rides the interior tone layer under the live band bed while `on`; an
  applause sting fires once on the on->done edge. DELETE the GIG_BED bridge — debt #1, and it was a LIVE
  BUG: E renamed pubrock-live->gig-pubrock, so the bridge (gig-pubrock->pubrock-live) pointed at a dead
  key => the band would have mimed in silence. room.audio.gigKey (already the manifest key) feeds directly.
- index.html: per-venue cover charge (a night at two venues is two covers), concurrent per-venue setGig
  surges, and the outdoor VenueQueue lifecycle (D shipped the class + left the street-side seam to F:
  spawn at doors off B's queueZoneFor, drive each frame, dispose by done), exposed as queueCountOf(id).
- interior_scale_check.py: human-size assert now measures STATURE (crown-foot, platform-independent);
  world-crown ceiling-clip assert kept as its own line (debt #6).
- flags_check.py smoke_gigs -> THE DISTRICT: band->4, per-venue loop (every gigKey resolves across 3
  genres, crowd <= its own watchPoints, no-giants by stature), street budget at the busiest venue block,
  the queue assert; plus the R12 determinism/cover/noassets/flags-off arms.
- CITY_SPEC: pin the window.PROCITY.gigs runtime surface. gig_shot.py -> r13 interior + venue_night shots.

qa.sh --strict GREEN 6/6, 0 warn. Interior 63 draws/35k tris; street @ venue block 121 draws/51k tris;
band+crowd stature 1.61-1.81 m under a 4 m ceiling; flags-off fingerprint 0x3fa36874 unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:22:03 +10:00

156 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 24 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); },
};
}