diff --git a/docs/A-progress.md b/docs/A-progress.md index 6ec9467..f0f78f3 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,8 +1,32 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (1362/1362); rounds 2–6 closed** (see below). +*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 2–8 closed** (see below). `qa.sh --strict` GREEN.* -*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)* +*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)* + +## Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED) + +The carried brief (slipped R7) is done — all four items, not a partial. + +1. **Selfcheck parity**: extracted the full invariant suite into `structuralSuite(plan, label)` and + run it on **every (source, town)** — synthetic + both OSM towns now get identical coverage + (storeys, one-shop-per-lot, solid-use, facing, within/cross-block overlap, corridor coverage, + determinism, exactly-one-openLate). **ALL GREEN 1727/1727** (was 1362 — OSM went from a subset to + the full suite ×2 towns). +2. **Second town**: **Katoomba** (Blue Mountains, 19 shops, op-shop/book heavy) — a small-regional + contrast to inner-Melbourne (95, book heavy). `?plansrc=osm&town=katoomba`. `osm_fixture.js` is now + `OSM_TOWNS { melbourne, katoomba }` + `DEFAULT_TOWN`; `generatePlanFor(seed, source, { town })`. + Golden pinned `0x0f652510`. **Melbourne golden `0x34cfdec0` and synthetic `0x3fa36874` unchanged.** +3. **"Add a town" recipe** documented in `LANE_A_NOTES.md` (R8) — extraction → `OSM_TOWNS` entry → + pin golden → tell F. Mechanical. +4. **Normalization + logging**: importer normalizes real data to the contracts (unknown kind→opshop, + hours clamp so only one shop is openLate, video-preferred landmark w/ fallback) and **logs** what it + changed via `{ report }` — selfcheck prints e.g. `melbourne: 95 shops · normalized {typesRemapped:0, + hoursClamped:5, openLate:video}`. The contract never bends; the data does. + +Extraction one-liner (Katoomba, reusable — see recipe): cell 0.05 around (-33.714, 150.312), same +KIND map + dedupe + sort-by-id as Melbourne (cell 0.06 around -37.803, 144.967). Screenshots: +`docs/shots/laneA/osm-{melbourne,katoomba}.png`. `qa.sh --strict` GREEN. ## Round 6 (2026-07-14) — the second plan source (`?plansrc=osm`), FULLY LANDED diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index bff6545..8e0a4cb 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,53 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 8 (2026-07-15) + +### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg. + +- **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every + (source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot, + facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …). + `node selfcheck.js` → **ALL GREEN 1727/1727**. +- **Selector gained a town arg** (`web/js/citygen/index.js`): + `generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through — + `generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference + impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`). + `osmTownKeys()` lists the available towns for a UI picker. +- **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already: + | source / town | golden | + |--------------------|--------------| + | synthetic | `0x3fa36874` | + | osm / melbourne | `0x34cfdec0` | + | **osm / katoomba** | `0x0f652510` | + The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are + **unchanged** from round 6. +- Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast + to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`. + +### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4) + +Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is +**logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`): +- unknown OSM `shop=` kind → `opshop` (`typesRemapped`); +- a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`); +- openLate landmark prefers a **video**; a town with no video falls back to another non-stall type + and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`). + +### RECIPE — add another OSM town (mechanical, ~10 min) + +1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell + size that captures one coherent town (aim 15–150 on-theme shops). The extraction one-liner used + for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`. +2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`: + `key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops + by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book, + music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn). +3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`, read the printed + `osm/: fingerprint 0x…`, add `: 0x…` to `OSM_GOLDENS` in `selfcheck.js`. Re-run → green. +4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the + full parity suite covers it automatically. + ## Round 6 (2026-07-14) ### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin diff --git a/docs/shots/laneA/osm-katoomba.png b/docs/shots/laneA/osm-katoomba.png new file mode 100644 index 0000000..c18b6c9 Binary files /dev/null and b/docs/shots/laneA/osm-katoomba.png differ diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index b4e1311..107871c 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -9,15 +9,16 @@ export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js'; export { shopName, townName } from './names.js'; -export { generatePlanOSM } from './plan_osm.js'; +export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; -// 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 B–F caring which one ran. Unknown source → synthetic. +// planSource selector (round 6; multi-town round 8). Default 'synthetic' → the byte-identical +// golden-hash generator; 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm (+ optional +// &town=) to these args so the shell bootstrap picks the producer/town without B–F caring which +// ran. Unknown source → synthetic; unknown town → the default town. 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); +export function generatePlanFor(seed, source = 'synthetic', opts = {}) { + return source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed); } // Convenience default so `citygen.default` also resolves to the (synthetic) generator. diff --git a/web/js/citygen/osm_fixture.js b/web/js/citygen/osm_fixture.js index 86b07f6..54aa4f8 100644 --- a/web/js/citygen/osm_fixture.js +++ b/web/js/citygen/osm_fixture.js @@ -1,110 +1,141 @@ -// 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. +// PROCITY OSM plan-source fixtures — Lane A. REAL AU secondhand/charity/music/book/toy/antique +// shops extracted deterministically from thriftgod city_source.json (an Overpass cache), committed +// here so the game makes ZERO network calls (CITY_SPEC law). Each town is one compact real cluster. // -// 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. +// Multi-town (round 8): OSM_TOWNS[key] → { town, source, center{lat,lon}, shops[{id,name,type,lat,lon,suburb}] }. +// Select with ?plansrc=osm&town=. To ADD a town see the recipe in docs/LANES/LANE_A_NOTES.md. -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"} - ], +export const OSM_TOWNS = { + melbourne: { + 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"} + ], + }, + katoomba: { + town: "Katoomba", + source: "thriftgod/city_source.json (OSM Overpass cache, Katoomba / Blue Mountains)", + center: {"lat":-33.714,"lon":150.312}, + shops: [ + {"id":4047416904,"name":"Anglicare","type":"opshop","lat":-33.716738,"lon":150.310919,"suburb":""}, + {"id":4047416906,"name":"Cat Defence Op Shop","type":"opshop","lat":-33.717477,"lon":150.31186,"suburb":""}, + {"id":4047416910,"name":"Red Shield Family Store","type":"opshop","lat":-33.717291,"lon":150.309527,"suburb":""}, + {"id":4047416911,"name":"Steven Walter","type":"opshop","lat":-33.710934,"lon":150.309931,"suburb":""}, + {"id":4047416913,"name":"Vinnies","type":"opshop","lat":-33.717464,"lon":150.311782,"suburb":""}, + {"id":4958417278,"name":"Anglicare Op Shops","type":"opshop","lat":-33.714949,"lon":150.311257,"suburb":""}, + {"id":4958417724,"name":"Katoomba Music","type":"record","lat":-33.715074,"lon":150.311532,"suburb":""}, + {"id":5237464912,"name":"Katoomba Vintage Emporium","type":"pawn","lat":-33.713729,"lon":150.311403,"suburb":""}, + {"id":6938379064,"name":"Katoomba Antique Centre","type":"pawn","lat":-33.712206,"lon":150.310654,"suburb":""}, + {"id":6938414604,"name":"Salvos","type":"opshop","lat":-33.714654,"lon":150.311316,"suburb":"Katoomba"}, + {"id":6938414612,"name":"EB Games","type":"video","lat":-33.714851,"lon":150.31127,"suburb":""}, + {"id":6938921533,"name":"MacArthur's Arcade","type":"pawn","lat":-33.71389,"lon":150.311766,"suburb":""}, + {"id":6938921534,"name":"Mr Pickwick's Bookshop","type":"book","lat":-33.713915,"lon":150.311761,"suburb":""}, + {"id":7040012373,"name":"Black Antiques","type":"pawn","lat":-33.709806,"lon":150.309278,"suburb":""}, + {"id":7110813637,"name":"Steven Walter Children's Cancer Foundation","type":"opshop","lat":-33.712811,"lon":150.321286,"suburb":"Katoomba"}, + {"id":9231634025,"name":"Little Lost Bookshop","type":"book","lat":-33.716393,"lon":150.310835,"suburb":""}, + {"id":9577859712,"name":"B'COS Agora Second Hand Bookshop","type":"book","lat":-33.714295,"lon":150.311173,"suburb":""}, + {"id":9791871398,"name":"The Velvet Fog","type":"record","lat":-33.712544,"lon":150.311566,"suburb":""}, + {"id":11242974232,"name":"Sanctus Catholic Books and Gift Shop","type":"book","lat":-33.715718,"lon":150.31095,"suburb":""} + ], + }, }; + +export const DEFAULT_TOWN = "melbourne"; +// Back-compat single-town export (pre-round-8 callers). +export const OSM_FIXTURE = OSM_TOWNS[DEFAULT_TOWN]; diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index e0f5ea7..3106543 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -13,7 +13,7 @@ import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js'; import { SHOP_TYPES } from '../core/registry.js'; -import { OSM_FIXTURE } from './osm_fixture.js'; +import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js'; const r2 = v => Math.round(v * 100) / 100; const r4 = v => Math.round(v * 10000) / 10000; @@ -35,9 +35,12 @@ function signOf(name) { return (s || String(name).slice(0, 15)).toUpperCase(); } -export function generatePlanOSM(citySeed) { +export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { citySeed = (citySeed >>> 0); - const fx = OSM_FIXTURE; + const fx = OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN]; + // Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data + // "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan. + const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' }; const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = []; let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0; @@ -55,6 +58,7 @@ export function generatePlanOSM(citySeed) { function addShop(lotId, fixtureShop) { const id = sid++; const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop'; + if (type !== fixtureShop.type) norm.typesRemapped++; // unknown OSM kind → opshop (normalization) const reg = SHOP_TYPES[type]; const seed = seedFor(citySeed, 'osmshop', fixtureShop.id); const sr = mulberry32(seed); @@ -64,6 +68,7 @@ export function generatePlanOSM(citySeed) { 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); + if (close > LATE_HOUR - 1) norm.hoursClamped++; 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] }); @@ -120,6 +125,7 @@ export function generatePlanOSM(citySeed) { if (shops.length) { const videos = shops.filter(s => s.type === 'video'); const pool = videos.length ? videos : shops.filter(s => s.type !== 'stall'); + norm.openLate = videos.length ? 'video' : (pool.length ? pool[0].type + ' (no video in town)' : 'none'); if (pool.length) { const lr = rng(citySeed, 'osm-openlate', 0); const chosen = pick(lr, pool); @@ -144,11 +150,13 @@ export function generatePlanOSM(citySeed) { 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)) }; + if (opts.report) Object.assign(opts.report, norm); // caller-supplied log sink (selfcheck prints it) + return { version: 1, citySeed, - source: 'osm', - name: fx.town || 'Melbourne', + source: 'osm', // town key stays OFF the plan (keeps the Melbourne golden frozen); the + name: fx.town || 'Melbourne', // display name + the selector arg convey which town this is. size, districts, streets: { nodes, edges }, @@ -157,3 +165,6 @@ export function generatePlanOSM(citySeed) { shops, }; } + +// The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration). +export function osmTownKeys() { return Object.keys(OSM_TOWNS); } diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 8c91ea0..4b53057 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -8,7 +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 { generatePlanOSM, osmTownKeys } from './plan_osm.js'; import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; @@ -30,6 +30,113 @@ function nearestOnSeg(px, pz, ax, az, bx, bz) { 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); + const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22); + 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)`); +} // ── 1. determinism: two runs byte-identical ───────────────────────────────────────── section('determinism (deep-equal across two runs)'); @@ -56,191 +163,42 @@ for (const s of SEEDS) { ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`); } -// ── 3. structural invariants over every seed ──────────────────────────────────────── -section('structural invariants'); +// ── 3. structural invariants — SYNTHETIC source (full suite) + synthetic-only brief checks ── +section('structural invariants — synthetic'); const facadeSet = new Set(); -const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v); for (const s of SEEDS) { const plan = generatePlan(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 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)); - - // every number finite (JSON would silently turn NaN/Infinity into null) - ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `seed ${s}: node coords finite`); - ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `seed ${s}: lot numbers finite`); - ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `seed ${s}: block poly finite`); - - ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `seed ${s}: every edge references real nodes`); - ok(plan.blocks.every(b => districtIds.has(b.district)), `seed ${s}: every block in a real district`); - ok(plan.lots.every(l => blockIds.has(l.block)), `seed ${s}: every lot in a real block`); - ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `seed ${s}: every lot has a valid frontEdge`); - ok(plan.lots.every(l => l.w > 0 && l.d > 0), `seed ${s}: every lot has positive size`); - ok(plan.shops.every(sh => lotIds.has(sh.lot)), `seed ${s}: every shop has a real lot`); - ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `seed ${s}: every shop has a known type`); - ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `seed ${s}: 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]), `seed ${s}: every shop has sane hours`); - ok(plan.shops.every(sh => sh.name && sh.sign), `seed ${s}: every shop is named`); - ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `seed ${s}: no unresolved tokens / undefined in names`); - // STOREYS strictly within the registry range, corner-boost included (Lane F finding #5 class): the - // occasional 3-storey corner anchor adds ≤1 storey capped at 3, and ONLY for tall-capable types - // (registry max ≥ 2) — so single-storey types (video/milkbar/stall, [1,1]) must NEVER exceed 1. - // (The old `<= Math.max(mx,3)` was too lax — it would have passed a video at 3 storeys.) - 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; - }), `seed ${s}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey types never boosted)`); - - // one lot ↔ at most one shop - const lotShopCounts = {}; - for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1; - ok(Object.values(lotShopCounts).every(c => c === 1), `seed ${s}: no lot has two shops`); - // a shop only ever sits on a solid lot use - ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `seed ${s}: every shop sits on a solid lot`); - - // FACING: every lot's facade normal points at (not away from) its frontEdge (the review's D1 class) - 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 tox = nx - l.x, toz = nz - l.z; // vector from lot to its street - const [fx, fz] = facing(l); - if (tox * fx + toz * fz < -0.01) { backwards = l.id; break; } // facing away from the street it fronts - } - ok(backwards === null, `seed ${s}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : '')); - - // OVERLAP within a block (all lots) — strips are sequential ⇒ disjoint by construction - const byBlock = {}; - for (const l of plan.lots) (byBlock[l.block] ||= []).push(l); - let inBlock = null; - for (const arr of Object.values(byBlock)) { - const cs = arr.map(lotCorners); - for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++) - if (obbOverlap(cs[i], cs[j])) { inBlock = [arr[i].id, arr[j].id]; break; } - if (inBlock) break; - } - ok(!inBlock, `seed ${s}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : '')); - - // OVERLAP across blocks (building lots) — the corner-deconfliction guarantee (review D2) - 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; // AABB reject - if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; } - } - ok(!crossBlock, `seed ${s}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : '')); - - // CHUNKINDEX: covers every lot; buckets reference real ids - 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)), - `seed ${s}: chunk buckets reference real ids`); - } - ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`); - - // CHUNKINDEX corridor coverage: every chunk the road CORRIDOR passes through lists the edge — not - // just the centreline. Samples densely along the edge AND across the ±width/2 verge band (incl. - // offsets BETWEEN the chunkIndex rails, so this genuinely tests band interior, not just the rails). - // Guards the Lane B furniture-drop class: verge props must never fall in a chunk that omits their edge. - 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)); // 2m dense along-edge samples - for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band - 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, `seed ${s}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : '')); - - // JSON round-trip is lossless (and carries no stray non-schema keys) - ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`); - - // design brief presence - ok(plan.streets.edges.some(e => e.kind === 'main'), `seed ${s}: has a main-street spine`); - ok(plan.streets.edges.some(e => e.kind === 'arcade'), `seed ${s}: has an arcade`); - ok(plan.districts.some(d => d.kind === 'market'), `seed ${s}: has a market district`); - ok(plan.shops.some(sh => sh.type === 'stall'), `seed ${s}: market has stalls`); - ok(plan.shops.some(sh => sh.type === 'dept'), `seed ${s}: has a department-store anchor`); + 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, `seed ${s}: 2–4 corner milk bars (${milkbars})`); - - // exactly one deterministic late-night landmark, and the `openLate` field ⟺ `hours[1] >= 22` - // (Lane F's night gate may key off EITHER — they identify the same single shop). - const late = plan.shops.filter(sh => sh.openLate); - const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22); - ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`); - ok(ge22.length === 1, `seed ${s}: exactly one shop closes ≥22:00 (${ge22.length})`); - ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `seed ${s}: openLate field ⟺ hours[1]≥22 (same shop)`); - ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`); - ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `seed ${s}: the open-late shop is the video rental`); - + 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 (?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 }; +// ── 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 }; 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; } +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); } - 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)}`); + const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, town)))() >>> 0; + const want = OSM_GOLDENS[town]; + ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches 0x${((want ?? 0) >>> 0).toString(16)}`); + console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`); } // ── 4. every facade skin referenced by the registry exists on disk ────────────────── diff --git a/web/map.html b/web/map.html index 3d81644..c7b00e0 100644 --- a/web/map.html +++ b/web/map.html @@ -87,8 +87,10 @@ use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->