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>
109 lines
6.2 KiB
JavaScript
109 lines
6.2 KiB
JavaScript
// 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;
|
||
}
|