PROCITY/web/js/citygen/gigs.js
m3ultra 110c82833b Lane A round 21: venue cluster-adjacency bias — D's relocation finding (ledger #1)
D found katoomba's pub 1,000m from the retail cluster — a gig with no passing crowd. Measuring all
five towns first showed it was SYSTEMIC: 11 of 15 venues sat with <3 shops within 160m (newtown's were
0/0/1 — every venue stranded). After the bias: 0 of 15. Katoomba's pub 1 → 9. Castlemaine cleared without
needing the floor exception the brief anticipated.

- pickVenues builds a CLUSTER-ADJACENT field first (>= CLUSTER_MIN 3 shops within CLUSTER_R 160m), then the
  kind flavors (pub corner / band_room fringe / rsl off-spine) bias WITHIN that field rather than overriding
  it (per the brief: biases, not absolutes) — a fringe band_room in the cluster beats one a kilometre from
  anyone. A town with no cluster at all falls back to the pool.
- SYNTHETIC FROZEN BY CONSTRUCTION, not by luck. The brief assumed a blanket filter would be a no-op because
  'the synthetic town is one dense cluster anyway'. Measurement says otherwise: 4 synthetic candidates PER
  SEED sit under the threshold (min cluster 0-1), so a blanket filter WOULD have moved the frozen golden.
  Gated on plan.source === 'osm' (undefined on synthetic) — impossible rather than unlikely. The marched
  fixtures are 'osm' but genuinely dense (min cluster 3/11/13) ⇒ verified no-op.
- Only the GIG goldens moved (venue selection is gig-layer; base town goldens untouched): katoomba
  0xbf586b77, newtown 0xacdb3eee, fremantle 0xcf3426fc, bendigo 0x646b2c23, castlemaine 0xf9713a69.
  classic 0x3fa36874 / gig 0xb1d48ea1 / marched fixtures FROZEN. selfcheck ALL GREEN 66419/66419;
  scaffold + consistency GREEN; cross-process deterministic.
- FYI: E's v4.0 pack (glebe/marrickville/newcastle) already boots clean through my loader (validates + full
  suites pass), listed UNPINNED — I pin the pack in R22; it does not gate the beta tag.

Handshake → C + D: cluster bias landed, goldens re-pinned, start your verifies (before/after table in
LANE_A_NOTES §R21).

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

349 lines
20 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 town's VENUES (24: 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 → 24; 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;
// ROUND20 poster cap: the spine pole run is "2 per main edge", which on a sparse REAL town (few shops, big
// edge graph) reads busy (katoomba: 438). Cap it SCALED BY SHOP COUNT — a seeded subsample of the run. On
// the synthetic town the shop count is in the hundreds, so the cap never binds and the gig golden is frozen.
const POSTER_PER_SHOP = 1.5, POSTER_SPINE_FLOOR = 12;
// 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 startSeg1 (DUSK)
const VENUES_PER_TOWN = [2, 4]; // seeded count (charter: 24 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'];
// ROUND21 (D's relocation finding): a venue 1 km from the retail cluster is a gig with no passing crowd
// (katoomba's pub had 1 shop within 160 m; the RSL had 13). Venue candidates prefer cluster adjacency.
const CLUSTER_R = 160, CLUSTER_MIN = 3;
// 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-<genreKey>`. 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;
};
// ROUND21: how many OTHER shops sit within CLUSTER_R of this candidate — its retail-cluster adjacency.
// Real-data plans only: `plan.source === 'osm'` is undefined on the synthetic generator, so the bias
// below can never touch the frozen synthetic goldens BY CONSTRUCTION (not by luck — measured: 4
// synthetic candidates per seed sit under CLUSTER_MIN, so a blanket filter WOULD have moved them).
const realData = plan.source === 'osm';
const shopLots = realData ? plan.shops.map(lotOf).filter(Boolean) : [];
const clusterCount = s => {
const l = lotOf(s); if (!l) return 0;
let n = 0;
for (const o of shopLots) if (o !== l && Math.hypot(o.x - l.x, o.z - l.z) <= CLUSTER_R) n++;
return n;
};
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
// ROUND21 ledger #1: prefer CLUSTER-ADJACENT candidates (the retail heart), then let the kind flavors
// bias WITHIN that set rather than override it — a fringe band_room in the cluster beats a fringe
// band_room a kilometre from anyone. A town with no cluster at all falls back to the whole pool.
let field = pool;
if (realData) {
const clustered = pool.filter(s => clusterCount(s) >= CLUSTER_MIN);
if (clustered.length) field = clustered;
}
let cand;
if (kind === 'band_room') {
cand = field.filter(s => blockKindOf(s) === 'warehouse');
if (!cand.length) cand = field.filter(s => !onMain(s)); // osm: no warehouse district
} else if (kind === 'rsl') {
cand = field.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse');
if (!cand.length) cand = field.filter(s => !onMain(s));
} else {
cand = field.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 (widened ROUND20): keep a venue's frontage poster off a crossing street's bitumen.
// Prefer candidates whose +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the primary set has
// none, broaden to any off-spine clear lot before falling back. R16 exempted `pub` because on the
// synthetic/marched towns its +Z is always open — but on a REAL road graph at density the pub can land
// where its facade overhangs a cross street (newtown, R20). The filter now covers every venue kind: on
// the synthetic town every pub/rsl candidate already clears, so it stays a no-op there (golden frozen).
{
let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR);
if (!clear.length) clear = field.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR); // stay in the cluster
if (clear.length) cand = clear;
}
if (!cand.length) cand = field; // last resort: anything cluster-adjacent
if (!cand.length) cand = pool; // truly degenerate: 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 46 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;
// ROUND19 (E's finding): on a REAL street graph a spine edge is crossed by side streets, so a pole
// placed on the spine verge can overhang a crossing carriageway. Skip any candidate that lands on a
// carriageway — measured EXACTLY as the selfcheck's floor (nearest edge, `roadWidth/2`). This is a
// no-op on the synthetic town (its spine posters already clear every carriageway, so nothing skips and
// the gig golden is frozen); it only drops the overhanging ones on real graphs. `pr()` is consumed
// before the check, so the rng stream — hence the synthetic output — is unchanged.
const onCarriageway = (x, z) => {
let best = Infinity, bestE = null;
for (const e2 of plan.streets.edges) {
const a2 = nodeById.get(e2.a), b2 = nodeById.get(e2.b); if (!a2 || !b2) continue;
const ex = b2.x - a2.x, ez = b2.z - a2.z, L2 = ex * ex + ez * ez || 1;
let tt = ((x - a2.x) * ex + (z - a2.z) * ez) / L2; tt = Math.max(0, Math.min(1, tt));
const d = Math.hypot(x - (a2.x + ex * tt), z - (a2.z + ez * tt));
if (d < best) { best = d; bestE = e2; }
}
return bestE && best < roadWidth(bestE) / 2 - 1e-6;
};
const pr = rng(citySeed, 'posters', 0);
const PER_EDGE = 2;
const spineCands = [];
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;
if (onCarriageway(r2(x), r2(z))) continue; // check the r2-rounded position `add` stores — overhangs a crossing → skip (E's R18 finding)
spineCands.push({ x, z, ry: Math.atan2(nx, nz) });
}
}
// ROUND20 poster cap: thin the pole run to ~POSTER_PER_SHOP × shops (a seeded subsample), so a sparse
// real town reads like a main street, not a poster wall. cap ≫ candidates on synthetic ⇒ no-op ⇒ frozen.
const cap = Math.max(POSTER_SPINE_FLOOR, Math.round(POSTER_PER_SHOP * plan.shops.length));
const chosen = spineCands.length > cap ? shuffle(rng(citySeed, 'postercap', 0), spineCands.slice()).slice(0, cap) : spineCands;
let k = 0;
for (const c of chosen) add(c.x, c.z, c.ry, 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
// 24 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;
}