PROCITY/web/js/citygen/gigs.js
m3ultra acf734a1e5 Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt
Publishes the gig interface the whole round hangs off (A runs first). Everything is
behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector),
so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm
melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm).

- Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot
  id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains
  a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base
  plan byte-identical.
- plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5,
  endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door).
- Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json
  drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets).
- plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B.
- Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes).
- map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged.

Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden
0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3
section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:33:53 +10:00

109 lines
6.2 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 CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and
// adds the venue (one pub), the nightly gig schedule (`plan.gigs`), and poster assignments
// (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})),
// so the base plan stays byte-identical and the frozen goldens don't move (CITY_SPEC prime flag law).
//
// Source-agnostic: works on synthetic AND osm plans (it only reads shops/lots/edges). Deterministic
// from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law).
import { rng, seedFor, mulberry32, pick, shuffle } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { shopName, bandName } from './names.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
// Alpha model: one genre, a night gig (doors at DUSK seg4, on during NIGHT seg5 — see lighting.js
// segment→hour map). A week of nightly entries at the one venue; F's clock picks tonight's.
const NIGHTS = 7;
const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on"
const GIG_END_SEG = 5; // single coarse night segment; F does doors at startSeg1 (DUSK)
const GENRE = 'pubrock'; // the alpha genre (Lane E ships the pub-rock live bed)
// Pick the venue: prefer a spine-END plain shop (a 'shop'-use lot fronting a 'main' edge, farthest
// from the origin — a destination you follow the sound to), NEVER the openLate landmark. Fallback
// for osm / no-spine towns: the farthest-from-centre plain shop. Seeded pick among the farthest few.
function pickVenue(plan, citySeed) {
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const candidates = plan.shops.filter(s => {
if (s.openLate) return false; // must not displace the night landmark
const lot = lotById.get(s.lot);
return lot && lot.use === 'shop'; // convert a plain shop → keeps SOLID + geometry
});
if (!candidates.length) return null;
const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id));
const dist2 = s => { const l = lotById.get(s.lot); return l.x * l.x + l.z * l.z; };
let pool = candidates.filter(s => mainEdges.has(lotById.get(s.lot).frontEdge));
if (!pool.length) pool = candidates; // osm / no spine → any plain shop
pool = pool.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); // farthest first, id tiebreak
const topN = pool.slice(0, Math.min(5, pool.length));
return pick(rng(citySeed, 'venue', 0), topN);
}
// Band names across the nights: John's OPTIONAL custom names (from web/assets/custom_bands.json,
// loaded by the shell and passed as opts.customBands) get PRIORITY — shuffled deterministically and
// filled first, so a town surfaces up to NIGHTS of them — and the rest are generated. Deterministic
// given (citySeed, customBands). Absent/empty custom list ⇒ pure generator (the ?noassets path).
function assignBands(citySeed, nGigs, customBands) {
const custom = (Array.isArray(customBands) ? customBands : [])
.filter(b => typeof b === 'string' && b.trim()).map(b => b.trim());
const shuffled = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : [];
const out = [];
for (let i = 0; i < nGigs; i++) {
out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i)));
}
return out;
}
// Posters advertising tonight's gig (night 0), near the venue + along the central streets B already
// streams (poles/walls). Modest count for the one-pub alpha; positions seeded + deterministic. B
// renders E's poster skin at (x,z) rotated ry; F wires "poster → tonight's gig" discoverability.
function buildPosters(plan, citySeed, venue, gig) {
const posters = [];
const add = (x, z, ry) => posters.push({ id: posters.length, gigId: gig.gigId, x: r2(x), z: r2(z), ry: r4(ry) });
const vlot = plan.lots.find(l => l.id === venue.lot); // the venue's LOT carries the coords (shop doesn't)
if (vlot) add(vlot.x, vlot.z, vlot.ry); // one on the venue frontage itself
const mainNodeIds = new Set();
for (const e of plan.streets.edges) if (e.kind === 'main') { mainNodeIds.add(e.a); mainNodeIds.add(e.b); }
let cand = plan.streets.nodes.filter(n => mainNodeIds.has(n.id));
if (cand.length < 4) cand = plan.streets.nodes.slice();
const chosen = shuffle(rng(citySeed, 'posters', 0), cand.slice()).slice(0, Math.min(5, cand.length));
for (const n of chosen) add(n.x + 2, n.z, Math.atan2(n.x, n.z)); // small verge offset; face town centre
return posters;
}
// Public: augment a finished plan with the gig layer. Mutates + returns `plan` (caller passes a fresh
// plan). Adds `plan.gigs`, `plan.posters`, and converts one shop into the `pub` venue in place.
export function withGigs(plan, citySeed, opts = {}) {
citySeed = citySeed >>> 0;
const venue = pickVenue(plan, citySeed);
if (!venue) { plan.gigs = []; plan.posters = []; return plan; } // degenerate town (no plain shops)
// Convert the chosen shop into the pub venue, in place (keeps its lot id + geometry ⇒ no overlap).
const reg = SHOP_TYPES.pub;
const vseed = seedFor(citySeed, 'venue', venue.id);
const vr = mulberry32(vseed);
venue.type = 'pub';
venue.facadeSkin = pick(vr, reg.facades);
const nm = shopName(vseed, 'pub');
venue.name = nm.name; venue.sign = nm.sign;
venue.storeys = Math.max(venue.storeys | 0, 2); // a corner hotel reads as ≥2 storeys
venue.hours = [reg.hours.open, reg.hours.close]; // [17,23] — open into NIGHT so the gig runs
venue.venue = true; // easy lookup alongside venueShopId
// Nightly gig schedule at the venue.
const bands = assignBands(citySeed, NIGHTS, opts.customBands);
plan.gigs = [];
for (let n = 0; n < NIGHTS; n++) {
const gr = mulberry32(seedFor(citySeed, 'gig', n));
const cover = gr() < 0.5 ? 0 : 2 + ((gr() * 9) | 0); // ~half free, else integer $2$10
plan.gigs.push({
gigId: n, venueShopId: venue.id, bandName: bands[n], genreKey: GENRE,
night: n, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover,
});
}
plan.posters = buildPosters(plan, citySeed, venue, plan.gigs[0]);
return plan;
}