// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and adds // the town's VENUES (2–4: pub / band_room / rsl), the week's 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/blocks/edges). Deterministic // from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law). // // ROUND13 (v3.0-beta, the district): one venue → 2–4; one genre → three (by kind); one night's poster run // → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off. import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js'; import { SHOP_TYPES, poleOffset, roadWidth } 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; // A venue's own frontage poster seats on the +Z street facade, `d/2 + FACADE_PROUD` out from the lot // centre (shared by pickVenues' clearance test and buildPosters). ROUND16 (ledger #6): band_room/rsl // selection prefers a lot whose facade clears every carriageway by ≥ POSTER_CLEAR, so the poster can // never sit on a back-street's bitumen (a narrow through-lot otherwise abuts the block's other street). // selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it. export const POSTER_CLEAR = 0.5; const FACADE_PROUD = 0.06; // nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure). const nearestSeg = (px, pz, ax, az, bx, bz) => { const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t)); return [ax + dx * t, az + dz * t]; }; // A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map. 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 VENUES_PER_TOWN = [2, 4]; // seeded count (charter: 2–4 venues per town) const GIGS_PER_VENUE = [4, 6]; // per week — the rest are dark nights, which read true // Kind order as venues are added. A town of 4 gets a second pub at the far end of the spine — // two pubs and a footy club is the most Australian town there is. const VENUE_SEQUENCE = ['pub', 'band_room', 'rsl', 'pub']; // Cover law (ROUND13 §A2): ~half free town-wide; the RSL skews CHEAP (it's a club, the bistro pays the // bills), the band room skews FREE (the door is a hat on a milk crate). `cover` stays an integer in // {0} ∪ [2,10] for every kind, so F's wallet seam and the selfcheck assert are unchanged. const COVER_BY_KIND = { pub: { free: 0.50, min: 2, max: 10 }, band_room: { free: 0.70, min: 2, max: 6 }, rsl: { free: 0.45, min: 2, max: 5 }, }; // ── THE gigKey CONTRACT (ROUND13 debt #1) ───────────────────────────────────────────── // The audio manifest key for a genre IS the gigKey, and it is ALWAYS `gig-`. There is no // mapping table anywhere: C emits `audio.gigKey`, E names the manifest bed, B's spill reads it and F // resolves it — all through this one function. R12 lost a gig bed to a private rename (C asked for // `gig-pubrock`, E shipped `pubrock-live`, and because audio fails soft the band just mimed). One key, // zero mapping — import this rather than building the string yourself. export const gigKeyFor = genreKey => `gig-${genreKey}`; // ── venue selection ─────────────────────────────────────────────────────────────────── // Placement rules (charter + ROUND13 §A1): pub at a spine END (a destination you follow the sound to), // band_room on the warehouse fringe, RSL off-spine. Each pick converts a plain shop IN PLACE (keeps the // lot id + geometry ⇒ enterable, no overlap) and never takes the openLate landmark (the video survives). // Every rule degrades to "any remaining plain shop" so an osm town — which has no warehouse fringe and // may have no spine at all — still gets its venues. function pickVenues(plan, citySeed) { const lotById = new Map(plan.lots.map(l => [l.id, l])); const blockById = new Map(plan.blocks.map(b => [b.id, b])); const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id)); const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); const lotOf = s => lotById.get(s.lot); const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; }; const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); }; const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; }; const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); // clearance of a shop's +Z frontage-poster point to the nearest carriageway (ROUND16 ledger #6). const facadeClear = s => { const l = lotOf(s); if (!l) return Infinity; const off = l.d / 2 + FACADE_PROUD; const px = l.x + Math.sin(l.ry) * off, pz = l.z + Math.cos(l.ry) * off; let best = Infinity; for (const e of plan.streets.edges) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const [nx, nz] = nearestSeg(px, pz, a.x, a.z, b.x, b.z); best = Math.min(best, Math.hypot(px - nx, pz - nz) - roadWidth(e) / 2); } return best; }; const base = plan.shops.filter(s => { if (s.openLate) return false; // must not displace the night landmark const l = lotOf(s); return !!l && l.use === 'shop'; // a plain shop → keeps SOLID + geometry }); const taken = new Set(); const out = []; const n = irange(rng(citySeed, 'venueN', 0), VENUES_PER_TOWN[0], VENUES_PER_TOWN[1]); for (let i = 0; i < n; i++) { const kind = VENUE_SEQUENCE[i] || 'pub'; const pool = base.filter(s => !taken.has(s.id)); if (!pool.length) break; // degenerate town — fewer venues, still valid let cand; if (kind === 'band_room') { cand = pool.filter(s => blockKindOf(s) === 'warehouse'); if (!cand.length) cand = pool.filter(s => !onMain(s)); // osm: no warehouse district } else if (kind === 'rsl') { cand = pool.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse'); if (!cand.length) cand = pool.filter(s => !onMain(s)); } else { cand = pool.filter(onMain); // a SECOND pub belongs at the other end of the spine, not next door to the first const firstPub = out.find(v => v.kind === 'pub'); if (firstPub) { const pz = (lotOf(firstPub.shop) || {}).z || 0; const far = cand.filter(s => Math.sign((lotOf(s) || {}).z || 0) !== Math.sign(pz)); if (far.length) cand = far; } } // ROUND16 ledger #6: keep the off-spine kinds off a back-street's bitumen. Prefer candidates whose // +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the warehouse fringe is uniformly narrow, // broaden to ANY off-spine clear lot before falling back. (pub is spine-fronted — its +Z is open — // so it keeps its placement untouched; the hero shot doesn't move.) Always a clear lot exists in the // synthetic town; the fallback only ever fires on a degenerate/osm plan. if (kind === 'band_room' || kind === 'rsl') { let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR); if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR); if (clear.length) cand = clear; } if (!cand.length) cand = pool; // last resort: anything left const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length)); const shop = pick(rng(citySeed, 'venuepick', i), shortlist); if (!shop) break; taken.add(shop.id); out.push({ shop, kind }); } return out; } // Convert a chosen shop into a venue of `kind`, in place. function convertVenue(citySeed, shop, kind) { const reg = SHOP_TYPES[kind]; const vseed = seedFor(citySeed, 'venue', shop.id); const vr = mulberry32(vseed); shop.type = kind; shop.facadeSkin = pick(vr, reg.facades); const nm = shopName(vseed, kind); shop.name = nm.name; shop.sign = nm.sign; // clamp into the kind's registry band (the shop may have been a 3-storey corner anchor; a band room // is a single-storey shed). A pub reads as a ≥2-storey corner hotel. shop.storeys = Math.min(Math.max(shop.storeys | 0, reg.storeys[0]), reg.storeys[1]); if (kind === 'pub') shop.storeys = Math.max(shop.storeys, 2); shop.hours = [reg.hours.open, reg.hours.close]; // open into NIGHT so the gig runs shop.venue = true; // easy lookup alongside venueKind shop.venueKind = kind; shop.genreKey = reg.genre; // ⇒ gigKeyFor(genreKey) is the audio bed return shop; } // ── the week's schedule ─────────────────────────────────────────────────────────────── // 7 nights × every venue, but not every venue plays every night: each takes a seeded 4–6 of the 7 and // goes dark the rest. Slots are walked NIGHT-MAJOR so John's custom band names spread across the whole // week rather than filling one venue's residency. function buildSchedule(plan, citySeed, venues, customBands) { const playing = venues.map((v) => { const k = irange(rng(citySeed, 'gignights', v.shop.id), GIGS_PER_VENUE[0], GIGS_PER_VENUE[1]); const order = shuffle(rng(citySeed, 'gigwhich', v.shop.id), [0, 1, 2, 3, 4, 5, 6]); return new Set(order.slice(0, k)); }); // John's OPTIONAL custom names (web/assets/custom_bands.json via opts.customBands) get PRIORITY — // deduped, shuffled deterministically, filled first. Absent/empty ⇒ pure generator (?noassets path). const seen = new Set(); const custom = (Array.isArray(customBands) ? customBands : []) .filter(b => typeof b === 'string' && b.trim()) .map(b => b.trim()) .filter(b => (seen.has(b) ? false : (seen.add(b), true))); const queue = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : []; const gigs = []; const usedByNight = new Map(); let ci = 0, gi = 0; for (let night = 0; night < NIGHTS; night++) { let used = usedByNight.get(night); if (!used) usedByNight.set(night, used = new Set()); for (let vi = 0; vi < venues.length; vi++) { if (!playing[vi].has(night)) continue; // dark tonight const v = venues[vi]; // No band plays two venues at once. Custom names are deduped and used at most once each, so they // can never clash; a GENERATED name can collide with anything already booked tonight, so advance // the generator stream until it doesn't (deterministic: `gi` only ever moves forward). let band; if (ci < queue.length) band = queue[ci++]; else do { band = bandName(seedFor(citySeed, 'band', gi++)); } while (used.has(band)); used.add(band); const cv = COVER_BY_KIND[v.kind] || COVER_BY_KIND.pub; const gr = mulberry32(seedFor(citySeed, 'gig', v.shop.id * 8 + night)); const cover = gr() < cv.free ? 0 : cv.min + ((gr() * (cv.max - cv.min + 1)) | 0); gigs.push({ gigId: gigs.length, venueShopId: v.shop.id, bandName: band, genreKey: v.shop.genreKey, night, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover, }); } } return gigs; } // ── posters, town-wide ──────────────────────────────────────────────────────────────── // Each venue playing TONIGHT (night 0) gets a poster on its own frontage plus a seeded run along the // spine. Different venues' posters share the same pole runs — that IS the district reading. // // `ry` follows the ONE house convention, the same one lot.ry uses and selfcheck enforces: it is the // Y-rotation of a thing whose FRONT/outward normal is local −Z at ry=0, i.e. world facing // (−sin ry, −cos ry). A poster faces the people who read it. // // ROUND13 debt #5 — R12 put spine posters at `node.x + 2`, i.e. 2m from an intersection's CENTRELINE, // which is mid-bitumen on a 28m corridor. They now seat on the verge via registry.poleOffset(edge) // (just behind the kerb), and the venue's own poster sits on its FACADE rather than at the lot centre, // which is inside the building shell. function buildPosters(plan, citySeed, venues, gigs) { const posters = []; const add = (x, z, ry, gigId) => posters.push({ id: posters.length, gigId, x: r2(x), z: r2(z), ry: r4(ry) }); const lotById = new Map(plan.lots.map(l => [l.id, l])); const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); const tonight = new Map(); // venueShopId → tonight's gig for (const g of gigs) if (g.night === 0 && !tonight.has(g.venueShopId)) tonight.set(g.venueShopId, g); if (!tonight.size) return posters; // every venue dark tonight — no posters, reads true // 1. one on each playing venue's own frontage for (const v of venues) { const gig = tonight.get(v.shop.id); const lot = gig && lotById.get(v.shop.lot); if (!lot) continue; // ROUND15 (ledger #1): B's buildings put the street facade on local +Z (frontage/queue seat on // (sin ry, cos ry)); this once used −Z, so the poster sat ~16 m BEHIND the building, invisible from // the street — it's why queue_night framed no poster. Seat it on the +Z street face, AND turn it to // read FROM the street: venue.js renders a poster's printed face along (−sin ry, −cos ry) = A's // outward normal, so ry = lot.ry + π points that face outward (else DoubleSide shows a mirrored name). const fx = Math.sin(lot.ry), fz = Math.cos(lot.ry); // +Z: the street facade (B's canon) const off = lot.d / 2 + FACADE_PROUD; // out onto the facade plane, proud of it add(lot.x + fx * off, lot.z + fz * off, lot.ry + Math.PI, gig.gigId); } // 2. the spine run — seeded pole positions along main edges, on the verge, facing the footpath. const spine = plan.streets.edges.filter(e => e.kind === 'main'); const runs = spine.length ? spine : plan.streets.edges.slice(); const bill = [...tonight.values()]; if (!runs.length || !bill.length) return posters; const pr = rng(citySeed, 'posters', 0); const PER_EDGE = 2; let k = 0; for (const e of runs) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz); if (len < 8) continue; const ux = dx / len, uz = dz / len; const off = poleOffset(e); // verge, just behind the kerb — never bitumen for (let i = 0; i < PER_EDGE; i++) { const t = 0.2 + 0.6 * pr(); // somewhere along the block, not on the corner const side = pr() < 0.5 ? 1 : -1; // outward normal (road → pole), exactly marchStrip's convention: a pole is a tiny lot, its front // faces back across its own footpath, so ry = atan2(nx, nz). const nx = side > 0 ? -uz : uz, nz = side > 0 ? ux : -ux; const x = a.x + ux * len * t + nx * off, z = a.z + uz * len * t + nz * off; add(x, z, Math.atan2(nx, nz), bill[k++ % bill.length].gigId); } } 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 // 2–4 shops into venues in place. export function withGigs(plan, citySeed, opts = {}) { citySeed = citySeed >>> 0; const venues = pickVenues(plan, citySeed); if (!venues.length) { plan.gigs = []; plan.posters = []; return plan; } // no plain shops at all for (const v of venues) convertVenue(citySeed, v.shop, v.kind); plan.gigs = buildSchedule(plan, citySeed, venues, opts.customBands); plan.posters = buildPosters(plan, citySeed, venues, plan.gigs); return plan; }