PROCITY/web/js/citygen/gigs.js
m3ultra 2b13dd4ad7 Lane A round 16: v3.1 corner-poster clearance bias (#6) + no-spine osm fixture (#7)
Ledger #6 fix + the round's one sanctioned gig-golden re-pin, and ledger #7. Classic/synthetic
golden 0x3fa36874 frozen (classic boot byte-identical across all seeds); base plan untouched.

- Corner poster: the R15 finding (frontage poster near a crossing kerb) is NOT fixable by the brief's
  'nudge along the facade' — proved the crossing edge is PARALLEL and spans the facade (0/187 slides
  help); band_room/rsl land on through-lots on narrow warehouse blocks whose +Z wall (B's door side)
  overlaps the block's other street by up to ~3 m. Surfaced to Fable; ruling: bias pickVenues. It now
  prefers a band_room/rsl candidate whose +Z facade clears every carriageway by >= POSTER_CLEAR (0.5 m),
  broadening off-spine if the fringe is uniformly narrow. Result: 0/851 frontage posters below floor
  across 400 seeds (worst 0.51 m); pub untouched (hero shot unmoved).
- Gig golden re-pinned 0x4f4a549d -> 0xb1d48ea1 (posters moved). selfcheck's R15 blanket frontage
  exemption becomes a real floor: frontage >= POSTER_CLEAR, spine >= 0 (POSTER_CLEAR exported from the
  barrel). Mutation-tested non-vacuous.
- No-spine osm fixture 'silverton' (single-row town, 0 main edges) proves pickVenues' no-spine branch +
  buildPosters' no-main fallback, closing the R14 sweep gap. Base golden pinned 0xd4b351c9; sweep asserts
  0 main edges + graceful 2-4-venue placement. selfcheck ALL GREEN 14769/14769; scaffold + consistency GREEN.
- CITY_SPEC v3 amended (golden, poster clearance-bias prose, no-spine note, recorded amendment). Left the
  prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's amendment). For F:
  default-boot golden = 0xb1d48ea1; ?classic=1 target = 0x3fa36874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:35:30 +10:00

295 lines
16 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;
// 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'];
// 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;
};
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 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;
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
// 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;
}