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>
358 lines
21 KiB
JavaScript
358 lines
21 KiB
JavaScript
// 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;
|
||
// 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 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'];
|
||
// 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 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;
|
||
|
||
// 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
|
||
// 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;
|
||
}
|