Compare commits

...

8 Commits

Author SHA1 Message Date
m3ultra
55d5c634d9 Fable (integrator): round-19 instructions — the v4.0-alpha close (fragmentation ruling, BIG_CITY fix, the gate)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:06:18 +10:00
m3ultra
db33faa628 Lane D R18 (v4.0-alpha): citizens on real bones — audit + fragmentation finding (ledger #5)
Audited the citizen sim on real irregular street topology. E landed katoomba_real's
real roads (484 ways -> 872 nodes/799 edges) mid-round, so this is measured on the
real Katoomba graph plus a crafted irregular proxy.

VERDICT: the CityPlan graph contract IS the contract — zero sim changes. On real
Katoomba (7.2deg junctions, 1303m edge, 105 components): 0 NaN over 1,095,500 samples,
peds footpath-perfect (0 off-footpath), loiter/bench-sit/patronage all fire, chunk
streaming works over non-grid, determinism byte-identical (1167 identity sigs, R14
chunk-local law holds). No source touched.

FILED FOR A: katoomba_real's graph is 105 components — main holds 58% of street-metres
+ 18/20 shops, but 42% of street-metres + 2 shops strand (~10% of near-main crowd
presence lands on tiny fragments). Primary cause is expected: median fragment 71.5m
from main = peripheral/bbox-clipped roads. Secondary (corroborated with Lane B on A's
selfcheck fixture): per-way Douglas-Peucker drops collinear junction points + no
mid-edge split — bites only the 9/85 fragments <10m from main. Reconciles with B:
B verified 126 intersections form (main component); I add the global measurement
(still 105 components). A fidelity knob (charter risk #4), not a blocker; sim degrades
gracefully (no crash). A owns the connect-tolerance decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:42:06 +10:00
m3ultra
d4fcf68860 Lane B round 18 (v4.0-alpha REAL ROADS): irregular-geometry audit (ledger #4) — the failure list
Audited katoomba_real on E's real roads (schema v2, 484 OSM ways -> 799-edge/872-node graph, 126 real
intersections, edges 3.5m-1303m incl 28 short <12m, ~4x5km span). E's roads landed mid-audit; verified
fresh Chromium, cache-busted. Docs-only (no code change: streetscape is road-agnostic).

VERDICT: Lane B streetscape is ROAD-AGNOSTIC — boots real Katoomba 0-error, every B system builds and
renders on the irregular graph (ground, furniture 39 chunks/148 trees, town-wide pools, tram, chunk
streaming, buildings, wind). Real main street reads well (road+footpath+lamps+working gig poster+citizens).
Like C's interiors, needed no code change to eat real geometry.

FINDING -> F (gate-blocking, PROVEN one-line fix): the BIG_CITY adaptive heuristic misfires on real roads.
index.html BIG_CITY = plan.shops.length>120 -> Katoomba has 20 shops(<120) but 799 edges/5km, so it stays
small-town mode (radius 3 + shadows ON), streams 50-52 chunks -> busiest venue block ~280k tris (clean,
settled) = BREACHES 200k gate (draws 150 <300). Proven fix: ?r=2&shadows=0 (BIG_CITY mode) -> 84k day /
104k night, 20 chunks, 72 draws, well under. Recommend: BIG_CITY = shops>120 || edges>200 (catches
Katoomba's 799, leaves synthetic Boolarra's 22). B's R3 adaptive-streaming concept, edge-count not shop-count.

Tram (Fable's flagged spine risk): degrades gracefully, no crash. spinePolyline runs one main chain =
3527m/49 edges = 21% of 232 mains (longest walkable chain). Arbitrary-but-functional for alpha; smarter
route or hard fence = beta (no plan.mode marker exposed to fence off; didn't add untested fencing).

Furniture/pools scale: town-wide pools ~4989 instances = still ~1 draw; 28 short edges get 0 lamps
(graceful). Correction: the disconnected graph I first saw was a synthetic-fixture DP artifact (junction
vertices dropped on near-collinear ways) — real Katoomba forms 126 intersections correctly; flagged to A.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:35:55 +10:00
m3ultra
10db373e67 Lane E R18 (v4.0-alpha): the other 4 towns' road geometry (data-only) + Overpass retry
Newtown/Fremantle/Bendigo/Castlemaine now schema v2 with roads[] (1086/622/1552/397 real streets),
same fidelity cut (street tier only). Data-only: only katoomba gates the round; A hardens against these.
build_towns.py: added Overpass retry/backoff (public API 504s under load — hit one mid-run).

The poster-clearance finding is SYSTEMATIC: it reproduces on all 5 real-road towns, every seed (23
checks, -0.9 to -4.7m), confirming it's plan-level poster placement (A's pickVenues/POSTER_CLEAR), not
town data. Scoping holds: only the real/* v2 path fails; synthetic + fixtures + classic + default boots
stay green. A's one fix generalizes to all five. ODbL: raw roads committed, SOURCES.md current.

-> Lane A: all 5 towns' roads live; the systematic poster finding is yours to resolve + re-pin goldens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:32:10 +10:00
m3ultra
dbb52c2390 Lane E R18 (v4.0-alpha): katoomba_real real road geometry (schema v2, ledger #3)
Extend build_towns.py: bounded way[highway](bbox) out geom per town -> raw cached in-repo -> roads[]
in A's v2 contract ({id,kind,pts:[[lat,lon]]}, kind=raw OSM highway class). katoomba_real is now
schema v2 with 484 real streets (Bathurst Rd, Cliff Drive, ...). Fidelity cut (charter risk #4, flagged
to A): fetch every ROAD_KIND tier -> raw; EMIT the town street tier only, excluding service/track/
footway/path/steps/cycleway (1461 of 2506 Katoomba ways = bushwalking tracks/sidewalks/stairs/driveways).

A's lift builds a real town from it (verified): 484 streets -> 872 nodes/799 edges (main 232 side 531
arcade 36) -> 12 blocks, 20 lots, 3 venues (pub/band_room/rsl), 12 gigs. pickVenues lands venues on the
real graph (ledger #2 met).

FINDING for A (alpha failure-list, charter risk #3): on the real graph, spine posters overhang crossing
carriageways -- selfcheck kerb-clearance fails on 4 seeds (-1.6 to -2.5m; e.g. seed 20261990 poster 50 vs
edge 58 = -2.42m). Same class as A's R15/R16 corner-poster fix, now on real cross-street geometry. Owner:
A (pickVenues/POSTER_CLEAR on the real graph, the 'A again' harden step) -- my roads data is VALID (cache
validates; failures are plan-level poster placement). Golden settles after A's fix (pre-fix 0x6014a8e6).
ODbL: raw roads committed, SOURCES.md updated.

-> Lane A: katoomba roads live; harden the lift + resolve the poster finding + re-pin the golden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:27:55 +10:00
m3ultra
c7525a682a Lane C R18 (v4.0-alpha): interior spot-soak of katoomba_real (ledger #6)
Entered every katoomba_real shop the way the shell does (registerTownCache ->
generatePlanFor osm, gigs on = default boot -> buildInterior per shop, lot dims
from plan.lots). All 20 doors PASS: 0 throws / 0 carves / 0 path-fails / 0
over-budget, worst 153 draws (<=350); 7 types incl. the 3 gig venues (pub/rsl/
band_room) all built valid.

Degenerate-lot stress (preempts A's real-road re-seating): 32 extreme-lot x type
combos (incl. 0x0, 1x1, thin slivers) -> 0 throws/carves/path-fails; every room
floors at >=4x4 m. The interior contract absorbs any real frontage.

Honest caveat: current katoomba_real.json is schema v1 (roads:none) -> marched
fallback (E's roads cache not committed yet). Interiors are road-agnostic (lot dims
+ type + seed only), so the conclusion holds once roads seat shops on real edges;
one-line re-run if lot dims shift. Interior lane: 0 failures for the alpha list.
Docs-only (verification of shipped behaviour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:18:46 +10:00
m3ultra
a78dc76f84 Lane A round 18: the real-graph lift — OSM ways → CityPlan street graph (v4.0-alpha REAL ROADS)
The epoch headline (ledger #2). A v2 cache's roads[] now builds the CityPlan street graph FROM real
OSM geometry instead of marching shops onto synthetic avenues. SAME CityPlan schema out — B/C/D/F consume
it unchanged by contract. roads absent ⇒ the v1 marched fallback, so classic/gig/osm goldens are FROZEN.

plan_osm.buildRealRoads: project ways+shops to the local metre frame → simplify each way (Douglas-Peucker
eps~6m, the fidelity knob) → snap coincident points (<=3m) to shared nodes = real intersections →
straight-segment edges → classify main by OSM highway class + shop density → seat each shop on its NEAREST
real edge (real order, real side), marched with regularised spacing + a deterministic overlap-resolve pass.

Fidelity (charter risk #4): real edge/order/side + regularised spacing — NOT pixel positions (real shops
overlap; the no-overlap invariant is load-bearing). Alpha failure list in LANE_A_NOTES §R18: overflow drop
on short edges; intersections = shared-coordinates only (a D sim-graph risk); per-edge frontage-strip
blocks not planar faces (a B ground-render item); crowded-intersection overlap-resolve.

Proven: a committed synthetic irregular graph (main + two cross streets + a real intersection + an acute
angle + a dead-end lane + a curve) passes the FULL structuralSuite + gig district + byte-identical
determinism; mutation-tested non-vacuous (flip the facade facing → 'faces its frontEdge' fails). Robust on
messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched). All osm
goldens frozen; E's 4 trailing caches pinned on v1; katoomba_real UNPINNED (its real-roads golden pins when
E lands roads — the A->E->A finalization). selfcheck ALL GREEN 16810/16810; scaffold + consistency GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:10:14 +10:00
m3ultra
4dc80c2146 Lane A round 18: town-cache schema v2 (roads[]) — published FIRST (v4.0-alpha REAL ROADS)
The half-day handshake so E's build_towns.py can fetch road geometry (ledger #1). Optional roads[]
on the town cache carries real OSM way geometry; plan_osm's graph lift (consuming it) follows in the
next commit. v1 caches (roads absent) stay valid — the marched fallback — so nothing shipped regresses.

- validateTownCache accepts schema procity-town-cache/1 AND /2; when roads[] is present each road must be
  { kind:string, pts:[[lat,lon],…] } with >=2 finite points (else rejected); <2 pts dropped, unmapped
  highway kind → 'side' (warnings). ROAD_KIND maps OSM highway=* → CityPlan edge kind (main/side/lane/arcade).
- Full v2 contract in web/assets/towns/README.md (the doc E builds to): shape, road validity, the kind map,
  the fidelity knob (charter risk #4), and the bbox-bound-the-roads rule.
- E's 5 existing v1 caches re-validate clean; selfcheck ALL GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:54:19 +10:00
23 changed files with 950925 additions and 78 deletions

View File

@ -3,7 +3,37 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-16 (round 17) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-16 (round 18) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 18, v4.0-alpha REAL ROADS) — interior spot-soak of katoomba_real (ledger #6)
R18 §Lane C (small): enter every `katoomba_real` shop and assert valid interiors + no carve + budgets.
Built the town plan the way the shell does — `registerTownCache(katoomba_real)` → `generatePlanFor(seed,
'osm', {town, gigs:true})` (gigs on = the default boot) → `buildInterior` per shop (lot dims resolved from
`plan.lots`). **All 20 doors PASS:**
| metric | result |
|---|---|
| shops entered | **20 / 20 built** |
| throws · carves · path-fails · over-budget | **0 · 0 · 0 · 0** |
| worst draws | **153** (shop 16, opshop/hall) ≤ 350 |
| types resolved | opshop · book · record · video + venues pub · rsl · band_room |
| gig venues (default boot) | 3 (ids 3/5/19) — all built valid gig interiors (stage + watchPoints) |
| draw range | 108153 |
**Degenerate-lot stress (preempts A's real-road re-seating):** 32 combos (8 extreme lots {2×3, 1×1, 3×30,
40×4, 100×100, **0×0**, 4.2×4.1, 2.5×18} × 4 types incl. venues) → **0 throws · 0 carves · 0 path-fails**;
every room floors at **≥4×4 m** (`computeDims Math.max(4, lot0.6)`). So whatever frontage the real-graph
lift seats shops on, the interior contract absorbs it.
**Caveat (honest):** audited against the CURRENT `katoomba_real.json` — schema v1, `roads: none`, so it boots
the **marched fallback** (E's katoomba roads cache isn't committed yet; A's lift `a78dc76` is in). Interiors
are **road-agnostic** (they consume lot dims + type + seed, never road geometry), and the degenerate-lot proof
covers any lot the real graph produces, so the conclusion is stable. **Interior lane contributes 0 failures to
the alpha's failure list.** A one-line re-run once E's `roads[]` cache lands (lot dims may shift with real
frontage) — I'll re-confirm if asked, but the interior contract already absorbs the range. C otherwise on-call.
---

View File

@ -1,10 +1,42 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 217 closed** (see below).
v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs
2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).*
*Status: **all deliverables landed; self-check all-green (16810/16810); rounds 218 closed** (see below).
v4.0-alpha REAL ROADS: cache schema v2 (roads[]) published first + the real-graph lift (OSM ways →
CityPlan street graph) implemented, proven on a synthetic irregular graph. All osm/classic/gig goldens
frozen; katoomba_real's real-roads golden pins when E's roads land (A→E→A).*
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 + the real-graph lift (the epoch headline)
Lane A is the round's long pole (serial spine A→E→A). Two commits:
**Commit 1 — cache schema v2 (`roads[]`), published FIRST** (`4dc80c2`) so E's `build_towns.py` can fetch
road geometry. A v2 cache adds `roads:[{kind,pts:[[lat,lon],…]}]` (`kind` = OSM `highway=*`);
`validateTownCache` accepts schema `…/1` AND `…/2` (roads absent ⇒ marched fallback, nothing regresses);
`ROAD_KIND` maps the class to a CityPlan edge kind. Full contract in `web/assets/towns/README.md`.
**Commit 2 — the real-graph lift** (`plan_osm.buildRealRoads`): real OSM ways → the CityPlan street graph,
**same schema out** (B/C/D/F consume it unchanged). project → simplify (DouglasPeucker ε≈6 m) → snap
coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify `main`
by OSM class + shop density → seat each shop on its nearest real edge (real order/side), marched +
overlap-resolved. **Passes the full structuralSuite + gig district + determinism** on a committed synthetic
irregular graph (main + two cross streets + intersection + acute angle + dead-end lane + curve). Robust on
messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched fallback).
Frozen (the alpha changes only the roads path): classic `0x3fa36874`, gig `0xb1d48ea1`, osm melbourne/
katoomba/silverton, + E's 4 trailing real caches pinned on v1 (bendigo/castlemaine/fremantle/newtown).
`katoomba_real` stays UNPINNED — its golden re-pins to the real-ROADS output when E lands its roads (the
A→E→A finalization). selfcheck ALL GREEN **16810/16810**; scaffold + consistency GREEN.
Fidelity decision (charter risk #4): real edge + real order + real side + regularised spacing — NOT
pixel-exact (real shops overlap; no-overlap is load-bearing). Alpha failure list (LANE_A_NOTES §R18): (1)
overflow drop on short edges (`norm.dropped`); (2) intersections = shared coordinates only (crossings
without a shared OSM node → disconnected edges — a D sim-graph risk); (3) blocks are per-edge frontage
strips not planar faces (a B ground-render audit item); (4) crowded-intersection overlap-resolve drop.
Handshake: schema v2 → E (done). "katoomba_real boots with real roads, golden pinned" → B/C/D fires when
E's roads land (katoomba is v1/marched until then). B/C/D audit prep isn't blocked on me.
## Round 17 (2026-07-16) — v3.2 THE SCOUT: the town-cache contract + plan_osm hardened for real data
Lane A ran **first** (E's `build_towns.py` builds to the contract). Scope fence held: **no new plan

View File

@ -368,6 +368,28 @@ auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig su
**Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/
`attribution` fields; raw Overpass responses cached so re-runs never re-fetch).
### Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS)
> The gap the scout left: the marched fallback lays real shops onto **synthetic** parallel avenues.
> Schema v2 closes it — a cache carries the town's **real OSM street geometry** and `plan_osm` builds the
> CityPlan street graph FROM it. **Opt-in by the cache** (a v2 cache with `roads[]` = real roads; a v1
> cache, or `roads` absent, = the marched fallback), so default + classic boots are untouched by
> construction (they never load a town cache) and every shipped cache stays valid.
A **v2** cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` — simplified OSM ways, `kind` the
OSM `highway=*` class (`validateTownCache` accepts schema `…/1` and `…/2`; a road needs ≥2 finite points,
`ROAD_KIND` maps the class to a CityPlan edge kind). The graph lift (`plan_osm.buildRealRoads`):
1. **project** ways + shops to the local metre frame; **simplify** each way (DouglasPeucker, ε≈6 m — the
fidelity knob, charter risk #4);
2. **snap** coincident points (≤3 m) to shared nodes = **real intersections**; straight-segment **edges**;
3. **classify** `main`/`side` from the OSM class, promoting the shop-densest edge to the `main` spine if
none came from OSM;
4. **seat** each shop on its **nearest real edge** at its real order + real side, marched with regularised
spacing (real shops overlap — pixel-exact positions can't satisfy the no-overlap invariant), a
frontage-strip block per edge, then a deterministic **overlap-resolve** pass (crowded intersections drop
the later-id lot). **Same CityPlan schema out** — B/C/D/F consume it unchanged by contract; the golden
re-pins per the amendment law (`katoomba_real`'s when E's roads land; synthetic + fixtures untouched).
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for

View File

@ -2,6 +2,49 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift
### → E: **schema v2 is live (committed first) — fetch roads to it**
A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full
contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid →
marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox
as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM
already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds
+ validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden.
### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph
Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline:
project → simplify (DouglasPeucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real
intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on
its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full
structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets
(a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched
fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run
against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden).
### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list
I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions,
because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all
assume it). Written down for the alpha (fixed or fenced):
1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops
drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep
all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.*
2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node
become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph
may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared
coords at junctions (OSM convention) so most are fine.
3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads
these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a
measurement (it's my lot geometry to fix, not yours).
4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in
`norm.dropped`.
### → B/C/D: handshake state
`katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with
real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the
golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph
lift + the failure list above tell you exactly what irregular geometry to point your audits at.
## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data
### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake)

View File

@ -1,5 +1,58 @@
# LANE B — NOTES (measured)
## Round 18 (Fable's ROUND18 → Lane B) — irregular-geometry audit (ledger #4, v4.0-alpha REAL ROADS)
**The deliverable is the failure list.** Audited `katoomba_real` on E's real roads (schema v2, **484 OSM
ways → a 799-edge / 872-node graph, 126 real intersections** (99 deg-3, 27 deg-4), edges **3.5 m1303 m
(28 short <12 m)**, span **~3945 × 4944 m**). E's roads cache landed mid-audit; verified fresh Chromium,
cache-busted (the shell force-caches town JSON).
### Verdict: Lane B's streetscape is ROAD-AGNOSTIC — it consumes the CityPlan graph generically and survives
Real Katoomba boots with **0 page errors**; every B system builds and renders on the irregular graph —
ground strips, furniture (39 chunks / 148 trees), town-wide pools, tram, chunk streaming (`chunks.count`
scales), buildings, wind. The real main street reads well (road + footpath + streetlamps + a working gig
poster + citizens; `docs/shots` — the Screaming Utes bill renders on real bones). Like C's interiors, the
streetscape needed **no code change** to eat real geometry. The real risks are elsewhere (below).
### FINDING → F (gate-blocking, proven one-line fix): the `BIG_CITY` adaptive heuristic misfires on real roads
`index.html` sets `BIG_CITY = plan.shops.length > 120` → radius + shadow-pass. Real-roads towns invert
the assumption: Katoomba has **20 shops (< 120)** but a **799-edge / 5 km** graph, so it stays small-town
mode (**radius 3 + sun-shadow ON**) and streams **5052 chunks** → the busiest venue block hits
**~280k tris (clean load, settled) — BREACHES the 200k gate** (draws 150, still < 300).
**Proven fix** — boot with `?r=2&shadows=0` (= what BIG_CITY mode would give): venue block drops to
**84k tris day / 104k night, 20 chunks, 72 draws** — comfortably under. **Recommend**: extend the
heuristic to the graph, e.g. `BIG_CITY = plan.shops.length > 120 || (plan.streets?.edges?.length||0) > 200`
(catches Katoomba's 799; leaves synthetic Boolarra's 22 untouched). One line, index.html; the concept is
B's adaptive-streaming (LANE_B_NOTES R3) extended from shop-count to edge-count. This is the charter's
mega-strip/density risk in Lane B's budget.
### Tram (spine assumption — Fable's flagged risk): degrades gracefully, no crash — fence-ready, not fenced
`spinePolyline` walks one main chain from a degree-1 node. On real Katoomba (232 main edges) it runs a
**3527 m / 49-edge chain = 21% of mains** (the longest walkable chain — plausibly the real main road);
the other mains form other chains with no tram. **No crash**, deterministic, ≤2 draws as ever. Arbitrary-
but-functional for the alpha. A smarter route (pick the shop-adjacent chain) or a hard fence (tram off on
real-roads towns) is a **v4.0-beta** call — no clean `plan.mode` marker is exposed to key a fence off, so
I did **not** add untested fencing this round (alpha philosophy: surface + document). Fence-ready via the
same `edges.length > 200` signal if beta wants it.
### Furniture / pools scale to real geometry (no break)
Town-wide pools = **~4989 instances** on Katoomba (all in the ONE InstancedMesh → still **~1 draw**;
built upfront, fogged beyond the streaming radius). Lamp cadence = ~4906 town-wide. **28 short edges
(<12 m) get 0 lamps** (the `for(s=8; s<len-4; …)` loop graceful sparsity, not a crash). No fix needed.
### Correction — the "disconnected graph" I first saw was a SYNTHETIC-FIXTURE artifact, NOT real Katoomba
Stress-testing A's `selfcheck` roads fixture first (before E's real cache landed) produced a **fully
disconnected graph** (4 edges / 8 nodes / all deg-1 / 0 intersections) — DouglasPeucker flattened its
near-collinear coordinates and dropped the junction vertices. **Real Katoomba does NOT have this**
(126 intersections form correctly). So it's a fixture-degeneracy note, not a real-town finding — but
worth flagging to A that DP can drop junctions on near-straight ways (charter risk #4, fidelity).
### For F's alpha gate (assertion list, all green on real Katoomba except the budget finding)
boots 0-error · graph has real intersections (deg ≥ 3 present) · my modules all build · budget **needs
the BIG_CITY fix** (else 280k > 200k at the venue block; with it, 104k) · tram no-crash · pools ~1 draw.
---
## Round 17 (Fable's ROUND17 → Lane B) — wind sway SHIPS (ledger #5, parked since R7)
**Verdict: SHIPPED, not killed.** The R7 park reason ("touches shared materials") was tested against

View File

@ -6,6 +6,72 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 18 — v4.0-alpha REAL ROADS: citizens on real bones (ledger #5)
*PROCITY-D, 2026-07-16. The audit: does the citizen sim behave on real (irregular) street topology?
E landed `katoomba_real`'s real road geometry (484 OSM ways → 872 nodes / 799 edges) mid-audit, so
this is measured on the **real Katoomba graph**, not just a proxy. In-shell: fresh `CitizenSim` built
on `plan.streets`, driven headless-of-render (rAF throttled). No source changes — the finding is the
verdict + the failure list.*
### Verdict: the graph contract IS the contract — the sim needs ZERO changes
The sim's graph-walk is topology-agnostic by construction (seeded random walk over nodes/edges, dead-end
U-turn), so real geometry should just work. It does. Proven on a **crafted irregular town** (deg-4
crossroads, 3 T-junctions, 5 dead-ends, a 30° acute fork, a cycle) AND the **real Katoomba graph**
(7.2° acute junction, a 1303 m edge, 105 components):
| assertion | crafted irregular | **real Katoomba** |
|---|---|---|
| NaN/Inf ped positions | 0 / 240k | **0 / 1,095,500** |
| off-footpath samples (ped beyond its own edge band) | 0 / 240k | **0** (maxDist 12.9 m = main offset) |
| edges traversed | 11 / 11 | full main network |
| peds pinned at dead-ends | 0 | 0 |
| loiter fires | 154 ev | **174 ev** |
| **bench-sit fires (R17)** | 47 ev | **60 ev** |
| patronage fires (stream mode) | 165 visits | **24 visits, 4.3k inside-frames** |
| chunk streaming over non-grid | 7 live rosters / 93 active | **13 chunks / 307 active** |
| determinism (2 sims, same seed) | 0/120k pos mismatch; identity set visit-order-independent | **byte-identical rerun; 1167 sigs** |
Identity signature format is intact (`chunkKey#i:pedIndex:pvar:height:speed:edge:forward`), no `sit`
term — my R17 `benchsit` stream doesn't perturb it. **Nothing in the sim needed touching for real roads.**
### The measured leak → FILED FOR A: the real-roads graph is fragmented
`katoomba_real`'s street graph comes out in **105 disconnected components**. The main component holds
**58% of street-metres (52.8 km) and 18 of 20 shops** — the core town is coherent — but **42% of street
metres and 2 shops strand** on 102 islands (9 single nodes, 76 fragments of 24 nodes, 17 of 520).
Live cost to citizens: **~10% of near-main crowd presence** lands on tiny fragments (aimless wandering,
can't reach the main street or its shops); the 2 stranded shops get no main-crowd traffic.
- **Primary cause — expected, not a bug:** on real Katoomba the median fragment is **71.5 m** from the
main net → **mostly peripheral / bbox-clipped roads and genuinely-separate service ways**, which is
what an OSM extract in a bounded box always contains. The main town is coherent; this is the tail.
- **Secondary mechanism (corroborated with Lane B):** A's `buildRealRoads` runs DouglasPeucker **per-way
before snapping**, so a junction point that is collinear on the through-way gets **dropped**, with no
mid-edge split where one way's endpoint lands on another way's segment — a near-straight through-street
with a mid-way junction can lose it → the crossing way strands. I hit this **three times** crafting the
test town (fixed by modelling junctions as shared way-endpoints, as OSM does); **Lane B independently
hit the same DP-collinear drop** on A's `selfcheck` fixture (LANE_B_NOTES §18). On *real* Katoomba this
bites only the **9 of 85** tiny fragments that sit <10 m from the main network (20 within 30 m) the
ones a snap-tolerance / endpoint-split pass could legitimately join. A **fidelity knob** (charter risk #4).
- **Reconciles with Lane B:** B verified real Katoomba's **126 intersections form correctly** (= my 27
deg-4 + 99 deg-3 junction nodes) and concluded connectivity is fine — true *for the main component*. My
add is the **global** measurement B didn't take: those 126 junctions live in a 480-node main component,
but the full graph is still 105 components with 42 % of street-metres on islands. Both true, compatible.
- **The sim degrades gracefully** — fragment peds just walk their island; **zero NaN, no crash**. This is
A's graph-construction call (connect-tolerance / mid-edge split), not a sim change. A D-side mitigation
(bias spawns toward the main component) is a **beta** item and needs A's connectivity decision first.
### Handshakes
- **D → A:** the fragmentation measurement above (105 components, 42% street-metres + 2 shops stranded,
root cause + the 9 close-fragments that could be joined). Your knob, your call.
- **D → F (gate):** the sim is clean on the real graph — no D-side gate concern. Bench-sit + loiter +
patronage + determinism all hold on `katoomba_real`. When you re-run the town matrix with the roads
dimension, the citizen path is green from my side.
- **Re-run note:** the whole audit is parameterized on `plan.streets`; it re-runs on any town's real
graph in one call (used it on both the crafted town and real Katoomba this round).
---
## ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1)
*PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files

View File

@ -1,5 +1,53 @@
# LANE E — cross-lane notes
## Round 18 — v4.0-alpha REAL ROADS: the road-geometry fetch ⟨v4-alpha⟩
> Ledger #3 — E fetches the real street geometry; A's lift builds the CityPlan graph from it.
> **`katoomba_real` is now schema v2 with 484 real streets** (the alpha town). → **Lane A: roads are
> live; harden the lift, resolve the surfaced poster-clearance finding below, re-pin the golden.**
**The data.** `build_towns.py` extended with a bounded `way["highway"~…](bbox); out geom;` per town →
raw cached in `_raw/<key>.roads.overpass.json` (fetch once, John's standing Overpass auth) → `roads[]`
in A's v2 contract (`{id, kind, pts:[[lat,lon],…]}`; `kind` = raw OSM highway class, A's `ROAD_KIND`
maps it, full geometry shipped for A's DouglasPeucker). **katoomba_real: 484 streets** (residential
243, tertiary 65, trunk 58, secondary 51, primary 32, unclassified 23, pedestrian 12 — real names:
Bathurst Rd, Cliff Drive, Civic Place, …), 5344 points.
**Fidelity cut (charter risk #4 — flagged to A, who owns the knob).** I FETCH every `ROAD_KIND`-mapped
tier (→ raw cache, full provenance / beta-ready) but EMIT only the **town street tier** — drivable
roads + pedestrian malls — EXCLUDING `service`/`track`/`footway`/`path`/`steps`/`cycleway`. In
Katoomba's 3 km bbox that excluded tier is **1461 of 2506 ways** (Blue-Mountains bushwalking tracks,
sidewalks, stairs, driveways, parking aisles) — noise for a town street graph and a ~2500-edge blow-up.
The full set is in the raw cache if A wants a tier back (one filter change). This is the honest cut.
**A's lift builds a real town from it** (verified, `generatePlanFor` seed 20261990): 484 streets →
**872 nodes / 799 edges** (main 232, side 531, arcade 36) → 12 blocks, 20 lots, 20 shops, **3 venues
(pub/band_room/rsl), 12 gigs**. `pickVenues` lands venues on the real graph — ledger #2 requirement met.
**⚠ FINDING for Lane A (the alpha's failure-list deliverable — charter risk #3).** On the real graph,
**spine posters overhang a crossing carriageway**: selfcheck's kerb-clearance gate fails on 4 seeds
(spine poster clears its cross-edge by **1.6 to 2.5 m**; e.g. seed 20261990 poster 50 vs edge 58 =
2.42 m). Same CLASS as A's R15/R16 corner-poster fix, now on **real** cross-street geometry — a
venue's frontage seats on the spine where a real side street crosses, so its poster reaches over that
carriageway. **Owner: A** (`pickVenues` / `POSTER_CLEAR` on the real graph — the "A again" harden
step). **My roads data is valid** — the v2 cache validates (roads accepted); the 4 failures are
plan-level poster placement, not cache errors. Not a fidelity artifact either: the crossing streets are
real primary town streets, so trimming roads wouldn't fix it. Golden settles after A's fix (pre-fix
value `0x6014a8e6`). Handshake E→A: **katoomba roads live + this finding**.
**Other 4 towns** (data-only; A hardens against them, only katoomba gates): all now v2 —
newtown_real 1086 roads, bendigo_real 1552, fremantle_real 622, castlemaine_real 397 (dense/regional
towns → bigger graphs; real data, A's DP + density classification handle it). Roads are ODbL, raw
committed, `SOURCES.md` updated. (Robustness: `build_towns.py` gained Overpass retry/backoff — the
public API 504s under load.)
**The finding is SYSTEMATIC** — the spine-poster clearance fails identically on **all 5** real-road
towns, every seed (23 checks, 0.9 to 4.7 m). That it reproduces across five different real street
grids confirms it's **plan-level poster placement, not town data** → squarely A's (`pickVenues` /
`POSTER_CLEAR`). Scoping holds: **only the `real/*` v2 path fails — synthetic + fixtures + classic +
default boots are all green** (roads[] is the only changed path, per the alpha law). A's one fix
generalizes to all five.
## Round 17 — v3.2 THE SCOUT: real town caches + the verify fix ⟨v3.2 amendment⟩
> The real-map scout for **v4 THE REAL MAP** (John's pick) — bounded data groundwork, no new game

View File

@ -0,0 +1,95 @@
# PROCITY — Round 19 lane instructions (from Fable, integrator)
Date: 2026-07-16 · **R18 verdict: the alpha did exactly what the charter asked — it surfaced the
failure list. NOT yet tagged, and correctly so.** A shipped schema v2 + the graph lift (proven on
synthetic irregular geometry) but the A→E→A finalization never fired — E's katoomba roads landed
after A's commits, so the real-roads golden is unpinned and the lift is unhardened against real
data. B/C/D audited against E's landed data anyway (right call) and the audits are excellent:
**B** — the streetscape is road-agnostic, real Katoomba boots 0-error and reads well, but the
`BIG_CITY` heuristic misfires (20 shops < 120 small-town streaming on a 799-edge/5 km graph
**280k tris, breaching the 200k gate**; proven one-line fix). **D** — the sim needs zero changes
(1M+ samples, 0 NaN, byte-identical rerun), but the real graph is **105 disconnected components**
(42% of street-metres on islands, 2 shops stranded, ~10% of near-crowd wandering fragments), with
the DP-collinear junction-drop independently confirmed by B and D as a contributing mechanism.
**C** — 20/20 real doors build valid interiors. **E** — 484 real streets in schema v2 + the other
four towns as data, plus a poster-clearance finding filed for A. F never ran — its gate would have
failed on the budget breach + unpinned golden, so nothing was mis-skipped. This is the alpha
working as designed: surface, measure, file.
Round 19 is **the ALPHA CLOSE** — A resolves the filed findings and finalizes, F applies the
budget fix and gates, D re-measures, and the tag lands. Tag **`v4.0-alpha`** at close.
## The ledger
1. **A — the finalization (the round's spine; everything hangs off you).**
a. **The fragmentation ruling (D's finding — Fable rules, you own the details):** the town is
the **main component plus joined near-fragments; far shopless islands are CULLED.**
Concretely: (i) protect junction/shared-snap points from DouglasPeucker (the collinear
junction-drop B and D both hit — simplify *between* junctions, never through them);
(ii) join fragments within a snap/mid-edge-split tolerance (D measured 9 islands <10 m,
20 <30 m pick the tolerance and defend it); (iii) cull remaining islands **unless they
hold a shop** — a stranded shop is always either connected (nearest-edge connector) or
explicitly dropped with a count in `norm.dropped`. Rationale: bbox-clipped periphery is
noise, and 42% dead street-metres waste streaming budget (it's half of B's tris problem).
b. **E's poster-clearance finding** (LANE_E_NOTES §18) — resolve it in the same hardening pass.
c. **Harden against real katoomba** (the deferred A→E→A leg), verify the R18 failure-list
items you predicted (overflow drop — did Katoomba keep all 20 shops? crossing-without-
shared-node — did any survive into the graph?), **pin `katoomba_real`'s real-roads golden**,
and fire the handshake.
d. **Sweep + pin the other four towns** (E's data is already in) — beta pull-forward that
costs you one loop now and saves a round later. Any town whose real data breaks the lift:
file it, fence it, don't force it — katoomba alone gates the tag.
2. **F — B's `BIG_CITY` fix + the alpha gate (last).**
a. The one-liner is yours (`index.html`): extend the heuristic to the graph —
`plan.shops.length > 120 || (plan.streets?.edges?.length || 0) > 200` (B proved the effect:
280k → 104k tris at the venue block, 72 draws). Land it early so D/B re-measures see it.
b. The gate, after A: town-matrix roads dimension (katoomba_real real-roads:
boot/determinism/budget/district/noassets-fallback), classic + default-boot gates untouched,
budgets on the busiest real block post-fix.
c. **The money shot:** an uncommitted `docs/shots/v4_alpha/katoomba_mainstreet.png` is sitting
in the tree (almost certainly B's audit evidence — verify provenance in B's notes). Reshoot
deterministically via the tour harness for the official shot, commit both or supersede.
d. Docs (README "real towns" paragraph, LANE_F_NOTES §18/§19 honest record incl. why F
correctly didn't run in R18), tag **`v4.0-alpha`**, push (standing authorization).
3. **D — re-measure after A's connectivity pass.** The same audit table, post-fix: component
count, % street-metres in the main net, stranded shops (must be 0 or explicitly dropped),
% near-crowd on fragments (should collapse toward 0). One table, one verdict line. Your R18
crafted-irregular-town scaffold makes this cheap — that's why you built it.
4. **B — the town selector (beta pull-forward, NON-blocking for the tag).** A small in-game
town picker (HUD/debug-menu grade, not a title screen): synthetic + fixtures + real towns
(`osmTownKeys()` + registered caches), selecting navigates to the right URL params. Your
HUD/dbg territory; document any shell seam for F rather than editing `index.html` (F owns it).
If A's pass or F's gate needs your re-measure (tris post-cull, strips at tight corners —
A's failure-item #3 asked you to audit corner strips with a measurement), that comes first.
5. **C — on-call.** Your alpha work is done (20/20). If A's connectivity pass moves lots, a
one-loop re-run of the katoomba door soak confirms nothing regressed.
6. **E — on-call.** Re-emit caches only if A's junction handling needs different data (e.g.
pre-DP full geometry is already shipped — likely nothing). Your data discipline this epoch
has been faultless; nothing else asked.
## Laws
Charter laws (V4_REAL_MAP.md) unchanged. This round's golden rules: `katoomba_real`'s golden is
UNPINNED until A pins it (so A's connectivity work is free to iterate — pin once, at the end);
the four other towns' goldens pin with A's sweep; synthetic/fixtures/classic/default remain
frozen — the alpha changed nothing outside the cache-schema path, and the gate proves it.
## Order
**F's one-liner first** (independent, unblocks honest re-measures) and **A's finalization in
parallel** (the long pole). Then **D re-measures**, **C spot-confirms**, **B's selector** rides
alongside. **F gates last** once A pins and D confirms. Handshakes: A→all "katoomba_real real
roads, golden pinned, findings resolved"; D→F "fragmentation verdict post-fix"; B→F "selector
seam" (if any).
## Standing
- **John — nothing blocks this round.** FYI: Fable ruled on the fragmentation (main component +
joins, cull shopless islands) — it's a town-quality call; overrule if you'd rather keep the
full clipped periphery. The tag lands as `v4.0-alpha` at close.
- **v4.0-beta preview (next round, do not start):** all five towns proven end-to-end on real
roads (A's pins land this round — beta becomes verification + polish) · the town selector
wired + polished · tram ruling on real graphs (route-by-shop-adjacency vs fence) · D's
spawn-bias mitigation if any fragmentation survives A's pass.
- **Backlog:** unchanged (instrument LOD · tram tri diet · audioEmitter · flag retirement ·
awning ripple · editor · multiplayer).

View File

@ -17,7 +17,7 @@ attribution ships IN every cache (`license`/`attribution`) + `SOURCES.md`. Hours
registry supplies them at plan time (contract shop = {id,name,type,lat,lon,suburb}). Overpass is an
outward-facing fetch (John-authorized scout, once per town, raw cached).
"""
import os, sys, json, re, math, time, datetime, urllib.request, urllib.parse
import os, sys, json, re, math, time, datetime, urllib.request, urllib.parse, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOWNS_DIR = os.path.join(ROOT, "web", "assets", "towns")
@ -43,6 +43,18 @@ TYPE_MAP = {
"charity": "opshop", "second_hand": "opshop", "antiques": "opshop",
}
# ── roads[] (schema v2, ROUND18 REAL ROADS) ─────────────────────────────────────────────────────
# We FETCH every OSM highway tier A's ROAD_KIND maps (→ raw cache, full provenance / beta-ready) but
# EMIT only the town STREET graph. Fidelity cut (charter risk #4, flagged to A who owns the knob):
# the service/track/footway/path/steps/cycleway tier is EXCLUDED — in Katoomba that's 1461 of 2506
# ways (Blue-Mountains bushwalking tracks, sidewalks, stairs, driveways, parking aisles), noise for a
# town street graph and a way-count A's lift would build 2500+ edges from. The full set stays in the
# raw response if A wants a tier back. `kind` ships as the raw OSM highway class; A maps it (ROAD_KIND).
ROAD_HW = ("motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|road"
"|service|track|pedestrian|footway|path|steps|cycleway")
STREET_KINDS = {"motorway", "trunk", "primary", "secondary", "tertiary", "unclassified",
"residential", "living_street", "road", "pedestrian"}
# The big trademarked charity/franchise chains get loving parodies (ported from thriftgod, John's call
# for the live game); independent shops keep their real OSM names (factual ODbL data).
PARODY = [
@ -86,6 +98,22 @@ def haversine_m(a, b):
return 2 * R * math.asin(min(1.0, math.sqrt(h)))
def overpass_post(q):
"""POST an Overpass query with retry/backoff — the public API 504s / times out under load."""
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": UA})
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=180) as r:
return json.load(r).get("elements", [])
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
if attempt == 3:
raise
wait = 15 * (attempt + 1)
print(f" Overpass {getattr(e, 'code', type(e).__name__)} — retry {attempt + 1}/3 in {wait}s")
time.sleep(wait)
def fetch_raw(key, bb, refetch):
"""Return the raw Overpass elements list, cached in-repo so re-runs never re-fetch."""
os.makedirs(RAW_DIR, exist_ok=True)
@ -98,10 +126,7 @@ def fetch_raw(key, bb, refetch):
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
f'out center;')
print(f" [{key}] fetching Overpass (bounded {bb['minLat']:.3f},{bb['minLon']:.3f}{bb['maxLat']:.3f},{bb['maxLon']:.3f})")
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=150) as r:
els = json.load(r).get("elements", [])
els = overpass_post(q)
fetched = datetime.date.today().isoformat()
json.dump({"fetchedAt": fetched, "endpoint": OVERPASS, "query": q, "bbox": bb, "elements": els},
open(path, "w"), indent=1)
@ -109,7 +134,46 @@ def fetch_raw(key, bb, refetch):
return els, fetched
def process(key, cfg, els, fetched):
def fetch_roads_raw(key, bb, refetch):
"""Return the raw OSM highway ways (with geometry) for the town bbox, cached in-repo like the shops."""
os.makedirs(RAW_DIR, exist_ok=True)
path = os.path.join(RAW_DIR, key + ".roads.overpass.json")
if os.path.exists(path) and not refetch:
return json.load(open(path))["elements"]
q = (f'[out:json][timeout:120];'
f'way["highway"~"^({ROAD_HW})$"]'
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
f'out geom;')
print(f" [{key}] fetching Overpass roads (highway ways, bounded)")
ways = overpass_post(q)
json.dump({"fetchedAt": datetime.date.today().isoformat(), "endpoint": OVERPASS, "query": q,
"bbox": bb, "elements": ways}, open(path, "w"), indent=1)
time.sleep(2)
return ways
def roads_from_ways(ways):
"""OSM highway ways → roads[] (contract v2): {id, kind, name?, pts:[[lat,lon],…]}. Emit only the
STREET tier; ship full geometry (A simplifies with DouglasPeucker); deterministic (id-sorted)."""
roads = []
for w in ways:
hwy = w.get("tags", {}).get("highway")
geom = w.get("geometry")
if hwy not in STREET_KINDS or not isinstance(geom, list) or len(geom) < 2:
continue
pts = [[round(g["lat"], 6), round(g["lon"], 6)] for g in geom
if isinstance(g.get("lat"), (int, float)) and isinstance(g.get("lon"), (int, float))]
if len(pts) < 2:
continue
rd = {"id": w["id"], "kind": hwy, "pts": pts}
name = w.get("tags", {}).get("name")
if name:
rd["name"] = name[:60]
roads.append(rd)
return sorted(roads, key=lambda r: r["id"])
def process(key, cfg, els, fetched, roads=None):
center = cfg["center"]
rows, seen = [], set()
for el in els:
@ -144,14 +208,19 @@ def process(key, cfg, els, fetched):
shops.append({"id": r["id"], "name": pname, "type": TYPE_MAP.get(r["osm"], "opshop"),
"lat": round(r["lat"], 6), "lon": round(r["lon"], 6), "suburb": r["suburb"]})
bb = bbox(center, cfg["span_km"])
return {
"schema": "procity-town-cache/1", "key": key, "town": cfg["town"], "source": "osm",
counts = {"raw": len(els), "shops": len(shops)}
cache = {
"schema": "procity-town-cache/2" if roads else "procity-town-cache/1",
"key": key, "town": cfg["town"], "source": "osm",
"license": "ODbL 1.0", "attribution": "© OpenStreetMap contributors",
"generator": "pipeline/build_towns.py", "fetchedAt": fetched,
"center": {"lat": center[0], "lon": center[1]},
"bbox": bb, "counts": {"raw": len(els), "shops": len(shops)},
"shops": shops,
"bbox": bb, "counts": counts, "shops": shops,
}
if roads:
counts["roads"] = len(roads)
cache["roads"] = roads # schema v2 — A's plan_osm builds the real street graph
return cache
def span_km(shops):
@ -166,24 +235,35 @@ def span_km(shops):
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
refetch = "--refetch" in sys.argv
no_roads = "--no-roads" in sys.argv # build a v1 cache (marched fallback), no road fetch
keys = args or list(TOWNS)
os.makedirs(TOWNS_DIR, exist_ok=True)
kept, dropped = [], []
for key in keys:
cfg = TOWNS[key]
els, fetched = fetch_raw(key, bbox(cfg["center"], cfg["span_km"]), refetch)
cache = process(key, cfg, els, fetched)
bb = bbox(cfg["center"], cfg["span_km"])
els, fetched = fetch_raw(key, bb, refetch)
roads = None
if not no_roads:
roads = roads_from_ways(fetch_roads_raw(key, bb, refetch))
cache = process(key, cfg, els, fetched, roads)
n, sp = len(cache["shops"]), span_km(cache["shops"])
types = {}
for s in cache["shops"]:
types[s["type"]] = types.get(s["type"], 0) + 1
status = "OK " if n >= 6 and sp < 5.0 else "LOW"
if n >= 6 and sp < 5.0:
ok = n >= 6 and sp < 5.0
if ok:
json.dump(cache, open(os.path.join(TOWNS_DIR, key + ".json"), "w"), indent=1)
kept.append(key)
else:
dropped.append((key, n, sp))
print(f" {status} {key:16} raw={cache['counts']['raw']:>4} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}")
rstr = ""
if roads is not None:
rk = {}
for r in roads:
rk[r["kind"]] = rk.get(r["kind"], 0) + 1
rstr = f" roads={len(roads)} {dict(sorted(rk.items(), key=lambda x: -x[1]))}"
print(f" {'OK ' if ok else 'LOW'} {key:16} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}{rstr}")
print(f"\nBUILT {len(kept)} town cache(s) → web/assets/towns/: {', '.join(kept)}")
if dropped:
print(f"DROPPED (< 6 shops or span ≥ 5 km): {dropped}")

View File

@ -42,6 +42,40 @@ strip (the R17 mega-strip risk — a whole region of 2,918 shops became an 11.6
`type` should be a registry `SHOP_TYPE` (`record`/`opshop`/`toy`/`book`/`video`/`pawn`/`milkbar`/`dept`/`stall`);
`build_towns.py` maps OSM `shop=*` tags to these (unknowns land on `opshop`).
## Schema v2 — `roads[]` (ROUND18, v4.0-alpha REAL ROADS)
A **v2** cache adds an optional `roads[]` — the town's real OSM street geometry. When present, `plan_osm`
builds the CityPlan street graph from the **real roads** and seats each shop on its nearest real edge (its
real street, real order, real side) instead of marching shops onto synthetic parallel avenues. **`roads`
absent (or `schema` `procity-town-cache/1`) ⇒ the marched fallback** — every shipped v1 cache stays valid.
```jsonc
{
"schema": "procity-town-cache/2",
// … all v1 fields (center, shops, license, …) …
"roads": [
{ "id": 12345678, // optional (OSM way id)
"kind": "primary", // REQUIRED — the OSM highway=* class (see the map below)
"name": "Katoomba Street", // optional
"pts": [ [-33.7175, 150.3110], [-33.7140, 150.3112], [-33.7100, 150.3115] ] } // REQUIRED — ≥2 [lat,lon]
// …
]
}
```
**Road validity** (a bad road is a hard error; a soft one is absorbed):
- each road is `{ kind: string, pts: [[lat,lon], …] }` with **≥ 2 finite `[lat,lon]` points** — else **rejected**.
- `< 2` points → the road is **dropped** (warning); an unmapped `kind` falls to `side` (warning).
**`kind` → CityPlan edge kind** (`ROAD_KIND` in `plan_osm.js`; the lift then promotes a shop-dense `side`
road to the `main` spine): `motorway`/`trunk`/`primary`/`secondary` → `main`; `tertiary`/`unclassified`/
`residential`/`living_street`/`road` → `side`; `service`/`track` → `lane`; `pedestrian`/`footway`/`path`/
`steps`/`cycleway` → `arcade`. **Fidelity (charter risk #4, A's knob):** ship the geometry roughly as OSM
has it; `plan_osm` simplifies (DouglasPeucker) — don't pre-decimate below ~1 point per 5 m.
**Bound the roads to the town** (the bbox law): fetch `highway=*` within the same per-town bbox as the shops
so the graph is the town's streets, not a region's.
## Provenance (Lane E)
OSM data is **ODbL**. Ship attribution with the data: this `README`, a `SOURCES.md` (E's licence table),

View File

@ -27,10 +27,20 @@ the "© OpenStreetMap contributors (ODbL)" credit visible.
## Raw responses (`_raw/`)
The raw Overpass JSON for each town is cached in `_raw/<key>.overpass.json` (wrapped with `fetchedAt`,
`endpoint`, `query`, `bbox`). **Re-runs reuse the raw cache and never re-hit Overpass** — the fetch is
outward-facing network (John-authorized scout, once per town). `--refetch` forces a deliberate re-fetch.
`selfcheck.js` reads only top-level `*.json`, so `_raw/` is never loaded as a cache.
The raw Overpass JSON is cached in-repo (wrapped with `fetchedAt`, `endpoint`, `query`, `bbox`):
`_raw/<key>.overpass.json` (shops) and `_raw/<key>.roads.overpass.json` (OSM highway ways, schema v2).
**Re-runs reuse the raw cache and never re-hit Overpass** — the fetch is outward-facing network
(John-authorized scout, once per town). `--refetch` forces a deliberate re-fetch. `selfcheck.js` reads
only top-level `*.json`, so `_raw/` is never loaded as a cache.
## Roads — schema v2 (ROUND18, v4.0-alpha REAL ROADS)
A v2 cache carries `roads[]` — the town's real OSM street geometry (`highway=*` ways), same ODbL licence
and attribution as the shops. **Fidelity cut** (charter risk #4): the raw fetch grabs every `ROAD_KIND`
tier, but the emitted `roads[]` is the **town street graph only** — drivable roads + pedestrian malls;
`service`/`track`/`footway`/`path`/`steps`/`cycleway` are excluded (in Katoomba, 1461 of 2506 ways —
bushwalking tracks, sidewalks, stairs, driveways). The excluded tier stays in the raw response if a
tier is wanted back. `katoomba_real` is v2 (the alpha); the other towns' roads land as data-only.
## Names — parody vs factual

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
@ -48,9 +49,40 @@ function signOf(name) {
//
// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed —
// same as the fixtures). A blank name defaults to the type label. `suburb` is optional.
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/1';
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/2'; // v2 (ROUND18) adds optional roads[]
const ACCEPTED_SCHEMAS = new Set(['procity-town-cache/1', 'procity-town-cache/2']); // v1 stays valid → marched
export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare
export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note)
// ── roads[] (schema v2, ROUND18) ─────────────────────────────────────────────────────────────────
// OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road
// dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'.
export const ROAD_KIND = {
motorway: 'main', trunk: 'main', primary: 'main', secondary: 'main',
tertiary: 'side', unclassified: 'side', residential: 'side', living_street: 'side', road: 'side',
service: 'lane', track: 'lane',
pedestrian: 'arcade', footway: 'arcade', path: 'arcade', steps: 'arcade', cycleway: 'arcade',
};
export const roadEdgeKind = hwy => ROAD_KIND[hwy] || 'side';
// DouglasPeucker polyline simplification in projected metres (the road-fidelity knob, charter risk #4).
// Iterative (no recursion-depth risk on a long way). Keeps endpoints + any point > eps off the chord.
function simplifyDP(pts, eps) {
if (pts.length < 3) return pts.slice();
const keep = new Uint8Array(pts.length); keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [i0, i1] = stack.pop();
const a = pts[i0], b = pts[i1], dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz) || 1;
let far = -1, fd = eps;
for (let i = i0 + 1; i < i1; i++) {
const d = Math.abs((pts[i].x - a.x) * dz - (pts[i].z - a.z) * dx) / L; // perpendicular distance to the chord
if (d > fd) { fd = d; far = i; }
}
if (far >= 0) { keep[far] = 1; stack.push([i0, far], [far, i1]); }
}
return pts.filter((_, i) => keep[i]);
}
const isNum = v => typeof v === 'number' && Number.isFinite(v);
// Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not
@ -58,7 +90,7 @@ const isNum = v => typeof v === 'number' && Number.isFinite(v);
export function validateTownCache(cache) {
const errors = [], warnings = [];
if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings };
if (cache.schema && cache.schema !== TOWN_CACHE_SCHEMA) warnings.push(`schema '${cache.schema}' != '${TOWN_CACHE_SCHEMA}'`);
if (cache.schema && !ACCEPTED_SCHEMAS.has(cache.schema)) warnings.push(`unknown schema '${cache.schema}' (want one of ${[...ACCEPTED_SCHEMAS].join(', ')})`);
const c = cache.center;
if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers');
if (!Array.isArray(cache.shops)) errors.push('shops must be an array');
@ -87,6 +119,24 @@ export function validateTownCache(cache) {
if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`);
}
}
// v2: optional roads[] — simplified OSM ways. Each is `{ kind, pts:[[lat,lon],…] }` (id/name optional);
// `kind` is the OSM highway=* class. Absent (or a v1 cache) ⇒ the marched-avenue fallback, so nothing
// shipped regresses. A road needs ≥2 finite points; <2 is dropped, an unmapped kind falls to 'side'.
if (cache.roads !== undefined) {
if (!Array.isArray(cache.roads)) errors.push('roads must be an array (when present)');
else {
let badRoad = 0, shortRoad = 0, unknownKind = 0;
for (const rd of cache.roads) {
if (!rd || typeof rd.kind !== 'string' || !Array.isArray(rd.pts)) { badRoad++; continue; }
if (rd.pts.length < 2) { shortRoad++; continue; }
if (rd.pts.some(p => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1]))) badRoad++;
if (!ROAD_KIND[rd.kind]) unknownKind++;
}
if (badRoad) errors.push(`${badRoad} malformed road(s) — need { kind:string, pts:[[lat,lon],…] }`);
if (shortRoad) warnings.push(`${shortRoad} road(s) with < 2 points → dropped`);
if (unknownKind) warnings.push(`${unknownKind} road(s) with an unmapped highway kind → 'side'`);
}
}
if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)');
return { ok: errors.length === 0, errors, warnings };
}
@ -145,51 +195,115 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
return id;
}
// ── project real shops → local metres; bin by latitude into avenue rows, order by longitude ──
// ── projection to local metres (equirectangular about the cache centre) ──
const cosLat = Math.cos(fx.center.lat * Math.PI / 180);
const pts = fx.shops.map(s => ({
s, x: (s.lon - fx.center.lon) * EARTH_M * cosLat, z: (s.lat - fx.center.lat) * EARTH_M,
}));
const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z));
const span = (zMax - zMin) || 1;
const rowsArr = Array.from({ length: ROWS }, () => []);
for (const p of pts) {
const row = Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)));
rowsArr[row].push(p);
const projX = lon => (lon - fx.center.lon) * EARTH_M * cosLat;
const projZ = lat => (lat - fx.center.lat) * EARTH_M;
const footOf = t => { const fp = SHOP_TYPES[t]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; };
const district = addDistrict('mainstreet', 0, 0);
const roads = Array.isArray(fx.roads) ? fx.roads : [];
// v2 cache with roads[] ⇒ the real street graph; else the v1 marched fallback (SAME CityPlan out).
if (roads.length) { norm.mode = 'roads'; buildRealRoads(); } else { norm.mode = 'marched'; buildMarched(); }
// ── v1 marched fallback: bin shops by latitude into synthetic avenues off one S→N spine ──
function buildMarched() {
const pts = fx.shops.map(s => ({ s, x: projX(s.lon), z: projZ(s.lat) }));
const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z));
const span = (zMax - zMin) || 1;
const rowsArr = Array.from({ length: ROWS }, () => []);
for (const p of pts) rowsArr[Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)))].push(p);
const spineNodes = [];
const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length);
usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); });
for (let k = 0; k < spineNodes.length - 1; k++) addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main');
usedRows.forEach((o) => {
const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak
const zPos = o.zPos, fr = rowShops.map(p => footOf(p.s.type));
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
const avEdge = addEdge(addNode(0, zPos).id, addNode(avLen, zPos).id, AV_W, 'side');
const half = AV_W / 2, sd = DEPTH + 2;
const block = addBlock(district, 'mainstreet',
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue
let t = SPINE_CLEAR;
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
});
}
const district = addDistrict('mainstreet', 0, 0);
// spine: one main street running S→N through the origin, a node per (occupied) row
const spineNodes = [];
const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length);
usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); });
const spineEdges = [];
for (let k = 0; k < spineNodes.length - 1; k++) spineEdges.push(addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main'));
// ── each row → an eastwest avenue off the spine; its shops march into non-overlapping lots ──
usedRows.forEach((o, k) => {
const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak
const zPos = o.zPos;
// frontages from each shop's registry footprint (deterministic mid-band, no rng needed here)
const fr = rowShops.map(p => { const fp = SHOP_TYPES[p.s.type]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; });
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
const west = addNode(0, zPos), east = addNode(avLen, zPos);
const avEdge = addEdge(west.id, east.id, AV_W, 'side');
// block = the frontage strip on the south side of the avenue
const half = AV_W / 2, sd = DEPTH + 2;
const block = addBlock(district, 'mainstreet',
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
const ry = Math.atan2(0, -1); // outward normal (street→lot) points south (0,-1) ⇒ facade faces +z (north) to the avenue
let t = SPINE_CLEAR;
rowShops.forEach((p, j) => {
const f = fr[j];
const cx = t + f / 2;
const cz = zPos - (half + DEPTH / 2);
const lot = addLot(block, cx, cz, f, DEPTH, ry, avEdge, 'shop');
addShop(lot, p.s);
t += f + GAP;
});
});
// ── v4 REAL ROADS (schema v2): the CityPlan street graph FROM real OSM ways; shops on real streets ──
// project → simplify (DouglasPeucker) → snap coincident points to shared nodes (= real intersections)
// → straight-segment edges → classify main by shop density → seat each shop by marching along its
// NEAREST real edge in real order/real side (regularised spacing + an overlap-resolve pass ⇒ the
// stringent structuralSuite still holds). Fidelity is the alpha knob (charter risk #4): real edge,
// real order, real side, regularised spacing — not pixel positions (real shops overlap).
function buildRealRoads() {
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 6; // fidelity + geometry knobs (metres)
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
const ways = [];
for (const rd of roads) {
if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) continue;
let pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])).map(([lat, lon]) => ({ x: projX(lon), z: projZ(lat) }));
pl = simplifyDP(pl, EPS);
if (pl.length >= 2) ways.push({ kind: roadEdgeKind(rd.kind), pl });
}
const nodeAt = new Map();
const snap = p => { const k = `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`; let n = nodeAt.get(k); if (!n) { n = addNode(p.x, p.z); nodeAt.set(k, n); } return n; };
const seen = new Set(), realEdges = [];
for (const w of ways) for (let i = 0; i + 1 < w.pl.length; i++) {
const na = snap(w.pl[i]), nb = snap(w.pl[i + 1]);
if (na.id === nb.id || Math.hypot(nb.x - na.x, nb.z - na.z) < 3) continue;
const ek = `${Math.min(na.id, nb.id)}-${Math.max(na.id, nb.id)}`; if (seen.has(ek)) continue; seen.add(ek);
realEdges.push({ id: addEdge(na.id, nb.id, WKIND[w.kind] || AV_W, w.kind), a: na, b: nb, kind: w.kind });
}
if (!realEdges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; }
const nearest = (px, pz) => {
let best = null, bd = Infinity;
for (const e of realEdges) {
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, L2 = dx * dx + dz * dz || 1;
let t = ((px - e.a.x) * dx + (pz - e.a.z) * dz) / L2; t = Math.max(0, Math.min(1, t));
const d = Math.hypot(px - (e.a.x + dx * t), pz - (e.a.z + dz * t));
if (d < bd) { bd = d; best = { e, t, side: (dx * (pz - e.a.z) - dz * (px - e.a.x)) >= 0 ? 1 : -1 }; }
}
return best;
};
const perEdge = new Map();
for (const s of fx.shops) {
const a = nearest(projX(s.lon), projZ(s.lat)); if (!a) continue;
const key = a.e.id * 2 + (a.side > 0 ? 1 : 0);
(perEdge.get(key) || perEdge.set(key, { e: a.e, side: a.side, list: [] }).get(key)).list.push({ s, t: a.t });
}
if (!realEdges.some(e => e.kind === 'main')) { // classify main from shop density if none from OSM
const cnt = new Map(); for (const { e, list } of perEdge.values()) cnt.set(e.id, (cnt.get(e.id) || 0) + list.length);
let top = null, tc = -1; for (const e of realEdges) { const c = cnt.get(e.id) || 0; if (c > tc) { tc = c; top = e; } }
if (top) { const pe = edges.find(x => x.id === top.id); pe.kind = 'main'; pe.width = SPINE_W; }
}
const cands = [];
for (const { e, side, list } of perEdge.values()) {
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (Z) faces road
const off = KERB, dep = DEPTH;
const block = addBlock(district, 'mainstreet', [
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);
let along = NODE_CLEAR;
for (const { s } of list) {
const f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop');
if (along + f > len - NODE_CLEAR) break; // edge full — overflow shops drop out (real order kept)
cands.push({ block, x: e.a.x + ux * (along + f / 2) + nx * (off + dep / 2), z: e.a.z + uz * (along + f / 2) + nz * (off + dep / 2), w: f, d: dep, ry, frontEdge: e.id, s });
along += f + GAP;
}
}
cands.sort((p, q) => p.s.id - q.s.id); // real streets crowd at intersections → deterministic overlap-resolve
const placed = [];
for (const c of cands) {
const corners = lotCorners(c);
if (placed.some(p => obbOverlap(p.corners, corners))) { norm.dropped = (norm.dropped || 0) + 1; continue; }
placed.push({ corners, c });
}
for (const { c } of placed) addShop(addLot(c.block, c.x, c.z, c.w, c.d, c.ry, c.frontEdge, 'shop'), c.s);
}
// ── exactly one late-night landmark: a video rental, closes ≥ LATE_HOUR (matches synthetic) ──
if (shops.length) {

View File

@ -406,13 +406,55 @@ for (const [label, cache, needle] of [
ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`);
ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`);
}
// (e) v4 REAL ROADS (schema v2, ROUND18): a v2 cache with roads[] builds the CityPlan street graph FROM
// the real ways and STILL passes the full structural + gig suites. Synthetic irregular graph — a main
// street, two cross streets (a real intersection + an acute-ish angle), a dead-end lane, a slight curve —
// the committed proof of the graph lift; `katoomba_real` validates + pins its golden when E's roads land.
section('v4 real-roads graph lift (schema v2)');
{
const roadsCache = {
schema: 'procity-town-cache/2', key: 'roadstest', town: 'Roadstest', source: 'osm',
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.71, lon: 150.31 },
roads: [
{ kind: 'primary', name: 'Main St', pts: [[-33.7150, 150.3110], [-33.7130, 150.31108], [-33.7110, 150.31116], [-33.7090, 150.31124], [-33.7070, 150.31132]] },
{ kind: 'residential', name: 'Cross A', pts: [[-33.7130, 150.31108], [-33.7130, 150.3090]] },
{ kind: 'residential', name: 'Cross B', pts: [[-33.7100, 150.3112], [-33.7095, 150.3130]] },
{ kind: 'service', name: 'Back Lane', pts: [[-33.7120, 150.31112], [-33.7122, 150.3122]] },
],
shops: Array.from({ length: 18 }, (_, i) => ({
id: 700 + i, name: `Real St Shop ${i}`,
type: ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
lat: -33.7150 + i * 0.00045, lon: 150.3110 + ((i % 3) - 1) * 0.0005, suburb: 'Katoomba',
})),
};
const v = validateTownCache(roadsCache);
ok(v.ok, `roads cache: validates as schema v2` + (v.ok ? '' : `${v.errors.join('; ')}`));
for (const s of [1, 42, 20261990]) {
const p = generatePlanOSM(s, 'roadstest', { cache: roadsCache });
ok(p.streets.edges.length >= 3, `roads ${s}: multi-edge real graph (${p.streets.edges.length} edges from real ways)`);
ok(p.streets.edges.some(e => e.kind === 'main'), `roads ${s}: a main spine exists (OSM class or shop-density promoted)`);
ok(p.shops.length >= 6, `roads ${s}: ${p.shops.length} shops seated on real edges (overlap-resolved)`);
structuralSuite(p, `roads syn ${s}`);
}
ok(JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })) === JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })),
`roads: deterministic (byte-identical re-run)`);
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'roadstest', cache: roadsCache }), `roads syn`);
// roads-absent (v1) marches — proven identical to before by the frozen osm goldens above
ok(generatePlanOSM(1, 'x', { cache: { ...roadsCache, roads: undefined } }).streets.edges.some(e => e.kind === 'main'),
`roads absent: marched fallback still boots`);
}
// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ──
// Empty until E's caches land; each is validated, run through the full structural + gig suites, and
// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive.
section('real town caches (web/assets/towns/*.json)');
const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: <key>: 0x… */ };
// The trailing towns are pinned on their stable v1 (marched) output — regression guards. katoomba_real
// stays UNPINNED this round: it is the v4.0-alpha town, and its golden re-pins to the real-ROADS output
// the moment E lands its roads[] (amendment law — the A→E→A finalization). The other four get roads in beta.
const REAL_TOWN_GOLDENS = {
bendigo_real: 0x5e3e76a6, castlemaine_real: 0xf1a628a1, fremantle_real: 0xee9fd09a, newtown_real: 0x95978b45,
};
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json'));
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
for (const f of townFiles) {