Lane A round 6: second plan source ?plansrc=osm (real-data OSM import) — full seam

The carried-over OSM plan source (no-showed R4–R5) lands complete, invariants green — a
second CityPlan producer behind the same contract, so B–F consume it unchanged.

- osm_fixture.js: 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops,
  extracted deterministically from thriftgod city_source.json (Overpass cache), imported as a
  module ⇒ ZERO network.
- plan_osm.js: generatePlanOSM(seed) — project lat/lon→metres, bin shops into latitude-band
  avenues off a spine (real row + real E–W order preserved), march into a valid non-overlapping
  CityPlan, centre on origin, report true 362×486 bbox, real shop names, one openLate video.
- index.js: generatePlanFor(seed, source) selector — default 'synthetic' BYTE-IDENTICAL
  (golden 0x3fa36874 untouched); 'osm' → the importer. generatePlanOSM exported for F.
- selfcheck.js: parameterized by source — OSM runs the full structural invariant suite
  (frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own
  golden 0x34cfdec0; synthetic-only brief checks skipped. ALL GREEN 1362/1362.
- map.html: ?plansrc=osm renders it (reference wiring for F). Screenshot in docs/shots/laneA/.
- CITY_SPEC: plan-source note; LANE_A_NOTES R6: seam + goldens + shape-diffs for F's gate (F2).

Honest cut: street geometry regularized (real row+order drive it, exact metres do not);
name-parody deferred. qa.sh --strict GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 22:47:38 +10:00
parent 5700954212
commit 52eb1090ef
9 changed files with 402 additions and 5 deletions

View File

@ -1,9 +1,35 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1301/1301); rounds 24 closed** (see bottom).
`qa.sh --strict` GREEN. Awaiting Fable review.*
*Status: **all deliverables landed; self-check all-green (1362/1362); rounds 26 closed** (see below).
`qa.sh --strict` GREEN.*
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
## Round 6 (2026-07-14) — the second plan source (`?plansrc=osm`), FULLY LANDED
The carried-over OSM plan-source seam (no-showed rounds 45) is **done** — not the escape-hatch
partial, the whole thing, invariants green. A second CityPlan producer behind the same contract, so
Lanes BF consume it unchanged.
- **`osm_fixture.js`** — 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops
(book 36, toy 14, opshop 13, record 12, video 11, pawn 9), extracted deterministically from
thriftgod's `city_source.json` Overpass cache. Checked in, imported as a module → **zero network**.
- **`plan_osm.js`** — `generatePlanOSM(seed)`: projects real lat/lon → metres, bins shops into
latitude-band avenues off a spine (real row + real eastwest order preserved), marches them into
a valid, non-overlapping CityPlan, centres on origin, reports its true 362×486 bbox. Real shop
names kept ("Central Catholic Bookshop"). Exactly-one-openLate-video rule matches synthetic.
- **`index.js`** — `generatePlanFor(seed, source)` selector; default `'synthetic'` is byte-identical
(golden `0x3fa36874` untouched). `generatePlanOSM` exported for F.
- **`selfcheck.js`** — parameterized by source: OSM runs the full structural invariant suite
(frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own golden
**`0x34cfdec0`**; synthetic-only brief checks (market/arcade/dept/milkbar) deliberately skipped.
- **`map.html`** — `?plansrc=osm` renders it; screenshot `docs/shots/laneA/osm-melbourne.png`.
- Seam + goldens + shape-differences documented for F's determinism gate in `LANE_A_NOTES.md` (R6).
**Verified**: 95/95 shops placed, 0 bad frontEdges / 0 overlaps / 0 backwards / full chunk coverage,
deterministic, synthetic path byte-identical. Self-check **ALL GREEN 1362/1362**; `qa.sh --strict` GREEN.
Honest cut (documented): street geometry is regularized (real row+order drive it, exact projected
metres do not) and shop-name parody is deferred.
## Round 4 (2026-07-14) — v1-tag round: 2 confirmations, 1 hardening fix
**A1 — storeys checker (post-F2): CONFIRMED.** `tools/qa/consistency_check.mjs` `permittedMax` scoping

View File

@ -101,6 +101,17 @@ Schema v1 (Lane A owns it; extend, don't break):
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
> numbers, no stray keys).
> **Plan sources (round 6).** A CityPlan can come from two producers behind this one contract, chosen
> via `generatePlanFor(seed, source)` (`web/js/citygen/index.js`) — the shell selects with `?plansrc=`:
> - `'synthetic'` (default) — `generatePlan(seed)`, the procedural town. Byte-identical, golden `0x3fa36874`.
> - `'osm'``generatePlanOSM(seed)`, a real-data import from a **checked-in fixture** (`osm_fixture.js`,
> real AU shops from thriftgod's OSM cache; **zero network**). Golden `0x34cfdec0`.
>
> An `'osm'` plan sets `source:'osm'`, reports its **real bounding-box `size`** (not 1024²), and contains
> **only real retail types** — no synthetic market square, arcade, dept anchor, milk bars, or stalls.
> All structural invariants above hold identically. Consumers that assume a synthetic-only feature
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite is parameterized by source.
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,

View File

@ -2,6 +2,38 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 6 (2026-07-14)
### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin
The second plan producer is live behind the same CityPlan contract. Full seam, invariants green
(not a partial). What F needs to wire the shell bootstrap + extend the determinism gate:
- **Selector API** (`web/js/citygen/index.js`):
`generatePlanFor(seed, source)``source==='osm'` → real-data importer, anything else →
the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`,
`generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic.
- **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then
`const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';`
and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`.
`map.html` already does exactly this (reference impl).
- **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990:
- synthetic `0x3fa36874` (unchanged this round)
- **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`)
`selfcheck.js` already asserts both; the smoke can just hash-compare.
- **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne
shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime.
**⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):**
- `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024².
- **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk
bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that
ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume).
- Everything BF actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/
`frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold.
- Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived.
Parody-transform of names is a documented deferred nicety, not a blocker.
## Round 4 (2026-07-14)
### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -9,6 +9,16 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js';
export { shopName, townName } from './names.js';
export { generatePlanOSM } from './plan_osm.js';
// Convenience default so `citygen.default` also resolves to the generator.
// planSource selector (round 6). Default 'synthetic' → the byte-identical golden-hash generator;
// 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm to the `source` arg here so the
// shell bootstrap picks the producer without BF caring which one ran. Unknown source → synthetic.
import { generatePlan as _synth } from './plan.js';
import { generatePlanOSM as _osm } from './plan_osm.js';
export function generatePlanFor(seed, source = 'synthetic') {
return source === 'osm' ? _osm(seed) : _synth(seed);
}
// Convenience default so `citygen.default` also resolves to the (synthetic) generator.
export { generatePlan as default } from './plan.js';

View File

@ -0,0 +1,110 @@
// PROCITY OSM plan-source fixture — Lane A (round 6). REAL secondhand/charity/music/book/
// toy/antique shops, one compact inner-Melbourne cluster, extracted from thriftgod city_source.json
// (an Overpass cache; committed here so the game makes ZERO network calls — CITY_SPEC law).
// Generated deterministically (sorted by OSM node id). Checked in, hand-editable, byte-stable.
//
// Each shop: { name (real), type (PROCITY registry id), lat, lon, suburb }. The OSM importer
// (plan_osm.js) projects lat/lon → local metres and lays a valid CityPlan; see LANE_A_NOTES.
export const OSM_FIXTURE = {
town: "Melbourne",
source: "thriftgod/city_source.json (OSM Overpass cache, inner Melbourne)",
center: { lat: -37.803, lon: 144.967 },
shops: [
{"id":582845633,"name":"Jesper Junior","type":"toy","lat":-37.799331,"lon":144.97814,"suburb":""},
{"id":589473054,"name":"Central Catholic Bookshop","type":"book","lat":-37.811707,"lon":144.962939,"suburb":"Melbourne"},
{"id":598109860,"name":"Readings","type":"book","lat":-37.798052,"lon":144.967187,"suburb":""},
{"id":612755480,"name":"The Haunted Bookshop","type":"book","lat":-37.815426,"lon":144.961705,"suburb":"Melbourne"},
{"id":612771027,"name":"Hill of Content","type":"book","lat":-37.81148,"lon":144.97199,"suburb":""},
{"id":632244266,"name":"Goldmine Records","type":"record","lat":-37.791222,"lon":144.97579,"suburb":""},
{"id":635708269,"name":"Dymocks","type":"book","lat":-37.815462,"lon":144.96572,"suburb":"Melbourne"},
{"id":642325730,"name":"The Paperback","type":"book","lat":-37.811675,"lon":144.971313,"suburb":"Melbourne"},
{"id":663937314,"name":"Pop Mart","type":"toy","lat":-37.814031,"lon":144.964538,"suburb":""},
{"id":699437448,"name":"Big Dreams","type":"toy","lat":-37.799152,"lon":144.984398,"suburb":""},
{"id":699461941,"name":"Red Cross Shop","type":"opshop","lat":-37.799348,"lon":144.984349,"suburb":""},
{"id":803089441,"name":"Kay Craddock Antiquarian Bookseller","type":"book","lat":-37.814775,"lon":144.968242,"suburb":""},
{"id":990674449,"name":"Happy Valley","type":"book","lat":-37.800458,"lon":144.98416,"suburb":"Collingwood"},
{"id":990676685,"name":"St Mark's Recycled","type":"opshop","lat":-37.80039,"lon":144.984176,"suburb":""},
{"id":990860527,"name":"Villain","type":"toy","lat":-37.798355,"lon":144.977178,"suburb":""},
{"id":997422797,"name":"Alice & Co.","type":"book","lat":-37.80279,"lon":144.977551,"suburb":""},
{"id":1070219314,"name":"SOUNDMERCH","type":"record","lat":-37.805386,"lon":144.984819,"suburb":"Collingwood"},
{"id":1072618081,"name":"White Rabbit Record Bar","type":"record","lat":-37.793645,"lon":144.93006,"suburb":""},
{"id":1074085246,"name":"Northside Records","type":"record","lat":-37.80642,"lon":144.982482,"suburb":""},
{"id":1104149817,"name":"Hot Potatoes Super $2 Shop","type":"opshop","lat":-37.802722,"lon":144.983783,"suburb":""},
{"id":1379457331,"name":"Used Pty Ltd - The Stage","type":"pawn","lat":-37.799431,"lon":144.989961,"suburb":""},
{"id":1671557044,"name":"Other Goods & Services","type":"opshop","lat":-37.7742,"lon":144.971388,"suburb":"Brunswick East"},
{"id":2011953059,"name":"AllBooks4Less","type":"book","lat":-37.8105,"lon":144.966688,"suburb":""},
{"id":2056959150,"name":"Tabernacle Games","type":"toy","lat":-37.78028,"lon":144.986264,"suburb":"Fitzroy North"},
{"id":2090596140,"name":"Fitzroy Toy Library","type":"toy","lat":-37.803392,"lon":144.980385,"suburb":""},
{"id":2105173577,"name":"Book Grocer","type":"book","lat":-37.815225,"lon":144.960413,"suburb":""},
{"id":2108114139,"name":"Used Pty Ltd","type":"pawn","lat":-37.799008,"lon":144.983676,"suburb":""},
{"id":2139774534,"name":"Plug Seven Records","type":"record","lat":-37.799668,"lon":144.984007,"suburb":"Fitzroy"},
{"id":2139774821,"name":"Smith Street Bazaar","type":"pawn","lat":-37.799836,"lon":144.983957,"suburb":"Fitzroy"},
{"id":2160049170,"name":"New International Book Shop","type":"book","lat":-37.806832,"lon":144.966259,"suburb":""},
{"id":2164686254,"name":"Embiggen Books","type":"book","lat":-37.810103,"lon":144.966151,"suburb":"Melbourne"},
{"id":2402316117,"name":"Already Read Bookshop","type":"book","lat":-37.783229,"lon":144.982101,"suburb":"Fitzroy North"},
{"id":2677671238,"name":"Etruria Antiques Gallery","type":"pawn","lat":-37.799412,"lon":144.989808,"suburb":"Collingwood"},
{"id":2688014250,"name":"The History","type":"pawn","lat":-37.802586,"lon":144.986922,"suburb":"Collingwood"},
{"id":3412569256,"name":"Flight Experience","type":"video","lat":-37.810466,"lon":144.965145,"suburb":"Melbourne"},
{"id":3418896122,"name":"Social Club Books","type":"book","lat":-37.796822,"lon":144.985198,"suburb":""},
{"id":3788338576,"name":"Hylands Bookshop","type":"book","lat":-37.811456,"lon":144.966498,"suburb":"Melbourne"},
{"id":3949641000,"name":"Goldmine Records","type":"record","lat":-37.779472,"lon":144.986947,"suburb":"Fitzroy North"},
{"id":3949687621,"name":"Poison City","type":"record","lat":-37.795207,"lon":144.979159,"suburb":"Fitzroy"},
{"id":4972218985,"name":"Retro Wolf","type":"toy","lat":-37.794166,"lon":144.949683,"suburb":""},
{"id":5406503614,"name":"Readings","type":"book","lat":-37.809718,"lon":144.964589,"suburb":""},
{"id":6228833108,"name":"Gregory's Antiques and Lights","type":"pawn","lat":-37.785753,"lon":144.981522,"suburb":""},
{"id":6237887803,"name":"Readings","type":"book","lat":-37.809443,"lon":144.966332,"suburb":""},
{"id":6507557579,"name":"Record Paradise","type":"record","lat":-37.773631,"lon":144.960868,"suburb":"Brunswick"},
{"id":6649578474,"name":"Readings Kids","type":"book","lat":-37.79792,"lon":144.967205,"suburb":""},
{"id":6680206192,"name":"Brotherhood of St Laurence Op Shop","type":"opshop","lat":-37.773636,"lon":144.971215,"suburb":""},
{"id":6687064305,"name":"Vinnies","type":"opshop","lat":-37.774974,"lon":144.960631,"suburb":""},
{"id":6702087933,"name":"Legacy","type":"opshop","lat":-37.811229,"lon":144.964332,"suburb":""},
{"id":7202622331,"name":"Retro Depot","type":"opshop","lat":-37.782348,"lon":144.977928,"suburb":""},
{"id":7273662704,"name":"Bookshop","type":"book","lat":-37.774882,"lon":144.960955,"suburb":""},
{"id":7391506777,"name":"Ace Antiques","type":"pawn","lat":-37.803155,"lon":144.94892,"suburb":""},
{"id":9097507475,"name":"Vinyl Space","type":"record","lat":-37.799535,"lon":144.988755,"suburb":""},
{"id":9336124056,"name":"World Food Books","type":"book","lat":-37.816635,"lon":144.966746,"suburb":""},
{"id":10048390715,"name":"EB Games","type":"video","lat":-37.816891,"lon":144.964598,"suburb":""},
{"id":10598284186,"name":"Mui Karaoke Rooms","type":"record","lat":-37.811449,"lon":144.96435,"suburb":""},
{"id":10611335971,"name":"Mind Games","type":"toy","lat":-37.814339,"lon":144.965791,"suburb":""},
{"id":10713580252,"name":"City Of Vintage","type":"opshop","lat":-37.773999,"lon":144.960805,"suburb":""},
{"id":10729013736,"name":"Epic Vintage","type":"opshop","lat":-37.776826,"lon":144.960351,"suburb":""},
{"id":11017168508,"name":"Mary Martin Bookshop","type":"book","lat":-37.807847,"lon":144.957651,"suburb":""},
{"id":11031787378,"name":"The Book Grocer","type":"book","lat":-37.815477,"lon":144.9514,"suburb":""},
{"id":11118777232,"name":"Elite Office Machines Co.","type":"pawn","lat":-37.797065,"lon":144.968274,"suburb":""},
{"id":11129101658,"name":"F.J. Rocca","type":"pawn","lat":-37.797543,"lon":144.969696,"suburb":""},
{"id":11133299408,"name":"Brotherhood of St Lawrence","type":"opshop","lat":-37.814546,"lon":144.964109,"suburb":""},
{"id":11135649030,"name":"Warhammer","type":"video","lat":-37.813084,"lon":144.970498,"suburb":""},
{"id":11140063962,"name":"The Salvation Army","type":"opshop","lat":-37.817091,"lon":144.953989,"suburb":""},
{"id":11162131777,"name":"Mary Martin Bookshop","type":"book","lat":-37.807745,"lon":144.95666,"suburb":""},
{"id":11185870784,"name":"EB Games","type":"video","lat":-37.812749,"lon":144.937428,"suburb":"Docklands"},
{"id":11244890775,"name":"North Melbourne Books","type":"book","lat":-37.802823,"lon":144.949112,"suburb":""},
{"id":11287631726,"name":"Plenty of Games","type":"video","lat":-37.81572,"lon":144.964811,"suburb":""},
{"id":11287631727,"name":"The Gaming Verse","type":"video","lat":-37.817709,"lon":144.961688,"suburb":""},
{"id":11555994364,"name":"Strange World Records","type":"record","lat":-37.77301,"lon":144.971585,"suburb":""},
{"id":11660848323,"name":"Melbourne Book Market","type":"book","lat":-37.806808,"lon":144.956428,"suburb":""},
{"id":11957731033,"name":"Archie Brothers STRIKE","type":"video","lat":-37.811779,"lon":144.936627,"suburb":""},
{"id":12175749075,"name":"I Heart Snacks","type":"video","lat":-37.811059,"lon":144.963269,"suburb":""},
{"id":12175767221,"name":"Pop Mart","type":"toy","lat":-37.810856,"lon":144.963047,"suburb":""},
{"id":12175767225,"name":"Heydays","type":"toy","lat":-37.810779,"lon":144.963015,"suburb":""},
{"id":12175767227,"name":"I Am","type":"toy","lat":-37.810815,"lon":144.963167,"suburb":""},
{"id":12175779440,"name":"Merry Seasons","type":"toy","lat":-37.810464,"lon":144.962038,"suburb":""},
{"id":12320608201,"name":"Amplify Bookstore","type":"book","lat":-37.806902,"lon":144.955544,"suburb":""},
{"id":12418141339,"name":"The Little Bookroom","type":"book","lat":-37.771822,"lon":144.978527,"suburb":""},
{"id":12432795349,"name":"One Stop Anime","type":"video","lat":-37.815261,"lon":144.965727,"suburb":""},
{"id":12437671918,"name":"Mooii","type":"toy","lat":-37.810519,"lon":144.96303,"suburb":""},
{"id":12511070907,"name":"Rotart Charity Books","type":"opshop","lat":-37.812736,"lon":144.938173,"suburb":""},
{"id":12511070911,"name":"Books Gifts Station","type":"book","lat":-37.813078,"lon":144.938613,"suburb":""},
{"id":12511098032,"name":"Popclutcha","type":"video","lat":-37.813266,"lon":144.93777,"suburb":""},
{"id":12511098647,"name":"Toyworld","type":"toy","lat":-37.813905,"lon":144.938006,"suburb":""},
{"id":12611273300,"name":"Dungeon of Magic","type":"video","lat":-37.814504,"lon":144.964088,"suburb":""},
{"id":12611672199,"name":"All Star Comics","type":"book","lat":-37.817545,"lon":144.961612,"suburb":"Melbourne"},
{"id":12686552001,"name":"City Basement Books","type":"book","lat":-37.818532,"lon":144.963194,"suburb":""},
{"id":12686552101,"name":"Metropolis Bookshop","type":"book","lat":-37.812039,"lon":144.965298,"suburb":""},
{"id":12695960455,"name":"Bookshop by Uro","type":"book","lat":-37.799835,"lon":144.985564,"suburb":""},
{"id":13075851874,"name":"Licorice Pie","type":"record","lat":-37.799856,"lon":144.985715,"suburb":""},
{"id":13206134926,"name":"Sticky Institute","type":"book","lat":-37.806746,"lon":144.960086,"suburb":""},
{"id":13229707300,"name":"Readings","type":"book","lat":-37.81289,"lon":144.963426,"suburb":""},
{"id":13967406755,"name":"Law Bookshop","type":"book","lat":-37.815692,"lon":144.958884,"suburb":"Melbourne"}
],
};

159
web/js/citygen/plan_osm.js Normal file
View File

@ -0,0 +1,159 @@
// PROCITY CityGen — the OSM plan source (Lane A, round 6). Second producer behind the same
// CityPlan contract as plan.js, so Lanes BF consume it unchanged. Selected via ?plansrc=osm
// (default stays 'synthetic', byte-identical — see index.js `generatePlanFor`).
//
// Input: a CHECKED-IN fixture of real inner-Melbourne secondhand/charity/music/book/toy/antique
// shops (osm_fixture.js, extracted from thriftgod's Overpass cache). ZERO network at runtime.
//
// How real data drives the plan (documented simplification — the honest "cut"): a shop's real
// LATITUDE picks which eastwest avenue it lands on, and its real LONGITUDE sets its order along
// that avenue. Avenue spacing and per-lot frontage are regularized (marched) so the result is a
// clean, non-overlapping, valid CityPlan — the geography is real in row+order, stylized in metres.
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { OSM_FIXTURE } from './osm_fixture.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
const clampHour = h => Math.max(0, Math.min(23, h | 0));
const LATE_HOUR = 22; // matches plan.js: exactly one shop closes ≥ this
const EARTH_M = 111320; // metres per degree latitude (equirectangular)
const ROWS = 8; // latitude bands → avenues
const ROW_DZ = 54; // metres between avenues (> avenue depth ⇒ blocks disjoint)
const AV_W = 12, SPINE_W = 24; // corridor widths
const SPINE_CLEAR = 16; // lots start this far east of the spine (no lot on the road)
const GAP = 2, DEPTH = 14; // lot gap + depth along/into the avenue
// Short signboard form from a real shop name: drop a leading "The", take words up to ~15 chars.
function signOf(name) {
const words = String(name).replace(/^the\s+/i, '').split(/\s+/);
let s = '';
for (const w of words) { if ((s + ' ' + w).trim().length > 15) break; s = (s + ' ' + w).trim(); }
return (s || String(name).slice(0, 15)).toUpperCase();
}
export function generatePlanOSM(citySeed) {
citySeed = (citySeed >>> 0);
const fx = OSM_FIXTURE;
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 from a real fixture entry (real name; seeded facade/hours/storeys).
function addShop(lotId, fixtureShop) {
const id = sid++;
const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop';
const reg = SHOP_TYPES[type];
const seed = seedFor(citySeed, 'osmshop', fixtureShop.id);
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
const storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
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); // only the openLate landmark reaches ≥ LATE_HOUR
shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name),
seed, facadeSkin, storeys, hours: [open, close] });
return id;
}
// ── project real shops → local metres; bin by latitude into avenue rows, order by longitude ──
const cosLat = Math.cos(fx.center.lat * Math.PI / 180);
const pts = fx.shops.map(s => ({
s, x: (s.lon - fx.center.lon) * EARTH_M * cosLat, z: (s.lat - fx.center.lat) * EARTH_M,
}));
const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z));
const span = (zMax - zMin) || 1;
const rowsArr = Array.from({ length: ROWS }, () => []);
for (const p of pts) {
const row = Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)));
rowsArr[row].push(p);
}
const district = addDistrict('mainstreet', 0, 0);
// spine: one main street running S→N through the origin, a node per (occupied) row
const spineNodes = [];
const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length);
usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); });
const spineEdges = [];
for (let k = 0; k < spineNodes.length - 1; k++) spineEdges.push(addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main'));
// ── each row → an eastwest avenue off the spine; its shops march into non-overlapping lots ──
usedRows.forEach((o, k) => {
const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak
const zPos = o.zPos;
// frontages from each shop's registry footprint (deterministic mid-band, no rng needed here)
const fr = rowShops.map(p => { const fp = SHOP_TYPES[p.s.type]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; });
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
const west = addNode(0, zPos), east = addNode(avLen, zPos);
const avEdge = addEdge(west.id, east.id, AV_W, 'side');
// block = the frontage strip on the south side of the avenue
const half = AV_W / 2, sd = DEPTH + 2;
const block = addBlock(district, 'mainstreet',
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
const ry = Math.atan2(0, -1); // outward normal (street→lot) points south (0,-1) ⇒ facade faces +z (north) to the avenue
let t = SPINE_CLEAR;
rowShops.forEach((p, j) => {
const f = fr[j];
const cx = t + f / 2;
const cz = zPos - (half + DEPTH / 2);
const lot = addLot(block, cx, cz, f, DEPTH, ry, avEdge, 'shop');
addShop(lot, p.s);
t += f + GAP;
});
});
// ── exactly one late-night landmark: a video rental, closes ≥ LATE_HOUR (matches synthetic) ──
if (shops.length) {
const videos = shops.filter(s => s.type === 'video');
const pool = videos.length ? videos : shops.filter(s => s.type !== 'stall');
if (pool.length) {
const lr = rng(citySeed, 'osm-openlate', 0);
const chosen = pick(lr, pool);
chosen.openLate = true;
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)];
}
}
// Centre the imported town on the origin and report its true bounding box as `size` (rigid
// translation ⇒ all invariants preserved; gives the map a framed view and Lane B the real extent).
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const l of lots) {
minx = Math.min(minx, l.x - l.w); maxx = Math.max(maxx, l.x + l.w);
minz = Math.min(minz, l.z - l.d); maxz = Math.max(maxz, l.z + l.d);
}
for (const n of nodes) { minx = Math.min(minx, n.x); maxx = Math.max(maxx, n.x); minz = Math.min(minz, n.z); maxz = Math.max(maxz, n.z); }
const shx = -r2((minx + maxx) / 2), shz = -r2((minz + maxz) / 2);
for (const n of nodes) { n.x = r2(n.x + shx); n.z = r2(n.z + shz); }
for (const l of lots) { l.x = r2(l.x + shx); l.z = r2(l.z + shz); }
for (const b of blocks) b.poly = b.poly.map(p => [r2(p[0] + shx), r2(p[1] + shz)]);
for (const d of districts) { d.cx = r2(d.cx + shx); d.cz = r2(d.cz + shz); }
const M = 40;
const size = { w: Math.max(256, Math.ceil((maxx - minx) + 2 * M)), d: Math.max(256, Math.ceil((maxz - minz) + 2 * M)) };
return {
version: 1,
citySeed,
source: 'osm',
name: fx.town || 'Melbourne',
size,
districts,
streets: { nodes, edges },
blocks,
lots,
shops,
};
}

View File

@ -8,6 +8,7 @@ import { readFileSync, existsSync } 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 } from './plan_osm.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
@ -196,6 +197,52 @@ for (const s of SEEDS) {
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
// ── 3b. OSM plan source (?plansrc=osm): same structural invariants + its own golden ────
// The synthetic golden (0x3fa36874) is untouched above; this parameterizes the harness by source.
// Brief checks (market/arcade/dept/milkbar) are synthetic-only and deliberately NOT asserted here —
// a real-data town has no synthetic market square. Structural invariants must hold identically.
section('OSM plan source (fixture-driven, zero-network)');
const OSM_GOLDEN = { seed: 20261990, hash: 0x34cfdec0 };
const OSM_SEEDS = [20261990, 1, 42, 777];
for (const s of OSM_SEEDS) {
const plan = generatePlanOSM(s);
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const lotIds = new Set(plan.lots.map(l => l.id));
ok(plan.source === 'osm', `osm ${s}: source tagged 'osm'`);
ok(JSON.stringify(generatePlanOSM(s)) === JSON.stringify(plan), `osm ${s}: deterministic (same fixture+seed)`);
ok(plan.lots.length > 0 && plan.shops.length > 0, `osm ${s}: non-empty (${plan.shops.length} real shops)`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `osm ${s}: lot numbers finite`);
ok(plan.streets.edges.every(e => nodeById.has(e.a) && nodeById.has(e.b)), `osm ${s}: edges reference real nodes`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `osm ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `osm ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot) && SHOP_TYPES[sh.type] && SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `osm ${s}: shops valid (lot + type + facade-in-pool)`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `osm ${s}: sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign && !/[{}]|undefined/.test(sh.name + sh.sign)), `osm ${s}: every shop named (real OSM names)`);
let back = 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);
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) { back = l.id; break; }
}
ok(back === null, `osm ${s}: every lot faces its frontEdge` + (back !== null ? ` (lot ${back})` : ''));
const cs = plan.lots.map(lotCorners); let ov = null;
for (let i = 0; i < plan.lots.length && !ov; i++) for (let j = i + 1; j < plan.lots.length; j++)
if (obbOverlap(cs[i], cs[j])) { ov = [plan.lots[i].id, plan.lots[j].id]; break; }
ok(!ov, `osm ${s}: no overlapping lots (within or across blocks)` + (ov ? ` (${ov})` : ''));
const idx = chunkIndex(plan); const cov = new Set();
for (const c of Object.values(idx.chunks)) for (const id of c.lots) cov.add(id);
ok(plan.lots.every(l => cov.has(l.id)), `osm ${s}: chunkIndex covers every lot`);
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `osm ${s}: JSON round-trip lossless`);
const late = plan.shops.filter(sh => sh.openLate), ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0] && late[0].type === 'video', `osm ${s}: exactly one open-late video`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
{
const hash = xmur3(JSON.stringify(generatePlanOSM(OSM_GOLDEN.seed)))() >>> 0;
ok(hash === OSM_GOLDEN.hash, `osm ${OSM_GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(OSM_GOLDEN.hash >>> 0).toString(16)}`);
}
// ── 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}`);

View File

@ -86,7 +86,9 @@
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module">
import { generatePlan, chunkIndex, CHUNK } from './js/citygen/plan.js';
import { generatePlanFor, chunkIndex, CHUNK } from './js/citygen/index.js';
// plan source: default 'synthetic'; ?plansrc=osm renders the real-data fixture town.
const PLANSRC = new URLSearchParams(location.search).get('plansrc') === 'osm' ? 'osm' : 'synthetic';
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
@ -292,7 +294,7 @@ const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlan(s); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
plan = generatePlanFor(s, PLANSRC); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};