PROCITY/web/js/citygen/gigs.js
m3ultra 3face97046 Lane A round 22: absorb the town pack — 23 towns, 36 goldens pinned in one commit (v4.0, ledger #2)
E's 17 new towns + Lane G's newtown_godverse fed through the lift. ONE batched golden commit as
briefed: REAL_TOWN_GOLDENS + REAL_TOWN_GIG_GOLDENS go 5 -> 23 towns each, zero unpinned.

THE HEADLINE: everything built this epoch held at 23-town scale, FIRST TRY. 1,210 shops -> 1,133 seated
(77 dropped, 6.4%, all counted by cause); 1 CONNECTED COMPONENT and 3 VENUES on every one of the 23 towns.
fitzroy — 160 shops, 2x anything absorbed before — landed 139 seated, 1 component, 3 venues, 212 posters,
all invariants green. The junction-protected DP, fragmentation ruling, cluster bias, poster cap and
capacity fence needed NO new hardening: the pack needed absorbing, not fixing.

- Graded cluster fallback (the one refinement). Three towns have no candidate at the floor at all
  (ballarat/toowoomba/newtown_godverse top out at 2 shops within 160m); R21's bias switched off entirely
  there, stranding venues at 0. It now falls back to the DENSEST AVAILABLE — ballarat 0,2,0 -> 2,2,2,
  godverse 0,1,1 -> 2,2,2, toowoomba 1,0,0 -> 1,2,1. Fires only where the floor is unreachable, so NO
  already-pinned town moved (verified). Acceptance at pack scale: 20 of 23 towns have every venue >=3;
  the three above sit at their town's genuine ceiling — the carved-out case, stated explicitly.
- Drop outliers, counted not hidden: daylesford 19% (32 shops on a 171-road graph, the thinnest in the
  pack, all overflow), fitzroy 13%. RGAP is already 0.5m and NODE_CLEAR 3m; the only lever left is
  spilling overflow onto a DIFFERENT street, which buys a few percent by putting a shop on a road it
  isn't on. Same ruling as R20: drop and count beats teleport.
- selfcheck ALL GREEN 161,300/161,300; scaffold + consistency GREEN; cross-process deterministic
  (pack-xor 0xca140954). classic 0x3fa36874 / gig 0xb1d48ea1 / marched fixtures FROZEN.

Handshake -> C + D + F: the pack is in, 23 towns pinned, start your verifies. Tri diet (ledger #1): if
B's layer-by-layer names plan geometry as the venue_night driver, I own the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:05 +10:00

358 lines
21 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;
else {
// ROUND22 (pack scale): a genuinely thin town can have NO candidate at the floor — ballarat,
// toowoomba and newtown_godverse top out at 2 shops within 160 m. Rather than drop the bias
// entirely (which stranded venues at 0), fall back to the DENSEST available: the venue still
// lands on what little crowd the town has. Only fires where the floor is unreachable, so no
// already-pinned town moves.
let best = 0; for (const s of pool) { const n = clusterCount(s); if (n > best) best = n; }
if (best > 0) field = pool.filter(s => clusterCount(s) >= best);
}
}
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;
}