Compare commits

...

9 Commits

Author SHA1 Message Date
m3ultra
d678138ec1 Fable (integrator): round-21 instructions — the v4.0-beta close (venue cluster bias, selector index, density soak, the gate)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:14:39 +10:00
m3ultra
0d992803c5 Lane D R20 (v4.0-beta): citizens at density — five-town verdict + the main-street relocation finding (ledger #7)
Re-ran everything against A's absorbed graph (5d29863, selfcheck 51866/51866).
Katoomba barely moved from my pre-absorb read (null 77.2->78.1, visits 64->62,
hero share 30->29%), so the earlier conclusion held.

SIM VERDICT: zero changes needed at 4x density. All five towns: 0 NaN, chunk rosters
stream (15-24), identity sigs accumulate (262-483), patronage/loiter/bench-sit all fire,
up to 401 active peds, no degradation. Determinism unchanged (identity is graph+seed
keyed; shops don't touch it). D->F: GREEN.

THE FINDING (for E/C/Fable): the widening MOVED the main street off the secondhand strip.
Hero visit-share falling (29%->0%) is NOT a behaviour bug — patronage is type-agnostic, so
visits track the local mix exactly. The mix is the story. Pre-widening the densest cluster
WAS the secondhand strip (katoomba: 9 of 9 main-street shops were heroes). Post-widening
the centre of gravity relocated 308m (katoomba) / 434m (castlemaine) onto the general-
retail cluster, where heroes are a 0-19% minority. Castlemaine's main street now has ZERO
secondhand shops (0 of 4 heroes within 160m; median hero 463m away). The heroes weren't
drowned in place — they were demoted: the liveliest street is no longer theirs.

The good half stands: heroes kept absolute footfall (katoomba 17->18 visits) and the
street is 3.8x busier. The widening works; it overshot the dial. This is exactly the case
John's directive pre-authorised ("E may seed-subsample the texture classes"). My read
(measurement, not a ruling — E/C/Fable own it): subsample texture until the secondhand
strip is again the densest cluster. Sparse towns need it most (bendigo 7%, castlemaine 0%).

ALSO FILED (A/F): venues land off the retail hub at density — newtown's three are all
2190-2433m out with 0-1 neighbours; katoomba's pub still 1007m/1 (A's "pub facade bias"
fixed frontage, not position). Quality call, not a break; "venue placement still lands"
is A's ledger + F's district gate. Castlemaine shows the good case (band_room on the hub).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:10:03 +10:00
m3ultra
5d29863b78 Lane A round 20: absorb E's density widening — capacity, pub facade bias, goldens pinned (ledger #2)
E's five caches landed at 3-6x shops (292 total: katoomba 20→80, fremantle 21→80, newtown 18→72,
bendigo 9→36, castlemaine 6→24). All five absorb cleanly and still place 3 venues; the R20 poster cap
auto-scaled off the live shop count exactly as designed (katoomba 111 posters at 80 shops).

- CAPACITY WIDENED (the R18 overflow fence, load-tested). At 4x the dominant drop cause was OVERFLOW —
  an edge-side holding more shops than fit (katoomba 10 of 11 drops). Tightened the run: NODE_CLEAR 6→3
  and a ROADS-ONLY RGAP 2→0.5 (the shared marched GAP is untouched ⇒ marched fixtures stay frozen).
  Drops 7.9% → 5.5% (16/292). 0.5m also reads truer — a real AU main street is continuous terrace
  shopfronts, not detached boxes. Stopped there deliberately: the next lever (spilling overflow onto a
  DIFFERENT street) buys ~1% by putting a shop on a road it isn't on — dropping a crowded shop is the
  honest trade. Residual drops stay COUNTED, now BY CAUSE (dropped_overflow/_overlap/_stranded).
- VENUE FACADE BIAS NOW COVERS pub. R16 exempted pub ('spine-fronted, +Z always open') — true on the
  synthetic/marched towns, but on a REAL graph at density the pub landed where its frontage poster
  overhung a cross street (newtown, -0.91m). The POSTER_CLEAR filter now covers every venue kind; still a
  NO-OP on synthetic (every pub candidate already clears) ⇒ gig golden 0xb1d48ea1 frozen.
- Goldens re-pinned ONCE (base + gig, all five towns). classic 0x3fa36874 / gig 0xb1d48ea1 / marched
  fixtures FROZEN. selfcheck ALL GREEN 51866/51866; scaffold + consistency GREEN; cross-process
  deterministic.
- Fixed: my real-cache loader globbed *.json and tried to validate E's new index.json as a town cache —
  it now skips it (that's B's selector index, not a cache).

Handshake → all: densities absorbed, goldens pinned. → F: re-measure venue_night + gate the five towns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:05:11 +10:00
m3ultra
94e56ed5fa Lane B round 20 (v4.0-beta): the tram ruling — route by shop adjacency + per-town fence (ledger #3/#4)
Verified fresh Chromium across 9 town configs (synthetic, 3 fixtures, 5 real).

TRAM RULING (ledger #3): spinePolyline now walks from each main-subgraph dead-end, greedily taking the
unused main edge fronting the most shops (lot.frontEdge), keeping the best-scoring path (ties -> longer).
SYNTHETIC + FIXTURES BYTE-IDENTICAL by construction AND measurement (a synthetic main node has <=2 mains so
there's never a choice; its two dead-ends tie exactly and strict > keeps the pre-R20 start). Old vs new
logic on the same plan = identical polylines (Boolarra: 7 pts, 6 edges, 810m, 172 shops, 2 stops).

The alpha tram fronted 0 shops on 4 of 5 real towns (it was a highway bus). New: katoomba 0->3, newtown
0->8, fremantle 0->2, bendigo 0->1, castlemaine 1->6. Where the best main chain still fronts <5 shops the
tram is FENCED per-town (Fable's fallback). RUNS: synthetic, all fixtures, newtown (8 shops/1924m/16 stops),
castlemaine (6/2184m/10 stops). FENCED: katoomba (3), fremantle (2), bendigo (1) -- highway, not high street.
The fence keys off the REAL-ROADS signal (edges>200), NEVER shop count alone: the marched fixtures front 0
shops on their mains (bare spine, avenues carry shops) so a naive fence would have killed their v2 tram.
routeInfo{fenced,reason,shopsFronted,routeMetres} exposed for F's smoke.

BUG FOUND+FIXED: the stop projection accepted every shelter in the town with no distance gate -- harmless on
synthetic (2 shelters on the spine), nonsense on real graphs: Newtown made 149 phantom stops (~9 min of dwell
at points the tram never passes). Added a 30m gate (a shelter sits halfRoad+1.7 <=~16m off its centreline, so
genuine stops survive): newtown 149->16, castlemaine 37->10; synthetic 2 / fixtures 3 unchanged.

GROUND POLISH (ledger #4): measured, NO fix needed. Real Katoomba has 325 deg-3+ junctions, 28 under 30 deg,
worst 4.3 deg (near-parallel). At grazing eye level the worst reads clean -- no z-fight, no gaps. Layers are
already y-separated (road 0.0/foot 0.02/kerb 0.06) and road-on-road overlap is invisible by design (OVERLAP=1.5
overruns nodes on purpose; one skin, one y, one merged draw on uniform asphalt). Cosmetic only: footpath/kerb
double up on the sliver between <10deg near-parallel roads -- v4.0 candidate, not a defect.

Selector polish (ledger #5): BLOCKED -- E's towns/index.json not landed (build_towns.py WIP); REAL_TOWNS
hardcode stands, one-line swap when E lands.

-> A (finding, measured): only a slice of shops front a MAIN edge on real towns -- katoomba 7/69, fremantle
   4/79, bendigo 7/35, newtown 32/64, castlemaine 11/22. The real high street classifies as 'side', which is
   WHY three towns need the fence. If the main/side classifier promoted shop-dense ways to main, the fence
   would lift. Note onMain also gates gigs.js pickVenues pub placement.
-> F: tram verdict above; selector gating (HUD vs ?dbg) is your call, one line either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:35:56 +10:00
m3ultra
62cbb955b1 Lane D R20 (v4.0-beta): the density A/B + correcting my own sparsity misread (ledger #7, part 2)
SELF-CORRECTION first. My part-1 note called the 95-98% patronage null rate
"sparsity-throttled" and made it the headline metric. Both halves were wrong:
(1) it was measured at 21:00, when only 4 of 69 shops are open — retail is shut BY
DESIGN, so a high night null is correct behaviour, not sparsity; (2) it sampled each
town's first venue, and katoomba's is the pub — 1000m from the retail cluster with 1
neighbour. I had parked the camera on the one isolated venue in town. Same town, same
seed: at the pub 99.4% null / 0 visits; on the actual main street 77.2% / 64 visits.
The gig-crowd table stands (it measures the mechanism — 0 NaN, rosters, venue-win —
which is location-independent); only my sparsity reading was bad.

THE REAL A/B (main street, 14:00, same seed, pre-cache from git vs E's widened):
hub density 8 -> 25 shops within 160m; null 90.2% -> 77.2%; total visits 17 -> 64
(3.8x); hero visits 17 -> 19; texture visits 0 -> 45; hero share 100% -> 30% of visits
from 26% of shops.

THE DIRECTIVE LANDS, measured from the crowd side: the secondhand heroes did NOT lose
footfall (17 -> 19). The 45 texture visits are added ON TOP, not taken from them. The
street is 3.8x busier while the heroes stay over-indexed. "More alive, not a different
game."

FILED: (a) venue placement at density — katoomba's rsl/band_room sit in the strip
(13/10 neighbours) but the pub is 1000m out with 1; a gig there has no passing retail
crowd. Quality observation, A/F's lane. (b) GIG_RANGE is a 34m radius but
_nearestOpenShop searches only the ped's own 64m chunk — 8.7% of in-range peds are
chunk-blind. Real but small; v4.x candidate, not a mid-beta hot-path change.

POST numbers are vs E's committed caches but BEFORE A's absorb (11/80 still overflow-
drop). Final re-run + the other four towns once A lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:34:56 +10:00
m3ultra
078245f7bd Lane E R20 (v4.0-beta): the density widening — widened fetch, 5 caches re-emitted, towns index
Ledger #2 (E's half), built to C's LANE_C_PUB §6 table under John's SUBTLE directive.

- Widened fetch: C's tasteful staple list (shop=* + amenity=cafe per C's dagger note), mapped through
  C's copy-paste table onto EXISTING registry types (no new archetypes). Guardrails honoured: no
  vehicle/fuel/funeral/storage/industrial, no pure services. New raw snapshot per town (the fetcher
  auto-refetches when the class list changes -- query recorded in each snapshot), committed like the first.
- SUBTLE by construction: secondhand HEROES never subsampled; texture capped at 3x heroes (top of C's
  ~2-3x band), over-cap towns seed-subsampled deterministically (sha256(townKey:osmId)) with every drop
  counted in counts.textureDropped. Result 3.0x in all five: katoomba 20+60 (-4), newtown 18+54 (-156),
  fremantle 20+60 (-81), bendigo 9+27 (-81), castlemaine 6+18 (-43). Density 6-21 -> 24-80 shops (3-4x).
- web/assets/towns/index.json (new, E-owned): {schema, generated, license, attribution, towns[{key, town,
  state, shops, roads}]} -- B's selector can drop the REAL_TOWNS hardcode (hud.js:11).
- Deterministic (byte-identical re-run, 0 re-fetch). ODbL: SOURCES.md + per-cache license/attribution.

-> Lane A (absorb pass): (1) 10 expected golden re-pins -- the widening moves every real town's base+gig
   golden (designed E->A handoff); synthetic 0x3fa36874 UNMOVED, fixtures frozen. (2) SEAM: index.json
   trips selfcheck's town glob (selfcheck.js:467 takes every top-level towns/*.json as a cache) -- one-line
   fix in your file: .filter(f => f.endsWith('.json') && f !== 'index.json'). Didn't touch selfcheck.js
   (yours, you're mid-round in it); Fable spec'd the index at this path so the path stays.
-> Lane C: kept video_games -> video (absent from your §6 table; was already shipping as video pre-widening,
   so the fallback would have regressed it to opshop). Same family, same target -- flagging for your table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:30:50 +10:00
m3ultra
85745d2017 Lane D R20 (v4.0-beta): gig crowd on the five real towns — pre-density baseline (ledger #7, part 1)
The unblocked half of "citizens at density": the gig crowd's BEHAVIOUR on all five
real towns' venues, measured BEFORE E's widening so the after-table is a real A/B.
Density re-audit follows once E's caches + A's absorb land.

BEHAVIOUR IS CORRECT ON ALL FIVE: 0 NaN everywhere; tonightRoster populates (2-7),
so R14 identity continuity holds on real bones; every town generates 3 venues
(pub/band_room/rsl) on night hours; and the surge works — whenever a patronage check
finds any shop, the gig venue beats ordinary shopfronts (venue-win 100% on newtown/
fremantle/bendigo, 74% katoomba). Castlemaine's 40% is the 6-shop floor, not a leak:
3 of its 6 shops ARE venues, so "other" hits are mostly other venues.

FINDING (quantifies Fable's ledger #2 "real towns are sparse"): the crowd is
SPARSITY-throttled — 94.9-97.7% of patronage checks return null on every town, because
peds spend nearly all their time in shopless chunks (katoomba: 19 shops in ~5 door-chunks
of a ~78x78-chunk town). The mechanism is fine; the town has no shops per chunk. The
density widening attacks exactly this — null% is my headline before/after metric.

FILED (not fixed): GIG_RANGE is a 34m radius but _nearestOpenShop searches only the
ped's own 64m chunk, so in-range peds across a chunk edge are blind to the gig — 8.7%
of in-range ped-samples (852/9740) on katoomba. Small vs the 97% sparsity throttle;
not worth a hot-path change mid-beta. Revisit if the widening leaves null% high.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:27:53 +10:00
m3ultra
c7ca39b4f7 Lane C R20 (v4.0-beta): publish OSM-class -> archetype mapping (ledger #2, C's half)
The density widening's C half: real towns fetch general retail, not just secondhand.
Published the OSM shop-class -> registry-archetype mapping in LANE_C_PUB.md §6 (new
v4.0-beta amendment; frozen venue contract §0-§5 untouched). C->E handshake: E builds to it.

- No new archetypes. General retail wears existing recipes: food/grocer/newsagent/chemist
  -> milkbar; clothes/hardware/big-retail -> dept; produce/florist -> stall.
- Secondhand stays the hero (charity->opshop, books->book, music->record, video->video,
  pawn->pawn, toys->toy); never subsampled. Unknown/unlisted -> opshop (R18-proven).
- Subtle per John: texture not takeover. Copy-paste JSON map + tasteful fetch list (skip
  vehicle/funeral/industrial/pure-services) + guardrail for E to subsample if retail drowns
  the secondhand strip (count in norm.dropped).
- Mechanic: plan_osm keeps registry types, so E bakes a registry type per the map into each
  cache's shop.type -> no C code change, budgets unchanged.

Pre-validated before E builds to it: milkbar/dept/stall/opshop all build valid at a real
katoomba lot (8.5x14, 0 carves, <=143 draws); dept reads as a clean dressed-retail floor
(shelves+racks+counter, escalator does not dominate the 7.9x11.7 hall) -> clothes/hardware
-> dept confirmed. Full five-town door-soak (ledger #6) follows E's re-emitted caches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:32:05 +10:00
m3ultra
955ec3497b Lane A round 20: spine-poster cap scaled by shop count (v4.0-beta, ledger #1)
Closes my own R19 finding + F's one open budget edge. A sparse REAL town has few shops but a big edge
graph, so the 'two posters per main edge' pole run reads busy (katoomba 438) and drove venue_night to
200,965 tris (+0.5% over the 200k soft ceiling).

- gigs.js buildPosters caps the spine pole run at POSTER_PER_SHOP × shops (1.5/shop, floor 12) — a seeded
  subsample. SCALED BY SHOP COUNT, not edge count (the whole point). katoomba 438→31 posters (~2x the
  synthetic feel), fremantle 32, castlemaine 14. NO-OP on the shop-dense synthetic town (493 shops ⇒ cap
  ~740 ≫ its ~15 posters), so the synthetic gig golden 0xb1d48ea1 stays FROZEN.
- Posters are gig-layer (withGigs), so the real-town BASE goldens are unchanged (correctly green). Added
  REAL_TOWN_GIG_GOLDENS (the full district+capped-poster plan per town: katoomba 0xbd332e83, …) to
  regression-guard the capped output byte-exact, not just via districtInvariants. classic/gig/marched
  fixtures frozen; selfcheck ALL GREEN 51675/51675; scaffold + consistency GREEN; cross-process deterministic.

→ F: re-measure venue_night, the +965 edge should close. Density absorb (ledger #2) pends E's widened
caches — the poster cap already reads the live shop count, so it auto-scales to the widened towns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:27:29 +10:00
26 changed files with 12958 additions and 111 deletions

View File

@ -3,7 +3,40 @@
*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 19) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-16 (round 20) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 20, v4.0-beta FIVE TOWNS) — the class→archetype mapping (ledger #2, C's half)
R20 §Lane C (I lead — E builds to it, half-day rule): **published the OSM shop-class → interior archetype
mapping** in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) **§6** (new v4.0-beta amendment; the frozen venue
contract §0§5 untouched). This is the density widening — real towns fetch **general retail**, not just the
thriftgod secondhand classes, so the main street holds its real bakery/cafe/chemist/clothes shops.
**Design (John's directive: SUBTLE — texture, not takeover):**
- **No new archetypes.** General retail wears **existing** recipes: food/grocer/newsagent/chemist → `milkbar`
(the canonical AU counter shop); clothes/hardware/big-retail → `dept`; produce/florist → `stall`.
- **Secondhand stays the hero** — charity/antiques→opshop, books→book, music→record, video/games→video,
pawn→pawn, toys→toy; never subsampled.
- **Unknown/unlisted → `opshop`** (R18-proven safe). Included a copy-paste JSON map for E's `build_towns.py`
+ a tasteful fetch list (skip vehicle/funeral/industrial/pure-services like hairdresser/bank).
- **Guardrail for E:** if a town's general retail would visually drown the secondhand strip, seed-subsample
the texture classes toward ~23× the secondhand count and count the drops in `norm.dropped`.
**Mechanic note:** `plan_osm` keeps only registry types (unknowns flatten to opshop), so E bakes a **registry
type** per the map into each cache's `shop.type` — no C code change, all targets are existing recipes/registry
entries, budgets unchanged. **C→E handshake: §6 is live.**
**Pre-validated the mapping (measurement over folklore, before E builds to it):** milkbar/dept/stall/opshop all
build valid at the katoomba lot (8.5×14) — 0 carves, ≤143 draws, pathOK. Rendered `dept` (my one flagged case
— the escalator centrepiece): reads as a clean **dressed-retail floor** (cube shelves of goods + clothes rack +
counter + "GROUND FLOOR/HABERDASHERY" signage) in the 7.9×11.7 hall, escalator does *not* dominate — so
`clothes`/`hardware` → `dept` is confirmed, not a concern.
**Pending (ledger #6, after E re-emits the 5 widened caches):** the five-town sampled door-soak — every town,
sampled doors at the new density, all types resolve, no carve, budgets; one table, five rows. I'll watch that
`dept` reads right on small real lots (the escalator centrepiece). Docs-only this commit; door-soak follows E.
---

View File

@ -1,11 +1,40 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (51670/51670); rounds 219 closed** (see below).
v4.0-alpha alpha-close: fragmentation resolved (105→1 component on katoomba), E's spine-poster finding
fixed, all 5 real towns pinned on real-roads goldens (katoomba `0xb7ffbca2`). classic/gig/marched-fixtures
frozen.*
*Status: **all deliverables landed; self-check all-green (51866/51866); rounds 220 closed** (see below).
v4.0-beta: spine-poster cap scaled by shop count (katoomba 438→31, closes F's venue_night edge) + E's
density widening absorbed (5 towns at 36× shops, all place 3 venues, drops 5.5% counted by cause); all 10
real-town goldens (base + gig) pinned. classic/gig/marched-fixtures frozen.*
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
## Round 20 (2026-07-16) — v4.0-beta THE FIVE TOWNS: the poster cap (density absorb pends E)
Two Lane-A deliverables; the first (independent) landed this commit, the second waits on E's widened caches.
1. **Poster cap (ledger #1) — LANDED.** My R19 finding (real katoomba: 438 spine posters, driving F's one
budget edge, `venue_night` 200,965 tris) is fixed. `gigs.js buildPosters` caps the spine pole run at
**`POSTER_PER_SHOP × shops`** (1.5/shop, floor 12) — a seeded subsample of the run. **Scaled by shop
count, not edge count** (the whole point: a sparse real town has few shops but a big graph). Result:
katoomba **438 → 31** posters (~2× the synthetic feel), fremantle 32, castlemaine 14. **No-op on the
synthetic town** (493 shops ⇒ cap ~740 ≫ its ~15 posters), so the synthetic gig golden `0xb1d48ea1` is
frozen. Posters are gig-layer, so the real-town BASE goldens are unchanged — I added
`REAL_TOWN_GIG_GOLDENS` (the full district+poster plan per town: katoomba `0xbd332e83`, …) to
regression-guard the capped output byte-exact. selfcheck ALL GREEN **51675/51675**; scaffold + consistency
GREEN; cross-process deterministic. → F: re-measure `venue_night`, the +965 edge should close.
2. **Density absorb (ledger #2) — LANDED.** E's five caches at **36× shops** (292 total: katoomba 20→80,
fremantle 21→80, newtown 18→72, bendigo 9→36, castlemaine 6→24) absorbed; **all five place 3 venues**;
the poster cap auto-scaled off the live shop count as designed. **Capacity widened** — at 4× the dominant
drop cause was overflow (edge-side full), so `NODE_CLEAR` 6→3 + a **roads-only `RGAP`** 2→0.5 (shared
marched `GAP` untouched ⇒ fixtures frozen): drops **7.9% → 5.5%** (16/292), residual **counted by cause**
(`dropped_overflow`/`_overlap`/`_stranded`). Stopped there deliberately — the next lever (spilling
overflow onto a different street) buys ~1% by putting a shop on a road it isn't on. **Venue facade bias
extended to `pub`** (R16 exempted it; on a real graph at density the pub's frontage poster overhung a
cross street — newtown 0.91 m); still a no-op on synthetic. **Goldens re-pinned once** (base + gig, all
five). **Fixed:** the real-cache loader now skips E's new `index.json` (a selector index, not a cache).
selfcheck ALL GREEN **51866/51866**; scaffold + consistency GREEN; cross-process deterministic.
Handshake fired → all: **densities absorbed, goldens pinned.** → F: re-measure `venue_night` (poster cap)
and gate the five-town matrix.
## Round 19 (2026-07-16) — v4.0-alpha CLOSE: fragmentation ruling + findings hardened + goldens pinned
A was the round's spine (the A→E→A finalization deferred from R18). Every filed finding resolved; the tag is

View File

@ -311,7 +311,11 @@ camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to rea
R15 it was on the `Z` back and invisible from the street). So the poster never sits over a back-street's
bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`**
(ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along
the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole
the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**;
a candidate that would overhang a **real** crossing carriageway is skipped (ROUND19, E's finding), and the
run is **capped at `POSTER_PER_SHOP × shops`** (ROUND20) — a sparse real town has few shops but a big edge
graph, so the raw "2 per edge" run reads busy (katoomba 438 → 31). The cap never binds on the shop-dense
synthetic town, so its gig golden is frozen. Different venues' posters share the same pole
runs — that IS the district reading. `ry` follows the house convention (a poster's printed face =
`(sin ry, cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)`
rotated `ry`; F wires "poster → that venue's tonight-gig".

View File

@ -2,6 +2,42 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 20 (2026-07-16) — v4.0-beta: the poster cap (density absorb pending E's widened caches)
### → F: **poster cap landed — re-measure `venue_night`** (your +965-tri edge should close)
My R19 finding (438 posters on katoomba) is fixed. `gigs.js` now **caps the spine pole run at
`POSTER_PER_SHOP × shops` (1.5/shop, floor 12)** — a seeded subsample. Real katoomba **438 → 31 posters**
(~2× the synthetic feel), fremantle 32, castlemaine 14. Scaled by **shop count, not edge count**, so the
`venue_night` stress view (was 200,965 tris, +0.5% over 200k) should drop well under. Re-measure and close it.
### Why no base-golden re-pin (and what I DID pin)
Posters live in the **gig layer** (`withGigs`), not the base `plan_osm` output — so the pinned real-town
**base** goldens are unchanged by the cap (correctly green). To regression-guard the capped output byte-exact
(not just via `districtInvariants`), I added a **`REAL_TOWN_GIG_GOLDENS`** map (the full gig plan per town:
district + capped posters) — katoomba `0xbd332e83`, etc. Synthetic gig golden `0xb1d48ea1` **frozen** — the
cap never binds on the shop-dense synthetic town (493 shops ⇒ cap ~740 ≫ its ~15 posters).
### → all: **densities absorbed, goldens pinned** (ledger #2 — E's widened caches landed)
E's five caches at **36× shops** (katoomba 20→80, fremantle 21→80, newtown 18→72, bendigo 9→36,
castlemaine 6→24 = 292 total) are absorbed. **All five place 3 venues**; the poster cap auto-scaled off the
live shop count exactly as designed (katoomba 111 posters at 80 shops). selfcheck **ALL GREEN 51866/51866**.
- **Capacity widened** (the R18 overflow fence, load-tested). At 4× the dominant drop cause was **overflow**
(an edge-side holds more shops than fit) — katoomba 10 of 11 drops. Tightened the run: `NODE_CLEAR` 6→3
and a **roads-only `RGAP` 2→0.5** (the shared marched `GAP` is untouched ⇒ marched fixtures stay frozen).
Drops **7.9% → 5.5%** (16/292). 0.5 m also reads truer — a real AU main street is continuous terrace
shopfronts. Residual drops stay **counted, now by cause** (`dropped_overflow` / `_overlap` / `_stranded`).
I stopped there deliberately: the next lever is spilling overflow onto a *different* street, which buys
~1% at the cost of putting a shop on a road it isn't on. Dropping a crowded shop is the honest trade.
- **Venue facade bias now covers `pub`.** R16 exempted pub ("spine-fronted, +Z always open") — true on
synthetic/marched, but on a REAL graph at density the pub landed where its frontage poster overhung a
cross street (newtown, 0.91 m). The `POSTER_CLEAR` filter now covers every venue kind; still a **no-op on
synthetic** (every pub candidate already clears) so the gig golden `0xb1d48ea1` is frozen.
- **Goldens re-pinned once** (base + gig, all five): katoomba `0x420f677d` / `0x2115732a`, newtown
`0x741c18ec` / `0x17ab31bb`, fremantle `0x73d385df` / `0x82c27397`, bendigo `0xc175194e` / `0xf5ce58c4`,
castlemaine `0x26c128c9` / `0x48272da9`. classic/gig/marched fixtures **frozen**.
- **Fixed:** my real-cache loader globbed `*.json` and tried to validate E's new `index.json` as a town
cache. It now skips it (it's B's selector index, not a cache).
## Round 19 (2026-07-16) — the alpha close: fragmentation resolved + findings hardened + goldens pinned
### → B / C / D / F: **`katoomba_real` boots on real roads, golden pinned, findings resolved** (the handshake)

View File

@ -1,5 +1,70 @@
# LANE B — NOTES (measured)
## Round 20 (Fable's ROUND20 → Lane B) — the tram ruling + ground polish (ledger #3/#4, v4.0-beta)
### 1. The tram ruling (ledger #3) — route by SHOP ADJACENCY, fence per-town
`tram.js` `spinePolyline` now walks from each dead-end of the main subgraph, **greedily taking the
unused main edge that fronts the most shops** (`lot.frontEdge`), and keeps the best-scoring path (ties →
longer). Component logic isn't needed — a walk can't leave its component.
**Synthetic + fixtures are byte-identical, by construction and by measurement.** A synthetic main node has
≤2 mains so there's never a choice, and its two dead-ends tie exactly ⇒ strict `>` keeps the pre-R20 start
⇒ the same route. Verified by running the old and new logic against the same plan: **identical polylines**
(Boolarra Heads: 7 points, 6 edges, 810 m, 172 shops fronted, 2 stops — unchanged).
| town | shops | shops fronting mains | OLD route | NEW route | stops | tram |
|---|---|---|---|---|---|---|
| synthetic | 493 | 172 | 172 | **172** (810 m) | 2 | RUNS (unchanged) |
| fixture melbourne / katoomba | 95 / 19 | 0 / 0 | 0 | 0 (378 m) | 3 | RUNS (unchanged) |
| fixture silverton | 12 | — (0 mains) | — | — | 0 | no-op (unchanged) |
| **REAL newtown** | 64 | 32 | **0** | **8** (1924 m) | 16 | **RUNS** |
| **REAL castlemaine** | 22 | 11 | 1 | **6** (2184 m) | 10 | **RUNS** |
| REAL katoomba | 69 | 7 | **0** | 3 (3784 m) | — | **FENCED** |
| REAL fremantle | 79 | 4 | **0** | 2 (3054 m) | — | **FENCED** |
| REAL bendigo | 35 | 7 | **0** | 1 (4376 m) | — | **FENCED** |
The alpha tram fronted **0 shops on 4 of 5 real towns** — it really was a highway bus. Shop-adjacency
improves every town, but on katoomba/fremantle/bendigo the best main chain still fronts <5 shops, so per
Fable's fallback the tram is **fenced per-town**.
**The fence keys off the REAL-ROADS signal (`edges > 200`), never shop count alone** — because the marched
fixtures legitimately front **0 shops on their mains** (the marched spine is bare; the avenues carry the
shops), so a naive shop-count fence would have killed their v2 tram. Synthetic (22 edges) and fixtures (15)
are an order of magnitude under the threshold and can never be fenced. Threshold: `shopsFronted < 5`.
### 2. Bug found + fixed en route: phantom tram stops on real graphs
The stop projection mapped **every shelter in the town** onto the route with no distance gate — harmless on
synthetic (2 shelters, both on the spine), nonsense on a real graph: **Newtown produced 149 stops** (~9 min
of pure dwell at points the tram never passes). Added a **30 m** gate (a shelter sits `halfRoad+1.7` ≈ ≤16 m
off its own centreline, so every genuine stop survives): **newtown 149 → 16, castlemaine 37 → 10**;
synthetic **2** and fixtures **3** unchanged.
### 3. Ground polish at real intersections (ledger #4) — measured, NO fix needed
Real Katoomba: **325 degree-3+ junctions, 28 under 30°, worst 4.3°** (near-parallel). Viewed at grazing eye
level (the worst case for z-fighting) the worst junction reads **clean — no z-fight, no gaps**: continuous
tarmac, correct footpath/kerb. Why it holds: the layers are already y-separated (road `0.0` / footpath
`0.02` / kerb `0.06`) so nothing co-plane-fights across layers, and road-on-road overlap at junctions is
**invisible by design** — `OVERLAP = 1.5` makes strips overrun their nodes on purpose, and they share one
skin, one y and one merged draw, so a UV mismatch can't read on uniform asphalt.
- *Cosmetic only (v4.0 candidate, not a defect):* at ultra-acute (<10°) junctions the footpath/kerb strips
of the two near-parallel roads double up on the sliver between them. Reads busy, not broken.
### 4. Selector polish (ledger #5) — BLOCKED on E
E's `web/assets/towns/index.json` hasn't landed (`build_towns.py` still WIP), so `REAL_TOWNS` stays
hardcoded this round. It's a one-line swap to `fetch('assets/towns/index.json')` when E lands.
**→ Lane F (B→F tram verdict + selector):** tram **runs** on synthetic, all fixtures, **newtown** and
**castlemaine**; **fenced** on **katoomba, fremantle, bendigo** (`routeInfo.fenced` + `reason` are exposed
for your smoke). Selector gating (always-on HUD vs `?dbg`) is your call — one line either way.
**→ Lane A (finding, with measurements):** only a small slice of shops front a **main** edge on real towns —
katoomba **7/69**, fremantle **4/79**, bendigo **7/35**, newtown 32/64, castlemaine 11/22. The real high
street is coming out classified **`side`**, which is *why* the tram must be fenced on three towns. If your
main/side classifier promoted shop-dense ways to `main`, the fence would lift and the tram would find the
actual high street. Worth a look beyond the tram: **`onMain` also gates `gigs.js pickVenues` pub placement**.
---
## Round 19 (Fable's ROUND19 → Lane B) — the town selector (ledger #4, beta pull-forward, non-blocking)
Shipped the in-game town picker; verified fresh Chromium. **No `index.html` seam** — it lives in `hud.js`,

View File

@ -1,9 +1,10 @@
# LANE C — venue interface contract (v3.0-FROZEN: the district) → Lane D + Lane F
# LANE C — interiors contract (venues + real-town class mapping) → Lanes A/D/E/F
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. Describes the *shipped* state;
> changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (release audit).
> **v3.1 amendment (2026-07-16):** §3 becomes the `stage.backline[]` contract (ledger #5 — the amp
> unification); C dropped its primitive ampStacks. Everything else in this doc is unchanged and stands.
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0§5 describe the *shipped*
> venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit).
> **v3.1 amendment:** §3 became the `stage.backline[]` contract (ledger #5 — the amp unification).
> **v4.0-beta amendment (2026-07-16):** **§6 added — the real-town shop-class → archetype mapping**
> (ledger #2, the density widening; C→E handshake). The frozen venue contract (§0§5) is unchanged.
*The district ships **three** venue archetypes behind `?gigs=1``pub`, `band_room`, `rsl`. Lane A
converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off
@ -136,4 +137,84 @@ bar in the corner — the PA louder than the room. `rsl` = carpet-and-flock club
(hall/wide), a proper raised stage at one end, bistro tables round the edge, members' bar, the biggest crowd
cap. Both reuse the `pub` venue machinery (`theme.js venueSpec` + `layout.js` placeStage/placeWatchPoints);
only the dressing + `venueSpec` differ. Stage/PA keep GLB slots for Lane E (primitive fallback holds); the
primitive ampStacks are the backline fallback (see §3).
backline amp is D's, planted at `stage.backline[0]` (see §3).
---
## 6. Real-town shop-class → interior archetype mapping (v4.0-beta, ledger #2) → Lane E
**Directive (John, kickoff — SUBTLE: texture, not takeover).** The secondhand shops stay the **heroes** of
the town (they *are* the game). The widening adds general retail as *texture* — the everyday main-street
staples a real Australian town has — mapped onto **existing** archetypes (**no new archetypes this round**).
The player should feel the town's more alive, not that the game changed.
**How it's applied (E owns the emission).** `plan_osm` keeps only **registry types** (`SHOP_TYPES` — unknowns
flatten to `opshop`). So E maps each fetched OSM `shop=*` / `amenity=*` value to a **registry type** below and
bakes it into the cache's `shop.type`. Every target is an existing type, so C's recipes, A's registry, and the
budgets are all unchanged. Unknown/unlisted classes degrade safely to `opshop` (R18-proven: 18 real OSM types
all built valid).
**Heroes — secondhand (unchanged; NEVER subsampled):**
| OSM class | → type |
|---|---|
| `charity`, `second_hand`, `antiques`, `vintage` | `opshop` |
| `books`, `bookshop` | `book` |
| `music` | `record` |
| `video`, `games` | `video` |
| `pawnbroker` | `pawn` |
| `toys` | `toy` |
**Widened — everyday main-street texture (general retail):**
| OSM class(es) | → type | wears it as |
|---|---|---|
| `bakery`, `cafe`†, `confectionery`, `pastry`, `chocolate`, `deli`/`delicatessen`, `butcher`, `cheese`, `coffee`, `tea` | `milkbar` | the food counter |
| `convenience`, `kiosk`, `general`, `dairy` | `milkbar` | the corner store |
| `newsagent`, `stationery` | `milkbar` | papers + magazines |
| `chemist`, `pharmacy`, `cosmetics`, `perfumery` | `milkbar` | dispensary counter |
| `clothes`, `fashion`, `boutique`, `shoes`, `bag`, `jewelry`/`jewellery` | `dept` | the dressed retail floor |
| `department_store`, `variety_store`, `supermarket` | `dept` | big general retail |
| `hardware`, `doityourself`, `trade`, `electronics`, `mobile_phone`, `furniture`, `houseware`/`homeware` | `dept` | shelved goods |
| `greengrocer`, `florist`, `farm`, `garden_centre` | `stall` | produce/plants on trestles |
`cafe`/`fast_food` are `amenity=*`, not `shop=*` — fetch them from the amenity tag; everything else is `shop=*`.
**Fallback:** any fetched class not listed → `opshop`.
**Copy-paste map for E (`build_towns.py`):**
```json
{
"charity":"opshop","second_hand":"opshop","antiques":"opshop","vintage":"opshop",
"books":"book","bookshop":"book","music":"record","video":"video","games":"video",
"pawnbroker":"pawn","toys":"toy",
"bakery":"milkbar","cafe":"milkbar","confectionery":"milkbar","pastry":"milkbar","chocolate":"milkbar",
"deli":"milkbar","delicatessen":"milkbar","butcher":"milkbar","cheese":"milkbar","coffee":"milkbar","tea":"milkbar",
"convenience":"milkbar","kiosk":"milkbar","general":"milkbar","dairy":"milkbar",
"newsagent":"milkbar","stationery":"milkbar",
"chemist":"milkbar","pharmacy":"milkbar","cosmetics":"milkbar","perfumery":"milkbar",
"clothes":"dept","fashion":"dept","boutique":"dept","shoes":"dept","bag":"dept","jewelry":"dept","jewellery":"dept",
"department_store":"dept","variety_store":"dept","supermarket":"dept",
"hardware":"dept","doityourself":"dept","trade":"dept","electronics":"dept","mobile_phone":"dept",
"furniture":"dept","houseware":"dept","homeware":"dept",
"greengrocer":"stall","florist":"stall","farm":"stall","garden_centre":"stall"
}
```
Default (not in the map) → `opshop`.
**Subtlety guardrails (E):**
- Fetch only these tasteful staples — **not** every OSM `shop=*`. Skip vehicle/tyres/fuel, funeral, storage,
industrial, and pure services the player never browses (`hairdresser`, `beauty`, `travel_agency`, `bank`,
`laundry`, `estate_agent`).
- **Never** subsample the secondhand heroes.
- If a town's general-retail count would visually **drown** the secondhand strip, seed-subsample the *texture*
classes (the milkbar/dept/stall targets) toward **~23× the secondhand count**, and count the drops in
`norm.dropped` like every other drop.
**Why these targets:** `milkbar` (counter + fridge + magazine rack + glass case) is the canonical Australian
counter shop — it wears cafe/bakery/deli/newsagent/chemist naturally; `dept` (cube shelves + clothes racks +
glass cases) wears clothes + hardware + big retail; `stall` (trestles + crates) wears produce + florist. Three
general-retail looks + the six secondhand heroes + the three venues = variety with no new archetypes.
**Pre-verified (seed 20261990, real-town lot 8.5×14):** milkbar/dept/stall/opshop all build valid (0 carves,
≤143 draws); `dept` reads as a clean **dressed-retail floor** (cube shelves + clothes racks + counter, the
escalator does *not* dominate the ~7.9×11.7 room), so `clothes`/`hardware` → `dept` is confirmed. C re-checks
the full result in the five-town door-soak (ledger #6) once E's caches land. (FYI: C's `theme.js canonicalType`/ALIAS already recognizes several
of these — `deli`/`corner_store`→milkbar etc. — for any direct `buildInterior(rawClass)`; the authoritative
mapping for the caches is this table.)

View File

@ -6,6 +6,137 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 20 — v4.0-beta THE FIVE TOWNS: citizens at density (ledger #7)
*PROCITY-D, 2026-07-16. Part 1 of 2: the **pre-density baseline** — the gig crowd's behaviour on all five
real towns' venues (F's district gate owns placement; I own behaviour). This half is unblocked; the density
re-audit waits on E's widened caches → A's absorb. Deliberately measured BEFORE the widening so the
after-table is a real A/B.*
### The gig crowd is behaviourally CORRECT on all five real towns
Per town: stream sim on the real graph, `setGig` on its first venue, 21:00, camera at the venue, ~1400
frames. Every town generates 3 venues (pub/band_room/rsl — charter's 24) on night hours.
| town | shops | door chunks | patron checks | **null %** | hit venue | **venue win %** | roster | NaN |
|---|---|---|---|---|---|---|---|---|
| katoomba_real | 19 | ~5 | 703 | 97.3 | 14 | 74 | 2 | **0** |
| newtown_real | 18 | 15 | 267 | 95.5 | 12 | **100** | 4 | **0** |
| fremantle_real | 20 | 16 | 429 | 94.9 | 22 | **100** | 7 | **0** |
| bendigo_real | 9 | 9 | 343 | 97.7 | 8 | **100** | 3 | **0** |
| castlemaine_real | 6 | 5 | 364 | 95.9 | 6 | 40 | 4 | **0** |
- **0 NaN on all five.** The gig crowd is numerically safe on every real graph.
- **`tonightRoster` populates on all five** (27) — R14 identity continuity holds on real bones.
- **Venue-win % is the surge working**: whenever the patronage check finds *any* shop, the gig venue beats
ordinary shopfronts (GIG_RANGE/gigBest preference) — 100% on three towns, 74% on katoomba.
**castlemaine's 40% is not a leak**: at the 6-shop floor, 3 of its 6 shops ARE venues, so the "other
shop" hits are mostly *other venues*. A quirk of the sparse floor, not the mechanism.
### CORRECTION to my own first read of that null % — I misattributed it (measurement wins)
My first pass called the 94.997.7 % null rate "the crowd is sparsity-throttled" and made it the headline
before/after metric. **Both halves of that were wrong, and I'm correcting the record:**
1. **It was measured at gig hours.** At 21:00 only **4 of 69** katoomba shops are open — retail is shut *by
design*. A high null rate at 21:00 is **correct night behaviour**, not sparsity. The gig IS the nightlife.
2. **It was sampled at each town's first venue** (`shops.find(s => s.venue)`). For katoomba that is the
**pub, which sits 1,000 m from the retail cluster with 1 neighbour** — I had parked the camera on the one
isolated venue in town. Measured there, the daytime null was 99.4 % with **0 visits**; measured on the
actual main street, the same town same seed gives **77.2 % and 64 visits**. The number was an artefact of
where I stood.
The gig-crowd table above still **stands** — it measures the *mechanism* (0 NaN, rosters populate,
venue-win %), which is location-independent. Only the sparsity interpretation was bad.
### The real density A/B — the main street, retail hours (14:00), same seed, like-for-like
Camera on the **densest retail cluster** (the actual main street), pre-widening cache (from git) vs E's
widened cache, 2,000 frames each:
| main street @14:00 | PRE (20 shops) | **POST (80 shops)** |
|---|---|---|
| hub density (shops within 160 m) | 8 | **25** |
| null % | 90.2 | **77.2** |
| **total visits** | 17 | **64** (3.8×) |
| **hero visits** (opshop/book/record/video/pawn/toy) | 17 | **19** |
| texture visits (milkbar/dept/stall) | 0 | 45 |
| hero share of visits | 100 % | **30 %** |
| hero share of shops | 84 % | 26 % |
| NaN | 0 | 0 |
**The widening lands John's directive, measured from the crowd side.** The secondhand heroes did **not**
lose footfall — they went **17 → 19 visits**. The 45 texture visits are added *on top*, not taken from them.
The street is **3.8× busier** while the heroes stay **over-indexed** (26 % of shops, 30 % of visits). That is
precisely "the player should notice the town feels more alive, not that the game changed."
### Filed, not fixed
- **Venue placement at density (A/F):** katoomba's 3 venues — rsl **13** shops within 160 m, band_room **10**,
**pub 1** (1,000 m from the cluster). A gig at the pub has almost no passing retail crowd. It still draws a
roster (peds stream everywhere), so it's a *quality* observation, not a break. Placement is A's/F's lane.
- **`GIG_RANGE` vs the chunk lookup (mine):** GIG_RANGE is a 34 m *radius*, but `_nearestOpenShop` searches
only the ped's own 64 m chunk, so an in-range ped across a chunk edge is blind to the gig — **8.7 %** of
in-range ped-samples (852/9,740). Real but small; a hot-path change mid-beta isn't worth it. Fix (chunk +
8 neighbours) is a v4.x candidate.
### FINAL — post A's absorb (`5d29863`), all five towns at density
A's absorb landed (capacity widened, goldens pinned, `selfcheck` **51866/51866**). Re-ran everything against
the absorbed graph. **The katoomba numbers barely moved** (null 77.2→78.1, visits 64→62, hero share 30→29 %)
— the pre-absorb read was already sound.
| town | cache→seated | hub | null % | visits | hero | texture | hero share | NaN | maxActive | rosters | sigs |
|---|---|---|---|---|---|---|---|---|---|---|---|
| katoomba_real | 80→72 (8) | 26 | 78.1 | 62 | 18 | 44 | 29 % | **0** | 217 | 15 | 262 |
| fremantle_real | 80→79 (1) | 29 | 71.1 | 116 | 23 | 93 | 20 % | **0** | 401 | 24 | 483 |
| newtown_real | 72→67 (5) | 12 | 85.9 | 44 | 6 | 38 | 14 % | **0** | 268 | 19 | 324 |
| bendigo_real | 36→35 (1) | 14 | 87.2 | 45 | 1 | 44 | 2 % | **0** | 358 | 21 | 432 |
| castlemaine_real | 24→23 (1) | 6 | 94.9 | 9 | 0 | 9 | 0 % | **0** | 295 | 20 | 359 |
**Sim verdict: zero changes needed at 4× density.** 0 NaN on all five, chunk rosters stream (1524), identity
signatures accumulate (262483), patronage/loiter/bench-sit all fire, up to **401 active peds** with no
degradation. Determinism unchanged (identity is graph+seed keyed; shops don't touch it). **D→F: green.**
### THE FINDING → for E / C / Fable: the widening MOVED the main street off the secondhand strip
The hero share falling (29→0 %) is **not** a behaviour bug — patronage is type-agnostic, so visits track the
*local* shop mix exactly. The mix is the story. Main street = the densest shop cluster (what "walk the main
street" means to a player):
| town | main-st shops | **heroes on main st** | hero % of main st | town-wide hero % | **hub moved** |
|---|---|---|---|---|---|
| katoomba | 9 → **27** | **9 (100 %) → 5** | 100 % → **19 %** | 25 % | **308 m** |
| fremantle | — → 30 | 5 | **17 %** | 25 % | — |
| newtown | — → 13 | 2 | **15 %** | 27 % | — |
| bendigo | — → 15 | 1 | **7 %** | 25 % | — |
| castlemaine | 3 → **7** | **1 → 0** | 33 % → **0 %** | 20 % | **434 m** |
**Pre-widening the densest cluster WAS the secondhand strip** (katoomba: 9 of 9 main-street shops were
heroes). Post-widening the town's centre of gravity **relocated 308434 m onto the general-retail cluster**,
where the heroes are a **019 % minority**. Castlemaine's main street now has **zero** secondhand shops
(0 of 4 heroes within 160 m of its hub; median hero is 463 m away). The heroes weren't drowned *in place*
they were **demoted**: the liveliest street in town is no longer theirs.
- **The good half stands:** heroes kept their absolute footfall (katoomba 17 → 18 visits) and the street is
**3.8× busier**. The widening works; it just overshot the dial.
- **This is exactly the case John's directive pre-authorised:** *"if a real town's general retail would drown
the secondhand strip visually, E may seed-subsample the texture classes (count it, like every drop)."*
My read (measurement, not a ruling — E/C/Fable own it): **subsample texture until the secondhand strip is
again the town's densest cluster.** The sparse towns need it most (bendigo 7 %, castlemaine 0 %); katoomba
at 19 % is borderline. A hero-adjacency bias on texture seating would be the alternative lever (A's).
### Also filed — venue placement at density (A / F)
Venues are seeded from the shop pool, and at real density they land **off** the retail hub:
| town | venue distance to hub (shops within 160 m) |
|---|---|
| katoomba | pub **1007 m (1)** · rsl 161 m (10) · band_room 181 m (7) |
| fremantle | pub 969 m (1) · rsl 561 m (2) · band_room 612 m (2) |
| **newtown** | **all three 21902433 m (01)** |
| bendigo | pub 504 m (1) · rsl 451 m (0) · band_room 1157 m (0) |
| castlemaine | band_room **0 m (6)** · rsl 155 m (4) · pub 223 m (1) |
A gig 2.4 km from the high street has no passing retail trade. A's `5d29863` "pub facade bias" fixed frontage,
not town position — katoomba's pub is still 1007 m out with 1 neighbour. Crowd still forms (peds stream with
the camera), so it's a **quality** call, not a break — but "venue placement still lands" is in A's ledger and
F's district gate, so it's theirs. Castlemaine shows the good case (band_room *on* the hub).
---
## ROUND 19 — v4.0-alpha CLOSE: the fragmentation re-measure (ledger #3)
*PROCITY-D, 2026-07-16. A resolved my R18 finding (commit `6fc4b40`: junction-protected DP + bridge-joins

View File

@ -1,5 +1,54 @@
# LANE E — cross-lane notes
## Round 20 — v4.0-beta THE FIVE TOWNS: the density widening ⟨v4-beta⟩
> Ledger #2, E's half — built to **C's LANE_C_PUB §6 table** under **John's SUBTLE directive**
> ("texture, not takeover"). Widened fetch → re-emitted 5 caches → `towns/index.json`.
> → **Lane A: caches re-emitted, densities are yours to absorb + re-pin** (see the two flags below).
> → **Lane B: `index.json` is live — drop the `REAL_TOWNS` hardcode in `hud.js:11`.**
**The widening.** `build_towns.py` now fetches C's tasteful staple list (`shop=*` + `amenity=cafe`,
per C's †) and maps every class through C's copy-paste table onto **existing** registry types — no new
archetypes. Guardrails honoured: **no** vehicle/tyres/fuel, funeral, storage, industrial, or pure
services (hairdresser/beauty/bank/laundry/estate_agent). Raw is a **new snapshot** (the fetcher
auto-refetches when the class list changes — the query is recorded in each snapshot, so nobody has to
remember `--refetch`), committed like the first. Deterministic: same raw → byte-identical cache.
**SUBTLE, enforced by construction.** The secondhand **heroes are never subsampled** (they're the
game). Texture is capped at **3× the hero count** (the top of C's ~23× band — keeps the town alive
without letting retail take over); over-cap towns are **seed-subsampled deterministically**
(`sha256(townKey:osmId)` rank — stable across rebuilds so A's goldens hold) and **every drop counted**
in `counts.textureDropped`:
| town | heroes | texture | ratio | dropped | total | shop mix |
|---|---|---|---|---|---|---|
| katoomba_real | 20 | 60 | 3.0× | 4 | **80** | milkbar 33 · dept 22 · opshop 13 · stall 5 · book 4 · record 2 · video 1 |
| newtown_real | 18 | 54 | 3.0× | 156 | **72** | milkbar 30 · dept 21 · opshop 6 · record 5 · book 4 · stall 3 · toy 2 · video 1 |
| fremantle_real | 20 | 60 | 3.0× | 81 | **80** | milkbar 43 · dept 16 · opshop 9 · book 5 · toy 2 · video 2 · pawn 1 · record 1 · stall 1 |
| bendigo_real | 9 | 27 | 3.0× | 81 | **36** | milkbar 14 · dept 12 · opshop 3 · book 3 · stall 1 · record 1 · video 1 · pawn 1 |
| castlemaine_real | 6 | 18 | 3.0× | 43 | **24** | milkbar 9 · dept 6 · opshop 4 · stall 3 · book 2 |
Density is now **34× pre-widening** (621 → 2480 shops) — inside the ledger's 36× expectation for A.
Katoomba reads right: the op shops (Cancer Help, Tanglecare, Cat Defence, Red Shield) still anchor the
strip, now with The Hatters Cafe, Blue Mountains Chocolate Co, K Hub and Bunnings as texture around them.
**`web/assets/towns/index.json`** (new, E-owned): `{schema:"procity-towns-index/1", generated, license,
attribution, towns:[{key, town, state, shops, roads}]}` — 5 towns, built from every cache on disk.
### ⚠ Two flags for Lane A (both land in your absorb pass)
1. **10 expected golden re-pins** — the widening moves every real town's base + gig golden (5 × 2).
That's the designed E→A handoff (ledger #2). **Synthetic `0x3fa36874` is UNMOVED**; fixtures frozen.
2. **`index.json` trips selfcheck's town glob** (a real seam — `selfcheck.js:467` takes *every*
top-level `towns/*.json` as a cache, so the index validates as a town and fails). **One-line fix in
your file:** `.filter(f => f.endsWith('.json') && f !== 'index.json')`. I didn't touch `selfcheck.js`
— it's yours and you're mid-round in it. Fable spec'd the index at this path (ledger #2/#5, B reads
it there), so the path stays; the glob is what needs to learn about it.
### → Lane C (one-line map addition)
`video_games` wasn't in your §6 table (you listed `video`/`games` → `video`). It was already shipping as
`video` pre-widening, so I kept **`video_games``video`** rather than regress those shops to `opshop`
via the fallback. Same family, same target — flagging so your table and my emit stay the one truth.
## 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.

View File

@ -0,0 +1,91 @@
# PROCITY — Round 21 lane instructions (from Fable, integrator)
Date: 2026-07-16 · **R20 verdict: the density round delivered — but the beta is NOT tagged.**
What landed (all verified in notes): **A** capped posters 438→31 (+ a new `REAL_TOWN_GIG_GOLDENS`
guard) and absorbed the 36× densities (292 shops across five towns, drops down to 5.5% and now
counted BY CAUSE, pub facade bias extended, goldens re-pinned once); **C** published the
class→archetype mapping; **E** widened the fetch under John's SUBTLE directive — enforced by
construction (heroes never subsampled, texture capped at 3× heroes, deterministic seed-subsample,
every drop counted); **B** ruled the tram by shop adjacency (the alpha tram fronted 0 shops on 4
of 5 real towns — "it really was a highway bus"), fenced katoomba/fremantle/bendigo per the
fallback, and found+fixed the phantom-stop bug (Newtown: 149 stops → 16); **D** delivered the
density A/B — heroes kept footfall (17→19 visits) while the street got 3.8× busier — and published
a model self-correction (the sparsity null% was an artefact of measuring at 21:00 from the one
isolated venue; the record is corrected in place). Quality is at series-best.
**Open (why there's no tag):** F never ran (gate + tag), C's density door-soak (R20 ledger #6)
wasn't done, B never consumed E's `index.json` (`hud.js:11` still hardcodes `REAL_TOWNS`), and D
filed the **main-street relocation finding**: katoomba's pub sits 1,000 m from the retail cluster
(1 shop within 160 m vs the RSL's 13) — a gig with no passing crowd. Round 21 closes all of it.
Tag **`v4.0-beta`** at close.
## The ledger
1. **Venue cluster bias (A — D's relocation finding).** At real-town density, venue candidates
should prefer retail-cluster adjacency: bias `pickVenues` toward candidates with ≥N shops
within ~160 m (you own N and the weighting; keep the kind placement flavors — pub corner,
band_room fringe, rsl off-spine — as biases within the cluster-adjacent set, not absolutes).
**Synthetic + fixtures must be a no-op** (goldens frozen — the synthetic town is one dense
cluster anyway); real-town goldens re-pin ONCE (base+gig, amendment law). Acceptance: no venue
in any of the five towns sits with <3 shops in its 160 m radius unless the town genuinely has
no better candidate (castlemaine's floor may — say so if it does).
2. **Selector from the index (B — the missed R20 #5).** Drop the `hud.js:11` hardcode; fetch
E's `web/assets/towns/index.json` (fail-soft: index missing → current hardcode as fallback,
zero errors). Selector shows shop counts from the index if it's cheap. Nothing else — your
tram/ground work is done and verified.
3. **Density door-soak (C — the missed R20 #6).** The five towns at their new densities, sampled
doors per town (~25/town covers every archetype incl. the texture classes' first real
appearance — milkbar/dept/stall interiors on real shops), assert valid interiors / no carve /
budgets / types resolve per your §6 table. One table, five rows. **Run AFTER A lands** (venue
relocation changes which lots are venues).
4. **Relocation verify (D).** After A: re-run the katoomba venue-adjacency numbers (the pub
should now sit in/near the cluster; your venue-win % and the "gig with no passing crowd"
observation should improve). One before/after line per town. Your GIG_RANGE chunk-blindness
finding stays filed for v4.x (agreed — not a mid-beta hot-path change).
5. **Town-pack pull-forward (E — NON-GATING for the tag).** Begin v4.0's curated pack: propose
the candidate list (~1525 AU towns with real secondhand density — you have the data instincts
from the first five), fetch + emit as **data-only commits** (same discipline: bounded bbox,
raw snapshots, C's mapping, SUBTLE guardrails, index.json grows). A absorbs + pins in R22 —
do NOT block the beta tag on any of it. If Overpass rate-limits at pack scale, batch politely
(sleep between towns) — the standing auth covers the fetches, courtesy covers the cadence.
6. **The beta gate (F, last).**
a. Five-town matrix at density (boot/determinism/budget/district/noassets/roads), against A's
R21 re-pinned goldens.
b. **`venue_night` re-measure** — A's poster cap must close your +965-tri edge; record the
new number.
c. Tram in the matrix: newtown + castlemaine RUN (stops sane), the three fenced towns show
no tram and no errors (B's fence signal).
d. **Selector gating ruling** (your R19 open call): always-in-HUD vs `?dbg`-gated — rule it,
one line, done.
e. Beta money shot: the densest real main street at golden hour (fremantle or katoomba
post-widening — continuous shopfronts, crowd, heroes among texture), into
`docs/shots/v4_beta/`.
f. Docs (README beta paragraph, LANE_F_NOTES §20/§21 incl. the honest why-R20-had-no-tag
record), tag **`v4.0-beta`**, push (standing authorization).
## Laws
Charter unchanged. One golden move this round (A's #1, batched base+gig, once). Synthetic
`0x3fa36874`, gig `0xb1d48ea1`, fixtures, classic — frozen. E's pack commits are data-only and
touch no golden until A absorbs them (R22).
## THE EXACT RUN ORDER (John asked)
1. **Start together: A + B + E.** (A: venue bias — the only golden-moving item, the round's
spine. B: selector-from-index — independent. E: town-pack fetches — independent, non-gating.)
2. **When A commits (goldens re-pinned): start C + D.** (C: door-soak on the relocated world.
D: relocation verify. Both read A's handshake note for the new goldens.)
3. **When C + D have committed (and B is in): start F.** (E does NOT gate F — if E's pack is
still fetching, F tags without it.)
Three waves: **[A, B, E] → [C, D] → [F].**
## Standing
- **John — nothing blocks this round.** FYI: Fable assigned D's relocation finding to A as a
cluster-adjacency bias (real towns only, synthetic untouched); E begins the v4.0 town pack
early as data-only commits. Both are course-corrections inside the chartered epoch.
- **v4.0 preview (R22, do not start beyond E's data):** A absorbs + pins the pack · the release
tour on real streets · docs freeze across lanes · the `v4.0` tag · then the epoch retro.
- **Backlog:** GIG_RANGE chunk-neighbour lookup (D, v4.x) · 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, urllib.error
import os, sys, json, re, math, time, datetime, hashlib, 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")
@ -28,20 +28,55 @@ UA = "PROCITY-townscout/0.1 (real-map scout; OpenStreetMap ODbL data)"
# Compact real AU towns: center (lat,lon) + query box span (km). `katoomba_real` is the fixture
# comparison (vs the hand-made `katoomba` in osm_fixture.js). Spans kept tight so each is ONE town.
TOWNS = {
"katoomba_real": {"town": "Katoomba", "center": (-33.7145, 150.3120), "span_km": 3.0},
"newtown_real": {"town": "Newtown", "center": (-33.8985, 151.1790), "span_km": 2.6},
"fremantle_real": {"town": "Fremantle", "center": (-32.0555, 115.7480), "span_km": 2.6},
"bendigo_real": {"town": "Bendigo", "center": (-36.7580, 144.2810), "span_km": 3.4},
"castlemaine_real": {"town": "Castlemaine", "center": (-37.0640, 144.2170), "span_km": 3.0},
"katoomba_real": {"town": "Katoomba", "state": "NSW", "center": (-33.7145, 150.3120), "span_km": 3.0},
"newtown_real": {"town": "Newtown", "state": "NSW", "center": (-33.8985, 151.1790), "span_km": 2.6},
"fremantle_real": {"town": "Fremantle", "state": "WA", "center": (-32.0555, 115.7480), "span_km": 2.6},
"bendigo_real": {"town": "Bendigo", "state": "VIC", "center": (-36.7580, 144.2810), "span_km": 3.4},
"castlemaine_real": {"town": "Castlemaine", "state": "VIC", "center": (-37.0640, 144.2170), "span_km": 3.0},
}
# OSM shop tags we scout (on-theme: the game's secondhand/media/hobby world) → registry SHOP_TYPE.
SHOP_RE = "charity|second_hand|music|antiques|books|toys|games|video_games|pawnbroker"
TYPE_MAP = {
"books": "book", "music": "record", "toys": "toy", "games": "toy",
"video_games": "video", "video": "video", "pawnbroker": "pawn",
"charity": "opshop", "second_hand": "opshop", "antiques": "opshop",
# ── the class list (v4.0-beta, ROUND20 ledger #2 — C's LANE_C_PUB §6 table, John's SUBTLE directive) ──
# HEROES = the secondhand shops (they ARE the game) — never subsampled. TEXTURE = the everyday
# main-street staples a real AU town has (bakery/cafe/chemist/newsagent/clothes/…). Tasteful staples
# ONLY per C's guardrail: no vehicle/tyres/fuel, funeral, storage, industrial, and no pure services
# the player never browses (hairdresser/beauty/travel_agency/bank/laundry/estate_agent).
HERO_CLASSES = {"charity", "second_hand", "antiques", "vintage", "books", "bookshop",
"music", "video", "games", "video_games", "pawnbroker", "toys"}
TEXTURE_CLASSES = {
"bakery", "cafe", "confectionery", "pastry", "chocolate", "deli", "delicatessen", "butcher",
"cheese", "coffee", "tea", "convenience", "kiosk", "general", "dairy", "newsagent", "stationery",
"chemist", "pharmacy", "cosmetics", "perfumery", "clothes", "fashion", "boutique", "shoes", "bag",
"jewelry", "jewellery", "department_store", "variety_store", "supermarket", "hardware",
"doityourself", "trade", "electronics", "mobile_phone", "furniture", "houseware", "homeware",
"greengrocer", "florist", "farm", "garden_centre",
}
AMENITY_CLASSES = {"cafe"} # C's †: cafe is amenity=*, not shop=*
SHOP_CLASSES = (HERO_CLASSES | TEXTURE_CLASSES) - AMENITY_CLASSES
# C's copy-paste map (LANE_C_PUB §6). Every target is an EXISTING registry type — no new archetypes.
# (+ `video_games`→video: C listed video/games→video; video_games is the same family and was already
# shipping as `video` pre-widening — kept so those shops don't regress to opshop. Flagged to C.)
TYPE_MAP = {
"charity": "opshop", "second_hand": "opshop", "antiques": "opshop", "vintage": "opshop",
"books": "book", "bookshop": "book", "music": "record", "video": "video", "games": "video",
"video_games": "video", "pawnbroker": "pawn", "toys": "toy",
"bakery": "milkbar", "cafe": "milkbar", "confectionery": "milkbar", "pastry": "milkbar",
"chocolate": "milkbar", "deli": "milkbar", "delicatessen": "milkbar", "butcher": "milkbar",
"cheese": "milkbar", "coffee": "milkbar", "tea": "milkbar", "convenience": "milkbar",
"kiosk": "milkbar", "general": "milkbar", "dairy": "milkbar", "newsagent": "milkbar",
"stationery": "milkbar", "chemist": "milkbar", "pharmacy": "milkbar", "cosmetics": "milkbar",
"perfumery": "milkbar",
"clothes": "dept", "fashion": "dept", "boutique": "dept", "shoes": "dept", "bag": "dept",
"jewelry": "dept", "jewellery": "dept", "department_store": "dept", "variety_store": "dept",
"supermarket": "dept", "hardware": "dept", "doityourself": "dept", "trade": "dept",
"electronics": "dept", "mobile_phone": "dept", "furniture": "dept", "houseware": "dept",
"homeware": "dept",
"greengrocer": "stall", "florist": "stall", "farm": "stall", "garden_centre": "stall",
}
# John's directive: texture, not takeover. If a town's general retail would drown the secondhand
# strip, seed-subsample the TEXTURE classes toward C's ~23× band; heroes are never touched and
# every drop is counted (counts.textureDropped).
TEXTURE_CAP = 3.0
# ── roads[] (schema v2, ROUND18 REAL ROADS) ─────────────────────────────────────────────────────
# We FETCH every OSM highway tier A's ROAD_KIND maps (→ raw cache, full provenance / beta-ready) but
@ -114,18 +149,27 @@ def overpass_post(q):
time.sleep(wait)
def shops_query(bb):
box = f'{bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f}'
return (f'[out:json][timeout:180];('
f'nwr["shop"~"^({"|".join(sorted(SHOP_CLASSES))})$"]["name"]({box});'
f'nwr["amenity"~"^({"|".join(sorted(AMENITY_CLASSES))})$"]["name"]({box});'
f');out center;')
def fetch_raw(key, bb, refetch):
"""Return the raw Overpass elements list, cached in-repo so re-runs never re-fetch."""
"""Raw Overpass elements, cached in-repo so re-runs never re-fetch. Auto-refetches when the CLASS
LIST changes (each snapshot records its query) that's how the R20 widening lands as a NEW raw
snapshot without anyone remembering --refetch."""
os.makedirs(RAW_DIR, exist_ok=True)
path = os.path.join(RAW_DIR, key + ".overpass.json")
q = shops_query(bb)
if os.path.exists(path) and not refetch:
w = json.load(open(path))
return w["elements"], w.get("fetchedAt")
q = (f'[out:json][timeout:120];'
f'nwr["shop"~"^({SHOP_RE})$"]["name"]'
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})")
if w.get("query") == q:
return w["elements"], w.get("fetchedAt")
print(f" [{key}] class list changed → new raw snapshot")
print(f" [{key}] fetching Overpass shops (widened, bounded)")
els = overpass_post(q)
fetched = datetime.date.today().isoformat()
json.dump({"fetchedAt": fetched, "endpoint": OVERPASS, "query": q, "bbox": bb, "elements": els},
@ -178,7 +222,7 @@ def process(key, cfg, els, fetched, roads=None):
rows, seen = [], set()
for el in els:
t = el.get("tags", {})
name, osm_shop = t.get("name"), t.get("shop")
name, osm_shop = t.get("name"), (t.get("shop") or t.get("amenity")) # cafe rides amenity=*
lat = el.get("lat", el.get("center", {}).get("lat"))
lon = el.get("lon", el.get("center", {}).get("lon"))
if not (name and osm_shop and isinstance(lat, (int, float)) and isinstance(lon, (int, float))):
@ -206,9 +250,24 @@ def process(key, cfg, els, fetched, roads=None):
continue
seen2.add(k2)
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"]})
"lat": round(r["lat"], 6), "lon": round(r["lon"], 6), "suburb": r["suburb"],
"_osm": r["osm"]})
# ── SUBTLE (John's directive): texture, not takeover ────────────────────────────────────────
# The secondhand HEROES are never subsampled — they're the game. If a town's general retail would
# drown the strip, seed-subsample the TEXTURE toward C's ~23× band (deterministic: rank by
# sha256(townKey:osmId), keep the top `cap`) and COUNT the drops like every other drop.
heroes = [s for s in shops if s["_osm"] in HERO_CLASSES]
texture = [s for s in shops if s["_osm"] not in HERO_CLASSES]
cap, dropped = int(round(TEXTURE_CAP * len(heroes))), 0
if len(texture) > cap:
texture.sort(key=lambda s: hashlib.sha256(f"{key}:{s['id']}".encode()).hexdigest())
dropped, texture = len(texture) - cap, texture[:cap]
shops = sorted(heroes + texture, key=lambda s: s["id"]) # deterministic emit order
for s in shops:
s.pop("_osm", None)
bb = bbox(center, cfg["span_km"])
counts = {"raw": len(els), "shops": len(shops)}
counts = {"raw": len(els), "shops": len(shops), "heroes": len(heroes),
"texture": len(texture), "textureDropped": dropped}
cache = {
"schema": "procity-town-cache/2" if roads else "procity-town-cache/1",
"key": key, "town": cfg["town"], "source": "osm",
@ -257,16 +316,30 @@ def main():
kept.append(key)
else:
dropped.append((key, n, sp))
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}")
c = cache["counts"]
sub = f" {c['textureDropped']} subsampled" if c.get("textureDropped") else ""
print(f" {'OK ' if ok else 'LOW'} {key:16} shops={n:>3} (heroes {c['heroes']:>2} + texture "
f"{c['texture']:>3}{sub}) roads={len(roads or [])!s:>4} {dict(sorted(types.items()))}")
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}")
write_index()
def write_index():
"""web/assets/towns/index.json (ROUND20 ledger #2) — B's selector derives the town list from this
instead of hardcoding REAL_TOWNS. Built from every cache on disk, not just this run."""
towns = []
for f in sorted(x for x in os.listdir(TOWNS_DIR) if x.endswith(".json") and x != "index.json"):
c = json.load(open(os.path.join(TOWNS_DIR, f)))
k = c.get("key", f[:-5])
towns.append({"key": k, "town": c.get("town", k), "state": TOWNS.get(k, {}).get("state", ""),
"shops": len(c.get("shops", [])), "roads": bool(c.get("roads"))})
idx = {"schema": "procity-towns-index/1", "generated": datetime.date.today().isoformat(),
"license": "ODbL 1.0", "attribution": "© OpenStreetMap contributors", "towns": towns}
json.dump(idx, open(os.path.join(TOWNS_DIR, "index.json"), "w"), indent=1)
print(f"INDEX → web/assets/towns/index.json ({len(towns)} towns: "
f"{', '.join(t['key'] for t in towns)})")
if __name__ == "__main__":

View File

@ -17,13 +17,30 @@ the "© OpenStreetMap contributors (ODbL)" credit visible.
## Provenance table
| cache | town | source | licence | fetched | shops | raw response |
Shop counts are post-widening (v4.0-beta); `index.json` is the machine-readable roster.
| cache | town | source | licence | fetched | shops (heroes+texture) | raw response |
|---|---|---|---|---|---|---|
| `katoomba_real.json` | Katoomba, NSW | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 20 | `_raw/katoomba_real.overpass.json` |
| `newtown_real.json` | Newtown, Sydney NSW | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 18 | `_raw/newtown_real.overpass.json` |
| `fremantle_real.json` | Fremantle, WA | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 21 | `_raw/fremantle_real.overpass.json` |
| `bendigo_real.json` | Bendigo, VIC | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 9 | `_raw/bendigo_real.overpass.json` |
| `castlemaine_real.json` | Castlemaine, VIC | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 6 | `_raw/castlemaine_real.overpass.json` |
| `katoomba_real.json` | Katoomba, NSW | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 80 (20+60) | `_raw/katoomba_real.overpass.json` |
| `newtown_real.json` | Newtown, Sydney NSW | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 72 (18+54) | `_raw/newtown_real.overpass.json` |
| `fremantle_real.json` | Fremantle, WA | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 80 (20+60) | `_raw/fremantle_real.overpass.json` |
| `bendigo_real.json` | Bendigo, VIC | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 36 (9+27) | `_raw/bendigo_real.overpass.json` |
| `castlemaine_real.json` | Castlemaine, VIC | OSM Overpass (bounded) | ODbL 1.0 | 2026-07-16 | 24 (6+18) | `_raw/castlemaine_real.overpass.json` |
## The density widening (v4.0-beta, ROUND20 ledger #2)
The fetch covers the **secondhand heroes** plus the everyday main-street **texture** (bakery/cafe/deli/
newsagent/chemist/clothes/hardware/greengrocer/…), mapped to existing registry types per **C's
LANE_C_PUB §6** table. Tasteful staples only — no vehicle/fuel, funeral, storage, industrial, or pure
services. `cafe` rides `amenity=*`; everything else is `shop=*`. Per John's **subtle** directive the
heroes are **never** subsampled and texture is capped at **3× the hero count** (deterministic
`sha256(townKey:osmId)` rank); every drop is counted in each cache's `counts.textureDropped`.
## `index.json` — the towns roster
`{schema:"procity-towns-index/1", generated, license, attribution, towns:[{key, town, state, shops,
roads}]}`. Same ODbL terms as the caches. Consumers (B's selector) derive the town list from this
rather than hardcoding.
## Raw responses (`_raw/`)

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"fetchedAt": "2026-07-16",
"endpoint": "https://overpass-api.de/api/interpreter",
"query": "[out:json][timeout:120];nwr[\"shop\"~\"^(charity|second_hand|music|antiques|books|toys|games|video_games|pawnbroker)$\"][\"name\"](-37.07751,144.20006,-37.05049,144.23394);out center;",
"query": "[out:json][timeout:180];(nwr[\"shop\"~\"^(antiques|bag|bakery|books|bookshop|boutique|butcher|charity|cheese|chemist|chocolate|clothes|coffee|confectionery|convenience|cosmetics|dairy|deli|delicatessen|department_store|doityourself|electronics|farm|fashion|florist|furniture|games|garden_centre|general|greengrocer|hardware|homeware|houseware|jewellery|jewelry|kiosk|mobile_phone|music|newsagent|pastry|pawnbroker|perfumery|pharmacy|second_hand|shoes|stationery|supermarket|tea|toys|trade|variety_store|video|video_games|vintage)$\"][\"name\"](-37.07751,144.20006,-37.05049,144.23394);nwr[\"amenity\"~\"^(cafe)$\"][\"name\"](-37.07751,144.20006,-37.05049,144.23394););out center;",
"bbox": {
"minLat": -37.077513513513516,
"minLon": 144.2000649826232,
@ -9,6 +9,132 @@
"maxLon": 144.23393501737684
},
"elements": [
{
"type": "node",
"id": 294087787,
"lat": -37.0656116,
"lon": 144.2183312,
"tags": {
"amenity": "cafe",
"name": "Pannini"
}
},
{
"type": "node",
"id": 2956949396,
"lat": -37.0671475,
"lon": 144.2178039,
"tags": {
"addr:housenumber": "54",
"addr:street": "Forest Street",
"name": "Forest St Discount Traders",
"shop": "furniture"
}
},
{
"type": "node",
"id": 4168483728,
"lat": -37.0562458,
"lon": 144.2136412,
"tags": {
"addr:housenumber": "9",
"addr:postcode": "3450",
"addr:state": "VIC",
"addr:street": "Walker Street",
"addr:suburb": "Castlemaine",
"amenity": "cafe",
"name": "Das Kaffeehaus",
"opening_hours": "Mo-Su 08:00-16:00",
"outdoor_seating": "yes",
"phone": "+61 3 5470 6270",
"takeaway": "yes",
"toilets": "yes",
"website": "https://coffeebasics.com/"
}
},
{
"type": "node",
"id": 4714218689,
"lat": -37.0648632,
"lon": 144.2171511,
"tags": {
"addr:housenumber": "184",
"addr:street": "Barker Street",
"name": "Hot & Crusty Bakery",
"name:en": "Hot & Crusty Bakery",
"opening_hours": "Mo-Fr 07:00-17:30; Sa-Su 07:00-15:00",
"shop": "bakery"
}
},
{
"type": "node",
"id": 5883185477,
"lat": -37.0653774,
"lon": 144.218071,
"tags": {
"addr:housenumber": "61",
"addr:postcode": "3450",
"addr:state": "VIC",
"addr:street": "Mostyn Street",
"addr:suburb": "Castlemaine",
"name": "Chemmart",
"shop": "chemist"
}
},
{
"type": "node",
"id": 8909393345,
"lat": -37.0562934,
"lon": 144.2142302,
"tags": {
"contact:facebook": "https://www.facebook.com/harvestfruitandveg/",
"name": "Harvest",
"opening_hours": "Th 10:00-17:00; Fr 09:00-17:00; Sa 09:00-13:00; Su-We closed",
"phone": "+61 438 606 777",
"shop": "greengrocer",
"website": "https://castlemaineharvest.com.au/"
}
},
{
"type": "node",
"id": 8909393346,
"lat": -37.0560724,
"lon": 144.2136998,
"tags": {
"addr:housenumber": "9",
"addr:street": "Walker Street",
"contact:facebook": "https://www.facebook.com/longpaddockcheese/",
"name": "Long Paddock Cheese",
"phone": "+61 3 5472 3142",
"shop": "cheese",
"website": "https://longpaddockcheese.com.au/"
}
},
{
"type": "node",
"id": 8909393347,
"lat": -37.0562105,
"lon": 144.213966,
"tags": {
"addr:housenumber": "9",
"addr:postcode": "3450",
"addr:street": "Walker Street",
"contact:instagram": "https://www.instagram.com/sproutbakerycastlemaine/",
"name": "Sprout Bakery",
"shop": "bakery",
"website": "https://millcastlemaine.com.au/portfolio/sprout-bakery/"
}
},
{
"type": "node",
"id": 9125031531,
"lat": -37.0564952,
"lon": 144.2185694,
"tags": {
"amenity": "cafe",
"name": "Maine Foodstore"
}
},
{
"type": "node",
"id": 9231704552,
@ -24,6 +150,72 @@
"website": "https://www.vinnies.org.au/shops/view/360"
}
},
{
"type": "node",
"id": 9231729701,
"lat": -37.0629924,
"lon": 144.2164624,
"tags": {
"addr:housenumber": "31",
"addr:street": "Templeton Street",
"amenity": "cafe",
"name": "Fig Cafe",
"phone": "+61 3 5472 5311",
"website": "http://figcastlemaine.com.au/"
}
},
{
"type": "node",
"id": 10050223909,
"lat": -37.067116,
"lon": 144.2174714,
"tags": {
"addr:housenumber": "46",
"addr:postcode": "3450",
"addr:state": "VIC",
"addr:street": "Forest Street",
"addr:suburb": "Castlemaine",
"name": "Johnny Baker Downtown",
"opening_hours": "Mo-Su 07:00-15:00",
"phone": "+61 3 5470 5695",
"shop": "bakery",
"website": "https://johnnybaker.com.au/"
}
},
{
"type": "node",
"id": 11699200334,
"lat": -37.063504,
"lon": 144.2179756,
"tags": {
"amenity": "cafe",
"name": "Saint Florian"
}
},
{
"type": "node",
"id": 11699200335,
"lat": -37.0633677,
"lon": 144.2169181,
"tags": {
"amenity": "cafe",
"cuisine": "coffee_shop",
"name": "Nira",
"opening_hours": "We-Sa 08:00-14:00",
"outdoor_seating": "yes",
"website": "https://www.instagram.com/nira.castlemaine/"
}
},
{
"type": "node",
"id": 11699200336,
"lat": -37.0629803,
"lon": 144.2163297,
"tags": {
"name": "Green Goes The Grocer",
"shop": "greengrocer"
}
},
{
"type": "node",
"id": 11699200337,
@ -34,6 +226,87 @@
"shop": "antiques"
}
},
{
"type": "node",
"id": 11699200341,
"lat": -37.0632737,
"lon": 144.2158686,
"tags": {
"name": "The Tea Dispensary",
"shop": "tea",
"website": "https://theteadispensarycastlemaine.com.au"
}
},
{
"type": "node",
"id": 11699205335,
"lat": -37.0637846,
"lon": 144.216911,
"tags": {
"amenity": "cafe",
"name": "Origini"
}
},
{
"type": "node",
"id": 11699205343,
"lat": -37.0637329,
"lon": 144.2169387,
"tags": {
"name": "Warm Vintage Clothes",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11699205344,
"lat": -37.0639543,
"lon": 144.216863,
"tags": {
"name": "Beleza School Uniforms",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11699208635,
"lat": -37.0555701,
"lon": 144.2189103,
"tags": {
"name": "Just Because She Said So",
"shop": "shoes"
}
},
{
"type": "node",
"id": 11699208636,
"lat": -37.0563736,
"lon": 144.2185894,
"tags": {
"name": "Zala Floristry",
"shop": "florist"
}
},
{
"type": "node",
"id": 11699208637,
"lat": -37.0551631,
"lon": 144.2181645,
"tags": {
"name": "Johnny Baker Uptown",
"shop": "bakery"
}
},
{
"type": "node",
"id": 11699208640,
"lat": -37.0576933,
"lon": 144.2179368,
"tags": {
"name": "Redgum & Quartz",
"shop": "jewelry"
}
},
{
"type": "node",
"id": 11699208641,
@ -44,6 +317,107 @@
"shop": "books"
}
},
{
"type": "node",
"id": 11699208654,
"lat": -37.0575593,
"lon": 144.2179531,
"tags": {
"amenity": "cafe",
"name": "Blackbird",
"opening_hours": "We-Su 07:00-15:00"
}
},
{
"type": "node",
"id": 11699209595,
"lat": -37.0647361,
"lon": 144.2178397,
"tags": {
"name": "Goldfields Games",
"shop": "stationery"
}
},
{
"type": "node",
"id": 11699209596,
"lat": -37.0648223,
"lon": 144.2186911,
"tags": {
"amenity": "cafe",
"name": "Togs Cafe"
}
},
{
"type": "node",
"id": 11699209597,
"lat": -37.0647399,
"lon": 144.2178817,
"tags": {
"name": "Castlemaine Office Supplies - COS",
"shop": "stationery"
}
},
{
"type": "node",
"id": 11699209601,
"lat": -37.0648245,
"lon": 144.2187713,
"tags": {
"name": "mulberrys",
"shop": "deli"
}
},
{
"type": "node",
"id": 11699225335,
"lat": -37.0641272,
"lon": 144.2168128,
"tags": {
"name": "Mcshanag\u2019s",
"shop": "bakery"
}
},
{
"type": "node",
"id": 11699228873,
"lat": -37.0655255,
"lon": 144.2172776,
"tags": {
"name": "Aolita",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11699228875,
"lat": -37.0655044,
"lon": 144.2172182,
"tags": {
"name": "Ivy & Grace",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11699230554,
"lat": -37.0656365,
"lon": 144.2184531,
"tags": {
"name": "Castlemaine Fresh",
"shop": "greengrocer"
}
},
{
"type": "node",
"id": 11699233860,
"lat": -37.0609871,
"lon": 144.2200073,
"tags": {
"amenity": "cafe",
"name": "The Dove Country Eats"
}
},
{
"type": "node",
"id": 11699237531,
@ -54,6 +428,187 @@
"shop": "antiques"
}
},
{
"type": "node",
"id": 11699237532,
"lat": -37.0730134,
"lon": 144.2070605,
"tags": {
"name": "Cliffords Quality Cuts",
"shop": "butcher"
}
},
{
"type": "node",
"id": 11699271590,
"lat": -37.0515551,
"lon": 144.2213327,
"tags": {
"amenity": "cafe",
"name": "Firebean Coffee"
}
},
{
"type": "node",
"id": 11699277526,
"lat": -37.0562996,
"lon": 144.2139999,
"tags": {
"name": "Oakwood Smallgoods Co.",
"shop": "deli"
}
},
{
"type": "node",
"id": 11699277527,
"lat": -37.0560376,
"lon": 144.2137053,
"tags": {
"addr:housenumber": "9",
"addr:street": "Walker Street",
"name": "Cabosse & Feve Chocolates",
"shop": "chocolate",
"website": "https://www.cabosseandfeve.com.au/"
}
},
{
"type": "node",
"id": 11715203318,
"lat": -37.0697474,
"lon": 144.2133853,
"tags": {
"clothes": "workwear",
"name": "Hip Pocket",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735362964,
"lat": -37.0657,
"lon": 144.2189529,
"tags": {
"name": "Legion Office Works",
"shop": "stationery"
}
},
{
"type": "node",
"id": 11735362965,
"lat": -37.06572,
"lon": 144.2191432,
"tags": {
"name": "JML Jewellers",
"shop": "jewelry"
}
},
{
"type": "node",
"id": 11735362966,
"lat": -37.0656918,
"lon": 144.2189044,
"tags": {
"addr:housenumber": "87",
"addr:street": "Mostyn Street",
"name": "Beehave",
"shop": "shoes"
}
},
{
"type": "node",
"id": 11735362967,
"lat": -37.0656219,
"lon": 144.218376,
"tags": {
"name": "D\u00e9sa",
"shop": "shoes"
}
},
{
"type": "node",
"id": 11735383822,
"lat": -37.0669274,
"lon": 144.2194209,
"tags": {
"addr:housenumber": "12",
"addr:street": "Hargraves Street",
"name": "Mutual Muse",
"shop": "clothes",
"website": "https://www.mutualmuse.net/"
}
},
{
"type": "node",
"id": 11735383824,
"lat": -37.0661069,
"lon": 144.2196811,
"tags": {
"name": "RIKE Design",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735383825,
"lat": -37.0667708,
"lon": 144.2194268,
"tags": {
"name": "Little General Kids",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735383826,
"lat": -37.0661345,
"lon": 144.2199456,
"tags": {
"name": "Shibby Store",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735383827,
"lat": -37.0668429,
"lon": 144.2194282,
"tags": {
"addr:housenumber": "14",
"addr:street": "Hargraves Street",
"amenity": "cafe",
"name": "Tortoise Espresso"
}
},
{
"type": "node",
"id": 11735383834,
"lat": -37.0661462,
"lon": 144.2200522,
"tags": {
"name": "Miss Maisie",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735383837,
"lat": -37.0666799,
"lon": 144.2194393,
"tags": {
"name": "Oni Street Wear",
"shop": "clothes"
}
},
{
"type": "node",
"id": 11735383839,
"lat": -37.0661674,
"lon": 144.2202264,
"tags": {
"amenity": "cafe",
"name": "SAFFS Cafe"
}
},
{
"type": "node",
"id": 11760538035,
@ -72,6 +627,138 @@
"website": "https://www.salvosstores.com.au/stores/vic/3450-castlemaine-5317"
}
},
{
"type": "node",
"id": 11934078464,
"lat": -37.062982,
"lon": 144.2164808,
"tags": {
"amenity": "cafe",
"name": "Lazy Bones"
}
},
{
"type": "node",
"id": 12542955304,
"lat": -37.0662172,
"lon": 144.218827,
"tags": {
"addr:housenumber": "50",
"addr:postcode": "3450",
"addr:street": "Mostyn Street",
"addr:unit": "5",
"branch": "Castlemaine",
"brand": "Bakers Delight",
"brand:wikidata": "Q4849261",
"name": "Bakers Delight",
"opening_hours": "Mo-Su 07:30-20:00",
"phone": "+61 3 5470 6073",
"shop": "bakery",
"website": "http://Bakersdelight.com.au"
}
},
{
"type": "node",
"id": 12740901493,
"lat": -37.0566616,
"lon": 144.2146204,
"tags": {
"amenity": "cafe",
"contact:instagram": "https://www.instagram.com/frankieatthemill/",
"name": "Frankie Espresso",
"note": "Cafe is in a container in front of the building",
"takeaway": "only"
}
},
{
"type": "node",
"id": 12740975418,
"lat": -37.0568574,
"lon": 144.2141443,
"tags": {
"name": "Hand Picked Floristry",
"note": "Unit 78",
"shop": "florist",
"website": "https://www.handpickedfloristry.com/"
}
},
{
"type": "node",
"id": 13954997201,
"lat": -37.0702085,
"lon": 144.2115086,
"tags": {
"amenity": "cafe",
"check_date": "2026-06-21",
"name": "Aitken\u2018s Corner"
}
},
{
"type": "way",
"id": 26808662,
"center": {
"lat": -37.0664479,
"lon": 144.2186219
},
"nodes": [
294089047,
8768419613,
8768419614,
8768419615,
6665030737,
1347583493,
294089048,
1347583492,
1347583494,
1347583495,
294089049,
294089050,
8768419611,
8768419612,
7836396117,
294089047
],
"tags": {
"addr:postcode": "3450",
"addr:state": "VIC",
"addr:suburb": "Castlemaine",
"branch": "Castlemaine",
"brand": "IGA",
"brand:wikidata": "Q5970945",
"building": "yes",
"name": "Maxi IGA",
"opening_hours": "Mo-Su 07:00-21:00",
"operator": "IGA",
"phone": "+61 3 5472 2477",
"shop": "supermarket",
"website": "https://www.iga.com.au/",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 41889132,
"center": {
"lat": -37.0629808,
"lon": 144.2169327
},
"nodes": [
518274906,
518274907,
2803274456,
518274909,
518274910,
518274906
],
"tags": {
"brand": "IGA",
"brand:wikidata": "Q5970945",
"brand:wikipedia": "en:IGA (Australian supermarket group)",
"building": "yes",
"name": "IGA",
"shop": "supermarket"
}
},
{
"type": "way",
"id": 315282080,
@ -94,6 +781,136 @@
"shop": "books",
"wheelchair": "limited"
}
},
{
"type": "way",
"id": 315282081,
"center": {
"lat": -37.0657523,
"lon": 144.2198063
},
"nodes": [
3213928216,
3213928217,
3213928218,
3213928213,
3213928216
],
"tags": {
"amenity": "cafe",
"building": "yes",
"name": "Gaia's Goodness",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 315282082,
"center": {
"lat": -37.0657268,
"lon": 144.2199002
},
"nodes": [
3213928218,
3213928219,
3213928220,
3213928221,
3213928217,
3213928218
],
"tags": {
"building": "yes",
"name": "Castlemaine Floristry",
"shop": "florist",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 446991894,
"center": {
"lat": -37.0627807,
"lon": 144.2176913
},
"nodes": [
4441953702,
3213937049,
3213937050,
518274901,
3213937051,
3213937052,
4441953702
],
"tags": {
"brand": "BP Shop",
"brand:wikidata": "Q152057",
"brand:wikipedia": "en:BP",
"building": "yes",
"name": "BP Shop",
"shop": "convenience"
}
},
{
"type": "way",
"id": 834631308,
"center": {
"lat": -37.0653266,
"lon": 144.2176748
},
"nodes": [
7791187709,
7791187710,
7791187711,
7791187712,
7791187713,
7791187714,
7791187741,
7791187715,
7791187709
],
"tags": {
"brand": "Kmart",
"brand:wikidata": "Q6421682",
"brand:wikipedia": "en:Kmart Australia",
"building": "yes",
"name": "K Hub",
"shop": "department_store"
}
},
{
"type": "way",
"id": 1387137327,
"center": {
"lat": -37.0678903,
"lon": 144.2223679
},
"nodes": [
12841247956,
13639673523,
13639673522,
12841247957,
12841247958,
12841247959,
13639673521,
12841247960,
12841247961,
12841247962,
13639673520,
13639673517,
13639673518,
13639673519,
12841247963,
12841247956
],
"tags": {
"brand": "Woolworths",
"brand:wikidata": "Q3249145",
"building": "yes",
"name": "Woolworths",
"operator": "Woolworths Group",
"operator:wikidata": "Q607272",
"shop": "supermarket"
}
}
]
}

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

@ -18,11 +18,22 @@
"maxLon": 144.30011618044566
},
"counts": {
"raw": 9,
"shops": 9,
"raw": 120,
"shops": 36,
"heroes": 9,
"texture": 27,
"textureDropped": 81,
"roads": 1552
},
"shops": [
{
"id": 51469190,
"name": "Woolworths",
"type": "dept",
"lat": -36.766914,
"lon": 144.265197,
"suburb": "Ironbark"
},
{
"id": 329659785,
"name": "Slave-ohs",
@ -31,6 +42,22 @@
"lon": 144.262936,
"suburb": "Ironbark"
},
{
"id": 470420143,
"name": "Cafe 307",
"type": "milkbar",
"lat": -36.771517,
"lon": 144.262027,
"suburb": "Ironbark"
},
{
"id": 826091288,
"name": "The Old Green Bean",
"type": "milkbar",
"lat": -36.75875,
"lon": 144.284821,
"suburb": "Bendigo"
},
{
"id": 827550052,
"name": "Sinnies",
@ -39,6 +66,70 @@
"lon": 144.286017,
"suburb": "Bendigo"
},
{
"id": 829687117,
"name": "Asian Foods Emporium Grocery",
"type": "dept",
"lat": -36.760588,
"lon": 144.283087,
"suburb": "Bendigo"
},
{
"id": 835692264,
"name": "The Subtle Eye",
"type": "milkbar",
"lat": -36.757863,
"lon": 144.28179,
"suburb": "Bendigo"
},
{
"id": 837682665,
"name": "Everbean Caf\u00e9",
"type": "milkbar",
"lat": -36.76049,
"lon": 144.281758,
"suburb": "Bendigo"
},
{
"id": 837704574,
"name": "Noone Imagewear",
"type": "dept",
"lat": -36.761008,
"lon": 144.281254,
"suburb": "Bendigo"
},
{
"id": 838398536,
"name": "JM Leech Jewellers",
"type": "dept",
"lat": -36.759482,
"lon": 144.280567,
"suburb": "Bendigo"
},
{
"id": 838398546,
"name": "shop 12 boutique",
"type": "dept",
"lat": -36.75916,
"lon": 144.280448,
"suburb": "Bendigo"
},
{
"id": 838844008,
"name": "Virtu",
"type": "dept",
"lat": -36.75983,
"lon": 144.281265,
"suburb": "Bendigo"
},
{
"id": 1289247489,
"name": "Bendigo Original Pie Shop",
"type": "milkbar",
"lat": -36.759358,
"lon": 144.280841,
"suburb": "Bendigo"
},
{
"id": 1443367904,
"name": "Book Now",
@ -47,6 +138,38 @@
"lon": 144.282307,
"suburb": "Bendigo"
},
{
"id": 4952033541,
"name": "Favourite Flavours",
"type": "milkbar",
"lat": -36.749749,
"lon": 144.290565,
"suburb": "Bendigo"
},
{
"id": 7756585874,
"name": "APM employment services",
"type": "dept",
"lat": -36.759976,
"lon": 144.282313,
"suburb": "Bendigo"
},
{
"id": 7816837806,
"name": "Lollies2Go",
"type": "milkbar",
"lat": -36.76132,
"lon": 144.282124,
"suburb": "Bendigo"
},
{
"id": 7816868914,
"name": "The Erindale Farm Butchery",
"type": "milkbar",
"lat": -36.761341,
"lon": 144.282266,
"suburb": "Bendigo"
},
{
"id": 7816868915,
"name": "Red Floss Shop",
@ -55,6 +178,54 @@
"lon": 144.282046,
"suburb": "Bendigo"
},
{
"id": 7816868916,
"name": "Bakers Delight",
"type": "milkbar",
"lat": -36.761241,
"lon": 144.282231,
"suburb": "Bendigo"
},
{
"id": 7821369727,
"name": "The Flower Spot Bendigo",
"type": "stall",
"lat": -36.760911,
"lon": 144.281603,
"suburb": "Bendigo"
},
{
"id": 7828645967,
"name": "Bakery Express Caffe",
"type": "milkbar",
"lat": -36.760079,
"lon": 144.281709,
"suburb": "Bendigo"
},
{
"id": 7828645972,
"name": "Stearns Showcase Jewellers",
"type": "dept",
"lat": -36.760227,
"lon": 144.280907,
"suburb": "Bendigo"
},
{
"id": 7828645974,
"name": "Gaijin Eatery",
"type": "milkbar",
"lat": -36.760424,
"lon": 144.281128,
"suburb": "Bendigo"
},
{
"id": 8040964387,
"name": "The Plaza Lady Salon",
"type": "dept",
"lat": -36.76003,
"lon": 144.278691,
"suburb": "Bendigo"
},
{
"id": 8040967386,
"name": "Dymocks",
@ -71,6 +242,30 @@
"lon": 144.276186,
"suburb": "Bendigo"
},
{
"id": 8089526650,
"name": "Magnolia Lane",
"type": "milkbar",
"lat": -36.75956,
"lon": 144.278963,
"suburb": "Bendigo"
},
{
"id": 8090307856,
"name": "Bendigo Hat Shop",
"type": "dept",
"lat": -36.760607,
"lon": 144.276984,
"suburb": "Bendigo"
},
{
"id": 8090344402,
"name": "Zenbu",
"type": "dept",
"lat": -36.759938,
"lon": 144.27843,
"suburb": "Bendigo"
},
{
"id": 8225415317,
"name": "Bat City Music",
@ -79,6 +274,14 @@
"lon": 144.277469,
"suburb": "Bendigo"
},
{
"id": 8649203078,
"name": "Jay Jays",
"type": "dept",
"lat": -36.765669,
"lon": 144.280466,
"suburb": "Bendigo"
},
{
"id": 8649203082,
"name": "EB Games",
@ -87,6 +290,14 @@
"lon": 144.280806,
"suburb": "Bendigo"
},
{
"id": 8649203084,
"name": "The Body Shop",
"type": "milkbar",
"lat": -36.76569,
"lon": 144.280177,
"suburb": "Bendigo"
},
{
"id": 12421060314,
"name": "Cashtheists",
@ -94,6 +305,14 @@
"lat": -36.759374,
"lon": 144.27927,
"suburb": "Bendigo"
},
{
"id": 13646608627,
"name": "Reddy Express",
"type": "milkbar",
"lat": -36.756946,
"lon": 144.292295,
"suburb": "Bendigo"
}
],
"roads": [

View File

@ -18,18 +18,77 @@
"maxLon": 144.23393501737684
},
"counts": {
"raw": 6,
"shops": 6,
"raw": 67,
"shops": 24,
"heroes": 6,
"texture": 18,
"textureDropped": 43,
"roads": 397
},
"shops": [
{
"id": 26808662,
"name": "Maxi IGA",
"type": "dept",
"lat": -37.066448,
"lon": 144.218622,
"suburb": "Castlemaine"
},
{
"id": 41889132,
"name": "IGA",
"type": "dept",
"lat": -37.062981,
"lon": 144.216933,
"suburb": "Castlemaine"
},
{
"id": 315282080,
"name": "Stonemans Bookroom",
"type": "book",
"lat": -37.065704,
"lon": 144.219673,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 315282082,
"name": "Castlemaine Floristry",
"type": "stall",
"lat": -37.065727,
"lon": 144.2199,
"suburb": "Castlemaine"
},
{
"id": 446991894,
"name": "BP Shop",
"type": "milkbar",
"lat": -37.062781,
"lon": 144.217691,
"suburb": "Castlemaine"
},
{
"id": 834631308,
"name": "K Hub",
"type": "dept",
"lat": -37.065327,
"lon": 144.217675,
"suburb": "Castlemaine"
},
{
"id": 4714218689,
"name": "Hot & Crusty Bakery",
"type": "milkbar",
"lat": -37.064863,
"lon": 144.217151,
"suburb": "Castlemaine"
},
{
"id": 8909393346,
"name": "Long Paddock Cheese",
"type": "milkbar",
"lat": -37.056072,
"lon": 144.2137,
"suburb": "Castlemaine"
},
{
"id": 9231704552,
@ -37,7 +96,7 @@
"type": "opshop",
"lat": -37.062363,
"lon": 144.217526,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 11699200337,
@ -45,7 +104,15 @@
"type": "opshop",
"lat": -37.063201,
"lon": 144.218163,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 11699208635,
"name": "Just Because She Said So",
"type": "dept",
"lat": -37.05557,
"lon": 144.21891,
"suburb": "Castlemaine"
},
{
"id": 11699208641,
@ -53,7 +120,31 @@
"type": "book",
"lat": -37.061647,
"lon": 144.217247,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 11699225335,
"name": "Mcshanag\u2019s",
"type": "milkbar",
"lat": -37.064127,
"lon": 144.216813,
"suburb": "Castlemaine"
},
{
"id": 11699230554,
"name": "Castlemaine Fresh",
"type": "stall",
"lat": -37.065636,
"lon": 144.218453,
"suburb": "Castlemaine"
},
{
"id": 11699233860,
"name": "The Dove Country Eats",
"type": "milkbar",
"lat": -37.060987,
"lon": 144.220007,
"suburb": "Castlemaine"
},
{
"id": 11699237531,
@ -61,7 +152,39 @@
"type": "opshop",
"lat": -37.072912,
"lon": 144.206493,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 11699277526,
"name": "Oakwood Smallgoods Co.",
"type": "milkbar",
"lat": -37.0563,
"lon": 144.214,
"suburb": "Castlemaine"
},
{
"id": 11735383822,
"name": "Mutual Muse",
"type": "dept",
"lat": -37.066927,
"lon": 144.219421,
"suburb": "Castlemaine"
},
{
"id": 11735383825,
"name": "Little General Kids",
"type": "dept",
"lat": -37.066771,
"lon": 144.219427,
"suburb": "Castlemaine"
},
{
"id": 11735383839,
"name": "SAFFS Cafe",
"type": "milkbar",
"lat": -37.066167,
"lon": 144.220226,
"suburb": "Castlemaine"
},
{
"id": 11760538035,
@ -69,7 +192,31 @@
"type": "opshop",
"lat": -37.067129,
"lon": 144.217625,
"suburb": ""
"suburb": "Castlemaine"
},
{
"id": 11934078464,
"name": "Lazy Bones",
"type": "milkbar",
"lat": -37.062982,
"lon": 144.216481,
"suburb": "Castlemaine"
},
{
"id": 12740901493,
"name": "Frankie Espresso",
"type": "milkbar",
"lat": -37.056662,
"lon": 144.21462,
"suburb": "Castlemaine"
},
{
"id": 12740975418,
"name": "Hand Picked Floristry",
"type": "stall",
"lat": -37.056857,
"lon": 144.214144,
"suburb": "Castlemaine"
}
],
"roads": [

View File

@ -18,8 +18,11 @@
"maxLon": 115.76181856816406
},
"counts": {
"raw": 21,
"shops": 21,
"raw": 163,
"shops": 80,
"heroes": 20,
"texture": 60,
"textureDropped": 81,
"roads": 622
},
"shops": [
@ -31,6 +34,14 @@
"lon": 115.747749,
"suburb": "Fremantle"
},
{
"id": 140484887,
"name": "Lawley's Cafe & Bakery",
"type": "milkbar",
"lat": -32.059502,
"lon": 115.751724,
"suburb": "Fremantle"
},
{
"id": 310008675,
"name": "Slave-ohs",
@ -39,6 +50,22 @@
"lon": 115.74845,
"suburb": "Fremantle"
},
{
"id": 322574866,
"name": "Gallatis'",
"type": "milkbar",
"lat": -32.060257,
"lon": 115.752796,
"suburb": "Fremantle"
},
{
"id": 341104307,
"name": "Officeworks",
"type": "milkbar",
"lat": -32.045158,
"lon": 115.752556,
"suburb": "East Fremantle"
},
{
"id": 592578660,
"name": "Elizabeth's Warehouse",
@ -47,6 +74,30 @@
"lon": 115.750901,
"suburb": "Fremantle"
},
{
"id": 600753458,
"name": "kazoomies",
"type": "milkbar",
"lat": -32.053388,
"lon": 115.742238,
"suburb": "Fremantle"
},
{
"id": 600826931,
"name": "San Churro",
"type": "milkbar",
"lat": -32.055751,
"lon": 115.747406,
"suburb": "Fremantle"
},
{
"id": 662689900,
"name": "Gino's",
"type": "milkbar",
"lat": -32.055597,
"lon": 115.747122,
"suburb": "Fremantle"
},
{
"id": 976912099,
"name": "Toyworld",
@ -55,6 +106,46 @@
"lon": 115.749569,
"suburb": "Fremantle"
},
{
"id": 1029226779,
"name": "D'Angelo Cafe",
"type": "milkbar",
"lat": -32.053672,
"lon": 115.749625,
"suburb": "Fremantle"
},
{
"id": 1112796032,
"name": "Plated",
"type": "milkbar",
"lat": -32.047705,
"lon": 115.754197,
"suburb": "East Fremantle"
},
{
"id": 1176518483,
"name": "Whistle Stop",
"type": "milkbar",
"lat": -32.052132,
"lon": 115.745259,
"suburb": "Fremantle"
},
{
"id": 2535026143,
"name": "Ethos Deli & Dining Room",
"type": "milkbar",
"lat": -32.043992,
"lon": 115.761648,
"suburb": "East Fremantle"
},
{
"id": 2774072502,
"name": "Freo Merchant",
"type": "milkbar",
"lat": -32.05319,
"lon": 115.741799,
"suburb": "Fremantle"
},
{
"id": 3144618368,
"name": "Elizabeth's",
@ -63,6 +154,30 @@
"lon": 115.747192,
"suburb": "Fremantle"
},
{
"id": 3329420423,
"name": "Leisure Cafe",
"type": "milkbar",
"lat": -32.047955,
"lon": 115.75238,
"suburb": "Fremantle"
},
{
"id": 4364304694,
"name": "Two Fat Wombats",
"type": "milkbar",
"lat": -32.053674,
"lon": 115.74582,
"suburb": "Fremantle"
},
{
"id": 4601037690,
"name": "Blue Lotus",
"type": "milkbar",
"lat": -32.062758,
"lon": 115.757131,
"suburb": "Fremantle"
},
{
"id": 4601037692,
"name": "New Edition Bookshop",
@ -71,6 +186,30 @@
"lon": 115.744283,
"suburb": "Fremantle"
},
{
"id": 4601038092,
"name": "Cafe on Hampton",
"type": "milkbar",
"lat": -32.06219,
"lon": 115.756841,
"suburb": "Fremantle"
},
{
"id": 4601049291,
"name": "Denim/Store",
"type": "dept",
"lat": -32.054757,
"lon": 115.745966,
"suburb": "Fremantle"
},
{
"id": 4601057690,
"name": "Hush",
"type": "milkbar",
"lat": -32.054802,
"lon": 115.745114,
"suburb": "Fremantle"
},
{
"id": 4601057991,
"name": "Bill Campbell Books",
@ -79,6 +218,14 @@
"lon": 115.744405,
"suburb": "Fremantle"
},
{
"id": 4637114093,
"name": "The Prestige Italian Caf\u00e8",
"type": "milkbar",
"lat": -32.055089,
"lon": 115.742432,
"suburb": "Fremantle"
},
{
"id": 5026468221,
"name": "Mothballs Vintage",
@ -87,6 +234,62 @@
"lon": 115.747843,
"suburb": "Fremantle"
},
{
"id": 5179250521,
"name": "Fremantle Convenience Store",
"type": "milkbar",
"lat": -32.053175,
"lon": 115.747879,
"suburb": "Fremantle"
},
{
"id": 5219523076,
"name": "Abhi's Bread",
"type": "milkbar",
"lat": -32.065622,
"lon": 115.752583,
"suburb": "Fremantle"
},
{
"id": 5321919673,
"name": "Gorman",
"type": "dept",
"lat": -32.054756,
"lon": 115.746702,
"suburb": "Fremantle"
},
{
"id": 5321920048,
"name": "Terrace Men",
"type": "dept",
"lat": -32.054894,
"lon": 115.746462,
"suburb": "Fremantle"
},
{
"id": 5321921316,
"name": "Culley's",
"type": "milkbar",
"lat": -32.054222,
"lon": 115.746884,
"suburb": "Fremantle"
},
{
"id": 5321921457,
"name": "Breglia Piccolo's Lunch Bar",
"type": "milkbar",
"lat": -32.055217,
"lon": 115.743815,
"suburb": "Fremantle"
},
{
"id": 5883083585,
"name": "Best Wishes",
"type": "milkbar",
"lat": -32.053391,
"lon": 115.746519,
"suburb": "Fremantle"
},
{
"id": 5885985088,
"name": "Cashtheists",
@ -95,6 +298,30 @@
"lon": 115.748787,
"suburb": "Fremantle"
},
{
"id": 6933734247,
"name": "Kerfuffle",
"type": "milkbar",
"lat": -32.064294,
"lon": 115.752551,
"suburb": "Fremantle"
},
{
"id": 6944071310,
"name": "Vanilla Bean",
"type": "milkbar",
"lat": -32.059234,
"lon": 115.750989,
"suburb": "Fremantle"
},
{
"id": 7196223177,
"name": "Broody Hen",
"type": "milkbar",
"lat": -32.046988,
"lon": 115.752531,
"suburb": "East Fremantle"
},
{
"id": 8153813968,
"name": "The Record Finder",
@ -103,6 +330,38 @@
"lon": 115.744369,
"suburb": "Fremantle"
},
{
"id": 8299402082,
"name": "True Brew",
"type": "milkbar",
"lat": -32.056482,
"lon": 115.749276,
"suburb": "Fremantle"
},
{
"id": 8808000382,
"name": "No Escape Cafe",
"type": "milkbar",
"lat": -32.054938,
"lon": 115.752908,
"suburb": "Fremantle"
},
{
"id": 8889079808,
"name": "Little Sister",
"type": "milkbar",
"lat": -32.053654,
"lon": 115.745975,
"suburb": "Fremantle"
},
{
"id": 9039407645,
"name": "Freo Mart",
"type": "milkbar",
"lat": -32.056461,
"lon": 115.749232,
"suburb": "Fremantle"
},
{
"id": 9156620320,
"name": "Shave the Children",
@ -111,6 +370,30 @@
"lon": 115.752589,
"suburb": "Fremantle"
},
{
"id": 9156728321,
"name": "Bodkin's Bootery",
"type": "dept",
"lat": -32.054769,
"lon": 115.745231,
"suburb": "Fremantle"
},
{
"id": 9717067495,
"name": "IGA",
"type": "dept",
"lat": -32.054498,
"lon": 115.749252,
"suburb": "Fremantle"
},
{
"id": 9793980117,
"name": "Little French Nest",
"type": "milkbar",
"lat": -32.052161,
"lon": 115.749173,
"suburb": "Fremantle"
},
{
"id": 9794077228,
"name": "Adorn Vintage",
@ -127,6 +410,70 @@
"lon": 115.744727,
"suburb": "Fremantle"
},
{
"id": 9797581350,
"name": "Caffissimo",
"type": "milkbar",
"lat": -32.054604,
"lon": 115.739298,
"suburb": "Fremantle"
},
{
"id": 9843343165,
"name": "Unwind on Wray",
"type": "milkbar",
"lat": -32.059985,
"lon": 115.75219,
"suburb": "Fremantle"
},
{
"id": 9843343167,
"name": "Kartique Corner",
"type": "dept",
"lat": -32.059934,
"lon": 115.752075,
"suburb": "Fremantle"
},
{
"id": 9843343170,
"name": "Bukesh-la",
"type": "dept",
"lat": -32.059682,
"lon": 115.752112,
"suburb": "Fremantle"
},
{
"id": 9878259117,
"name": "Somes Newsageny",
"type": "milkbar",
"lat": -32.055098,
"lon": 115.744827,
"suburb": "Fremantle"
},
{
"id": 9878259217,
"name": "High on 55",
"type": "milkbar",
"lat": -32.055118,
"lon": 115.744766,
"suburb": "Fremantle"
},
{
"id": 9947458058,
"name": "Elk Fish Robotics",
"type": "dept",
"lat": -32.059517,
"lon": 115.748414,
"suburb": "Fremantle"
},
{
"id": 10585254715,
"name": "Red Floss Shop",
"type": "dept",
"lat": -32.053363,
"lon": 115.749563,
"suburb": "Fremantle"
},
{
"id": 10585254716,
"name": "Sinnies",
@ -135,6 +482,30 @@
"lon": 115.749669,
"suburb": "Fremantle"
},
{
"id": 10660056921,
"name": "Mi-Life",
"type": "dept",
"lat": -32.053343,
"lon": 115.74655,
"suburb": "Fremantle"
},
{
"id": 10833576345,
"name": "Utopia Fremantle",
"type": "milkbar",
"lat": -32.055981,
"lon": 115.747808,
"suburb": "Fremantle"
},
{
"id": 11323014781,
"name": "Wear2",
"type": "dept",
"lat": -32.054542,
"lon": 115.748308,
"suburb": "Fremantle"
},
{
"id": 11323015046,
"name": "The Picker Concept",
@ -143,6 +514,22 @@
"lon": 115.748306,
"suburb": "Fremantle"
},
{
"id": 11323035617,
"name": "Surf Dive 'n Ski",
"type": "dept",
"lat": -32.054122,
"lon": 115.74721,
"suburb": "Fremantle"
},
{
"id": 11337000968,
"name": "Telstra",
"type": "dept",
"lat": -32.054429,
"lon": 115.747001,
"suburb": "Fremantle"
},
{
"id": 11337031591,
"name": "Nifty in Freo",
@ -152,11 +539,59 @@
"suburb": "Fremantle"
},
{
"id": 11337040744,
"name": "Red Floss Shop",
"type": "opshop",
"lat": -32.053937,
"lon": 115.74609,
"id": 11596694746,
"name": "Festal Cafe",
"type": "milkbar",
"lat": -32.054923,
"lon": 115.748535,
"suburb": "Fremantle"
},
{
"id": 11730692453,
"name": "Floral Army",
"type": "stall",
"lat": -32.044197,
"lon": 115.761144,
"suburb": "East Fremantle"
},
{
"id": 11831943936,
"name": "Amante & Co Coffee",
"type": "milkbar",
"lat": -32.062452,
"lon": 115.752073,
"suburb": "Fremantle"
},
{
"id": 11890633269,
"name": "T.O.P. Coffee and Tea",
"type": "milkbar",
"lat": -32.055047,
"lon": 115.748329,
"suburb": "Fremantle"
},
{
"id": 12096135462,
"name": "Fremantle Pharmacy",
"type": "milkbar",
"lat": -32.054664,
"lon": 115.745559,
"suburb": "Fremantle"
},
{
"id": 12096277829,
"name": "Three Stories",
"type": "dept",
"lat": -32.054661,
"lon": 115.746332,
"suburb": "Fremantle"
},
{
"id": 12307062505,
"name": "Cassia",
"type": "milkbar",
"lat": -32.055207,
"lon": 115.749886,
"suburb": "Fremantle"
},
{
@ -167,14 +602,30 @@
"lon": 115.749467,
"suburb": "Fremantle"
},
{
"id": 12389475293,
"name": "Rustic Spoon",
"type": "milkbar",
"lat": -32.056402,
"lon": 115.746922,
"suburb": "Fremantle"
},
{
"id": 12490289378,
"name": "Warhammer",
"type": "toy",
"type": "video",
"lat": -32.052216,
"lon": 115.749274,
"suburb": "Fremantle"
},
{
"id": 12522399956,
"name": "Fremantle Opals",
"type": "dept",
"lat": -32.054823,
"lon": 115.746447,
"suburb": "Fremantle"
},
{
"id": 12787392414,
"name": "Vintage On High",
@ -190,6 +641,30 @@
"lat": -32.053366,
"lon": 115.747261,
"suburb": "Fremantle"
},
{
"id": 13064860607,
"name": "Il Brutto",
"type": "milkbar",
"lat": -32.056303,
"lon": 115.746115,
"suburb": "Fremantle"
},
{
"id": 13130252840,
"name": "Ladies Auxilliary Kiosk",
"type": "milkbar",
"lat": -32.058524,
"lon": 115.751161,
"suburb": "Fremantle"
},
{
"id": 13810284696,
"name": "Perth Surf Vintage",
"type": "dept",
"lat": -32.055218,
"lon": 115.748222,
"suburb": "Fremantle"
}
],
"roads": [

View File

@ -0,0 +1,43 @@
{
"schema": "procity-towns-index/1",
"generated": "2026-07-16",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"towns": [
{
"key": "bendigo_real",
"town": "Bendigo",
"state": "VIC",
"shops": 36,
"roads": true
},
{
"key": "castlemaine_real",
"town": "Castlemaine",
"state": "VIC",
"shops": 24,
"roads": true
},
{
"key": "fremantle_real",
"town": "Fremantle",
"state": "WA",
"shops": 80,
"roads": true
},
{
"key": "katoomba_real",
"town": "Katoomba",
"state": "NSW",
"shops": 80,
"roads": true
},
{
"key": "newtown_real",
"town": "Newtown",
"state": "NSW",
"shops": 72,
"roads": true
}
]
}

View File

@ -18,11 +18,54 @@
"maxLon": 150.3282458418835
},
"counts": {
"raw": 20,
"shops": 20,
"raw": 85,
"shops": 80,
"heroes": 20,
"texture": 60,
"textureDropped": 4,
"roads": 484
},
"shops": [
{
"id": 20973534,
"name": "The Hatters Cafe",
"type": "milkbar",
"lat": -33.71685,
"lon": 150.310897,
"suburb": "Katoomba"
},
{
"id": 20973744,
"name": "Blue Mountains Chocolate Company",
"type": "milkbar",
"lat": -33.727787,
"lon": 150.310686,
"suburb": "Katoomba"
},
{
"id": 220588486,
"name": "K Hub",
"type": "dept",
"lat": -33.715253,
"lon": 150.310046,
"suburb": "Katoomba"
},
{
"id": 397769505,
"name": "EG Australia",
"type": "milkbar",
"lat": -33.706992,
"lon": 150.306813,
"suburb": "Katoomba"
},
{
"id": 615402710,
"name": "Bunnings Warehouse",
"type": "dept",
"lat": -33.712464,
"lon": 150.320412,
"suburb": "Katoomba"
},
{
"id": 753467867,
"name": "Blue Mountains Cancer Help Op Shop",
@ -31,6 +74,78 @@
"lon": 150.310861,
"suburb": "Katoomba"
},
{
"id": 761089046,
"name": "Bunnings Garden Center",
"type": "stall",
"lat": -33.712763,
"lon": 150.320644,
"suburb": "Katoomba"
},
{
"id": 802666966,
"name": "Savoy Cafe",
"type": "milkbar",
"lat": -33.712834,
"lon": 150.311983,
"suburb": "Katoomba"
},
{
"id": 1369162051,
"name": "Coles",
"type": "dept",
"lat": -33.713861,
"lon": 150.310503,
"suburb": "Katoomba"
},
{
"id": 1468118824,
"name": "Aldi",
"type": "dept",
"lat": -33.717427,
"lon": 150.310496,
"suburb": "Katoomba"
},
{
"id": 2381632365,
"name": "Blue Mountains Food Co-op",
"type": "dept",
"lat": -33.714245,
"lon": 150.310943,
"suburb": "Katoomba"
},
{
"id": 2459170313,
"name": "Mitre 10",
"type": "dept",
"lat": -33.710384,
"lon": 150.319933,
"suburb": "Katoomba"
},
{
"id": 2763942902,
"name": "Woolworths",
"type": "dept",
"lat": -33.716617,
"lon": 150.309785,
"suburb": "Katoomba"
},
{
"id": 2763958073,
"name": "The Handy Store",
"type": "milkbar",
"lat": -33.705791,
"lon": 150.312268,
"suburb": "Katoomba"
},
{
"id": 3525171098,
"name": "The Yellow Deli",
"type": "milkbar",
"lat": -33.717377,
"lon": 150.31108,
"suburb": "Katoomba"
},
{
"id": 4047416904,
"name": "Tanglecare",
@ -63,6 +178,14 @@
"lon": 150.309931,
"suburb": "Katoomba"
},
{
"id": 4047416912,
"name": "Todarello's",
"type": "stall",
"lat": -33.710196,
"lon": 150.309242,
"suburb": "Katoomba"
},
{
"id": 4047416913,
"name": "Sinnies",
@ -71,6 +194,22 @@
"lon": 150.311782,
"suburb": "Katoomba"
},
{
"id": 4861436783,
"name": "Big W",
"type": "dept",
"lat": -33.715823,
"lon": 150.309926,
"suburb": "Katoomba"
},
{
"id": 4957656365,
"name": "Jaycar",
"type": "dept",
"lat": -33.717449,
"lon": 150.310738,
"suburb": "Katoomba"
},
{
"id": 4958417278,
"name": "Tanglecare Op Shops",
@ -95,6 +234,62 @@
"lon": 150.311403,
"suburb": "Katoomba"
},
{
"id": 5237464916,
"name": "Xpresso",
"type": "milkbar",
"lat": -33.712201,
"lon": 150.31176,
"suburb": "Katoomba"
},
{
"id": 5237464921,
"name": "Clarendon Restaurant",
"type": "milkbar",
"lat": -33.718026,
"lon": 150.312594,
"suburb": "Katoomba"
},
{
"id": 5237464923,
"name": "Smokey Bundits",
"type": "milkbar",
"lat": -33.711901,
"lon": 150.310436,
"suburb": "Katoomba"
},
{
"id": 6259524189,
"name": "Cafe Lurline",
"type": "milkbar",
"lat": -33.724917,
"lon": 150.310787,
"suburb": "Katoomba"
},
{
"id": 6340856985,
"name": "Made with Love Cafe",
"type": "milkbar",
"lat": -33.713029,
"lon": 150.311952,
"suburb": "Katoomba"
},
{
"id": 6417269322,
"name": "Pomegranate Cafe",
"type": "milkbar",
"lat": -33.713511,
"lon": 150.311538,
"suburb": "Katoomba"
},
{
"id": 6417269860,
"name": "Hominy Bakery",
"type": "milkbar",
"lat": -33.716472,
"lon": 150.310974,
"suburb": "Katoomba"
},
{
"id": 6938379064,
"name": "Katoomba Antique Centre",
@ -103,6 +298,14 @@
"lon": 150.310654,
"suburb": "Katoomba"
},
{
"id": 6938379066,
"name": "Pearce Furniture & Bedding",
"type": "dept",
"lat": -33.712288,
"lon": 150.310753,
"suburb": "Katoomba"
},
{
"id": 6938414604,
"name": "Slave-ohs",
@ -119,6 +322,110 @@
"lon": 150.31127,
"suburb": "Katoomba"
},
{
"id": 6938593142,
"name": "True to the Bean",
"type": "milkbar",
"lat": -33.715282,
"lon": 150.311217,
"suburb": "Katoomba"
},
{
"id": 6938593144,
"name": "Can Blue",
"type": "milkbar",
"lat": -33.71536,
"lon": 150.311202,
"suburb": "Katoomba"
},
{
"id": 6938593151,
"name": "8 Things",
"type": "milkbar",
"lat": -33.715508,
"lon": 150.311171,
"suburb": "Katoomba"
},
{
"id": 6938593155,
"name": "Katoomba French Hot Bread",
"type": "milkbar",
"lat": -33.715557,
"lon": 150.311159,
"suburb": "Katoomba"
},
{
"id": 6938593159,
"name": "Katoomba Fine Flowers",
"type": "stall",
"lat": -33.715469,
"lon": 150.31118,
"suburb": "Katoomba"
},
{
"id": 6938593162,
"name": "Blue Mountains Buttery",
"type": "milkbar",
"lat": -33.71583,
"lon": 150.311109,
"suburb": "Katoomba"
},
{
"id": 6938593166,
"name": "The Elephant Bean",
"type": "milkbar",
"lat": -33.715925,
"lon": 150.311082,
"suburb": "Katoomba"
},
{
"id": 6938593174,
"name": "Kent + Lane",
"type": "dept",
"lat": -33.71653,
"lon": 150.31096,
"suburb": "Katoomba"
},
{
"id": 6938677873,
"name": "Fish & Fowl",
"type": "milkbar",
"lat": -33.715274,
"lon": 150.310735,
"suburb": "Katoomba"
},
{
"id": 6938921422,
"name": "LuJo By Design",
"type": "dept",
"lat": -33.716657,
"lon": 150.311224,
"suburb": "Katoomba"
},
{
"id": 6938921424,
"name": "Katoomba Workwear",
"type": "dept",
"lat": -33.716397,
"lon": 150.311281,
"suburb": "Katoomba"
},
{
"id": 6938921480,
"name": "Black Cockatoo",
"type": "milkbar",
"lat": -33.715974,
"lon": 150.311074,
"suburb": "Katoomba"
},
{
"id": 6938921482,
"name": "aBrasKadaBras",
"type": "dept",
"lat": -33.714468,
"lon": 150.311655,
"suburb": "Katoomba"
},
{
"id": 6938921533,
"name": "MacArthur's Arcade",
@ -135,6 +442,14 @@
"lon": 150.311761,
"suburb": "Katoomba"
},
{
"id": 7012045885,
"name": "Mountain Ama Cafe",
"type": "milkbar",
"lat": -33.716568,
"lon": 150.310968,
"suburb": "Katoomba"
},
{
"id": 7040012373,
"name": "Black Antiques",
@ -143,6 +458,22 @@
"lon": 150.309278,
"suburb": "Katoomba"
},
{
"id": 7059841233,
"name": "Tunk's Landscapes",
"type": "dept",
"lat": -33.712966,
"lon": 150.31851,
"suburb": "Katoomba"
},
{
"id": 7110813636,
"name": "Aquip Supplies",
"type": "dept",
"lat": -33.712766,
"lon": 150.320997,
"suburb": "Katoomba"
},
{
"id": 7110813637,
"name": "Steven Walter Children's Cancer Foundation Warehouse",
@ -151,6 +482,78 @@
"lon": 150.321286,
"suburb": "Katoomba"
},
{
"id": 7882541494,
"name": "BP Shop",
"type": "milkbar",
"lat": -33.706897,
"lon": 150.316949,
"suburb": "Katoomba"
},
{
"id": 8175995312,
"name": "On The Soul Side",
"type": "milkbar",
"lat": -33.711672,
"lon": 150.3122,
"suburb": "Katoomba"
},
{
"id": 8303103752,
"name": "Mountain Herbs",
"type": "stall",
"lat": -33.701038,
"lon": 150.306761,
"suburb": "Katoomba"
},
{
"id": 8558905196,
"name": "Heroy Asian Grocery",
"type": "dept",
"lat": -33.717511,
"lon": 150.310724,
"suburb": "Katoomba"
},
{
"id": 8791558114,
"name": "Cafe Del Ra",
"type": "milkbar",
"lat": -33.712238,
"lon": 150.310646,
"suburb": "Katoomba"
},
{
"id": 8964690649,
"name": "Metromix Concrete",
"type": "dept",
"lat": -33.702945,
"lon": 150.31863,
"suburb": "Katoomba"
},
{
"id": 8995679112,
"name": "Mantra Uniforms",
"type": "dept",
"lat": -33.707815,
"lon": 150.310875,
"suburb": "Katoomba"
},
{
"id": 9222666517,
"name": "Unique Patisserie",
"type": "milkbar",
"lat": -33.716093,
"lon": 150.31104,
"suburb": "Katoomba"
},
{
"id": 9225814624,
"name": "Bakers Delight",
"type": "milkbar",
"lat": -33.716238,
"lon": 150.310619,
"suburb": "Katoomba"
},
{
"id": 9231634025,
"name": "Little Lost Bookshop",
@ -159,6 +562,22 @@
"lon": 150.310835,
"suburb": "Katoomba"
},
{
"id": 9287300149,
"name": "High Q Trades",
"type": "dept",
"lat": -33.712312,
"lon": 150.318904,
"suburb": "Katoomba"
},
{
"id": 9413746790,
"name": "Little Paris Cafe",
"type": "milkbar",
"lat": -33.71444,
"lon": 150.311091,
"suburb": "Katoomba"
},
{
"id": 9577859712,
"name": "B'COS Agora Second Hand Bookshop",
@ -167,6 +586,22 @@
"lon": 150.311173,
"suburb": "Katoomba"
},
{
"id": 9584302786,
"name": "Sip Sip Drink Bar",
"type": "milkbar",
"lat": -33.712736,
"lon": 150.311999,
"suburb": "Katoomba"
},
{
"id": 9584302788,
"name": "Bring the Jungle",
"type": "stall",
"lat": -33.712986,
"lon": 150.311957,
"suburb": "Katoomba"
},
{
"id": 9791871398,
"name": "The Velvet Fog",
@ -175,6 +610,22 @@
"lon": 150.311566,
"suburb": "Katoomba"
},
{
"id": 11242865086,
"name": "Odd Mountains Oddities",
"type": "dept",
"lat": -33.7167,
"lon": 150.310928,
"suburb": "Katoomba"
},
{
"id": 11242941181,
"name": "Tibetan Three Sisters",
"type": "dept",
"lat": -33.715893,
"lon": 150.311092,
"suburb": "Katoomba"
},
{
"id": 11242974232,
"name": "Sanctus Catholic Books and Gift Shop",
@ -182,6 +633,38 @@
"lat": -33.715718,
"lon": 150.31095,
"suburb": "Katoomba"
},
{
"id": 12443820960,
"name": "Katoomba Falls Kiosk",
"type": "milkbar",
"lat": -33.726555,
"lon": 150.30399,
"suburb": "Katoomba"
},
{
"id": 12475599270,
"name": "Catalina Cafe",
"type": "milkbar",
"lat": -33.715151,
"lon": 150.303325,
"suburb": "Katoomba"
},
{
"id": 12500043180,
"name": "Fidelity",
"type": "milkbar",
"lat": -33.716268,
"lon": 150.310835,
"suburb": "Katoomba"
},
{
"id": 12642112599,
"name": "Good Fat Pastry",
"type": "milkbar",
"lat": -33.716468,
"lon": 150.310982,
"suburb": "Katoomba"
}
],
"roads": [

View File

@ -18,11 +18,30 @@
"maxLon": 151.1931100389657
},
"counts": {
"raw": 19,
"shops": 18,
"raw": 233,
"shops": 72,
"heroes": 18,
"texture": 54,
"textureDropped": 156,
"roads": 1086
},
"shops": [
{
"id": 62203508,
"name": "Bunnings Warehouse",
"type": "dept",
"lat": -33.905746,
"lon": 151.19369,
"suburb": "Alexandria"
},
{
"id": 279681996,
"name": "Woolworths Metro",
"type": "dept",
"lat": -33.89859,
"lon": 151.181704,
"suburb": "Newtown"
},
{
"id": 291725581,
"name": "Kidstuff",
@ -31,13 +50,93 @@
"lon": 151.176459,
"suburb": "Camperdown"
},
{
"id": 292127221,
"name": "The Hospitality Store",
"type": "dept",
"lat": -33.88782,
"lon": 151.17241,
"suburb": "Camperdown"
},
{
"id": 305516372,
"name": "IGA",
"type": "dept",
"lat": -33.89597,
"lon": 151.180575,
"suburb": "Newtown"
},
{
"id": 423218371,
"name": "Foodcraft",
"type": "milkbar",
"lat": -33.90308,
"lon": 151.185224,
"suburb": "Erskineville"
},
{
"id": 605984545,
"name": "EB Games",
"type": "video",
"lat": -33.90641,
"lon": 151.171739,
"suburb": "Newtown"
"suburb": "Marrickville"
},
{
"id": 605984546,
"name": "Nextra",
"type": "milkbar",
"lat": -33.906418,
"lon": 151.171836,
"suburb": "Marrickville"
},
{
"id": 605984562,
"name": "Lowes",
"type": "dept",
"lat": -33.906876,
"lon": 151.171753,
"suburb": "Marrickville"
},
{
"id": 605984563,
"name": "Kimmy Jewellery",
"type": "dept",
"lat": -33.906791,
"lon": 151.171765,
"suburb": "Marrickville"
},
{
"id": 605984565,
"name": "Forcast",
"type": "dept",
"lat": -33.906889,
"lon": 151.17189,
"suburb": "Marrickville"
},
{
"id": 605984566,
"name": "Bras N Things",
"type": "dept",
"lat": -33.907022,
"lon": 151.171843,
"suburb": "Marrickville"
},
{
"id": 605984567,
"name": "Sussan",
"type": "dept",
"lat": -33.907073,
"lon": 151.171834,
"suburb": "Marrickville"
},
{
"id": 605984571,
"name": "Sunset Flowers",
"type": "stall",
"lat": -33.907283,
"lon": 151.171871,
"suburb": "Marrickville"
},
{
"id": 605984580,
@ -45,6 +144,118 @@
"type": "toy",
"lat": -33.907371,
"lon": 151.172438,
"suburb": "Marrickville"
},
{
"id": 605984595,
"name": "Sergio's Cake Shop",
"type": "milkbar",
"lat": -33.906821,
"lon": 151.172217,
"suburb": "Marrickville"
},
{
"id": 605984610,
"name": "Espresso Brothers",
"type": "milkbar",
"lat": -33.906382,
"lon": 151.172126,
"suburb": "Marrickville"
},
{
"id": 1039467648,
"name": "Storeroom Vintage",
"type": "dept",
"lat": -33.898406,
"lon": 151.176285,
"suburb": "Newtown"
},
{
"id": 1039467672,
"name": "Natural Fit Footwear",
"type": "dept",
"lat": -33.90725,
"lon": 151.172143,
"suburb": "Marrickville"
},
{
"id": 1039555349,
"name": "The Athlete's Foot",
"type": "dept",
"lat": -33.906646,
"lon": 151.17215,
"suburb": "Marrickville"
},
{
"id": 1079380693,
"name": "Coffee @ Fisher",
"type": "milkbar",
"lat": -33.886833,
"lon": 151.190043,
"suburb": "Darlington"
},
{
"id": 1732566722,
"name": "Welcome Mart",
"type": "dept",
"lat": -33.89993,
"lon": 151.184575,
"suburb": "Erskineville"
},
{
"id": 1735908953,
"name": "Boh\u00e4us Cafe",
"type": "milkbar",
"lat": -33.905708,
"lon": 151.191348,
"suburb": "Alexandria"
},
{
"id": 2125897845,
"name": "Pastizzi Cafe",
"type": "milkbar",
"lat": -33.903197,
"lon": 151.179532,
"suburb": "Newtown"
},
{
"id": 2136793910,
"name": "Store Espresso",
"type": "milkbar",
"lat": -33.890334,
"lon": 151.176325,
"suburb": "Camperdown"
},
{
"id": 2136793911,
"name": "Gather on the Green",
"type": "milkbar",
"lat": -33.890355,
"lon": 151.176263,
"suburb": "Camperdown"
},
{
"id": 2163387594,
"name": "Grounded 2050",
"type": "milkbar",
"lat": -33.887901,
"lon": 151.180526,
"suburb": "Camperdown"
},
{
"id": 2170430692,
"name": "Carslaw Kitchen",
"type": "milkbar",
"lat": -33.888238,
"lon": 151.190481,
"suburb": "Darlington"
},
{
"id": 2365225259,
"name": "Ken Neale Overflow",
"type": "dept",
"lat": -33.90187,
"lon": 151.178671,
"suburb": "Newtown"
},
{
@ -55,6 +266,30 @@
"lon": 151.179525,
"suburb": "Newtown"
},
{
"id": 2787084005,
"name": "Amazing Paints",
"type": "dept",
"lat": -33.891631,
"lon": 151.176271,
"suburb": "Camperdown"
},
{
"id": 2965437480,
"name": "Cafe Abercrombie",
"type": "milkbar",
"lat": -33.892142,
"lon": 151.193024,
"suburb": "Darlington"
},
{
"id": 2968343588,
"name": "Bakerman",
"type": "milkbar",
"lat": -33.899893,
"lon": 151.183961,
"suburb": "Erskineville"
},
{
"id": 3278485277,
"name": "The Vintage Record",
@ -71,6 +306,110 @@
"lon": 151.171395,
"suburb": "Stanmore"
},
{
"id": 3278493016,
"name": "Wildwood Designs",
"type": "dept",
"lat": -33.887904,
"lon": 151.169934,
"suburb": "Stanmore"
},
{
"id": 3278493018,
"name": "Inspired Design Furniture",
"type": "dept",
"lat": -33.887902,
"lon": 151.170079,
"suburb": "Stanmore"
},
{
"id": 3278509468,
"name": "Recharging",
"type": "milkbar",
"lat": -33.887515,
"lon": 151.167686,
"suburb": "Annandale"
},
{
"id": 3951219159,
"name": "Miracle",
"type": "dept",
"lat": -33.892823,
"lon": 151.186108,
"suburb": "Newtown"
},
{
"id": 4860774437,
"name": "Newtown Garden Market",
"type": "stall",
"lat": -33.903009,
"lon": 151.179679,
"suburb": "Newtown"
},
{
"id": 5203491923,
"name": "Cynthia's Caf\u00e9",
"type": "milkbar",
"lat": -33.908747,
"lon": 151.191977,
"suburb": "Alexandria"
},
{
"id": 5205362421,
"name": "Little Vietnam Roll & Juice",
"type": "milkbar",
"lat": -33.906344,
"lon": 151.181132,
"suburb": "Newtown"
},
{
"id": 5299091858,
"name": "Hui Bro\u2019s Cafe",
"type": "milkbar",
"lat": -33.898095,
"lon": 151.179152,
"suburb": "Newtown"
},
{
"id": 5300089045,
"name": "Handcraft Specialty Coffee",
"type": "milkbar",
"lat": -33.892575,
"lon": 151.185688,
"suburb": "Newtown"
},
{
"id": 5599458217,
"name": "The Floral Decorator",
"type": "stall",
"lat": -33.899566,
"lon": 151.184195,
"suburb": "Erskineville"
},
{
"id": 5839239307,
"name": "Luke Avenue",
"type": "milkbar",
"lat": -33.897984,
"lon": 151.175225,
"suburb": "Newtown"
},
{
"id": 5905712358,
"name": "The Coffee Nook on Swanson",
"type": "milkbar",
"lat": -33.900301,
"lon": 151.189403,
"suburb": "Erskineville"
},
{
"id": 5931284416,
"name": "Patisserie Petunia",
"type": "milkbar",
"lat": -33.90509,
"lon": 151.18052,
"suburb": "Newtown"
},
{
"id": 5931284422,
"name": "Towers Antiques",
@ -95,13 +434,29 @@
"lon": 151.179587,
"suburb": "Newtown"
},
{
"id": 6283737899,
"name": "Dangerfield",
"type": "dept",
"lat": -33.895217,
"lon": 151.181947,
"suburb": "Newtown"
},
{
"id": 6564462487,
"name": "Marani Deli",
"type": "milkbar",
"lat": -33.895992,
"lon": 151.182698,
"suburb": "Newtown"
},
{
"id": 6901192228,
"name": "Co-op",
"type": "book",
"lat": -33.890884,
"lon": 151.192028,
"suburb": "Newtown"
"suburb": "Darlington"
},
{
"id": 9518337417,
@ -111,6 +466,22 @@
"lon": 151.166497,
"suburb": "Annandale"
},
{
"id": 9572458384,
"name": "Budha Bowl",
"type": "milkbar",
"lat": -33.898699,
"lon": 151.176397,
"suburb": "Newtown"
},
{
"id": 9776428741,
"name": "K\u00fcrt\u0151sh",
"type": "milkbar",
"lat": -33.907897,
"lon": 151.171833,
"suburb": "Marrickville"
},
{
"id": 9888616491,
"name": "Haze Guitars",
@ -127,13 +498,29 @@
"lon": 151.178408,
"suburb": "Newtown"
},
{
"id": 11984912425,
"name": "Provocator",
"type": "dept",
"lat": -33.901201,
"lon": 151.178441,
"suburb": "Newtown"
},
{
"id": 12200892725,
"name": "Feather & Bone",
"type": "milkbar",
"lat": -33.909398,
"lon": 151.168471,
"suburb": "Marrickville"
},
{
"id": 12958032466,
"name": "St Luke\u2019s Op Shop",
"type": "opshop",
"lat": -33.898898,
"lon": 151.170272,
"suburb": "Newtown"
"suburb": "Enmore"
},
{
"id": 13122105023,
@ -143,6 +530,22 @@
"lon": 151.176713,
"suburb": "Camperdown"
},
{
"id": 13341304001,
"name": "AS Colour",
"type": "dept",
"lat": -33.895051,
"lon": 151.181701,
"suburb": "Newtown"
},
{
"id": 13585970805,
"name": "Numero Uno Coffee Roasters",
"type": "milkbar",
"lat": -33.908693,
"lon": 151.18052,
"suburb": "St Peters"
},
{
"id": 13686752819,
"name": "Better Read Than Dead",
@ -165,7 +568,39 @@
"type": "record",
"lat": -33.898666,
"lon": 151.174207,
"suburb": "Enmore"
},
{
"id": 13719824202,
"name": "A.P Town",
"type": "milkbar",
"lat": -33.893929,
"lon": 151.183827,
"suburb": "Newtown"
},
{
"id": 13785846501,
"name": "The Happy Herb Shop Newtown",
"type": "milkbar",
"lat": -33.898379,
"lon": 151.178005,
"suburb": "Newtown"
},
{
"id": 13919280070,
"name": "Tonton Bread",
"type": "milkbar",
"lat": -33.90484,
"lon": 151.17338,
"suburb": "Marrickville"
},
{
"id": 13919369194,
"name": "Mamuki",
"type": "milkbar",
"lat": -33.898789,
"lon": 151.173092,
"suburb": "Enmore"
}
],
"roads": [

View File

@ -23,6 +23,10 @@ const r4 = v => Math.round(v * 10000) / 10000;
// selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it.
export const POSTER_CLEAR = 0.5;
const FACADE_PROUD = 0.06;
// ROUND20 poster cap: the spine pole run is "2 per main edge", which on a sparse REAL town (few shops, big
// edge graph) reads busy (katoomba: 438). Cap it SCALED BY SHOP COUNT — a seeded subsample of the run. On
// the synthetic town the shop count is in the hundreds, so the cap never binds and the gig golden is frozen.
const POSTER_PER_SHOP = 1.5, POSTER_SPINE_FLOOR = 12;
// nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure).
const nearestSeg = (px, pz, ax, az, bx, bz) => {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
@ -121,12 +125,13 @@ function pickVenues(plan, citySeed) {
if (far.length) cand = far;
}
}
// ROUND16 ledger #6: keep the off-spine kinds off a back-street's bitumen. Prefer candidates whose
// +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the warehouse fringe is uniformly narrow,
// broaden to ANY off-spine clear lot before falling back. (pub is spine-fronted — its +Z is open —
// so it keeps its placement untouched; the hero shot doesn't move.) Always a clear lot exists in the
// synthetic town; the fallback only ever fires on a degenerate/osm plan.
if (kind === 'band_room' || kind === 'rsl') {
// ROUND16 ledger #6 (widened ROUND20): keep a venue's frontage poster off a crossing street's bitumen.
// Prefer candidates whose +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the primary set has
// none, broaden to any off-spine clear lot before falling back. R16 exempted `pub` because on the
// synthetic/marched towns its +Z is always open — but on a REAL road graph at density the pub can land
// where its facade overhangs a cross street (newtown, R20). The filter now covers every venue kind: on
// the synthetic town every pub/rsl candidate already clears, so it stays a no-op there (golden frozen).
{
let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR);
if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR);
if (clear.length) cand = clear;
@ -275,7 +280,7 @@ function buildPosters(plan, citySeed, venues, gigs) {
};
const pr = rng(citySeed, 'posters', 0);
const PER_EDGE = 2;
let k = 0;
const spineCands = [];
for (const e of runs) {
const a = nodeById.get(e.a), b = nodeById.get(e.b);
if (!a || !b) continue;
@ -291,9 +296,15 @@ function buildPosters(plan, citySeed, venues, gigs) {
const nx = side > 0 ? -uz : uz, nz = side > 0 ? ux : -ux;
const x = a.x + ux * len * t + nx * off, z = a.z + uz * len * t + nz * off;
if (onCarriageway(r2(x), r2(z))) continue; // check the r2-rounded position `add` stores — overhangs a crossing → skip (E's R18 finding)
add(x, z, Math.atan2(nx, nz), bill[k++ % bill.length].gigId);
spineCands.push({ x, z, ry: Math.atan2(nx, nz) });
}
}
// ROUND20 poster cap: thin the pole run to ~POSTER_PER_SHOP × shops (a seeded subsample), so a sparse
// real town reads like a main street, not a poster wall. cap ≫ candidates on synthetic ⇒ no-op ⇒ frozen.
const cap = Math.max(POSTER_SPINE_FLOOR, Math.round(POSTER_PER_SHOP * plan.shops.length));
const chosen = spineCands.length > cap ? shuffle(rng(citySeed, 'postercap', 0), spineCands.slice()).slice(0, cap) : spineCands;
let k = 0;
for (const c of chosen) add(c.x, c.z, c.ry, bill[k++ % bill.length].gigId);
return posters;
}

View File

@ -238,9 +238,18 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
// 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)
// ROUND20 capacity (the density widening load-tested the R18 overflow fence): at 36× shops the
// dominant drop cause was OVERFLOW — more shops assign to an edge-side than fit. Widened by tightening
// the run: NODE_CLEAR 6→3 and a roads-only RGAP 2→0.5 (the shared marched `GAP` is untouched, so the
// marched fixtures stay frozen). 0.5 m also reads truer — a real AU main street is continuous terrace
// shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and
// dropping a crowded shop beats teleporting it onto a street it isn't on.
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres)
const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
// every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density
// accounting stays honest as real towns widen (ROUND20).
const drop = (why, n = 1) => { norm.dropped = (norm.dropped || 0) + n; norm['dropped_' + why] = (norm['dropped_' + why] || 0) + n; };
const skey = p => `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`;
const rawWays = [];
for (const rd of roads) {
@ -316,7 +325,7 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
const perEdge = new Map();
for (const s of fx.shops) {
const a = nearest(projX(s.lon), projZ(s.lat));
if (!a || a.d > SEAT_MAX) { norm.dropped = (norm.dropped || 0) + 1; continue; } // stranded past bridging → drop + COUNT (ruling)
if (!a || a.d > SEAT_MAX) { drop('stranded'); continue; } // stranded past bridging → drop + COUNT (ruling)
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 });
}
@ -337,16 +346,16 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
let along = NODE_CLEAR;
for (let k = 0; k < list.length; k++) {
const s = list[k].s, f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop');
if (along + f > len - NODE_CLEAR) { norm.dropped = (norm.dropped || 0) + (list.length - k); break; } // edge full — overflow drops COUNTED (ruling)
if (along + f > len - NODE_CLEAR) { drop('overflow', list.length - k); break; } // edge-side full — overflow COUNTED
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;
along += f + RGAP;
}
}
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; }
if (placed.some(p => obbOverlap(p.corners, corners))) { drop('overlap'); 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);

View File

@ -449,15 +449,24 @@ section('v4 real-roads graph lift (schema v2)');
// 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');
// ROUND19: all five real towns are now on REAL ROADS (E's schema-v2 roads[] landed) and pinned on the
// ROUND20: re-pinned once for E's DENSITY WIDENING (5 caches at 3-6x shops) + A's poster cap +
// capacity widen. All five real towns are on REAL ROADS and pinned on the
// real-roads graph output — the A→E→A finalization, katoomba's golden included. Re-pinned this round after
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
const REAL_TOWN_GOLDENS = {
katoomba_real: 0xb7ffbca2, newtown_real: 0x6f984c81, fremantle_real: 0xfb197000,
bendigo_real: 0x21179b3b, castlemaine_real: 0x7c47f639,
katoomba_real: 0x420f677d, newtown_real: 0x741c18ec, fremantle_real: 0x73d385df,
bendigo_real: 0xc175194e, castlemaine_real: 0x26c128c9,
};
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json'));
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
// absorb to come) are regression-guarded byte-exact, not just by districtInvariants.
const REAL_TOWN_GIG_GOLDENS = {
katoomba_real: 0x2115732a, newtown_real: 0x17ab31bb, fremantle_real: 0x82c27397,
bendigo_real: 0xf5ce58c4, castlemaine_real: 0x48272da9,
};
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
for (const f of townFiles) {
const key = f.replace(/\.json$/, '');
@ -470,8 +479,12 @@ for (const f of townFiles) {
for (const s of GIG_SEEDS) districtInvariants(generatePlanFor(s, 'osm', { gigs: true, town: key, cache }), `real/${key} ${s}`);
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, key, { cache })))() >>> 0;
const want = REAL_TOWN_GOLDENS[key];
if (want === undefined) console.log(` ⚠ real/${key}: UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`);
else ok(hash === (want >>> 0), `real/${key}: golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`);
if (want === undefined) console.log(` ⚠ real/${key}: base UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`);
else ok(hash === (want >>> 0), `real/${key}: base golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`);
const ghash = xmur3(JSON.stringify(generatePlanFor(20261990, 'osm', { gigs: true, town: key, cache })))() >>> 0;
const gwant = REAL_TOWN_GIG_GOLDENS[key];
if (gwant === undefined) console.log(` ⚠ real/${key}: gig UNPINNED — add REAL_TOWN_GIG_GOLDENS['${key}'] = 0x${ghash.toString(16).padStart(8, '0')}`);
else ok(ghash === (gwant >>> 0), `real/${key}: gig golden 0x${ghash.toString(16)} matches pinned 0x${(gwant >>> 0).toString(16)}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────

View File

@ -13,23 +13,66 @@ const SPEED = 9; // m/s cruising
const DWELL = 3.5; // s door pause at each stop
const LANE = 3.2; // offset from the road centreline (drive on the left, AU)
// Ordered spine polyline: walk the chain of MAIN edges from a spine end. Returns [[x,z],…] or null.
// Ordered spine polyline: the tram runs the HIGH STREET. Returns [[x,z],…] or null.
//
// ROUND20 ruling (Fable, ledger #3) — route by SHOP ADJACENCY. The alpha walked whatever main chain
// happened to contain the first degree-1 node, which on a real graph is arbitrary (Katoomba: a 3.5 km
// highway chain = 21% of the mains, not the high street). Now: split the main-edge subgraph into
// connected components, score each by the shops fronting its edges (`lot.frontEdge`), and walk the
// winner — ties break on total metres (the longer high street).
//
// SYNTHETIC IS UNMOVED BY CONSTRUCTION: a synthetic town's mains form ONE component, and the walk below
// is seeded by iterating the ORIGINAL `mains` order (filtered to the winner), so `compByNode` is
// insertion-identical to the old `byNode` ⇒ same start node ⇒ byte-identical route. Goldens/feel hold.
function spinePolyline(plan) {
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const mains = plan.streets.edges.filter((e) => e.kind === 'main');
if (!mains.length) return null;
const byNode = new Map();
for (const e of mains) for (const nid of [e.a, e.b]) { if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e); }
let start = null;
for (const [nid, es] of byNode) if (es.length === 1) { start = nid; break; }
if (start == null) start = mains[0].a;
const seq = [start]; const used = new Set(); let cur = start;
for (;;) {
const next = (byNode.get(cur) || []).find((e) => !used.has(e.id));
if (!next) break;
used.add(next.id); cur = next.a === cur ? next.b : next.a; seq.push(cur);
// how many shops front each edge (the high-street signal)
const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
const shopsOnEdge = new Map();
for (const s of (plan.shops || [])) {
const l = lotById.get(s.lot);
if (l && l.frontEdge != null) shopsOnEdge.set(l.frontEdge, (shopsOnEdge.get(l.frontEdge) || 0) + 1);
}
return seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; });
const edgeShops = (e) => shopsOnEdge.get(e.id) || 0;
// Walk from `start`, at each junction taking the unused main edge that fronts the MOST shops — i.e.
// follow the retail, which is what "the high street" means. Component logic isn't needed: the walk
// can't leave its component. On a synthetic town every main node has ≤2 mains, so there is never a
// choice and this reduces to the pre-R20 walk exactly.
const walk = (start) => {
const seq = [start]; const used = new Set(); let cur = start, shops = 0, metres = 0;
for (;;) {
const cands = (byNode.get(cur) || []).filter((e) => !used.has(e.id));
if (!cands.length) break;
let nx = cands[0];
for (const c of cands) if (edgeShops(c) > edgeShops(nx)) nx = c; // ties keep `mains` order
used.add(nx.id); shops += edgeShops(nx);
const A = nodes.get(cur), nid = nx.a === cur ? nx.b : nx.a, B = nodes.get(nid);
if (A && B) metres += Math.hypot(B.x - A.x, B.z - A.z);
cur = nid; seq.push(cur);
}
return { seq, shops, metres, edges: used.size };
};
// Candidate starts: every dead-end of the main subgraph, in `mains` order. Score each walk by shops
// fronted (ties → longer). STRICT `>` keeps the FIRST start on a tie, and a synthetic town's two ends
// tie exactly (same chain, reversed) ⇒ the pre-R20 start wins ⇒ byte-identical route.
const starts = [];
for (const [nid, es] of byNode) if (es.length === 1) starts.push(nid);
if (!starts.length) starts.push(mains[0].a);
let best = null;
for (const s of starts) {
const r = walk(s);
if (!best || r.shops > best.shops || (r.shops === best.shops && r.metres > best.metres)) best = r;
}
const poly = best.seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; });
poly.info = { mainEdges: mains.length, candidateStarts: starts.length, routeEdges: best.edges,
shopsFronted: best.shops, shopsTotal: (plan.shops || []).length, routeMetres: Math.round(best.metres) };
return poly;
}
// bus livery texture (canvas — no fetch): cream body, window band, a red stripe + a route blind.
@ -47,10 +90,28 @@ function busTexture() {
return t;
}
// ROUND20 per-town fence (Fable's ledger-#3 fallback). On a REAL-ROADS town the best shop-adjacent main
// chain can still be a near-shopless highway — real high streets come out of OSM classified `side` (filed
// for A with measurements), so the tram would be a highway bus, not a town loop. Fence those towns.
//
// The fence keys off the REAL-ROADS signal (`edges > 200`), never on shop count alone — because the marched
// fixtures legitimately front ZERO shops on their mains (the marched spine is bare; the avenues carry the
// shops), and they must keep their v2 tram. Synthetic (22 edges) and fixtures (~15) are far under the
// threshold, so they can never be fenced: behaviour there is untouched.
const REAL_ROADS_EDGES = 200; // a real OSM graph; synthetic/fixtures are an order of magnitude smaller
const FENCE_MIN_SHOPS = 5; // fewer than this on the whole line ⇒ not a high street
export function createTram({ scene, plan, camera, lighting }) {
const route = spinePolyline(plan);
const group = new THREE.Group(); group.name = 'tram';
if (!route || route.length < 2) { scene.add(group); return { group, update() {}, ring() {}, dispose() { scene.remove(group); } }; }
const realRoads = (plan.streets?.edges?.length || 0) > REAL_ROADS_EDGES;
const shopsFronted = (route && route.info && route.info.shopsFronted) || 0;
const fenced = realRoads && shopsFronted < FENCE_MIN_SHOPS;
if (!route || route.length < 2 || fenced) {
scene.add(group);
const info = { ...((route && route.info) || {}), fenced, reason: fenced ? `real-roads town, best main chain fronts ${shopsFronted} shops (< ${FENCE_MIN_SHOPS}) — highway, not a high street` : (route ? 'no usable spine' : 'no main edges') };
return { group, update() {}, ring() {}, dispose() { scene.remove(group); }, get stops() { return 0; }, get routeInfo() { return info; } };
}
// ── cumulative arc length + stop positions projected to arc length ──
const seg = []; // { x0,z0, dx,dz, len, s0 }
@ -66,12 +127,18 @@ export function createTram({ scene, plan, camera, lighting }) {
const d = s - g.s0;
return { x: g.x0 + g.dx * d, z: g.z0 + g.dz * d, dx: g.dx, dz: g.dz };
};
// project each shelter stop onto the route → sorted arc-length stop marks
// Project each shelter onto the route → sorted arc-length stop marks, keeping only shelters that are
// ACTUALLY ON this route. ROUND20 fix: the projection used to accept every shelter in the town, which
// is harmless on synthetic (2 shelters, both on the spine) but nonsense on a real graph — Newtown's
// ~148 town-wide shelters all projected onto the line, giving 149 phantom stops (~9 min of dwell at
// points the tram never passes). A shelter sits `halfRoad + 1.7` off its own centreline (≤ ~16 m even
// on the wide synthetic main), so 30 m keeps every genuine stop and drops the far ones.
const STOP_NEAR = 30;
const stopS = busShelterStops(plan).map((st) => {
let best = 0, bd = 1e18;
for (let s = 0; s <= total; s += 3) { const p = posAt(s); const dd = (p.x - st.x) ** 2 + (p.z - st.z) ** 2; if (dd < bd) { bd = dd; best = s; } }
return best;
}).sort((a, b) => a - b);
return { s: best, d2: bd };
}).filter((x) => x.d2 <= STOP_NEAR * STOP_NEAR).map((x) => x.s).sort((a, b) => a - b);
// ── tram mesh: textured body (1 draw) + merged emissive headlights (1 draw) ──
const bodyMat = new THREE.MeshStandardMaterial({ map: busTexture(), roughness: 0.7, metalness: 0.1 });
@ -113,7 +180,9 @@ export function createTram({ scene, plan, camera, lighting }) {
heads.geometry.dispose(); headMat.dispose();
scene.remove(group);
}
return { group, update, ring, dispose, get stops() { return stopS.length; } };
// routeInfo: the R20 shop-adjacency verdict, for F's smoke + the notes (which chain the tram picked).
return { group, update, ring, dispose, get stops() { return stopS.length; },
get routeInfo() { return { ...(route.info || {}), stops: stopS.length, fenced: false }; } };
}
function mergeQuads(geos) {