v1-tag round. Two confirmations + one hardening fix; qa.sh --strict GREEN (4/4).
A2 — openLate: F's night gate `hours[1] >= 22` matched 4–15 shops/seed (regular videos
jitter base-21 → 22), not the single landmark the shell assumes. Fixed in plan.js: regular
shops close by LATE_HOUR-1 (21:00); only the deterministically chosen openLate landmark
reaches ≥22, pick is now video-first. So `hours[1] >= 22` and the `openLate` field are the
same singleton, always the video rental (verified 15 seeds). Makes generation match
web/index.html:159-161 + soak.py:68 ("at night only the openLate shop is open"). selfcheck
asserts exactly-one / field⟺threshold / is-video. ALL GREEN 1301/1301.
A1 — Lane F's F2 permittedMax storeys scoping: confirmed 0 warnings across 7 seeds + a
false-negative test (injected video@2 in a scratch checker → caught every seed).
GOLDEN.hash 0x098eec2b → 0x3fa36874: intended A2 output change (a few videos close 21 not
22 for seed 20261990), NOT drift; F2 is tooling-only. Same lots/shops/types/names/skins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
449 lines
24 KiB
JavaScript
449 lines
24 KiB
JavaScript
// PROCITY CityGen — Layer 1. generatePlan(citySeed) → CityPlan (pure JSON-serializable data).
|
||
// NO THREE import anywhere in this file (CITY_SPEC law). All randomness flows through core/prng.
|
||
// Deterministic: same citySeed ⇒ byte-identical plan, forever, in <100ms.
|
||
//
|
||
// Coordinate frame (CITY_SPEC): metres, +Y up, ground = XZ. Origin (0,0) = centre of the town.
|
||
// +x = east, -x = west, +z = north, -z = south. The main-street spine runs roughly N–S.
|
||
//
|
||
// A lot's `ry` is the Y-rotation that makes its facade's outward normal point at its street edge
|
||
// (GLB convention: model faces -Z at ry=0). Lane B rotates the building shell by ry.
|
||
|
||
import { rng, seedFor, mulberry32, pick, frange, irange, shuffle } from '../core/prng.js';
|
||
import { SHOP_TYPES, pickWeightedType } from '../core/registry.js';
|
||
import { shopName, townName } from './names.js';
|
||
|
||
const r2 = v => Math.round(v * 100) / 100; // position/size precision (2dp metres)
|
||
const r4 = v => Math.round(v * 10000) / 10000; // angle precision (radians)
|
||
const clampHour = h => Math.max(0, Math.min(23, h | 0));
|
||
// The town has exactly ONE late-night landmark. Regular shops close by LATE_HOUR-1; only the
|
||
// deterministically-chosen `openLate` shop reaches ≥ LATE_HOUR — so `hours[1] >= LATE_HOUR`
|
||
// and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate).
|
||
const LATE_HOUR = 22;
|
||
|
||
// World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B:
|
||
// a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre.
|
||
export function lotCorners(l) {
|
||
const cs = Math.cos(l.ry), sn = Math.sin(l.ry), hw = l.w / 2, hd = l.d / 2;
|
||
return [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(([ox, oz]) => [
|
||
l.x + ox * cs + oz * sn, l.z - ox * sn + oz * cs,
|
||
]);
|
||
}
|
||
|
||
// Separating-Axis-Theorem overlap of two convex quads, tolerance 5cm (absorbs 2dp rounding at
|
||
// touching edges without masking real overlaps, which are metres). Axes are normalised so EPS is metric.
|
||
export function obbOverlap(a, b) {
|
||
const EPS = 0.05;
|
||
for (const quad of [a, b]) {
|
||
for (let i = 0; i < quad.length; i++) {
|
||
const p1 = quad[i], p2 = quad[(i + 1) % quad.length];
|
||
let ax = -(p2[1] - p1[1]), az = p2[0] - p1[0];
|
||
const m = Math.hypot(ax, az) || 1; ax /= m; az /= m;
|
||
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
|
||
for (const [x, z] of a) { const d = x * ax + z * az; if (d < minA) minA = d; if (d > maxA) maxA = d; }
|
||
for (const [x, z] of b) { const d = x * ax + z * az; if (d < minB) minB = d; if (d > maxB) maxB = d; }
|
||
if (maxA < minB + EPS || maxB < minA + EPS) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function generatePlan(citySeed) {
|
||
citySeed = citySeed >>> 0;
|
||
|
||
// ── plan accumulators + id-stamping factories ───────────────────────────────────
|
||
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
|
||
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
|
||
|
||
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
|
||
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
|
||
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
|
||
const addBlock = (district, kind, poly) => {
|
||
const id = bid++;
|
||
blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) });
|
||
return id;
|
||
};
|
||
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
|
||
const id = lid++;
|
||
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
|
||
return id;
|
||
};
|
||
|
||
// Create a shop record for a lot. `type` from registry; facade/storeys/hours seeded off id.
|
||
function createShop(lotId, type, opts = {}) {
|
||
const id = sid++;
|
||
const seed = seedFor(citySeed, 'shop', id);
|
||
const reg = SHOP_TYPES[type] || SHOP_TYPES.opshop;
|
||
const sr = mulberry32(seed);
|
||
const facadeSkin = pick(sr, reg.facades);
|
||
let storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
|
||
// occasional 3-storey corner anchor — but only for types that already build tall (registry
|
||
// max ≥ 2); never make an inherently single-storey type (video/milkbar/stall) taller.
|
||
if (opts.cornerBoost && reg.storeys[1] >= 2 && sr() < 0.5) storeys = Math.min(3, storeys + 1);
|
||
let open = reg.hours.open, close = reg.hours.close;
|
||
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
|
||
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
|
||
if (close <= open) close = clampHour(open + 2);
|
||
close = Math.min(close, LATE_HOUR - 1); // regular shops close by 21:00 (only the openLate landmark is later)
|
||
// (the town's one guaranteed late-night shop is chosen deterministically after the whole
|
||
// shop set is final — see the `openLate` pass at the end of generatePlan.)
|
||
const { name, sign } = shopName(seed, type);
|
||
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
|
||
return id;
|
||
}
|
||
|
||
// March lots along one side of a street edge A→B. Lots are sequential intervals along the edge
|
||
// ⇒ never overlap within their block by construction. Returns { blockId, lots:[{lot,use,cx,cz}] }.
|
||
function marchStrip(streamKind, streamId, districtId, districtKind, edgeId, A, B, corridorW, side, band) {
|
||
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
|
||
const out = { blockId: -1, lots: [] };
|
||
if (len < 1) return out;
|
||
const ux = dx / len, uz = dz / len;
|
||
const nx = side > 0 ? -uz : uz; // outward normal: street → lot
|
||
const nz = side > 0 ? ux : -ux;
|
||
const half = corridorW / 2;
|
||
const stripDepth = band.dmax + 2;
|
||
const c0 = [A.x + nx * half, A.z + nz * half];
|
||
const c1 = [B.x + nx * half, B.z + nz * half];
|
||
const c2 = [B.x + nx * (half + stripDepth), B.z + nz * (half + stripDepth)];
|
||
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
|
||
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
|
||
out.blockId = blockId;
|
||
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street
|
||
const r = rng(citySeed, streamKind, streamId);
|
||
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
|
||
let t = iStart; const end = len - iEnd;
|
||
while (end - t >= band.fmin) {
|
||
let f = frange(r, band.fmin, band.fmax);
|
||
if (t + f > end) f = end - t;
|
||
if (f < band.fmin * 0.75) break;
|
||
const d = frange(r, band.dmin, band.dmax);
|
||
const along = t + f / 2, off = half + d / 2;
|
||
const cx = A.x + ux * along + nx * off;
|
||
const cz = A.z + uz * along + nz * off;
|
||
const lot = addLot(blockId, cx, cz, f, d, ry, edgeId, band.use);
|
||
out.lots.push({ lot, use: band.use, cx, cz });
|
||
t += f + (band.gap ?? 0);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Assign shop types to a retail block's 'shop' lots, with same-type clustering (a record shop
|
||
// next to an opshop next to a bookshop is the vibe — bias each lot toward the block's lead type).
|
||
function assignRetail(blockId, districtKind, lotsArr) {
|
||
const r = rng(citySeed, 'shopmix', blockId);
|
||
const lead = pickWeightedType(r, districtKind);
|
||
lotsArr.forEach((L, i) => {
|
||
if (L.use !== 'shop') return;
|
||
let type = (r() < 0.55 && lead) ? lead : pickWeightedType(r, districtKind);
|
||
if (!type) type = 'opshop';
|
||
const corner = (i === 0 || i === lotsArr.length - 1);
|
||
createShop(L.lot, type, { cornerBoost: corner && districtKind === 'mainstreet' });
|
||
});
|
||
}
|
||
|
||
// ── districts (coarse area markers; every block belongs to one) ──────────────────
|
||
const dMain = addDistrict('mainstreet', 0, 0);
|
||
const dRes = addDistrict('residential', 0, 0);
|
||
|
||
// ── the spine: 7 stations S→N through the origin, x-jitter ±30m ───────────────────
|
||
const NST = 7, zTop = 400;
|
||
const spinePts = [];
|
||
for (let i = 0; i < NST; i++) {
|
||
const z = -zTop + (2 * zTop) * i / (NST - 1);
|
||
const rr = rng(citySeed, 'spine', i);
|
||
const x = (i === 3) ? frange(rr, -8, 8) : frange(rr, -30, 30); // keep origin on the spine
|
||
spinePts.push(addNode(x, z));
|
||
}
|
||
const spineEdges = [];
|
||
for (let i = 0; i < NST - 1; i++) spineEdges.push(addEdge(spinePts[i].id, spinePts[i + 1].id, 28, 'main'));
|
||
|
||
// ── cross-street rungs at stations 1,2,4,5 (central 2 = second high streets) ──────
|
||
const rungStations = [1, 2, 4, 5];
|
||
const rungs = [];
|
||
for (const st of rungStations) {
|
||
const base = spinePts[st];
|
||
const rr = rng(citySeed, 'rung', st);
|
||
const theta = frange(rr, -0.14, 0.14); // ±8° jitter off pure E–W
|
||
const central = (st === 2 || st === 4);
|
||
const width = central ? 20 : 14;
|
||
const eLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
|
||
const wLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
|
||
const ex = Math.cos(theta), ez = Math.sin(theta);
|
||
const eastEnd = addNode(base.x + ex * eLen, base.z + ez * eLen);
|
||
const westEnd = addNode(base.x - ex * wLen, base.z - ez * wLen);
|
||
const eEdge = addEdge(base.id, eastEnd.id, width, 'side');
|
||
const wEdge = addEdge(westEnd.id, base.id, width, 'side');
|
||
const districtId = central ? dMain : addDistrict('backstreets', 0, base.z);
|
||
rungs.push({ st, base, eastEnd, westEnd, eEdge, wEdge, width, central, districtId });
|
||
}
|
||
|
||
// ── main-street retail strips along the spine (reserve origin: W=market, E=arcade) ─
|
||
for (let i = 0; i < NST - 1; i++) {
|
||
const A = spinePts[i], B = spinePts[i + 1];
|
||
const dz = B.z - A.z, len = Math.hypot(B.x - A.x, dz), uz = dz / len;
|
||
for (const side of [1, -1]) {
|
||
const west = (side > 0 ? -uz : uz) < 0; // outward normal's x-sign: <0 ⇒ this side faces west
|
||
if (west && (i === 2 || i === 3)) continue; // market square occupies west-of-origin
|
||
if (!west && i === 3) continue; // arcade cuts east through this mid-spine block
|
||
const band = { fmin: 6, fmax: 9, dmin: 12, dmax: 20, gap: 0, use: 'shop', insetStart: 12, insetEnd: 12 };
|
||
const res = marchStrip('spinelot', i * 2 + (side > 0 ? 0 : 1), dMain, 'mainstreet', spineEdges[i], A, B, 28, side, band);
|
||
assignRetail(res.blockId, 'mainstreet', res.lots);
|
||
}
|
||
}
|
||
|
||
// ── cross-street retail strips (both arms, both sides) ────────────────────────────
|
||
for (const rung of rungs) {
|
||
const districtKind = rung.central ? 'mainstreet' : 'backstreets';
|
||
const arms = [
|
||
{ edge: rung.eEdge, A: rung.base, B: rung.eastEnd, tag: 'e' },
|
||
{ edge: rung.wEdge, A: rung.westEnd, B: rung.base, tag: 'w' },
|
||
];
|
||
// Reserve the intersection corner: the near-spine end of each arm must clear not just the spine
|
||
// ROAD (half-corridor 14m) but the spine RETAIL STRIP's full depth (dmax 20m) so cross-street
|
||
// lots don't interpenetrate the spine lots. 14+20 = 34m. (A final resolution pass mops up any
|
||
// residual corner collisions from the ±8° rung jitter — see resolveOverlaps below.)
|
||
const SPINE_CLEAR = 34;
|
||
for (const arm of arms) {
|
||
for (const side of [1, -1]) {
|
||
const band = {
|
||
fmin: rung.central ? 6 : 7, fmax: rung.central ? 9 : 10,
|
||
dmin: 12, dmax: 18, gap: rung.central ? 0 : 2, use: 'shop',
|
||
insetStart: arm.tag === 'e' ? SPINE_CLEAR : 6, // near-spine end clears the spine strip depth
|
||
insetEnd: arm.tag === 'e' ? 6 : SPINE_CLEAR,
|
||
};
|
||
const streamId = rung.st * 100 + (arm.tag === 'e' ? 0 : 10) + (side > 0 ? 0 : 1);
|
||
const res = marchStrip('runglot', streamId, rung.districtId, districtKind, arm.edge, arm.A, arm.B, rung.width, side, band);
|
||
assignRetail(res.blockId, districtKind, res.lots);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── market square (west of origin): stalls in rows + the dept anchor fronting the spine ─
|
||
{
|
||
const spineX = spinePts[3].x;
|
||
const eastEdge = spineX - 14; // west kerb of the spine corridor
|
||
const marketW = 120, zLo = -110, zHi = 120;
|
||
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
|
||
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
|
||
const block = addBlock(dMarket, 'market', poly);
|
||
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal
|
||
// (street→lot); a west-of-street lot's outward normal is −x, so ry = atan2(−1, 0) (= −π/2). The
|
||
// facade then faces −(outward) = +x, matching marchStrip's convention. frontEdge is the spine
|
||
// segment each lot actually fronts by z-band: stalls (z≈−100..−23) front segment 2; dept (z=55) → 3.
|
||
const ryEast = Math.atan2(-1, 0);
|
||
const stallEdge = spineEdges[2]; // spine segment spanning z∈[−133,0] — the stalls' band
|
||
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
|
||
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
|
||
for (let gx = 0; gx < COLS; gx++) {
|
||
for (let gz = 0; gz < ROWS; gz++) {
|
||
const x = eastEdge - 12 - gx * pitch;
|
||
const z = zLo + 10 + gz * pitch; // z ∈ [−100, −23], all south of the dept strip (z≈41+)
|
||
addLot(block, x, z, sSize, sSize, ryEast, stallEdge, 'stall');
|
||
}
|
||
}
|
||
// shops for every stall lot (in id order for determinism)
|
||
for (const lot of lots) if (lot.block === block && lot.use === 'stall') createShop(lot.id, 'stall');
|
||
// dept anchor: big building on the square's north strip, fronting the spine
|
||
const deptW = 28, deptD = 26;
|
||
const deptId = addLot(block, eastEdge - deptD / 2, 55, deptW, deptD, ryEast, deptEdge, 'anchor');
|
||
createShop(deptId, 'dept', { cornerBoost: false });
|
||
}
|
||
|
||
// ── arcade: a covered pedestrian lane cutting east through a mid-spine block ───────
|
||
{
|
||
const midX = (spinePts[3].x + spinePts[4].x) / 2;
|
||
const midZ = (spinePts[3].z + spinePts[4].z) / 2;
|
||
const start = addNode(midX + 14, midZ);
|
||
const end = addNode(midX + 14 + 42, midZ);
|
||
const aEdge = addEdge(start.id, end.id, 5, 'arcade');
|
||
const dArcade = addDistrict('arcade', (start.x + end.x) / 2, midZ);
|
||
const band = { fmin: 3, fmax: 5, dmin: 5, dmax: 8, gap: 0.5, use: 'shop', insetStart: 2, insetEnd: 2 };
|
||
for (const side of [1, -1]) {
|
||
const res = marchStrip('arclot', side > 0 ? 0 : 1, dArcade, 'arcade', aEdge, start, end, 5, side, band);
|
||
assignRetail(res.blockId, 'arcade', res.lots);
|
||
}
|
||
}
|
||
|
||
// ── warehouse fringe: sparse big lots beyond one spine end (seeded N or S) ─────────
|
||
{
|
||
const rr = rng(citySeed, 'ware', 0);
|
||
const north = rr() < 0.5;
|
||
const z0 = north ? 480 : -480;
|
||
const A = addNode(-140, z0), B = addNode(140, z0);
|
||
const edge = addEdge(A.id, B.id, 12, 'side');
|
||
const dWare = addDistrict('warehouse', 0, z0);
|
||
// A→B runs +x (u=(1,0)); side>0 ⇒ normal (0,+1)=north, side<0 ⇒ (0,-1)=south. Face lots inward:
|
||
const useSide = north ? -1 : 1; // north fringe: lots to its south; south fringe: to its north
|
||
const band = { fmin: 20, fmax: 30, dmin: 24, dmax: 34, gap: 8, use: 'infill', insetStart: 8, insetEnd: 8 };
|
||
const res = marchStrip('warelot', 0, dWare, 'warehouse', edge, A, B, 12, useSide, band);
|
||
// sparsely activate a few as pawn/junk shops
|
||
for (const L of res.lots) {
|
||
const r3 = rng(citySeed, 'wareup', L.lot);
|
||
if (r3() < 0.3) { lots[L.lot].use = 'shop'; createShop(L.lot, 'pawn'); }
|
||
}
|
||
}
|
||
|
||
// ── residential collar: a loose ring road of houses, with 2–4 corner milk bars ────
|
||
{
|
||
const Wc = 430, Hc = 445;
|
||
const NW = addNode(-Wc, Hc), NE = addNode(Wc, Hc), SE = addNode(Wc, -Hc), SW = addNode(-Wc, -Hc);
|
||
const ringEdges = [
|
||
{ e: addEdge(NW.id, NE.id, 14, 'side'), A: NW, B: NE, inward: -1 }, // N: lots south (inward)
|
||
{ e: addEdge(NE.id, SE.id, 14, 'side'), A: NE, B: SE, inward: 1 }, // E: lots west (inward)
|
||
{ e: addEdge(SE.id, SW.id, 14, 'side'), A: SE, B: SW, inward: -1 }, // S: lots north (inward)
|
||
{ e: addEdge(SW.id, NW.id, 14, 'side'), A: SW, B: NW, inward: 1 }, // W: lots east (inward)
|
||
];
|
||
const cornerLots = [];
|
||
ringEdges.forEach((R, k) => {
|
||
const band = { fmin: 14, fmax: 20, dmin: 12, dmax: 18, gap: 5, use: 'house', insetStart: 24, insetEnd: 24 };
|
||
const res = marchStrip('houselot', k, dRes, 'residential', R.e, R.A, R.B, 14, R.inward, band);
|
||
if (res.lots.length) { cornerLots.push(res.lots[0], res.lots[res.lots.length - 1]); }
|
||
});
|
||
const shuffled = shuffle(rng(citySeed, 'milkbar', 0), cornerLots.slice());
|
||
const k = irange(rng(citySeed, 'milkbarN', 0), 2, 4);
|
||
for (let i = 0; i < Math.min(k, shuffled.length); i++) {
|
||
lots[shuffled[i].lot].use = 'shop';
|
||
createShop(shuffled[i].lot, 'milkbar');
|
||
}
|
||
}
|
||
|
||
// ── laneways behind the central main-street blocks (back-door flavour + yard lots) ─
|
||
{
|
||
const spineX = spinePts[3].x;
|
||
const eLaneX = spineX + 36, wLaneX = spineX - 36;
|
||
const laneA_E = addNode(eLaneX, -140), laneB_E = addNode(eLaneX, 140);
|
||
const laneA_W = addNode(wLaneX, -140), laneB_W = addNode(wLaneX, 140);
|
||
const eLane = addEdge(laneA_E.id, laneB_E.id, 4, 'lane');
|
||
const wLane = addEdge(laneA_W.id, laneB_W.id, 4, 'lane');
|
||
const band = { fmin: 8, fmax: 12, dmin: 8, dmax: 12, gap: 4, use: 'yard', insetStart: 12, insetEnd: 12 };
|
||
marchStrip('lanelot', 0, dMain, 'mainstreet', eLane, laneA_E, laneB_E, 4, -1, band); // yards on far (east) side
|
||
marchStrip('lanelot', 1, dMain, 'mainstreet', wLane, laneA_W, laneB_W, 4, 1, band); // yards on far (west) side
|
||
}
|
||
|
||
// ── corner de-confliction: guarantee no two BUILDING lots overlap across block boundaries ──
|
||
// Strips are non-overlapping within a block by construction, but perpendicular strips can still
|
||
// interpenetrate at street corners. Broad-phase over a spatial grid, then OBB/SAT; when two solid
|
||
// lots from different blocks collide, demote the higher-id one to 'infill' (dropping its shop).
|
||
// Deterministic: lots are visited in id order, so the earlier-built lot always wins the corner.
|
||
{
|
||
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']);
|
||
const GCELL = 24; // broad-phase cell; correctness is independent of size (we register+query full AABB)
|
||
const grid = new Map();
|
||
const gkey = (cx, cz) => `${cx},${cz}`;
|
||
const demoted = new Set();
|
||
for (const lot of lots) {
|
||
if (!SOLID.has(lot.use)) continue;
|
||
const q = lotCorners(lot);
|
||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
|
||
for (const [x, z] of q) { if (x < minx) minx = x; if (x > maxx) maxx = x; if (z < minz) minz = z; if (z > maxz) maxz = z; }
|
||
const cx0 = Math.floor(minx / GCELL), cx1 = Math.floor(maxx / GCELL);
|
||
const cz0 = Math.floor(minz / GCELL), cz1 = Math.floor(maxz / GCELL);
|
||
let hit = false;
|
||
for (let cx = cx0; cx <= cx1 && !hit; cx++) for (let cz = cz0; cz <= cz1 && !hit; cz++) {
|
||
const bucket = grid.get(gkey(cx, cz)); if (!bucket) continue;
|
||
for (const oid of bucket) {
|
||
if (lots[oid].block === lot.block) continue; // within-block is disjoint by construction
|
||
if (obbOverlap(q, lotCorners(lots[oid]))) { hit = true; break; }
|
||
}
|
||
}
|
||
if (hit) { demoted.add(lot.id); continue; } // don't index a demoted lot (it becomes non-solid)
|
||
for (let cx = cx0; cx <= cx1; cx++) for (let cz = cz0; cz <= cz1; cz++) {
|
||
const k = gkey(cx, cz); let b = grid.get(k); if (!b) grid.set(k, b = []); b.push(lot.id);
|
||
}
|
||
}
|
||
if (demoted.size) {
|
||
for (const id of demoted) lots[id].use = 'infill';
|
||
for (let i = shops.length - 1; i >= 0; i--) if (demoted.has(shops[i].lot)) shops.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// ── exactly one late-night landmark: the town's designated "open till late" shop ──────
|
||
// The brief calls for one weirdo trading late. Make it a deterministic, explicitly-flagged
|
||
// landmark (not a per-shop dice, which gave 0..many and often none past 22:00) so Lane B can
|
||
// light it after dark and Lane F can gate on a known field instead of a magic hours threshold.
|
||
// The video rental is the archetypal 90s late-night shop, so pick a video first; fall back to a
|
||
// milk bar, then any non-stall, only if a town somehow has none. Market stalls (morning-only)
|
||
// are never it. Runs AFTER corner de-confliction so the chosen shop can't then be spliced out.
|
||
if (shops.length) {
|
||
const videos = shops.filter(s => s.type === 'video');
|
||
const milkbars = shops.filter(s => s.type === 'milkbar');
|
||
const pool = videos.length ? videos : (milkbars.length ? milkbars : shops.filter(s => s.type !== 'stall'));
|
||
if (pool.length) {
|
||
const lr = rng(citySeed, 'openlate', 0);
|
||
const chosen = pick(lr, pool);
|
||
chosen.openLate = true;
|
||
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)]; // 22:00 or 23:00 — the town's only shop ≥ LATE_HOUR
|
||
}
|
||
}
|
||
|
||
return {
|
||
version: 1,
|
||
citySeed,
|
||
name: townName(citySeed),
|
||
size: { w: 1024, d: 1024 },
|
||
districts,
|
||
streets: { nodes, edges },
|
||
blocks,
|
||
lots,
|
||
shops,
|
||
};
|
||
}
|
||
|
||
// ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ──────
|
||
export const CHUNK = 64;
|
||
export const chunkKey = (cx, cz) => `${cx},${cz}`;
|
||
|
||
export function chunkIndex(plan, chunkSize = CHUNK) {
|
||
const cell = v => Math.floor(v / chunkSize);
|
||
const chunks = {};
|
||
const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] });
|
||
|
||
// each lot → the chunks its (rotated) footprint AABB covers
|
||
const lotKeys = new Map();
|
||
for (const lot of plan.lots) {
|
||
const cs = Math.cos(lot.ry), sn = Math.sin(lot.ry), hw = lot.w / 2, hd = lot.d / 2;
|
||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
|
||
for (const [ox, oz] of [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]]) {
|
||
const wx = lot.x + ox * cs + oz * sn, wz = lot.z - ox * sn + oz * cs;
|
||
if (wx < minx) minx = wx; if (wx > maxx) maxx = wx;
|
||
if (wz < minz) minz = wz; if (wz > maxz) maxz = wz;
|
||
}
|
||
const keys = [];
|
||
for (let cx = cell(minx); cx <= cell(maxx); cx++)
|
||
for (let cz = cell(minz); cz <= cell(maxz); cz++) {
|
||
const k = chunkKey(cx, cz); bucket(k).lots.push(lot.id); keys.push(k);
|
||
}
|
||
lotKeys.set(lot.id, keys);
|
||
}
|
||
|
||
// shops ride along with their lot's chunks
|
||
for (const shop of plan.shops)
|
||
for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id);
|
||
|
||
// edges → every chunk the road CORRIDOR touches (centreline ± width/2 — the road + verge band
|
||
// where Lane B drops verandah posts, trees and street furniture). Each edge is split into
|
||
// ≤chunkSize pieces and every cell of each piece's corridor-rectangle AABB is bucketed. This is
|
||
// gap-free by construction: every point of the corridor lies inside some piece's AABB, so a prop
|
||
// placed anywhere in the verge always resolves to a chunk that lists its edge — closing the
|
||
// furniture-drop class Lane B hit against the old centreline-only sampling. (selfcheck asserts it.)
|
||
const addEdgeCell = (id, cx, cz) => { const bk = bucket(chunkKey(cx, cz)); if (!bk.edges.includes(id)) bk.edges.push(id); };
|
||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||
for (const e of plan.streets.edges) {
|
||
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) || 1;
|
||
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular half-corridor
|
||
const pieces = Math.max(1, Math.ceil(len / chunkSize));
|
||
for (let s = 0; s < pieces; s++) {
|
||
const x0 = a.x + dx * s / pieces, z0 = a.z + dz * s / pieces;
|
||
const x1 = a.x + dx * (s + 1) / pieces, z1 = a.z + dz * (s + 1) / pieces;
|
||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity; // AABB of this piece's corridor rect
|
||
for (const [qx, qz] of [[x0 + px, z0 + pz], [x0 - px, z0 - pz], [x1 + px, z1 + pz], [x1 - px, z1 - pz]]) {
|
||
if (qx < minx) minx = qx; if (qx > maxx) maxx = qx; if (qz < minz) minz = qz; if (qz > maxz) maxz = qz;
|
||
}
|
||
for (let cx = cell(minx); cx <= cell(maxx); cx++)
|
||
for (let cz = cell(minz); cz <= cell(maxz); cz++) addEdgeCell(e.id, cx, cz);
|
||
}
|
||
}
|
||
return { chunkSize, chunks };
|
||
}
|