// 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 startSeg−1 (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; }