PROCITY/web/js/citygen/plan.js
m3ultra c1e862cf7c Lane A R27 wave 5: THE FACADE FIX — it was every town, not just the real ones (the covenant amended)
selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the
amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in
LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files).

THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING.
Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18".
Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path,
and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY
PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because
that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the
premise itself.

MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING:
  synthetic seed 20261990   0 toward / 681 away   (dot = -1.0000, exactly anti-parallel)
  marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton   0 toward, all away
  real-roads katoomba   0/72   <- B's number, reproduced
637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by
corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted
?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into
the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has
been a row of back walls since v1.
After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT
PLAYTHINGS -- a strip of shopfronts facing the road.

FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT:
  plan.js:122    synthetic strip   "facade faces back down the outward normal, to the street"  <- it didn't
  plan.js:244    market row        "the facade then faces -(outward) = +x"                     <- it didn't
  plan_osm:330   marched avenue    "facade faces +z (north) to the avenue"                     <- due south
  plan_osm:443   real roads        B's proven one-liner (B measured it, then reverted it -- A's file)
All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment
said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was
wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72.

THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T.
Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I
stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is
anti-drift machinery, not a defect preservative. 51 pins in this commit:
  classic/synthetic  0x3fa36874 -> 0x5f76e76
  gig                0xb1d48ea1 -> 0xec7a2d39
  fixtures           melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4
  23 real towns      base + gig (46)
Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment as
a ONE-TIME, ruling-gated precedent -- from this hash forward the law reads as it always did.

A SECOND BUG THE FIX EXPOSED (fixed here, counted). buildRealRoads seated blocks at KERB = 4 m from the
CENTRELINE, but a `main` carriageway is 10 m wide -- its kerb is at 5 m. EVERY MAIN-STREET BUILDING HAS
STOOD 1 m INSIDE THE ROAD SINCE R18. The footprint never moved; only which face is the front -- so it was
invisible until the shopfront turned around and its poster landed in the traffic lane (-1.07 m clearance,
6 real failures). Blocks now seat behind the kerb per A's own published corridor law (roadWidth/2 +
FOOTPATH). Pack drops 6.4% -> 6.6%, counted, not hidden.

THE CROSS-CONVENTION GATE -- built to F's lesson, not to a spec line. F proved that measuring lots against
CITY_SPEC:71's own convention is SELF-AGREEMENT: 72/72 "facing" over a town of blank walls. So this gate
restates nothing. It replicates LANE B'S OWN maths from buildings.js (toWorld(lot, 0, y, d/2 + 0.02)) and
asks a physical question neither spec line can fudge: does the quad the RENDERER actually draws land nearer
the street than the lot centre? It fires if A's ry sign drifts OR if B's zf sign drifts. It would have
caught this on day one of v1.

CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention
is documented separately as the different thing it is, with the explicit rule -- never assert a lot's
facing against a spec line, assert it against the geometry the renderer draws.

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

467 lines
26 KiB
JavaScript
Raw Permalink 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 — 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 NS.
//
// 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;
// Canonical CLOSING-TIME LAW (frozen v2 ruling; Lane D/F consume this — the "debt" made code).
// Openness is HALF-OPEN: a shop is open for `open ≤ hour < close`, so a shop closing at 17 is shut
// AT 17:00. New entry is refused when closed; occupants already inside finish their dwell and drain
// (never popped); the player is never force-ejected. `hour` is a 024 float (Lane F's currentHour()).
// Pass a shop (or any { hours:[open,close] }). No hours ⇒ treated as always open.
export function isOpen(shopOrHours, hour) {
const h = Array.isArray(shopOrHours) ? shopOrHours : (shopOrHours && shopOrHours.hours);
if (!h) return true;
return hour >= h[0] && hour < h[1];
}
// 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;
// ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back
// down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e.
// facing away — this line's own comment has described the intended result since v1; only the sign was
// wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (Z) into the lots contract.
const ry = Math.atan2(-nx, -nz); // facade (+Z) 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 EW
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). ROUND27 THE FACADE FIX: ry now
// aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒
// ry = atan2(+1, 0) (= +π/2). It was atan2(1, 0), which pointed +Z at x — away from the spine, into
// the market's back. Same one-sign error as marchStrip above, and the old comment described the
// intended result ("the facade then faces (outward) = +x") that the code did not produce.
// 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 24 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 };
}