Lane A round 8: OSM to parity + a second town (Katoomba) — carried R7 brief, full
The carried brief (slipped R7) lands complete — all four items, qa.sh --strict GREEN.
1. Selfcheck PARITY: extracted structuralSuite(plan,label) and run the FULL invariant suite
on every (source, town) — synthetic + both OSM towns get identical coverage (storeys,
one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism,
exactly-one-openLate). ALL GREEN 1727/1727 (was 1362; OSM went subset → full ×2 towns).
2. SECOND TOWN: Katoomba (Blue Mountains, 19 shops, op-shop/book heavy) — regional contrast
to inner-Melbourne. osm_fixture.js → OSM_TOWNS{melbourne,katoomba}+DEFAULT_TOWN;
generatePlanFor(seed, source, {town}); ?plansrc=osm&town=katoomba. Golden 0x0f652510.
Melbourne 0x34cfdec0 + synthetic 0x3fa36874 UNCHANGED (town key kept off the plan).
3. "Add a town" recipe in LANE_A_NOTES (R8): extract → OSM_TOWNS → pin golden → tell F.
4. Normalization + LOG: importer bends real data to the contracts (unknown kind→opshop,
hours-clamp so only one shop is openLate, video-preferred landmark w/ fallback) and logs
it via {report}; selfcheck prints per-town (e.g. melbourne hoursClamped:5, openLate:video).
For F: three goldens to pin, gate keyed (seed, plansrc, town); selector gained {town} — see
LANE_A_NOTES R8. Screenshots docs/shots/laneA/osm-{melbourne,katoomba}.png.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a667283cf3
commit
f952f513d0
@ -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
|
||||
|
||||
|
||||
@ -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/<key>: fingerprint 0x…`, add `<key>: 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
|
||||
|
||||
BIN
docs/shots/laneA/osm-katoomba.png
Normal file
BIN
docs/shots/laneA/osm-katoomba.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@ -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=<key>) 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.
|
||||
|
||||
@ -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=<key>. 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];
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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 ──────────────────
|
||||
|
||||
@ -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. -->
|
||||
<script type="module">
|
||||
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';
|
||||
// plan source: default 'synthetic'; ?plansrc=osm renders a real-data fixture town, ?town=<key> picks it.
|
||||
const _q = new URLSearchParams(location.search);
|
||||
const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
|
||||
const TOWN = _q.get('town') || undefined;
|
||||
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
|
||||
|
||||
// ── palette ─────────────────────────────────────────────────────────────────────────
|
||||
@ -294,7 +296,7 @@ const pad=h=>String(h).padStart(2,'0')+':00';
|
||||
// ── panel ─────────────────────────────────────────────────────────────────────────
|
||||
function regen(seed){
|
||||
const s = (seed>>>0);
|
||||
plan = generatePlanFor(s, PLANSRC); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
|
||||
plan = generatePlanFor(s, PLANSRC, { town: TOWN }); 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={};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user