All 8 tree reds cleared. selfcheck ALL GREEN 156,211/156,211; scaffold + consistency green; classic
0x3fa36874 and gig 0xb1d48ea1 frozen. F: the tag is unblocked from my side, nothing of mine is pending.
SIX OF THE EIGHT REDS WERE MY OWN GATE ASSERTING RULING 2 AWAY. I built the R24 identity arm on an
assumption that was true when I wrote it and false the moment G shipped: that a godverse cache is ALL
census shops. Ruling 2 makes it MIXED -- a keyed GODVERSE layer plus an inherited OSM texture layer that
G measured to be load-bearing, not cosmetic (census-only Red Hill: 306 m spacing warn + a poster inside a
kerb). So newtown_godverse (18 keyed + 54 texture) and redhill_godverse (10 + 27) were HEALTHY TOWNS MY
GATE CALLED BROKEN -- it fired 54/72 on a good town. Keyed asserts now run PER-LAYER over the keyed subset
only; unkeyed texture shops raise nothing. The validator's "N/M missing" warn dies with it. The one honest
requirement left at that layer: a `godverse+osm` cache must have a NON-EMPTY godverse layer (else it's an
osm cache wearing the wrong `source`) -- still a warn, for the R24 charter reasons that still hold.
THE UNIQUENESS CHECK WAS BROKEN IN BOTH DIRECTIONS. `new Set(all ids).size === shops.length` collapses
every `undefined` into ONE Set entry, so it (a) failed structurally on any mixed cache -- 54 texture shops
became one entry -- and (b) could HIDE A REAL DUPLICATE behind that same collapse. Now over DEFINED ids
only. And per the vacuous-gate law I made it prove it bites rather than asserting that it does:
validateTownCache rejects duplicates at the front door, so a dup can never reach the plan that way -- the
test feeds one STRAIGHT TO THE LIFT (generatePlanOSM does not validate) and asserts the predicate catches
it, with a healthy mixed cache passing the same predicate. A check nobody can watch fail is not a check.
NEW -- THE ORPHANED-ATLAS GATE (my own R24 finding, now enforced instead of filed). The lift legitimately
drops shops (overlap-resolve, counted since R19). Dropping a shop that HAS a committed atlas means real
stock keyed to a shop that isn't in the town -- exactly the failure I filed in R24, and one F's #7b could
only catch in a browser at the end of a round. Now a node gate: for each godverse town, every
stock_godverse/<id>/ on disk belonging to that cache MUST be seated, else FAIL. Subject-aware -- it SKIPs
by name when a town has no atlas, rather than passing quietly.
It already found one, benign today: redhill's lift drops keyed shop 2286 "Empire Revival" (10 keyed in
cache -> 9 seated). No atlas, so nothing is orphaned and the gate passes -- but it prints, because a drop
must never be silent (the R19/R20 ruling). G: stocking Empire Revival at the beta would orphan it. Monster
Robot Party is seated (lot 8), which is the one that matters this round.
THE SIX PINS, cross-checked two ways before landing -- byte-identical across two fresh processes, AND
matching the three values G stated independently in 86e2985:
newtown_godverse base 0xcf69b387 gig 0x1e266f60 (moved by G's keyed re-emit)
redhill_real base 0x6046700f gig 0x673785ee
redhill_godverse base 0xb2224939 gig 0xc6f80e18
No other golden moved -- the diff is exactly these six lines.
Two lines still print; both deliberate, neither pending:
⊘ SKIP newtown_godverse orphaned-atlas -- correct, the only committed atlas (3962749) keys to Red Hill.
⚠ redhill_godverse: 1 keyed shop dropped -- the honest disclosure above.
Committed strictly by pathspec: F's six staged files sit untouched, exactly as its session left them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
714 lines
49 KiB
JavaScript
714 lines
49 KiB
JavaScript
// PROCITY CityGen self-check — `node web/js/citygen/selfcheck.js`. Plain node, zero deps.
|
||
// Asserts the Lane A acceptance contract. Prints all-green or dies loud with the first failure.
|
||
//
|
||
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and
|
||
// the generator can never disagree about what "overlap" or "facing" means.
|
||
|
||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
|
||
import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS,
|
||
medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js';
|
||
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
|
||
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
|
||
import { xmur3 } from '../core/prng.js';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ASSETS = join(HERE, '..', '..', 'assets', 'gen');
|
||
const STOCK_GODVERSE_DIR = join(HERE, '..', '..', 'assets', 'stock_godverse'); // G's tier-1 atlases (orphan check)
|
||
|
||
let failures = 0, checks = 0;
|
||
const ok = (cond, msg) => { checks++; if (!cond) { failures++; console.error(' ✗ ' + msg); } };
|
||
const section = s => console.log('\n' + s);
|
||
|
||
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
|
||
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
|
||
|
||
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)).
|
||
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)];
|
||
// nearest point on segment AB to point P
|
||
function nearestOnSeg(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];
|
||
}
|
||
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
|
||
|
||
// The FULL structural invariant suite for ANY CityPlan — synthetic OR osm (round-8 parity: both
|
||
// sources get identical coverage). `label` prefixes each check. Synthetic-only "brief" checks
|
||
// (market/arcade/dept/milkbar) live at the call site, guarded, since a real town has none.
|
||
function structuralSuite(plan, label) {
|
||
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
|
||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
|
||
const blockIds = new Set(plan.blocks.map(b => b.id));
|
||
const lotIds = new Set(plan.lots.map(l => l.id));
|
||
const districtIds = new Set(plan.districts.map(d => d.id));
|
||
|
||
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `${label}: node coords finite`);
|
||
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `${label}: lot numbers finite`);
|
||
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `${label}: block poly finite`);
|
||
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `${label}: every edge references real nodes`);
|
||
ok(plan.blocks.every(b => districtIds.has(b.district)), `${label}: every block in a real district`);
|
||
ok(plan.lots.every(l => blockIds.has(l.block)), `${label}: every lot in a real block`);
|
||
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `${label}: every lot has a valid frontEdge`);
|
||
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `${label}: every lot has positive size`);
|
||
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `${label}: every shop has a real lot`);
|
||
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `${label}: every shop has a known type`);
|
||
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `${label}: every facade is in its type's pool`);
|
||
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `${label}: every shop has sane hours`);
|
||
ok(plan.shops.every(sh => sh.name && sh.sign), `${label}: every shop is named`);
|
||
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `${label}: no unresolved tokens / undefined in names`);
|
||
ok(plan.shops.every(sh => {
|
||
const [mn, mx] = SHOP_TYPES[sh.type].storeys; const pmax = mx >= 2 ? Math.min(3, mx + 1) : mx;
|
||
return sh.storeys >= mn && sh.storeys <= pmax;
|
||
}), `${label}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey never boosted)`);
|
||
|
||
const lotShopCounts = {};
|
||
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
|
||
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
|
||
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
|
||
|
||
let backwards = null;
|
||
for (const l of plan.lots) {
|
||
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
|
||
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; }
|
||
}
|
||
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
|
||
|
||
const byBlock = {};
|
||
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
|
||
let inBlock = null;
|
||
for (const arr of Object.values(byBlock)) {
|
||
const cq = arr.map(lotCorners);
|
||
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
|
||
if (obbOverlap(cq[i], cq[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
|
||
if (inBlock) break;
|
||
}
|
||
ok(!inBlock, `${label}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
|
||
|
||
const solids = plan.lots.filter(l => SOLID.has(l.use));
|
||
const cs = solids.map(lotCorners);
|
||
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
|
||
let crossBlock = null;
|
||
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
|
||
if (solids[i].block === solids[j].block) continue;
|
||
const A = aabb[i], B = aabb[j];
|
||
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue;
|
||
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
|
||
}
|
||
ok(!crossBlock, `${label}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
|
||
|
||
const idx = chunkIndex(plan);
|
||
const covered = new Set();
|
||
for (const cellB of Object.values(idx.chunks)) {
|
||
for (const id of cellB.lots) covered.add(id);
|
||
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)), `${label}: chunk buckets reference real ids`);
|
||
}
|
||
ok(plan.lots.every(l => covered.has(l.id)), `${label}: chunkIndex covers every lot`);
|
||
|
||
const cell = v => Math.floor(v / CHUNK);
|
||
let edgeGap = null;
|
||
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;
|
||
const steps = Math.max(1, Math.ceil(len / 2));
|
||
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) {
|
||
for (let k = 0; k <= steps && !edgeGap; k++) {
|
||
const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f;
|
||
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
|
||
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)];
|
||
}
|
||
if (edgeGap) break;
|
||
}
|
||
if (edgeGap) break;
|
||
}
|
||
ok(!edgeGap, `${label}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
|
||
|
||
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `${label}: JSON round-trip lossless`);
|
||
|
||
// exactly one late-night landmark; openLate field ⟺ hours[1]≥22 (same shop); it's the video (or,
|
||
// for a town with no video at all, the fallback landmark). Holds for synthetic AND osm.
|
||
const late = plan.shops.filter(sh => sh.openLate);
|
||
// The v3 gig venue (`sh.venue`) is a night place that also runs ≥22:00 by design, so it's exempt
|
||
// from the single-late-shop rule. Base plans have no venue ⇒ this filter is a no-op there (goldens
|
||
// unaffected); gig plans exclude the pub so the openLate video is still the lone landmark.
|
||
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22 && !sh.venue);
|
||
ok(late.length === 1, `${label}: exactly one open-late shop (${late.length})`);
|
||
ok(ge22.length === 1, `${label}: exactly one shop closes ≥22:00 (${ge22.length})`);
|
||
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `${label}: openLate field ⟺ hours[1]≥22 (same shop)`);
|
||
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `${label}: open-late shop closes ≥22:00 and isn't a stall`);
|
||
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `${label}: the open-late shop is the video rental (or fallback if no video)`);
|
||
}
|
||
|
||
// The v3 GIG layer (?gigs=1). Runs the full structuralSuite (venue-aware) plus gig-specific
|
||
// invariants. Validates the DISTRICT interface every downstream lane (C/D/B/F) hangs off this round.
|
||
function gigSuite(plan, label) {
|
||
structuralSuite(plan, label); // full structural coverage; venues exempt from late-shop rule
|
||
districtInvariants(plan, label); // + the v3 district contract (also run standalone by the ROUND14 sweep)
|
||
}
|
||
|
||
// The v3 DISTRICT contract, factored out of gigSuite (ROUND14) so the 400-seed sweep below can assert it
|
||
// at SCALE without re-running the heavy structuralSuite (already covered on the 6 SEEDS + osm parity).
|
||
function districtInvariants(plan, label) {
|
||
const venues = plan.shops.filter(s => s.venue);
|
||
const byId = new Map(venues.map(v => [v.id, v]));
|
||
|
||
// ── the district: 2–4 venues, kinds from the registry, never the openLate landmark ──
|
||
ok(venues.length >= 2 && venues.length <= 4, `${label}: 2–4 venues (${venues.length})`);
|
||
ok(venues.every(v => VENUE_KINDS.includes(v.venueKind)), `${label}: every venue has a known venueKind`);
|
||
ok(venues.every(v => v.type === v.venueKind), `${label}: venue type ≡ venueKind (C keys its recipe off type)`);
|
||
ok(venues.some(v => v.venueKind === 'pub'), `${label}: the town has a pub`);
|
||
ok(venues.every(v => !v.openLate), `${label}: no venue displaced the openLate landmark`);
|
||
ok(venues.every(v => (v.hours || [0, 0])[1] >= 22), `${label}: every venue is open into night so its gig can run`);
|
||
ok(new Set(venues.map(v => v.lot)).size === venues.length, `${label}: venues occupy distinct lots`);
|
||
ok(venues.every(v => v.genreKey === genreForVenueKind(v.venueKind)), `${label}: genreKey matches the registry kind→genre map`);
|
||
// debt #1: the manifest key IS the gigKey. One key, zero mapping.
|
||
ok(venues.every(v => gigKeyFor(v.genreKey) === `gig-${v.genreKey}`), `${label}: gigKey ≡ 'gig-'+genreKey`);
|
||
|
||
// ── the week ──
|
||
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a gig schedule`);
|
||
ok(plan.gigs.every(g => byId.has(g.venueShopId)), `${label}: every gig plays a real venue`);
|
||
ok(plan.gigs.every(g => Number.isInteger(g.cover) && (g.cover === 0 || (g.cover >= 2 && g.cover <= 10))), `${label}: cover ∈ {0}∪[2,10]`);
|
||
ok(plan.gigs.every(g => typeof g.bandName === 'string' && g.bandName && !/[{}]|undefined/.test(g.bandName)), `${label}: band names resolved`);
|
||
ok(plan.gigs.every(g => Number.isInteger(g.startSeg) && Number.isInteger(g.endSeg) && g.startSeg >= 0 && g.endSeg <= 5 && g.startSeg <= g.endSeg), `${label}: gig segment window sane`);
|
||
ok(plan.gigs.every(g => Number.isInteger(g.night) && g.night >= 0 && g.night < 7), `${label}: night index in [0,7)`);
|
||
ok(plan.gigs.every(g => g.genreKey === byId.get(g.venueShopId).genreKey), `${label}: gig genre ≡ its venue's genre`);
|
||
ok(new Set(plan.gigs.map(g => g.gigId)).size === plan.gigs.length, `${label}: gigIds unique across town`);
|
||
// ~4–6 gigs per venue per week, and never two on the same night at one venue
|
||
for (const v of venues) {
|
||
const mine = plan.gigs.filter(g => g.venueShopId === v.id);
|
||
ok(mine.length >= 4 && mine.length <= 6, `${label}: venue ${v.id} (${v.venueKind}) plays 4–6 nights (${mine.length})`);
|
||
ok(new Set(mine.map(g => g.night)).size === mine.length, `${label}: venue ${v.id} plays ≤1 gig per night`);
|
||
}
|
||
// no band plays two venues at once (≤1 slot per name per night across town)
|
||
let clash = null;
|
||
for (let n = 0; n < 7 && !clash; n++) {
|
||
const names = plan.gigs.filter(g => g.night === n).map(g => g.bandName);
|
||
if (new Set(names).size !== names.length) clash = n;
|
||
}
|
||
ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : ''));
|
||
|
||
// ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ──
|
||
const gigIds = new Set(plan.gigs.map(g => g.gigId));
|
||
ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`);
|
||
ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`);
|
||
// Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon:
|
||
// buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/
|
||
// rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster
|
||
// must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps
|
||
// the plain no-bitumen floor (≥ 0, the R12 regression guard). −0.02 absorbs r2 poster-coord rounding vs
|
||
// pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m).
|
||
const lotByIdD = new Map(plan.lots.map(l => [l.id, l]));
|
||
const onFacade = p => venues.some(v => {
|
||
const lot = lotByIdD.get(v.lot); if (!lot) return false;
|
||
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD)
|
||
return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5;
|
||
});
|
||
let onRoad = null;
|
||
for (const p of plan.posters) {
|
||
let best = Infinity, bestEdge = null;
|
||
for (const e of plan.streets.edges) {
|
||
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
|
||
if (!a || !b) continue;
|
||
const [nx, nz] = nearestOnSeg(p.x, p.z, a.x, a.z, b.x, b.z);
|
||
const d = Math.hypot(p.x - nx, p.z - nz);
|
||
if (d < best) { best = d; bestEdge = e; }
|
||
}
|
||
if (!bestEdge) continue;
|
||
const frontage = onFacade(p);
|
||
const clearance = best - roadWidth(bestEdge) / 2;
|
||
const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6;
|
||
if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; }
|
||
}
|
||
ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : ''));
|
||
}
|
||
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
|
||
|
||
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
|
||
section('determinism (deep-equal across two runs)');
|
||
for (const s of SEEDS) {
|
||
const a = JSON.stringify(generatePlan(s)), b = JSON.stringify(generatePlan(s));
|
||
ok(a === b, `seed ${s}: two runs identical`);
|
||
}
|
||
|
||
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
||
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
||
section('golden fingerprint (output-drift guard)');
|
||
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 };
|
||
{
|
||
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
|
||
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
|
||
}
|
||
|
||
// ── 2. performance: generate under 100ms ────────────────────────────────────────────
|
||
section('performance (<100ms per plan)');
|
||
for (const s of SEEDS) {
|
||
const t0 = process.hrtime.bigint();
|
||
generatePlan(s);
|
||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||
ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`);
|
||
}
|
||
|
||
// ── 3. structural invariants — SYNTHETIC source (full suite) + synthetic-only brief checks ──
|
||
section('structural invariants — synthetic');
|
||
const facadeSet = new Set();
|
||
for (const s of SEEDS) {
|
||
const plan = generatePlan(s);
|
||
structuralSuite(plan, `syn ${s}`);
|
||
// synthetic-only brief presence (a real OSM town has none of these)
|
||
ok(plan.streets.edges.some(e => e.kind === 'main'), `syn ${s}: has a main-street spine`);
|
||
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `syn ${s}: has an arcade`);
|
||
ok(plan.districts.some(d => d.kind === 'market'), `syn ${s}: has a market district`);
|
||
ok(plan.shops.some(sh => sh.type === 'stall'), `syn ${s}: market has stalls`);
|
||
ok(plan.shops.some(sh => sh.type === 'dept'), `syn ${s}: has a department-store anchor`);
|
||
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
|
||
ok(milkbars >= 2 && milkbars <= 4, `syn ${s}: 2–4 corner milk bars (${milkbars})`);
|
||
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
|
||
}
|
||
|
||
// ── 3b. OSM plan-source PARITY: every town runs the identical structuralSuite + its own golden ──
|
||
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
|
||
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
|
||
section('OSM plan source parity (fixture-driven, zero-network)');
|
||
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 };
|
||
const OSM_SEEDS = [20261990, 1, 42, 777];
|
||
for (const town of osmTownKeys()) {
|
||
const report = {};
|
||
for (const s of OSM_SEEDS) {
|
||
const plan = generatePlanOSM(s, town, s === 20261990 ? { report } : {});
|
||
ok(plan.source === 'osm', `osm/${town} ${s}: source tagged 'osm'`);
|
||
ok(JSON.stringify(generatePlanOSM(s, town)) === JSON.stringify(plan), `osm/${town} ${s}: deterministic (same fixture+seed)`);
|
||
structuralSuite(plan, `osm/${town} ${s}`);
|
||
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
|
||
}
|
||
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, town)))() >>> 0;
|
||
const want = OSM_GOLDENS[town];
|
||
// Unpinned town (just added via the "add a town" recipe) → tell the human the exact line to paste.
|
||
if (want === undefined) console.log(` ⚠ osm/${town}: UNPINNED — add to OSM_GOLDENS → ${town}: 0x${hash.toString(16).padStart(8, '0')},`);
|
||
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches pinned 0x${((want ?? 0) >>> 0).toString(16)}`);
|
||
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
|
||
}
|
||
|
||
// ── 3c. v3 GIG layer (?gigs=1): flags-off identity + gig invariants (synthetic + osm) ──
|
||
section('v3 gig layer (?gigs=1) — flags-off identity + gig invariants');
|
||
// PRIME FLAG LAW: ?gigs off must be byte-identical to the base generator (goldens can't move).
|
||
for (const s of SEEDS) ok(JSON.stringify(generatePlanFor(s)) === JSON.stringify(generatePlan(s)), `seed ${s}: ?gigs off ≡ base (byte-identical)`);
|
||
const GIG_SEEDS = [20261990, 1, 42, 777];
|
||
for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'synthetic', { gigs: true }), `gig syn ${s}`);
|
||
for (const town of osmTownKeys()) for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'osm', { gigs: true, town }), `gig osm/${town} ${s}`);
|
||
// determinism of the gig layer (same seed ⇒ byte-identical gig plan)
|
||
ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })), `gig layer deterministic`);
|
||
// custom-band drop-in: passed names surface with priority, deterministically given (seed, list)
|
||
{
|
||
const custom = ['AAA Custom One', 'BBB Custom Two', 'CCC Custom Three'];
|
||
const pc = generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom });
|
||
ok(pc.gigs.map(g => g.bandName).filter(b => custom.includes(b)).length === custom.length, `custom bands: all ${custom.length} passed names surface`);
|
||
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
|
||
}
|
||
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
|
||
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
|
||
{
|
||
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
|
||
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
|
||
ok(GIG_GOLDEN !== 0 && hash === (GIG_GOLDEN >>> 0), `gig layer golden 0x${hash.toString(16)} matches pinned 0x${(GIG_GOLDEN >>> 0).toString(16)}`);
|
||
}
|
||
|
||
// ── 3d. district invariant sweep (ROUND14): 400 synthetic seeds + osm graceful placement ───────────
|
||
// Release-round Lane A gate: the district contract must hold at SCALE, not just the 4 hero seeds. Runs
|
||
// districtInvariants ONLY (structuralSuite already covers the 6 SEEDS + osm parity) so 400 full gig-on
|
||
// generations stay well under a second. Pure validation — NO new rng draws, no golden moves.
|
||
section('district invariant sweep (ROUND14: 400 synthetic seeds + osm graceful placement)');
|
||
const SWEEP_N = 400;
|
||
const sweepVenueCounts = new Set();
|
||
for (let s = 1; s <= SWEEP_N; s++) {
|
||
const plan = generatePlanFor(s, 'synthetic', { gigs: true });
|
||
districtInvariants(plan, `sweep syn ${s}`);
|
||
sweepVenueCounts.add(plan.shops.filter(v => v.venue).length);
|
||
}
|
||
// also cover the large/edge uint32 seeds (2e9, 8675309 …) — where rng edge cases hide — that fall
|
||
// outside 1..400 and otherwise only get the flags-off byte-identity check, not the district contract.
|
||
for (const s of SEEDS) if (s > SWEEP_N) districtInvariants(generatePlanFor(s, 'synthetic', { gigs: true }), `sweep syn ${s}`);
|
||
// the seeded 2–4 count is genuinely exercised across the range, not pinned to one value
|
||
ok([2, 3, 4].every(n => sweepVenueCounts.has(n)),
|
||
`sweep: venue count spans {2,3,4} across ${SWEEP_N} seeds (saw {${[...sweepVenueCounts].sort((a, b) => a - b).join(',')}})`);
|
||
|
||
// osm graceful placement — two branches of pickVenues' degradation, each proven by a fixture that
|
||
// genuinely lacks the structure the synthetic town has:
|
||
// • katoomba (19 shops): NO warehouse district ⇒ band_room/rsl hit their off-spine fallback.
|
||
// • silverton (single-row): NO main spine at all ⇒ pub's onMain filter is empty and buildPosters'
|
||
// spine run falls back to every edge (ROUND16 ledger #7 — closes the R14 gap where both osm
|
||
// fixtures had a spine). Both still land 2–4 valid venues incl. a pub.
|
||
for (const seed of GIG_SEEDS) {
|
||
const kt = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
|
||
ok(kt.blocks.every(b => b.kind !== 'warehouse'),
|
||
`sweep osm/katoomba ${seed}: fixture has no warehouse district (graceful-placement precondition)`);
|
||
districtInvariants(kt, `sweep osm/katoomba ${seed}`);
|
||
|
||
const sv = generatePlanFor(seed, 'osm', { gigs: true, town: 'silverton' });
|
||
ok(!sv.streets.edges.some(e => e.kind === 'main'),
|
||
`sweep osm/silverton ${seed}: fixture has no main spine (no-spine placement precondition)`);
|
||
districtInvariants(sv, `sweep osm/silverton ${seed}`);
|
||
}
|
||
|
||
// ── 3e. town-cache contract (ROUND17 ledger #6): validator + real-data hardening + registry seam ──
|
||
// A publishes the contract E's build_towns.py builds to; plan_osm must eat real data (dead ends, no
|
||
// spine, blank names, unknown types, odd densities) without new plan features. These are synthetic
|
||
// adversarial caches — the committed proof of the hardening (real caches load in §3f below).
|
||
section('town-cache contract (ROUND17: real data through plan_osm)');
|
||
const mkCache = (shops, extra = {}) => ({
|
||
schema: 'procity-town-cache/1', key: 'stress', town: 'Stress', source: 'osm',
|
||
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.7, lon: 150.3 }, shops, ...extra,
|
||
});
|
||
const cluster = (n, opt = {}) => Array.from({ length: n }, (_, i) => ({
|
||
id: 500 + i,
|
||
name: opt.blank && i % 3 === 0 ? '' : `Real Shop ${i}`,
|
||
type: opt.unknown && i % 4 === 0 ? 'op_shop_weird' : ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
|
||
lat: -33.7 + (opt.samePoint ? 0 : (i % 5) * 0.0008),
|
||
lon: 150.3 + (opt.samePoint ? 0 : Math.floor(i / 5) * 0.0008),
|
||
suburb: 'Testville',
|
||
}));
|
||
// (a) VALID-but-messy caches validate ok AND boot a full valid district (structural + gig invariants)
|
||
for (const [label, cache] of [
|
||
['compact 14-shop town', mkCache(cluster(14))],
|
||
['blank names → defaulted', mkCache(cluster(14, { blank: true }))],
|
||
['unknown types → opshop', mkCache(cluster(14, { unknown: true }))],
|
||
['all shops at one point', mkCache(cluster(14, { samePoint: true }))],
|
||
['minimum shop count', mkCache(cluster(MIN_TOWN_SHOPS))],
|
||
]) {
|
||
const v = validateTownCache(cache);
|
||
ok(v.ok, `cache "${label}": validates ok` + (v.ok ? '' : ` — ${v.errors.join('; ')}`));
|
||
if (!v.ok) continue;
|
||
for (const s of [1, 20261990]) structuralSuite(generatePlanOSM(s, 'stress', { cache }), `stress/${label} ${s}`);
|
||
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'stress', cache }), `stress/${label}`);
|
||
}
|
||
// (b) INVALID caches are REJECTED (they would not boot a valid district)
|
||
for (const [label, cache, needle] of [
|
||
['too few shops', mkCache(cluster(MIN_TOWN_SHOPS - 1)), 'shops required'],
|
||
['non-finite coords', mkCache(cluster(12).map((s, i) => i === 2 ? { ...s, lat: NaN } : s)), 'non-finite'],
|
||
['missing center', { schema: 'procity-town-cache/1', shops: cluster(12) }, 'center'],
|
||
['shops not an array', { ...mkCache(cluster(12)), shops: 'nope' }, 'array'],
|
||
['not an object', 'nope', 'not an object'],
|
||
]) {
|
||
const v = validateTownCache(cache);
|
||
ok(!v.ok && v.errors.some(e => e.includes(needle)), `cache "${label}": rejected` + (v.ok ? ' (WRONGLY ACCEPTED)' : ` — "${v.errors[0]}"`));
|
||
}
|
||
// (c) a region-wide cache still boots but WARNS (the R17 mega-strip risk: bound the query to one town)
|
||
{
|
||
const wide = mkCache(cluster(20).map((s, i) => ({ ...s, lat: -33.7 + i * 0.05 })));
|
||
const v = validateTownCache(wide);
|
||
ok(v.ok && v.warnings.some(w => /span|town/.test(w)), `wide-spread cache: valid but warns (${v.warnings.find(w => /span/.test(w)) || 'NO WARN'})`);
|
||
}
|
||
// (c2) the SPACING warn (ROUND23 ledger #5 — D's thin-tail finding as law). The span warn catches a town
|
||
// that's too WIDE; this catches one too SPARSE to read as a town. Warn, never error — and the town must
|
||
// still boot green, because that's exactly what toowoomba did through the entire v4.0 pack.
|
||
{
|
||
// a scattered town: 12 shops ~400 m apart (toowoomba's shape) — inside the 5 km span law, so the span
|
||
// warn does NOT fire; this is the gap D found and the reason the metric exists.
|
||
const scattered = mkCache(Array.from({ length: 12 }, (_, i) => ({
|
||
id: 600 + i, name: `Scattered ${i}`, type: 'opshop',
|
||
lat: -33.7 + (i % 4) * 0.0036, lon: 150.3 + Math.floor(i / 4) * 0.0036, suburb: 'Testville',
|
||
})));
|
||
const v = validateTownCache(scattered);
|
||
ok(v.ok, `sparse cache: still VALID (warn, not error) — a flagged town boots a real district`);
|
||
ok(v.warnings.some(w => /median shop spacing/.test(w)), `sparse cache: warns (${v.warnings.find(w => /spacing/.test(w)) || 'NO WARN'})`);
|
||
ok(!v.warnings.some(w => /span/.test(w)), `sparse cache: the SPAN warn stays silent — spacing catches what span cannot`);
|
||
structuralSuite(generatePlanOSM(20261990, 'sparse', { cache: scattered }), `sparse-warn town`);
|
||
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'sparse', cache: scattered }), `sparse-warn town`);
|
||
|
||
// a compact town must NOT warn (no false positive on the shape 20 of 23 pack towns have)
|
||
const tight = validateTownCache(mkCache(cluster(14)));
|
||
ok(!tight.warnings.some(w => /median shop spacing/.test(w)), `compact cache: no spacing warn (no false positive)`);
|
||
|
||
// the metric itself — D's definition: per shop, distance to the NEAREST OTHER shop, then the median.
|
||
ok(medianShopSpacing(mkCache(cluster(14))) < MAX_MEDIAN_SPACING_M, `medianShopSpacing: compact town under the floor`);
|
||
ok(medianShopSpacing(mkCache(cluster(14, { samePoint: true }))) === 0, `medianShopSpacing: coincident shops → 0 m`);
|
||
ok(medianShopSpacing(mkCache(cluster(1))) === null, `medianShopSpacing: < 2 shops → null (nothing to space)`);
|
||
ok(medianShopSpacing({ shops: [] }) === null, `medianShopSpacing: no shops → null, no throw`);
|
||
{
|
||
// exact-value check: two shops 100 m apart in latitude → both nearest-neighbours are 100 m → median 100.
|
||
const pair = mkCache([{ id: 1, name: 'A', type: 'opshop', lat: -33.7, lon: 150.3 },
|
||
{ id: 2, name: 'B', type: 'opshop', lat: -33.7 + 100 / 111320, lon: 150.3 }]);
|
||
const got = medianShopSpacing(pair);
|
||
ok(Math.abs(got - 100) < 0.5, `medianShopSpacing: 100 m pair measures ${got.toFixed(1)} m (projection sane)`);
|
||
}
|
||
}
|
||
// (c3) the IDENTITY FIELD (ROUND24 ledger #2, with G) — `shop.godverseShopId`, the key that resolves
|
||
// `stock_godverse/<id>/`. It is NOT `shop.id` and NOT "the census id": Monster Robot Party isn't in
|
||
// thriftgod's census at all, so its id is its dealgod store id (3962749), while census shops use
|
||
// thriftgod ids (max 2992). Two disjoint spaces, one opaque field — so these assert the FIELD's
|
||
// properties, never a relationship to `id`.
|
||
{
|
||
const gv = shops => ({ ...mkCache(shops), source: 'godverse+osm' });
|
||
const withIds = cluster(8).map((s, i) => ({ ...s, godverseShopId: 3962749 + i }));
|
||
|
||
// a well-formed godverse cache validates clean and carries no spurious warn
|
||
const good = validateTownCache(gv(withIds));
|
||
ok(good.ok, `godverse cache: valid with ids` + (good.ok ? '' : ` — ${good.errors.join('; ')}`));
|
||
ok(!good.warnings.some(w => /godverseShopId/.test(w)), `godverse cache: fully-keyed cache raises no identity warn`);
|
||
|
||
// MALFORMED → error. A path segment, not a label: the string "3962749" is the same folder but
|
||
// different JSON, and that coercion drift is the species F caught in the atlas contract.
|
||
for (const [label, bad] of [
|
||
['string id', '3962749'], ['float id', 3962749.5], ['zero', 0], ['negative', -5], ['NaN', NaN],
|
||
]) {
|
||
const v = validateTownCache(gv(withIds.map((s, i) => i === 1 ? { ...s, godverseShopId: bad } : s)));
|
||
ok(!v.ok && v.errors.some(e => /malformed godverseShopId/.test(e)), `godverse cache: ${label} REJECTED`);
|
||
}
|
||
// DUPLICATE → error. Two shops keyed to one atlas is mis-stocking (charter risk #3: "never mis-stocked").
|
||
{
|
||
const v = validateTownCache(gv(withIds.map((s, i) => i === 1 ? { ...s, godverseShopId: withIds[0].godverseShopId } : s)));
|
||
ok(!v.ok && v.errors.some(e => /duplicate godverseShopId/.test(e)), `godverse cache: duplicate id REJECTED (mis-stock)`);
|
||
}
|
||
// RULING 2 (ROUND25): a MIXED cache — keyed GODVERSE layer + unkeyed texture layer — is the DESIGN,
|
||
// not a gap. My R24 arm counted every unkeyed shop as missing and fired 54/72 on a healthy town; it
|
||
// was asserting the design away. A mixed cache is now simply normal: valid, and silent.
|
||
{
|
||
const mixed = gv(withIds.map((s, i) => i < 2 ? { ...s, godverseShopId: undefined } : s));
|
||
const v = validateTownCache(mixed);
|
||
ok(v.ok, `godverse cache: MIXED (keyed + texture) is VALID — Ruling 2 is the design`);
|
||
ok(!v.warnings.some(w => /godverseShopId/.test(w)), `godverse cache: MIXED raises NO identity warn (texture shops are legitimately unkeyed)`);
|
||
}
|
||
// ...but a godverse cache with NO identity at all has no godverse layer — an osm cache wearing the
|
||
// wrong `source`. That's the one honest requirement left at this layer, and it stays a warn.
|
||
{
|
||
const v = validateTownCache(gv(cluster(8)));
|
||
ok(v.ok, `godverse cache: empty GODVERSE layer stays VALID (tier 0 must always boot)`);
|
||
ok(v.warnings.some(w => /GODVERSE layer is empty/.test(w)), `godverse cache: empty layer WARNS (${v.warnings.find(w => /GODVERSE/.test(w)) || 'NO WARN'})`);
|
||
}
|
||
// a plain osm cache must never be asked for ids
|
||
ok(!validateTownCache(mkCache(cluster(8))).warnings.some(w => /godverseShopId|GODVERSE/.test(w)), `osm cache: no identity warn (godverse-only requirement)`);
|
||
|
||
// THE SEAM THAT MATTERS: the id survives the lift onto plan.shops. Without this the field is a
|
||
// cache-only decoration and F's per-shop base can never resolve — the runtime reads the PLAN.
|
||
{
|
||
const cache = gv(withIds.map((s, i) => i < 2 ? { ...s, godverseShopId: undefined } : s)); // mixed, like the real ones
|
||
const p = generatePlanOSM(20261990, 'gvtest', { cache });
|
||
const keyed = p.shops.filter(s => s.godverseShopId !== undefined);
|
||
ok(keyed.length > 0 && keyed.length <= p.shops.length, `godverse lift: the keyed layer survives onto plan.shops (${keyed.length} of ${p.shops.length} seated)`);
|
||
ok(keyed.every(s => Number.isSafeInteger(s.godverseShopId) && s.godverseShopId > 0), `godverse lift: ids survive as positive integers`);
|
||
ok(p.shops.filter(s => !('godverseShopId' in s)).length === p.shops.length - keyed.length, `godverse lift: texture shops carry NO key (absent, not undefined)`);
|
||
// and an unkeyed cache writes NO key — that absence is what keeps every pinned osm golden frozen
|
||
const plain = generatePlanOSM(20261990, 'plaintest', { cache: mkCache(cluster(8)) });
|
||
ok(plain.shops.every(s => !('godverseShopId' in s)), `plain lift: the key is ABSENT, not undefined (byte-identical JSON ⇒ goldens frozen)`);
|
||
}
|
||
|
||
// THE UNIQUENESS CHECK, REBUILT SO IT CAN ACTUALLY FAIL (ROUND25 ledger #1). The R24 version compared
|
||
// `new Set(all ids).size === shops.length`, which collapses every `undefined` into ONE entry — so on a
|
||
// mixed cache it fails structurally (54 texture shops → 1 entry) while a REAL duplicate among two
|
||
// defined ids could hide behind that same collapse. Uniqueness now runs over DEFINED ids only.
|
||
//
|
||
// And per the vacuous-gate law: a check nobody can see fail is not a check. `validateTownCache`
|
||
// rejects duplicates, so a dup can never reach the plan through the front door — `generatePlanOSM`
|
||
// does NOT validate, so this feeds one straight to the lift and proves the assert bites.
|
||
{
|
||
const dupCache = gv(withIds.map((s, i) => i === 3 ? { ...s, godverseShopId: withIds[0].godverseShopId } : s));
|
||
ok(!validateTownCache(dupCache).ok, `uniqueness: the validator still rejects a duplicate at the front door`);
|
||
const p = generatePlanOSM(20261990, 'duptest', { cache: dupCache }); // bypasses validation, as the lift does
|
||
const ids = p.shops.map(s => s.godverseShopId).filter(id => id !== undefined);
|
||
ok(new Set(ids).size < ids.length, `uniqueness: a duplicate that BYPASSES the validator is visible on the plan (${ids.length} ids → ${new Set(ids).size} unique) — the check can fail`);
|
||
// the same predicate on a healthy mixed cache must pass — no false positive from the undefineds
|
||
const okIds = generatePlanOSM(20261990, 'gvtest2', { cache: gv(withIds.map((s, i) => i < 2 ? { ...s, godverseShopId: undefined } : s)) })
|
||
.shops.map(s => s.godverseShopId).filter(id => id !== undefined);
|
||
ok(new Set(okIds).size === okIds.length, `uniqueness: a healthy MIXED cache passes the same predicate (no undefined-collapse false positive)`);
|
||
}
|
||
}
|
||
// (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it
|
||
{
|
||
registerTownCache('regtest', mkCache(cluster(14)));
|
||
ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`);
|
||
ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`);
|
||
}
|
||
// (e) v4 REAL ROADS (schema v2, ROUND18): a v2 cache with roads[] builds the CityPlan street graph FROM
|
||
// the real ways and STILL passes the full structural + gig suites. Synthetic irregular graph — a main
|
||
// street, two cross streets (a real intersection + an acute-ish angle), a dead-end lane, a slight curve —
|
||
// the committed proof of the graph lift; `katoomba_real` validates + pins its golden when E's roads land.
|
||
section('v4 real-roads graph lift (schema v2)');
|
||
{
|
||
const roadsCache = {
|
||
schema: 'procity-town-cache/2', key: 'roadstest', town: 'Roadstest', source: 'osm',
|
||
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.71, lon: 150.31 },
|
||
roads: [
|
||
{ kind: 'primary', name: 'Main St', pts: [[-33.7150, 150.3110], [-33.7130, 150.31108], [-33.7110, 150.31116], [-33.7090, 150.31124], [-33.7070, 150.31132]] },
|
||
{ kind: 'residential', name: 'Cross A', pts: [[-33.7130, 150.31108], [-33.7130, 150.3090]] },
|
||
{ kind: 'residential', name: 'Cross B', pts: [[-33.7100, 150.3112], [-33.7095, 150.3130]] },
|
||
{ kind: 'service', name: 'Back Lane', pts: [[-33.7120, 150.31112], [-33.7122, 150.3122]] },
|
||
],
|
||
shops: Array.from({ length: 18 }, (_, i) => ({
|
||
id: 700 + i, name: `Real St Shop ${i}`,
|
||
type: ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
|
||
lat: -33.7150 + i * 0.00045, lon: 150.3110 + ((i % 3) - 1) * 0.0005, suburb: 'Katoomba',
|
||
})),
|
||
};
|
||
const v = validateTownCache(roadsCache);
|
||
ok(v.ok, `roads cache: validates as schema v2` + (v.ok ? '' : ` — ${v.errors.join('; ')}`));
|
||
for (const s of [1, 42, 20261990]) {
|
||
const p = generatePlanOSM(s, 'roadstest', { cache: roadsCache });
|
||
ok(p.streets.edges.length >= 3, `roads ${s}: multi-edge real graph (${p.streets.edges.length} edges from real ways)`);
|
||
ok(p.streets.edges.some(e => e.kind === 'main'), `roads ${s}: a main spine exists (OSM class or shop-density promoted)`);
|
||
ok(p.shops.length >= 6, `roads ${s}: ${p.shops.length} shops seated on real edges (overlap-resolved)`);
|
||
structuralSuite(p, `roads syn ${s}`);
|
||
}
|
||
ok(JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })) === JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })),
|
||
`roads: deterministic (byte-identical re-run)`);
|
||
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'roadstest', cache: roadsCache }), `roads syn`);
|
||
// roads-absent (v1) marches — proven identical to before by the frozen osm goldens above
|
||
ok(generatePlanOSM(1, 'x', { cache: { ...roadsCache, roads: undefined } }).streets.edges.some(e => e.kind === 'main'),
|
||
`roads absent: marched fallback still boots`);
|
||
}
|
||
|
||
// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ──
|
||
// Empty until E's caches land; each is validated, run through the full structural + gig suites, and
|
||
// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive.
|
||
section('real town caches (web/assets/towns/*.json)');
|
||
const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
|
||
// ROUND20: re-pinned once for E's DENSITY WIDENING (5 caches at 3-6x shops) + A's poster cap +
|
||
// capacity widen. All five real towns are on REAL ROADS and pinned on the
|
||
// real-roads graph output — the A→E→A finalization, katoomba's golden included. Re-pinned this round after
|
||
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
|
||
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
|
||
const REAL_TOWN_GOLDENS = {
|
||
adelaide_real: 0x5c790920,
|
||
ballarat_real: 0xf0a9e8a1,
|
||
bendigo_real: 0xc175194e,
|
||
bowral_real: 0xb23ee067,
|
||
braddon_real: 0x2f388775,
|
||
brunswick_real: 0x7ecb5c3e,
|
||
castlemaine_real: 0x26c128c9,
|
||
darwin_real: 0xcfb1917f,
|
||
daylesford_real: 0x4ec7352c,
|
||
fitzroy_real: 0x6549f61b,
|
||
fremantle_real: 0x73d385df,
|
||
geelong_real: 0x2f65591e,
|
||
glebe_real: 0x908e1835,
|
||
hobart_real: 0xa96328b9,
|
||
katoomba_real: 0x420f677d,
|
||
launceston_real: 0x32b1ab7b,
|
||
marrickville_real: 0x60148e37,
|
||
newcastle_real: 0xbb2c3050,
|
||
newtown_godverse: 0xcf69b387,
|
||
newtown_real: 0x741c18ec,
|
||
northbridge_real: 0x1a645848,
|
||
// toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that
|
||
// cannot load is dead config: it can never fire, so it can never fail. Same species as the round's
|
||
// vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median
|
||
// NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.)
|
||
redhill_godverse: 0xb2224939,
|
||
redhill_real: 0x6046700f,
|
||
westend_real: 0x5b1e5ffe,
|
||
};
|
||
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
|
||
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
|
||
// absorb to come) are regression-guarded byte-exact, not just by districtInvariants.
|
||
// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens
|
||
// move — venue selection is gig-layer, so the base town goldens above are untouched.
|
||
const REAL_TOWN_GIG_GOLDENS = {
|
||
adelaide_real: 0x24ad3c27,
|
||
ballarat_real: 0xf097c331,
|
||
bendigo_real: 0x646b2c23,
|
||
bowral_real: 0xac809f0b,
|
||
braddon_real: 0xc3e5cfc0,
|
||
brunswick_real: 0x8c08914e,
|
||
castlemaine_real: 0xf9713a69,
|
||
darwin_real: 0xbc5605b5,
|
||
daylesford_real: 0x2fb34c8a,
|
||
fitzroy_real: 0x690e5ef9,
|
||
fremantle_real: 0xcf3426fc,
|
||
geelong_real: 0x6f47ec2f,
|
||
glebe_real: 0x042f01fb,
|
||
hobart_real: 0x22f80d7b,
|
||
katoomba_real: 0xbf586b77,
|
||
launceston_real: 0xeaf7ff4d,
|
||
marrickville_real: 0xea21be51,
|
||
newcastle_real: 0x66fc2bf9,
|
||
newtown_godverse: 0x1e266f60,
|
||
newtown_real: 0xacdb3eee,
|
||
northbridge_real: 0x83a1d5be,
|
||
// toowoomba_real RETIRED by E in R23 — see the note on the base map above.
|
||
redhill_godverse: 0xc6f80e18,
|
||
redhill_real: 0x673785ee,
|
||
westend_real: 0xb2c7ddb6,
|
||
};
|
||
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
|
||
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');
|
||
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
|
||
for (const f of townFiles) {
|
||
const key = f.replace(/\.json$/, '');
|
||
let cache; try { cache = JSON.parse(readFileSync(join(TOWNS_DIR, f), 'utf8')); } catch (e) { ok(false, `real/${key}: parses as JSON — ${e.message}`); continue; }
|
||
const v = validateTownCache(cache);
|
||
ok(v.ok, `real/${key}: valid cache` + (v.ok ? '' : ` — ${v.errors.join('; ')}`));
|
||
if (v.warnings.length) console.log(` ⚠ real/${key}: ${v.warnings.join(' · ')}`);
|
||
if (!v.ok) continue;
|
||
for (const s of OSM_SEEDS) structuralSuite(generatePlanOSM(s, key, { cache }), `real/${key} ${s}`);
|
||
for (const s of GIG_SEEDS) districtInvariants(generatePlanFor(s, 'osm', { gigs: true, town: key, cache }), `real/${key} ${s}`);
|
||
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, key, { cache })))() >>> 0;
|
||
const want = REAL_TOWN_GOLDENS[key];
|
||
if (want === undefined) console.log(` ⚠ real/${key}: base UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`);
|
||
else ok(hash === (want >>> 0), `real/${key}: base golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`);
|
||
const ghash = xmur3(JSON.stringify(generatePlanFor(20261990, 'osm', { gigs: true, town: key, cache })))() >>> 0;
|
||
const gwant = REAL_TOWN_GIG_GOLDENS[key];
|
||
if (gwant === undefined) console.log(` ⚠ real/${key}: gig UNPINNED — add REAL_TOWN_GIG_GOLDENS['${key}'] = 0x${ghash.toString(16).padStart(8, '0')}`);
|
||
else ok(ghash === (gwant >>> 0), `real/${key}: gig golden 0x${ghash.toString(16)} matches pinned 0x${(gwant >>> 0).toString(16)}`);
|
||
// ROUND25 ledger #1 — the identity gate, RECONCILED WITH RULING 2. A godverse cache is deliberately
|
||
// MIXED: a keyed GODVERSE layer + an inherited OSM texture layer that G measured to be load-bearing.
|
||
// So every assert runs over the GODVERSE LAYER ONLY (shops carrying an id); an unkeyed texture shop is
|
||
// the design, not a defect. My R24 arm asserted every-shop-keyed and failed 54/72 on a healthy town —
|
||
// it was asserting Ruling 2 away. Still written to the vacuous-gate law: names its subject, proves it
|
||
// touched it with a count, SKIPs loudly rather than passing when the subject is absent.
|
||
if (cache.source === 'godverse+osm') {
|
||
const cacheKeyed = cache.shops.filter(s => Number.isSafeInteger(s && s.godverseShopId) && s.godverseShopId > 0);
|
||
if (!cacheKeyed.length) {
|
||
console.log(` ⊘ SKIP real/${key}: godverse identity — 0/${cache.shops.length} shops carry godverseShopId. ` +
|
||
`Subject absent (the GODVERSE layer is empty). This gate binds the moment one id lands.`);
|
||
} else {
|
||
const seated = generatePlanOSM(20261990, key, { cache }).shops;
|
||
const seatedIds = seated.filter(s => s.godverseShopId !== undefined).map(s => s.godverseShopId);
|
||
const cacheIds = new Set(cacheKeyed.map(s => s.godverseShopId));
|
||
ok(seatedIds.length > 0,
|
||
`real/${key}: GODVERSE layer reaches the plan — ${seatedIds.length}/${seated.length} seated shops keyed (${cache.shops.length - cacheKeyed.length} texture shops legitimately unkeyed)`);
|
||
// uniqueness over DEFINED ids only: the R24 Set-collapse version folded every `undefined` into one
|
||
// entry, so it failed structurally on a mixed cache AND could hide a real duplicate behind them.
|
||
ok(new Set(seatedIds).size === seatedIds.length,
|
||
`real/${key}: godverse ids unique on the plan (${new Set(seatedIds).size}/${seatedIds.length} distinct — no shared atlas)`);
|
||
ok(seatedIds.every(id => cacheIds.has(id)),
|
||
`real/${key}: every seated godverse id came from the cache (the lift invents no identity)`);
|
||
// ORPHANED-ATLAS GATE. The lift legitimately drops shops (overlap-resolve, counted since R19) — but
|
||
// dropping a shop that HAS a committed atlas means real stock keyed to a shop that isn't in the
|
||
// town: exactly the failure R24 predicted and F's #7b would only catch at the end of a round.
|
||
// Subject = the atlas dirs on disk, so it SKIPs by name when this town has none.
|
||
const atlasIds = (existsSync(STOCK_GODVERSE_DIR) ? readdirSync(STOCK_GODVERSE_DIR) : [])
|
||
.filter(d => /^\d+$/.test(d)).map(Number).filter(id => cacheIds.has(id));
|
||
if (!atlasIds.length) {
|
||
console.log(` ⊘ SKIP real/${key}: orphaned-atlas check — no committed atlas under stock_godverse/ keys to this town.`);
|
||
} else {
|
||
const seatedSet = new Set(seatedIds);
|
||
const orphans = atlasIds.filter(id => !seatedSet.has(id));
|
||
ok(orphans.length === 0,
|
||
`real/${key}: no orphaned atlas — ${atlasIds.length} stocked shop(s) on disk, all seated` +
|
||
(orphans.length ? ` (ORPHANED: ${orphans.join(', ')} — real stock keyed to a shop the lift dropped)` : ''));
|
||
}
|
||
// a dropped keyed shop is legitimate but must never be silent (the R19/R20 drop-and-count ruling)
|
||
const dropped = cacheKeyed.filter(s => !new Set(seatedIds).has(s.godverseShopId));
|
||
if (dropped.length) console.log(` ⚠ real/${key}: ${dropped.length} keyed shop(s) dropped by the lift — ${dropped.map(s => `${s.godverseShopId} "${s.name}"`).join(', ')} (unstocked today; would orphan an atlas if stocked)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
|
||
section('assets on disk');
|
||
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);
|
||
for (const f of facadeSet) ok(existsSync(join(ASSETS, f)), `used facade exists: ${f}`);
|
||
|
||
// ── report ──────────────────────────────────────────────────────────────────────────
|
||
console.log(`\n${failures ? '✗ FAIL' : '✓ ALL GREEN'} — ${checks - failures}/${checks} checks passed`);
|
||
const sample = generatePlan(20261990);
|
||
console.log(` sample seed 20261990 → "${sample.name}": ` +
|
||
`${sample.streets.nodes.length} nodes, ${sample.streets.edges.length} edges, ` +
|
||
`${sample.blocks.length} blocks, ${sample.lots.length} lots, ${sample.shops.length} shops`);
|
||
console.log(` fingerprint hash: 0x${(xmur3(JSON.stringify(sample))() >>> 0).toString(16)} (paste into GOLDEN.hash)`);
|
||
|
||
process.exit(failures ? 1 : 0);
|