Compare commits

...

7 Commits

Author SHA1 Message Date
m3ultra
e4a49b5bc8 Lane F R17 (v3.2): real towns boot in the engine + the town-matrix scout gate (ledger #7)
F wired the last mile of the real-map scout and gated the whole matrix.

- index.js barrel re-exports registerTownCache/validateTownCache; the shell fetches + registers
  assets/towns/<key>.json before generatePlanFor when the town isn't a checked-in fixture — so
  ?plansrc=osm&town=bendigo_real boots a REAL AU town in the engine (the gap: E's caches were on disk +
  in the node selfcheck, but the shell never loaded them → silent Melbourne fallback). Fail-soft
  (404 / invalid / ?noassets -> fixture fallback); opt-in, never on the default or ?classic path.
- tools/qa/town_matrix.py (opt-in qa.sh --matrix): boot / determinism / budget / district / ?noassets
  across 10 towns (2 synthetic hero seeds + 3 osm fixtures + E's 5 real AU caches). ALL 5x10 GREEN —
  Castlemaine at the 6-shop floor still lands a 3-venue district. The existing CityPlan contract eats real
  Australian geography: the evidence John charters v4 = THE REAL MAP on.
- docs: LANE_F_NOTES §17 (the town x gate table), README real-town row, F-progress R17.

qa.sh --strict --soak GREEN 7/7. Verified: ?plansrc=osm&town=bendigo_real -> Bendigo, 9 shops, 3 venues, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:23:15 +10:00
m3ultra
64ded4ea01 Lane D R17 (v3.2): bench-sit loiter (ledger #2) — the carried R16 stretch
Street peds occasionally sit during a window-shop loiter (verandah/footpath
edge). makeActor gains an opt-in setSitting with a SCALE-AWARE re-plant (the
R16 spawnRig re-plant was scale-naive; the sim scales these rigs per-citizen),
played only on sit so walkers/placeholders/?classic are byte-identical. Seeded
via a dedicated benchsit rng stream (no shift to turn/loit/patron; identity
signature unchanged). Classic inert at the source (roll gated on sitClip).

In-shell (default, seed 20261990): head bone 1.466->1.070, bone span
1.667->1.299 (in seated gate [0.9,1.5]), feet re-plant to y=0 and hold through
sit-idle, stand-up restores inner.y 0.929 byte-for-byte, procitySeated tagged,
innerRotY:pi throughout. 9 distinct peds sat naturally per soak. Classic:
fleetSitClip:false, everSat:0. Plan golden 0x3fa36874 unchanged. Draws 35/tris 6k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:22 +10:00
m3ultra
483a4b75c6 Lane E R17 (v3.2): the real-map scout — 5 AU town caches + publish --verify fix
Ledger #4 (warm-up): publish.py --verify no longer false-flags non-procity_ published assets
(sit.glb, vintage-cash-register). It compares the _published.json name record against the depot's
full file list (fetch_depot_files) instead of a procity_ prefix filter; only OUR namespace flags as
unrecorded. --verify now: all 39 recorded assets live.

Ledger #6 (the scout): new pipeline/build_towns.py ports thriftgod build_city()'s Overpass route to
per-town caches in A's procity-town-cache/1 contract. 5 real compact AU towns under web/assets/towns/
(katoomba/newtown/fremantle/bendigo/castlemaine, 6-21 shops, spans <2.4km): bounded Overpass query ->
raw cached in-repo (re-runs never re-fetch) -> dedupe/suburb-fill/parody (trademarked chains only;
indie shops keep real OSM names)/type-map. Deterministic (byte-identical re-run, no RNG). ODbL:
SOURCES.md licence table + license/attribution on every cache. All 5 boot through the full
structural+gig suites (selfcheck 16596/16596 green; synthetic golden 0x3fa36874 unmoved). Overpass
fetch John-authorized (Standing #2).

-> Lane A: caches live under web/assets/towns/; pin the per-town goldens selfcheck printed (katoomba
   0x61760542 newtown 0x95978b45 fremantle 0xee9fd09a bendigo 0x5e3e76a6 castlemaine 0xf1a628a1) +
   feed through plan_osm. E->A handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:49:40 +10:00
m3ultra
6671cf08f4 Lane D R17 (v3.2): the drummer's pelvic-lean (ledger #1)
The R16 seated drummer sat bolt-upright (the sit clip can't carry a pelvic tilt
— Hips.quaternion is unusable on these rigs). Fix: a small constant forward torso
lean applied POST-MIX. spawnRig({seated}) caches the mid-spine bone (seatBone);
seatedLean(spine) rotates it forward ~0.34 rad each frame after the mixer resets
it (constant offset, not accumulation); band.js calls it for m.seated members.
The drummer leans into the kit — head 0.28m forward of the hips, reads as a real
drummer from the side.

Byte-identical (my signature): standing rigs get no seatBone (found only in the
seated block) and seatedLean runs for seated figs only. Verified with the lean
live — walkers 188/188 face travel, keeper standing rig (stature 1.718,
hasSeatBone:false, innerRotY pi). No existing consumer moves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:32:58 +10:00
m3ultra
6fffd19043 Lane A round 17: v3.2 the town-cache contract (published first) + plan_osm hardened for real data
The real-map scout (ledger #6), A's half. Scope fence held: NO new plan features, NO golden moved
(classic 0x3fa36874, gig 0xb1d48ea1, all three osm fixtures frozen). The scout proves the EXISTING
?plansrc=osm contract eats real geography.

- The town-cache contract (published FIRST — E builds build_towns.py to it). plan_osm.js gains
  validateTownCache() (authoritative {ok,errors,warnings}), registerTownCache(), a generatePlanOSM(..,
  {cache}) seam, and TOWN_CACHE_SCHEMA/MIN_TOWN_SHOPS(6)/MAX_TOWN_SPAN_M(5km). Shape: {schema,key,town,
  source,license,attribution,center{lat,lon},shops[{id,name,type,lat,lon,suburb?}]}. Home declared:
  web/assets/towns/<key>.json (+ README.md full spec). Documented in CITY_SPEC v3.2 + LANE_A_NOTES R17.
- plan_osm hardened: blank name -> registry label; the validator gates finite center/coords + a >=6-shop
  floor and warns on unknown-type(->opshop)/dup-id/span>5km. Proven vs thriftgod's real 2,828-shop
  city_source.json: boots a valid plan, 0 bad coords, 0 'undefined' names.
- selfcheck: a committed synthetic-adversarial contract suite (valid-messy caches boot + pass structural/
  gig; invalid reject; wide-spread warns; registry seam) + an auto-loader for web/assets/towns/*.json
  (validate -> suites -> pin per-town golden), empty and green until E lands caches. ALL GREEN 15277/15277.
- v4 charter risk list (LANE_A_NOTES R17): the MEGA-STRIP (unbounded bbox -> 11.6 km strip; town selection
  is E's real lever, warned >5km); sparse compact towns (2 km inner-Canberra held 6 shops); osm facades
  clear (rows 54 m apart) so R16's bias is a no-op on real towns.

E->A handshake: E drops a cache in web/assets/towns/, the selfcheck prints its golden, I pin it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:32:14 +10:00
m3ultra
ee16545941 Lane B round 17 (v3.2): wind sway SHIPS -- weather-driven gum-tree sway (ledger #5, parked since R7)
Verdict: shipped, not killed. The R7 park reason ("touches shared materials") is a non-blocker under
R16's measurement-over-brief standard: the gum-tree billboard material is B-owned (furniture.js), so
this is entirely Lane B -- no C/E material seam. Verified fresh Chromium seed 20261990.

- New web/js/world/wind.js: shared uniforms (uWindTime/uWindAmp) + applyWind(material) (onBeforeCompile
  vertex patch: top-weighted horizontal sway, per-instance world-position phase, USE_INSTANCING-guarded)
  + updateWind(dt,intensity). furniture.js applies it to the tree material; weather.js drives it from
  update(dt) (base breeze 0.05 clear -> 0.05+intensity*0.30 ~0.35 rain gusts).
- Trees only. Awnings excluded by MEASUREMENT: rigid posted-verandah box slabs don't flutter (fabric
  canopy ripple = separate v3.3, shares the rigid awning InstancedMesh today).
- Byte-identical when weather off (the covenant): updateWind runs only inside weather.update, and the
  weather system isn't constructed under ?weather=0 / ?classic=1 -> uWindAmp stays 0 -> sin(...)*0.0==0.0
  -> identical vertices. Proven: amp=0 two times = 0/180000 px differ; ?weather=0 boot = 0 tree-motion px;
  ?classic=1 = amp 0, trees render, 0 errors. No plan/fetch/rng change -> F's classic gate untouched.
- Sway works: rain amp 0.305 moves ~14100/180000 region px over 0.8s (exaggerated 2.5 -> 101k); trees
  render clean, no glitch. ~0 draws (ALU ops in the existing tree InstancedMesh vertex program), 0 errors.

-> Lane F (B->F handshake): SWAY SHIPPED. In old frames under the default boot; ?classic=1/?weather=0
   sway-free + byte-identical. Procedural (no asset -> E has nothing to do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:30:09 +10:00
m3ultra
abcadcba13 Lane C R17 (v3.2): close the backline render handshake (ledger #3)
D's R16 wiring (ab7a8d0) reads stage.backline[0] with an immediate primitive ampPrim()
fallback + GLB upgrade. Verified fresh (seed 20261990), 3 archetypes x 2 modes:
- amp plants at backline[0] exactly (offset 0.00); 1 amp per stage (double-amp gone).
- no interpenetration: amp-to-nearest-band AABB edge gaps all positive — pub 0.40/0.44,
  band_room 0.27/0.29, rsl 0.54/0.57 m (GLB/primitive). Rendered band_room confirms the read.

On-call (for A/E real-map scout): C interior type fallback eats real OSM shop types — 18 real
shop= values (+ ''/null/undefined/aliases) all build 0 throws, all pathOK, resolve to opshop.
No C code change (docs-only; verification of shipped behaviour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:18:36 +10:00
36 changed files with 3141 additions and 30 deletions

View File

@ -3,7 +3,28 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *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.* into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-16 (round 16) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-07-16 (round 17) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 17, v3.2 THE TAIL) — backline handshake CLOSED + real-town type fallback
R17 §Lane C (ledger #3): close the R16-pending render-verify of D's wired amp. D landed the wiring
(`ab7a8d0`): `band.js _addBackline` reads `stage.backline[0]` with an immediate primitive `ampPrim()` fallback
(asset-free) + GLB upgrade. **Handshake CLOSED — verified fresh (seed 20261990), 3 archetypes × 2 modes:**
- amp plants at `backline[0]` **exactly** (offset 0.00 GLB / 0.01 prim — rounding); **1 amp per stage** (no
more C primitive → the double-amp is gone).
- **no interpenetration** — amp↔nearest-band AABB edge gaps all positive: pub **0.40/0.44 m**, band_room
**0.27/0.29 m** (tightest; the bass extends downstage away from the up-stage amp), rsl **0.54/0.57 m**
(GLB/primitive). Rendered band_room gig confirms the read (amp up-stage-right behind the bassist, clear).
**On-call (proactive, for A/E's real-map scout):** confirmed C's interior type fallback eats real OSM shop
types. 18 real `shop=` values (hairdresser/bakery/convenience/supermarket/chemist/butcher/… + ''/null/
undefined/`SHOP`/`Op_Shop`/`antiques`) all build with **0 throws, all pathOK**, resolving to `opshop`
(theme.js `canonicalType`) or a mapped alias — so a player entering any real-town shop gets a valid interior.
Venue types (pub/band_room/rsl) are recognized; everything else degrades to opshop. No C change needed for v4.
Docs-only round (verification confirmed shipped behavior). C otherwise on-call.
--- ---

View File

@ -4,6 +4,22 @@
--- ---
## Round 17 (v3.2 THE TAIL + THE SCOUT) — F: the town matrix + the close
The tail drained (drummer lean + bench-sit, wind sway, backline verify, --verify fix) and E's real-map scout
landed (5 real AU town caches + A's cache contract + hardened plan_osm). **F closed it.**
- **Real towns boot in the engine** — the barrel re-exports `registerTownCache`; the shell fetches +
registers `assets/towns/<key>.json` before the plan generates (fail-soft; opt-in; never on default/classic).
`?plansrc=osm&town=bendigo_real`**Bendigo, 9 shops, 3-venue district, 0 errors**.
- **Town-matrix smoke** (`tools/qa/town_matrix.py`, `qa.sh --matrix`) — **10 towns × 5 gates all green**:
2 synthetic + 3 fixtures + 5 real caches, each boot / deterministic / in-budget (69216 draws) / a 3-venue
district / silent under `?noassets`. Castlemaine at the 6-shop floor still lands a district. **The existing
CityPlan contract eats real Australian geography** — the v4 = THE REAL MAP evidence.
- Close: `qa.sh --strict --soak` green, docs (LANE_F_NOTES §17, README), tag **`v3.2`**.
---
## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default ## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default
The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default. The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default.

View File

@ -74,6 +74,7 @@ master off:
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | | `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock | | `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness | | `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
| `?plansrc=osm&town=<key>` | — | boot a **real Australian town** (v3.2 scout): fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `docs`/`web/assets/towns/SOURCES.md`) |
| `?seed=N` | `20261990` | town seed | | `?seed=N` | `20261990` | town seed |
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on **Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on

View File

@ -1,10 +1,38 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 216 closed** (see below). *Status: **all deliverables landed; self-check all-green (15277/15277); rounds 217 closed** (see below).
v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs
`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.* 2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).*
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
## Round 17 (2026-07-16) — v3.2 THE SCOUT: the town-cache contract + plan_osm hardened for real data
Lane A ran **first** (E's `build_towns.py` builds to the contract). Scope fence held: **no new plan
features, no golden moved** — the scout proves the *existing* `?plansrc=osm` contract eats real geography.
Delivered (one commit):
1. **The town-cache contract (published FIRST, ledger #6).** `plan_osm.js` gains `validateTownCache()`
(the authoritative `{ok, errors[], warnings[]}` check), `registerTownCache()`, a `generatePlanOSM(…,
{cache})` seam, and `TOWN_CACHE_SCHEMA`/`MIN_TOWN_SHOPS`/`MAX_TOWN_SPAN_M`. Shape: `{schema, key, town,
source, license, attribution, center{lat,lon}, shops[{id,name,type,lat,lon,suburb?}]}`. Home declared:
`web/assets/towns/<key>.json` (+ a `README.md` with the full spec). Documented in CITY_SPEC (v3.2 section)
+ LANE_A_NOTES §R17 (the E handshake).
2. **`plan_osm` hardened** — blank name → registry label; the validator gates finite center/coords, a
≥6-shop floor, and warns on unknown types (→opshop), dup ids, and **span > 5 km** (the mega-strip). Proven
against thriftgod's real 2,828-shop `city_source.json`: boots a valid plan, 0 bad coords, 0 "undefined"
names. Marching regularises messy real coords, so dup/odd positions are a non-issue.
3. **Selfcheck: contract stress suite + real-cache path.** A committed synthetic-adversarial suite (valid
messy caches boot + pass structural/gig; invalid ones reject; wide-spread warns; the registry seam works),
plus an auto-loader for `web/assets/towns/*.json` — validates, runs the suites, pins per-town goldens.
Empty until E lands caches (guarded, green). ALL GREEN **15277/15277**; scaffold + consistency GREEN.
4. **The v4 risk list** (LANE_A_NOTES §R17, for Fable's charter): (a) **the mega-strip** — an unbounded
Overpass bbox marches a whole region (2,828 shops) into an 11.6 km strip; town SELECTION is E's real
quality lever, warned at >5 km, the generator can't rescue an over-broad query; (b) sparse compact towns
(a 2 km inner-Canberra radius held only 6 shops); (c) settled — osm facades clear (rows 54 m apart), so
the R16 clearance bias is a no-op on real towns.
Handshake open: E builds `build_towns.py` to the contract; E→A each cache → I pin its golden.
## Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7) ## Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7)
Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot

View File

@ -346,6 +346,28 @@ alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; chang
The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues` The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues`
places venues with no main spine (ROUND16 ledger #7). places venues with no main spine (ROUND16 ledger #7).
## Town caches — the real-map scout (v3.2, ROUND17 ledger #6)
> Bounded groundwork for **v4 THE REAL MAP** (John's pick). The `?plansrc=osm` producer already eats
> processed OSM data (three fixtures in `osm_fixture.js`); v3.2 lets it eat **real town caches** E scrapes
> from Overpass — **no new plan features**, the existing contract proven against real geography.
**The town-cache contract** (Lane-A-owned; authoritative implementation is `validateTownCache()` in
`web/js/citygen/plan_osm.js`, home `web/assets/towns/<key>.json`, full spec in that dir's `README.md`):
a processed town is `{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution,
center:{lat,lon}, shops:[ { id, name, type, lat, lon, suburb? } ] }` (+ optional `bbox`/`counts`/`fetchedAt`).
E's `build_towns.py` produces it (raw Overpass → dedupe/suburb/parody-name/seeded-hours); `plan_osm` marches
it identically to the fixtures. **Hard requirements:** finite `center.{lat,lon}`; `shops``MIN_TOWN_SHOPS`
(6 = up to 4 venues + the openLate landmark + a spare); finite shop `lat`/`lon`. **Absorbed (warnings):**
blank name → type label, unknown `type``opshop`, dup ids, and **span > 5 km → "likely more than one
town"** (a cache is ONE compact town — an over-broad bbox marches into an unusable multi-km strip).
**Wiring:** `generatePlanOSM(seed, key, { cache })` marches a cache; `registerTownCache(key, cache)` adds it
to the registry (`osmTownKeys()` + `generatePlanFor(seed,'osm',{town:key})` then see it). `selfcheck.js`
auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig suites, pins a per-town golden.
**Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/
`attribution` fields; raw Overpass responses cached so re-runs never re-fetch).
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for

View File

@ -2,6 +2,48 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data
### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake)
Your pipeline outputs one JSON per real town in the processed shape; `plan_osm` marches it like the
fixtures. **Home: `web/assets/towns/<key>.json`** (full spec in that dir's `README.md`). Shape:
```
{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution,
center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt
```
- **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js``{ok, errors[], warnings[]}`.
Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `shops` ≥ **6**
(`MIN_TOWN_SHOPS`), finite shop `lat`/`lon`. **Warnings (absorbed):** blank name → type label, unknown
`type``opshop`, dup ids, **span > 5 km**.
- `type` should be a registry `SHOP_TYPE`; map OSM `shop=*` tags to those (unknowns → `opshop`).
- **Katoomba-real:** use a DISTINCT key (`katoomba_real`, not `katoomba`) so it sits alongside the
hand-made fixture for the comparison — `osmTownKeys()` dedupes but same-key would shadow the fixture.
- Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me
(E→A handshake) and I'll pin `REAL_TOWN_GOLDENS` + it enters the sweep.
- **ODbL:** ship `SOURCES.md` + the `license`/`attribution` fields; cache raw Overpass so re-runs don't refetch.
### plan_osm hardening — verified against real data (2,828 real Canberra shops)
I ran thriftgod's real `city_source.json` through the hardened `plan_osm`: **it boots a valid plan** (0
bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are
a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest.
### → Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding)
1. **The mega-strip (the big one).** An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops
marched into an **11.6 km** single strip (350 shops/avenue) — a valid plan, an unusable "town". **Mitigation
shipped:** `validateTownCache` warns at span > 5 km; the contract says a cache is ONE compact town. **v4
risk:** town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator
can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline.
2. **Sparse compact towns.** A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small
towns can fall below a usable density. `MIN_TOWN_SHOPS` is 6 (the functional floor for the district); E
should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily).
3. **Not a risk (settled):** osm facades face `Z`/south with rows a fixed 54 m apart, so the R16 `+Z`
clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor.
### Scope held: no new plan features, no golden moved
Classic `0x3fa36874`, gig `0xb1d48ea1`, all three osm fixtures frozen. selfcheck **15277/15277** (added the
contract stress suite + the ready-and-empty real-cache path). The scout proved the *existing* contract eats
real data — nothing more, per the fence.
## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7) ## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7)
### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged) ### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)

View File

@ -1,5 +1,46 @@
# LANE B — NOTES (measured) # LANE B — NOTES (measured)
## Round 17 (Fable's ROUND17 → Lane B) — wind sway SHIPS (ledger #5, parked since R7)
**Verdict: SHIPPED, not killed.** The R7 park reason ("touches shared materials") was tested against
R16's measurement-over-brief standard and is a **non-blocker**: the gum-tree billboard material lives
in `furniture.js` (Lane B), so wind sway is **entirely Lane B — no C/E material seam**. New file
`web/js/world/wind.js` + two small hookups (`furniture.js` applies it, `weather.js` drives it).
### What ships
- **`wind.js`** — one shared uniform set (`uWindTime`, `uWindAmp`) + `applyWind(material)` (an
`onBeforeCompile` vertex patch: top-weighted horizontal sway, per-instance phase from world position
so neighbours desync, `#ifdef USE_INSTANCING`-guarded) + `updateWind(dt, intensity)`.
- **Trees only.** Gum-tree canopies sway. **Awnings are excluded — a *measured* call, not a punt:**
they're rigid posted-verandah box slabs (`buildings.js`), and a structural verandah roof doesn't
flutter. (Fabric-canopy ripple would be a separate v3.3 item — the striped canopies share the rigid
awning InstancedMesh today, so splitting them is its own task. Not needed for "wind sway ships.")
- **Weather-driven:** `updateWind` is called only from `weather.update(dt)` → base breeze on any
weather-on boot (`amp 0.05`, clear included) scaling to rain gusts (`0.05 + intensity·0.30 ≈ 0.35`).
### Byte-identical when weather is off (the covenant)
`updateWind` runs **only** inside `weather.update`, and the weather *system* is not constructed under
`?weather=0` or `?classic=1` — so `uWindAmp` stays at its init **0**, and the injected displacement is
`sin(...)·0.0·h == 0.0``transformed += 0.0` → identical vertices → identical pixels. Verified:
- `amp=0`, two different `uWindTime` values → **0 / 180,000 region pixels differ**.
- `?weather=0` boot → `uWindAmp 0`, advancing time → **0 tree-motion pixels**.
- `?classic=1` boot → `uWindAmp 0`, trees render, **0 console errors** (shader patched but inert).
- No plan change, no fetch, no rng (time-based, not seeded) — F's classic gate (`0x3fa36874` + fetch
delta) is untouched; the sway is pure render.
### Sway works + budget
- Realistic **rain amp 0.305**: advancing 0.8 s moves **~14,100 / 180,000** region pixels (clear
motion); exaggerated amp 2.5 moves 101k. Trees render normally with the shader (verified the
side-street verge — natural canopies, no glitch/explosion).
- **~0 draws** (a handful of ALU ops in the tree InstancedMesh's existing vertex program — no new
geometry, no new draw). **0 console errors** (GLSL compiles clean).
**→ Lane F (B→F handshake): SWAY SHIPPED.** It's in old frames now under the default boot (weather-on).
`?classic=1` / `?weather=0` are sway-free and byte-identical (proven above). README `?weather` note, if
you grow one, can mention "trees sway with the wind." No new asset (procedural), so E has nothing to do.
---
## Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline ## Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline
Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are

View File

@ -6,6 +6,80 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
--- ---
## ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1)
*PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files
`rigs.js`, `band.js`. Byte-identical proof holds (my signature discipline).*
The sit clip can't carry a pelvic tilt (`Hips.quaternion` is unusable on these rigs — R16), so the R16
drummer sat bolt-upright. **Fix: a small constant forward torso lean applied POST-MIX.** `rigs.js`
`spawnRig({seated})` now caches the mid-spine bone (`seatBone`), and `seatedLean(spine)` rotates it forward
~0.34 rad each frame AFTER the mixer resets it (so it's a constant offset, never an accumulation). `band.js`
calls it for `m.seated` members only. The drummer now leans into the kit — verified **head 0.28 m forward
of the hips**, reads as a real drummer from the side (the R16 stiffness is gone).
**Byte-identical proof**: standing rigs get **no `seatBone`** (found only in `spawnRig`'s seated block) and
`seatedLean` is only called for seated figs. Verified with the lean live: streamed walkers **188/188** face
travel, keeper is a standing rig (stature **1.718**, `hasSeatBone:false`, `innerRotY:π`). No existing
consumer moves. Seated-only, parallel path — same discipline as R16.
---
## ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch)
*PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a
window-shop stop. Files `rigs.js` (`makeActor` gains an opt-in `setSitting`), `sim.js` (the seeded
choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.*
### The finding I had to solve first
The R16 seated re-plant lives in `spawnRig` (drummer path) and is **scale-naive** — it drops `inner` by the
posed lowest-bone height, which only lands at y=0 for **unscaled** rigs. But the street crowd runs on
`makeActor` rigs that the sim scales **per-citizen** (`fig.scale = height/1.75`). So bench-sit is *not* the
one-line "reuse the existing seam" the brief hoped for — the re-plant had to become **scale-aware**.
### What shipped
- **`makeActor(rig, {…, sitClip})`** gains a third action `sitA`, **played only on `setSitting(true)`** — so
walkers, placeholders, and `?classic` (no `sitClip``sitA` null → `setSitting` no-ops) are byte-identical:
the sit action never advances the mixer and the plant never moves.
- **`setSitting(s)`** snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and
does the **scale-aware re-plant**: after baking the pose it measures the posed lowest bone's *world* y and
drops `inner` by `(worldLowestY figY) / fig.scale.y`, landing the posed soles on the footpath at any
citizen scale. Standing up restores the cached `bindPlantY` exactly. Tags `procitySeated`.
- **`sim.js`**: a **dedicated** rng stream `sitRng = rng(citySeed,'benchsit',id)` (independently keyed → does
**not** shift `turn`/`loit`/`patron`, so the walk/loiter pattern and the identity signature are unchanged).
At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips `c.sit` when a sit
clip is loaded (`?classic` inert at the source). The near-tier update toggles `setSitting(c.sit && loiter>0)`.
`RigPool.release` forces `setSitting(false)` so a pooled rig is never handed out mid-sit.
- No lean: bench-sitters sit **upright** (natural on a verandah step), so the drummer's `seatedLean` is not
applied here — only `setSitting`'s pose + re-plant. No bench-position binding — they sit where they loitered
(the footpath/verandah edge under the awnings).
### Byte-identical proof (in-shell, default boot, seed 20261990)
Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; `Box3` is bind-pose for skinned
meshes and lies here):
| metric | standing | seated | +1 s sit-idle | stood back |
|---|---|---|---|---|
| head bone y | 1.466 | **1.070** | 1.071 | 1.466 |
| bone span | 1.667 | **1.299** (∈ seated gate [0.9,1.5]) | 1.285 | 1.667 |
| lowest bone y (feet) | 0.027 | **0.000** (planted) | 0.005 (holds) | 0.027 |
| `inner.position.y` | 0.929 | 0.548 | 0.548 | **0.929** (restored) |
| `procitySeated` | false | **true** | true | false |
Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant **holds** through
the sit-idle, and standing up restores the rig **byte-for-byte**. `innerRotY:π` throughout (facing convention
intact). Draws **35 · tris 6k** at the seated block (seated peds are already-near rigs — ~0 draw delta).
- **Seeded path fires**: 69 distinct peds sat naturally per ~100 s soak (of ~10 window-shop loiters).
- **Determinism unchanged**: identity signature `-4,2#0:5:1:1.711:1.370:0:-1` — no `sit` term; plan golden
`0x3fa36874`, 15277/15277.
- **Classic covenant airtight at the source**: `?classic=1``fleetSitClip:false`, 31 loiters over the soak
but `everSat:0` (the gate never flips `c.sit`), zero fetch delta, `anySeatedNow:0`.
Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained.
---
## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification ## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`, *PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,

View File

@ -1,5 +1,42 @@
# LANE E — cross-lane notes # LANE E — cross-lane notes
## Round 17 — v3.2 THE SCOUT: real town caches + the verify fix ⟨v3.2 amendment⟩
> The real-map scout for **v4 THE REAL MAP** (John's pick) — bounded data groundwork, no new game
> systems. **5 real AU town caches produced + the `--verify` false-drift fixed.** → **Lane A: caches
> are live under `web/assets/towns/`; pin the per-town goldens** (printed below by selfcheck) and feed
> them through `plan_osm` — E→A handshake.
**Ledger #4`publish.py --verify` false-drift fixed (the warm-up).** The drift check filtered the
depot list to `procity_*`, so genuinely-published non-`procity_` assets (`sit.glb`,
`vintage-cash-register.glb`) were false-flagged "not live." Now it compares the published-NAME record
(`_published.json`) against the depot's full file list (`fetch_depot_files()`), confirming any name is
live — and only flags OUR `procity_` namespace as unrecorded, ignoring other projects' files on the
shared depot. `--verify` now: `✓ all 39 recorded assets live`.
**Ledger #6 — the real-map scout (the round's biggest item).** `pipeline/build_towns.py` ports
thriftgod `build_city()`'s Overpass route to **per-town** caches in A's `procity-town-cache/1` contract
(`validateTownCache()`). Pipeline: **bounded** Overpass query → raw response cached in-repo → dedupe
(OSM node+way) → suburb-fill → **parody** the trademarked charity/franchise chains (indie shops keep
real OSM names) → OSM `shop=*` → registry SHOP_TYPE → `web/assets/towns/<key>.json`.
| cache | town | shops | span | types | selfcheck golden (→ A pins) |
|---|---|---|---|---|---|
| `katoomba_real` | Katoomba NSW | 20 | 1.11 km | book4 opshop13 record2 video1 | `0x61760542` |
| `newtown_real` | Newtown, Sydney | 18 | 2.36 km | book4 opshop6 record5 toy2 video1 | `0x95978b45` |
| `fremantle_real` | Fremantle WA | 21 | 1.89 km | book5 opshop10 pawn1 record1 toy3 video1 | `0xee9fd09a` |
| `bendigo_real` | Bendigo VIC | 9 | 2.06 km | book3 opshop3 pawn1 record1 video1 | `0x5e3e76a6` |
| `castlemaine_real` | Castlemaine VIC | 6 | 1.25 km | book2 opshop4 | `0xf1a628a1` |
**Data discipline:** deterministic (same raw → byte-identical cache; shops id-sorted, no RNG — verified
re-run byte-identical); bounded (all spans < 2.4 km, well under the 5 km mega-strip floor); raw
responses committed to `web/assets/towns/_raw/` so **re-runs never re-fetch** (Overpass hit once per
town, John-authorized). `katoomba_real` is the fixture-comparison town (vs the hand-made `katoomba`).
All 5 boot through the full structural + gig suites (selfcheck **16596/16596 green, synthetic golden
`0x3fa36874` unmoved**). **Provenance:** OSM is **ODbL**`SOURCES.md` (E-owned licence table) +
`license`/`attribution` fields on every cache; only public POI data (name/type/coord/suburb), no
private-individual data.
## Round 16 — v3.1 THE FLIP: the drummer's sit clip (E asset half) ⟨v3.1 amendment⟩ ## Round 16 — v3.1 THE FLIP: the drummer's sit clip (E asset half) ⟨v3.1 amendment⟩
> Ledger #2 asset half — the drummer finally sits. **E produces the clip; D wires it** (`_rotWithHips` > Ledger #2 asset half — the drummer finally sits. **E produces the clip; D wires it** (`_rotWithHips`
@ -69,6 +106,7 @@ loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe | | Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata | | Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) | | Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
| Town caches **⟨v3.2⟩** | 5 real AU towns (Katoomba/Newtown/Fremantle/Bendigo/Castlemaine) | **OpenStreetMap** via Overpass API | `build_towns.py` (fetch → dedupe/suburb/parody/type-map) | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md`) |
**GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever: **GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever:
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only). ~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).

View File

@ -638,3 +638,40 @@ in the opening commit. Verified live: the seated drummer passes at **1.32 m stat
actual shader is B's own `winmap` smoke. actual shader is B's own `winmap` smoke.
- **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a - **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a
raw sit fetch under classic would break the covenant even though the drummer never spawns there. raw sit fetch under classic would break the covenant even though the drummer never spawns there.
---
## 17. Round 17 — v3.2 THE TAIL + THE SCOUT. F: the town matrix + the close (ledger #7).
The tail drained (D's drummer lean + bench-sit, B's wind sway, C's backline verify, E's `--verify` fix) and
the real-map scout ran (E's 5 real AU town caches + A's cache contract + hardened `plan_osm`). F wired the
last mile and gated the whole matrix.
### Real towns boot in the engine (the F wiring)
E's caches sat on disk (`web/assets/towns/*.json`) + registered in the node selfcheck, but **the shell never
loaded them** — `?plansrc=osm&town=bendigo_real` silently fell back to Melbourne. F closed the gap: the barrel
(`index.js`) re-exports `registerTownCache`/`validateTownCache`; the shell fetches `assets/towns/<TOWN>.json`
+ registers it before `generatePlanFor` when the town isn't a checked-in fixture. **Fail-soft** — 404 /
invalid / `?noassets` → plan_osm's fixture fallback; the scout data is opt-in, never on the default or
`?classic` path. Verified: `?plansrc=osm&town=bendigo_real`**Bendigo, 9 shops, a 3-venue district, 0 errors**.
### The town matrix (`tools/qa/town_matrix.py`, opt-in `qa.sh --matrix`) — the v4 evidence
Boots every town on the DEFAULT boot and gates boot / determinism / budget / district / `?noassets`. **All
10 × 5 green:**
| town | shops | budget (draws/tris) | district |
|---|---|---|---|
| synthetic/20261990 · 1234 | 493 · 486 | 124d/57k · 147d/71k | 3v ✓ |
| osm/melbourne · katoomba · silverton | 95 · 19 · 12 | 216d · 70d · 70d | 3v ✓ |
| real/bendigo · castlemaine · fremantle · katoomba · newtown | 9 · 6 · 21 · 20 · 18 | 82 · 86 · 159 · 69 · 93 d | 3v ✓ |
The headline: **the existing CityPlan contract eats real Australian geography** — Castlemaine at the 6-shop
floor still lands a 3-venue district; every real town is deterministic, in-budget, and silent under
`?noassets`. No new game systems — exactly the bounded scout the round asked for. This table is the evidence
John charters **v4 = THE REAL MAP** on.
### Gotchas
- **A published cache + selfcheck ≠ a bootable town.** The node sweep proved the *plan* generates; the
*browser* boot needed the shell to fetch+register the cache. Classic integration seam — F owns it.
- **`?noassets` can't fetch a real cache**, so a real-town + noassets boot correctly falls back to a fixture
(still silent-and-fine). The matrix's noassets gate tests that fallback, not the real town — as it should.

193
pipeline/build_towns.py Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""build_towns.py — the real-map scout (ROUND17 ledger #6). A port of thriftgod `build_city()`'s
Overpass route to PER-TOWN real Australian caches in the Lane-A `procity-town-cache/1` contract.
python3 pipeline/build_towns.py # build every town (cached raw reused, no re-fetch)
python3 pipeline/build_towns.py katoomba_real # one town
python3 pipeline/build_towns.py --refetch # ignore cached raw, re-hit Overpass (deliberate)
Pipeline per town: bounded Overpass query RAW response cached in-repo (`web/assets/towns/_raw/`, so
re-runs NEVER re-fetch) dedupe (OSM maps a shop as node AND way) suburb fill (untagged adopt the
nearest tagged neighbour) parody the big trademarked charity/franchise names (indie shops keep their
real OSM name) map OSM `shop=*` to a registry SHOP_TYPE `web/assets/towns/<key>.json`.
DATA DISCIPLINE (ledger #6): deterministic (same raw → byte-identical cache; shops sorted by OSM id,
no RNG); bounded queries (a cache is ONE compact town, span < 5 km the mega-strip risk); ODbL
attribution ships IN every cache (`license`/`attribution`) + `SOURCES.md`. Hours are NOT cached the
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
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOWNS_DIR = os.path.join(ROOT, "web", "assets", "towns")
RAW_DIR = os.path.join(TOWNS_DIR, "_raw")
OVERPASS = "https://overpass-api.de/api/interpreter"
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},
}
# 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 big trademarked charity/franchise chains get loving parodies (ported from thriftgod, John's call
# for the live game); independent shops keep their real OSM names (factual ODbL data).
PARODY = [
(r"salvos( stores?)?|(the )?salvation army", "Slave-ohs"),
(r"lifeline", "Tightrope"),
(r"rspca", "RSYMCA"),
(r"vinnies|st\.? ?vincent('?s| de paul)?( society)?|svdp", "Sinnies"),
(r"(australian )?red cross", "Red Floss"),
(r"save the children", "Shave the Children"),
(r"anglicare", "Tanglecare"),
(r"uniting ?care|unitingcare|uniting", "Unknitting"),
(r"good sammys?|good samaritans?", "Okay Sammys"),
(r"mission australia", "Mission Improbable"),
(r"brotherhood of st\.? ?laurence", "Brotherhood of St Lozenge"),
(r"cash ?converters?", "Cashtheists"),
]
def parodize(name):
for pat, rep in PARODY:
name = re.sub(pat, rep, name, flags=re.I)
return name
def punct_blind(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
def bbox(center, span_km):
lat, lon = center
dlat = (span_km / 2.0) / 111.0
dlon = (span_km / 2.0) / (111.0 * math.cos(math.radians(lat)))
return {"minLat": lat - dlat, "minLon": lon - dlon, "maxLat": lat + dlat, "maxLon": lon + dlon}
def haversine_m(a, b):
R = 6371000.0
p1, p2 = math.radians(a[0]), math.radians(b[0])
dp = math.radians(b[0] - a[0]); dl = math.radians(b[1] - a[1])
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(min(1.0, math.sqrt(h)))
def fetch_raw(key, bb, refetch):
"""Return the raw Overpass elements list, cached in-repo so re-runs never re-fetch."""
os.makedirs(RAW_DIR, exist_ok=True)
path = os.path.join(RAW_DIR, key + ".overpass.json")
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})")
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=150) as r:
els = json.load(r).get("elements", [])
fetched = datetime.date.today().isoformat()
json.dump({"fetchedAt": fetched, "endpoint": OVERPASS, "query": q, "bbox": bb, "elements": els},
open(path, "w"), indent=1)
time.sleep(2) # be a good Overpass citizen between towns
return els, fetched
def process(key, cfg, els, fetched):
center = cfg["center"]
rows, seen = [], set()
for el in els:
t = el.get("tags", {})
name, osm_shop = t.get("name"), t.get("shop")
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))):
continue
dkey = (punct_blind(name), round(lat, 3), round(lon, 3)) # node/way dup fuzz (~110 m)
if dkey in seen:
continue
seen.add(dkey)
suburb = next((t[k] for k in ("addr:suburb", "addr:city", "addr:town", "addr:place") if t.get(k)), "")
rows.append({"id": el["id"], "name": name[:60], "osm": osm_shop, "lat": lat, "lon": lon, "suburb": suburb})
# suburb fill: untagged shops adopt the nearest tagged neighbour within ~3 km (that's a suburb)
tagged = [r for r in rows if r["suburb"]]
for r in rows:
if r["suburb"] or not tagged:
continue
near = min(tagged, key=lambda s: haversine_m((r["lat"], r["lon"]), (s["lat"], s["lon"])))
if haversine_m((r["lat"], r["lon"]), (near["lat"], near["lon"])) <= 3000:
r["suburb"] = near["suburb"]
# parody + type-map + second dedupe (parodized name in same suburb = same shop); id-sorted = deterministic
shops, seen2 = [], set()
for r in sorted(rows, key=lambda r: r["id"]):
pname = parodize(r["name"])[:60]
k2 = (punct_blind(pname), r["suburb"])
if k2 in seen2:
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"]})
bb = bbox(center, cfg["span_km"])
return {
"schema": "procity-town-cache/1", "key": key, "town": cfg["town"], "source": "osm",
"license": "ODbL 1.0", "attribution": "© OpenStreetMap contributors",
"generator": "pipeline/build_towns.py", "fetchedAt": fetched,
"center": {"lat": center[0], "lon": center[1]},
"bbox": bb, "counts": {"raw": len(els), "shops": len(shops)},
"shops": shops,
}
def span_km(shops):
if not shops:
return 0.0
lats = [s["lat"] for s in shops]; lons = [s["lon"] for s in shops]
latm = haversine_m((min(lats), lons[0]), (max(lats), lons[0]))
lonm = haversine_m((lats[0], min(lons)), (lats[0], max(lons)))
return max(latm, lonm) / 1000.0
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
refetch = "--refetch" in sys.argv
keys = args or list(TOWNS)
os.makedirs(TOWNS_DIR, exist_ok=True)
kept, dropped = [], []
for key in keys:
cfg = TOWNS[key]
els, fetched = fetch_raw(key, bbox(cfg["center"], cfg["span_km"]), refetch)
cache = process(key, cfg, els, fetched)
n, sp = len(cache["shops"]), span_km(cache["shops"])
types = {}
for s in cache["shops"]:
types[s["type"]] = types.get(s["type"], 0) + 1
status = "OK " if n >= 6 and sp < 5.0 else "LOW"
if n >= 6 and sp < 5.0:
json.dump(cache, open(os.path.join(TOWNS_DIR, key + ".json"), "w"), indent=1)
kept.append(key)
else:
dropped.append((key, n, sp))
print(f" {status} {key:16} raw={cache['counts']['raw']:>4} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}")
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}")
if __name__ == "__main__":
main()

View File

@ -126,32 +126,40 @@ def main():
def drift_check(record): def drift_check(record):
"""After publishing, compare _published.json against the live depot procity_* list; warn on """Compare _published.json (the published-name RECORD) against the depot's actual file list.
drift (a published file missing from the record, or a recorded file absent from the depot).""" A recorded name absent from the depot is real drift (deleted / never landed); a `procity_` file
live on the depot but unrecorded is a provenance gap. R17 ledger #4: the old check filtered the
depot list to `procity_*` and so false-flagged genuinely-published non-`procity_` assets
(sit.glb, the reused vintage-cash-register) as "not live" now every published name is confirmed
by the name list itself, and only OTHER projects' non-`procity_` files on the shared depot are
ignored (they're expected, not ours)."""
try: try:
live = fetch_depot_procity() live = fetch_depot_files()
except Exception as e: except Exception as e:
print(f" (drift check skipped — depot list unreachable: {e})") print(f" (drift check skipped — depot list unreachable: {e})")
return return
rec = set(record) rec = set(record)
missing_from_record = live - rec # live on depot but not recorded → record is stale missing_from_depot = rec - live # recorded but not live → real drift
missing_from_depot = rec - live # recorded but not live → deleted/never landed unrecorded = {f for f in live if f.startswith("procity_")} - rec # our namespace live but unrecorded
if missing_from_record: if unrecorded:
print(f" DRIFT: {len(missing_from_record)} on depot not in _published.json: " print(f" DRIFT: {len(unrecorded)} procity_ on depot not in _published.json: "
f"{sorted(missing_from_record)[:5]}") f"{sorted(unrecorded)[:5]}")
if missing_from_depot: if missing_from_depot:
print(f" DRIFT: {len(missing_from_depot)} in _published.json not live on depot: " print(f" DRIFT: {len(missing_from_depot)} in _published.json not live on depot: "
f"{sorted(missing_from_depot)[:5]}") f"{sorted(missing_from_depot)[:5]}")
if not missing_from_record and not missing_from_depot: if not unrecorded and not missing_from_depot:
print(f" ✓ _published.json matches the live depot ({len(live)} procity assets).") print(f" ✓ _published.json matches the live depot (all {len(rec)} recorded assets live).")
def fetch_depot_procity(): def fetch_depot_files():
"""EVERY file name on the depot (not prefix-filtered), so the drift check confirms any published
name including non-`procity_` clips/reuses is live by the name itself, not a prefix heuristic."""
import urllib.request as _u import urllib.request as _u
with _u.urlopen(DEPOT + "/api/list", timeout=15) as r: req = _u.Request(DEPOT + "/api/list", headers={"User-Agent": "procity-validator/1.0"})
with _u.urlopen(req, timeout=15) as r:
d = json.loads(r.read().decode("utf-8")) d = json.loads(r.read().decode("utf-8"))
assets = d.get("assets", d) if isinstance(d, dict) else d assets = d.get("assets", d) if isinstance(d, dict) else d
return {a["file"] for a in assets if str(a.get("file", "")).startswith("procity_")} return {a["file"] for a in assets if a.get("file")}
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -126,6 +126,20 @@ if [ "$SOAK_RUN" = 1 ]; then
fi fi
fi fi
# ── Gate 7 (Lane F, round 17): town-matrix smoke — OPT-IN (--matrix), NON-STRICT ─
# The v4 real-map scout evidence: boot / determinism / budget / district / noassets across the full town
# matrix — synthetic hero seeds + the 3 osm fixtures + Lane E's 5 real AU town caches. One town × gate
# table. Slow (~20 boots) + diagnostic, so OPT-IN + warn-level. Wired per ROUND17 §Lane F (ledger #7).
MATRIX_RUN=0; for a in "$@"; do [ "$a" = "--matrix" ] && MATRIX_RUN=1; done
if [ "$MATRIX_RUN" = 1 ]; then
if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
warn_gate "town-matrix (synthetic + fixtures + real caches × 5 gates · Lane F R17)" \
tools/.venv/bin/python tools/qa/town_matrix.py
else
soft_skip "town-matrix" "Playwright venv absent"
fi
fi
# ── Summary ────────────────────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────────────────────
hr hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

169
tools/qa/town_matrix.py Normal file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""PROCITY Lane F — the town-matrix smoke (round 17, v3.2 · the real-map scout gate · ledger #7).
The gate side of the scout: run the core smokes across the FULL town matrix synthetic hero seeds, the
three checked-in osm fixtures, and Lane E's real AU town caches — and emit one town × gate table. This is
the evidence John charters v4 (THE REAL MAP) on: proof the existing CityPlan contract eats real geography
through the default boot, with no new game systems.
Gates per town (all on the DEFAULT boot gigs/weather/winmap/tram on, post-R16 flip):
boot the plan builds + the shell reaches window.PROCITY.plan, 0 console errors
determ two fresh in-page generations are byte-equal (the generator is deterministic)
budget the busiest default view (venue block at night) 300 draws / 200k tris
district the gig layer placed on this town: 1 venue, a week schedule, posters
noassets ?noassets=1 boots silent (0 errors, 0 network); real caches fall back to a fixture (correct)
Run: tools/.venv/bin/python tools/qa/town_matrix.py
Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17).
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
# the matrix: (row-label, boot-query). Synthetic hero seeds + 3 fixtures + E's 5 real AU caches.
MATRIX = (
[(f'synthetic/{s}', f'seed={s}') for s in (20261990, 1234)]
+ [(f'osm/{t}', f'plansrc=osm&town={t}') for t in ('melbourne', 'katoomba', 'silverton')]
+ [(f'real/{t}', f'plansrc=osm&town={t}')
for t in ('bendigo_real', 'castlemaine_real', 'fremantle_real', 'katoomba_real', 'newtown_real')]
)
# default-boot gates 1-4, in one page. Determinism regenerates the plan twice in-page (the real cache is
# already registered by the shell) and compares — no second full boot needed.
DEFAULT_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG;
const plan = P.plan;
const params = new URLSearchParams(location.search);
const src = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const town = params.get('town') || undefined;
const cg = await import('./js/citygen/index.js');
const g1 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true }));
const g2 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true }));
const venues = (plan.shops || []).filter(s => s.venue).length;
D.shot('venue_night'); // busiest default view: the venue block at night
await new Promise(r => setTimeout(r, 500));
const i = D.info();
return { name: plan.name, shops: (plan.shops || []).length, venues,
gigs: (plan.gigs || []).length, posters: (plan.posters || []).length,
determ: g1 === g2, draws: i.drawCalls, tris: i.tris };
}
"""
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
def run_town(new_page, query):
"""Returns a dict of gate → (ok, note) for one town."""
row = {}
# ── default boot: boot / determ / budget / district ──
pg = new_page()
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
try:
pg.goto(f'{HOST}/index.html?{query}&dbg=1')
pg.wait_for_function('window.PROCITY && window.PROCITY.plan && window.DBG && window.DBG.ready', timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function('() => !window.PROCITY.fleet || window.PROCITY.fleet.ready', timeout=12000)
except Exception:
pass
d = pg.evaluate(DEFAULT_JS)
row['name'] = d['name']; row['shops'] = d['shops']
row['boot'] = (bool(d['name']) and len(errs) == 0, f"{d['shops']} shops, {len(errs)} err")
row['determ'] = (d['determ'], 'byte-equal' if d['determ'] else 'DIFF')
row['budget'] = (d['draws'] <= STREET_DRAWS_MAX and d['tris'] <= STREET_TRIS_MAX,
f"{d['draws']}d/{d['tris']//1000}k")
row['district'] = (d['venues'] >= 1 and d['gigs'] > 0,
f"{d['venues']}v/{d['gigs']}g/{d['posters']}p")
except Exception as e:
row.setdefault('name', '?'); row.setdefault('shops', 0)
for g in ('boot', 'determ', 'budget', 'district'):
row.setdefault(g, (False, str(e)[:24]))
finally:
pg.close()
# ── ?noassets=1: silent-and-fine (real caches correctly fall back to a fixture — no network) ──
pg = new_page()
reqs, errs2 = [], []
pg.on('request', lambda r: reqs.append(r.url) if ('/assets/towns/' in r.url or '/assets/audio/' in r.url or r.url.endswith('.glb')) else None)
pg.on('pageerror', lambda e: errs2.append(str(e)))
try:
pg.goto(f'{HOST}/index.html?{query}&noassets=1&dbg=1')
pg.wait_for_function('window.PROCITY && window.PROCITY.plan', timeout=20000)
has_plan = pg.evaluate("() => !!(window.PROCITY.plan && window.PROCITY.plan.shops.length)")
row['noassets'] = (has_plan and len(errs2) == 0 and len(reqs) == 0,
f"{len(reqs)} fetch/{len(errs2)} err")
except Exception as e:
row['noassets'] = (False, str(e)[:24])
finally:
pg.close()
return row
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
print(f"\033[1mTOWN-MATRIX SMOKE\033[0m ({len(MATRIX)} towns × 5 gates · the v4 real-map scout evidence)")
srv = ensure_server()
GATES = ['boot', 'determ', 'budget', 'district', 'noassets']
rows = []
try:
with sync_playwright() as p:
b = p.chromium.launch()
def factory():
return b.new_page(viewport={'width': 1280, 'height': 720})
for label, query in MATRIX:
r = run_town(factory, query)
rows.append((label, r))
cells = ' '.join(f"{g}:{'' if r[g][0] else ''}" for g in GATES)
print(f" {label:22} {(r.get('name') or '?'):16} {cells}")
b.close()
finally:
if srv: srv.terminate()
# the table John reads
print(f"\n\033[1mTOWN × GATE\033[0m")
hdr = f"{'town':22} {'name':16} " + " ".join(f"{g:>9}" for g in GATES)
print(hdr); print('' * len(hdr))
fails = 0
for label, r in rows:
line = f"{label:22} {(r.get('name') or '?'):16} "
for g in GATES:
ok, note = r[g]
fails += 0 if ok else 1
line += f"{('' if ok else '') + note:>9} "[:10] + " "
print(line)
# per-gate note detail
print("\nnotes:", "; ".join(f"{label}[{g}={r[g][1]}]" for label, r in rows for g in GATES if not r[g][0]) or "all gates green")
print()
if fails:
print(f"\033[31m● {fails} gate failure(s)\033[0m across {len(rows)} towns — the matrix is not clean.")
sys.exit(1)
print(f"\033[32m● MATRIX GREEN\033[0m — {len(rows)} towns (synthetic + fixtures + real caches) pass all 5 gates.")
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,56 @@
# Real town caches — the home (`web/assets/towns/`)
**Lane A owns the contract; Lane E fills this directory** (ROUND17 ledger #6, the real-map scout).
Each real Australian town is one file, `<key>.json`, in the **town-cache contract** shape — the processed
output of `pipeline/build_towns.py` (Overpass → dedupe/suburb/parody-name/seeded-hours). `plan_osm.js`
marches it exactly like the checked-in fixtures in `web/js/citygen/osm_fixture.js`; the scout proves the
**existing** CityPlan contract eats real data — no new plan fields, no new geometry.
## The contract (authoritative: `validateTownCache()` in `web/js/citygen/plan_osm.js`)
```jsonc
{
"schema": "procity-town-cache/1",
"key": "katoomba_real", // matches the filename; distinct from the hand-made fixture key
"town": "Katoomba", // display name
"source": "osm",
"license": "ODbL 1.0", // REQUIRED — OSM is ODbL
"attribution": "© OpenStreetMap contributors",
"generator": "pipeline/build_towns.py", // optional provenance
"fetchedAt": "2026-07-16", // optional
"center": { "lat": -33.71, "lon": 150.31 },// REQUIRED — the equirectangular projection origin
"bbox": { "minLat": …, "minLon": …, "maxLat": …, "maxLon": … }, // optional metadata
"counts": { "raw": 512, "shops": 96 }, // optional metadata
"shops": [
{ "id": 318855426, "name": "Canty's Bookshop", "type": "book", "lat": -35.32, "lon": 149.18, "suburb": "Fyshwick" }
// …
]
}
```
**Hard requirements** (else the cache is rejected and does not boot):
- `center.{lat,lon}` finite numbers.
- `shops` an array of **`MIN_TOWN_SHOPS` (6)** entries (up to 4 venues + the one openLate landmark + a spare).
- every shop has finite `lat`/`lon`.
**Absorbed as warnings** (still boots): a blank `name` defaults to the type label; an unknown `type` remaps
to `opshop`; duplicate ids; and — importantly — **a cache spanning > 5 km warns "likely more than one
town"**. A cache MUST be ONE compact town: an over-broad Overpass bbox marches into an unusable multi-km
strip (the R17 mega-strip risk — a whole region of 2,918 shops became an 11.6 km avenue). Bound the query.
`type` should be a registry `SHOP_TYPE` (`record`/`opshop`/`toy`/`book`/`video`/`pawn`/`milkbar`/`dept`/`stall`);
`build_towns.py` maps OSM `shop=*` tags to these (unknowns land on `opshop`).
## Provenance (Lane E)
OSM data is **ODbL**. Ship attribution with the data: this `README`, a `SOURCES.md` (E's licence table),
and the `license`/`attribution` fields on every cache. Cache the **raw** Overpass responses too so re-runs
never re-fetch.
## Wiring
- `plan_osm.generatePlanOSM(seed, key, { cache })` marches a cache directly; `registerTownCache(key, cache)`
adds it to the runtime registry so `osmTownKeys()` and `generatePlanFor(seed, 'osm', { town: key })` see it.
- `web/js/citygen/selfcheck.js` auto-loads every `*.json` here: validates it, runs the full structural +
gig suites across the hero seeds, and pins a per-town golden (unpinned → prints the value to paste).

View File

@ -0,0 +1,40 @@
# Town-cache data sources & licence (`web/assets/towns/`)
**Lane E owns this file** (ROUND17 ledger #6). Every real-town cache here is derived from
**OpenStreetMap** data and ships under OSM's licence. This notice + the `license`/`attribution`
fields on every `<key>.json` are the attribution that travels with the data.
## Licence — OpenStreetMap, ODbL 1.0
> © **OpenStreetMap contributors**. Data available under the **Open Database License (ODbL) 1.0**.
> <https://www.openstreetmap.org/copyright> · <https://opendatacommons.org/licenses/odbl/1-0/>
The caches are a **produced work** derived from OSM via the Overpass API (`overpass-api.de`), filtered
to on-theme `shop=*` points, deduped, suburb-filled, and type-mapped by `pipeline/build_towns.py`.
Attribution ships in-repo (this file) and in every cache (`"license":"ODbL 1.0"`,
`"attribution":"© OpenStreetMap contributors"`). If a rendered/public build surfaces this data, keep
the "© OpenStreetMap contributors (ODbL)" credit visible.
## Provenance table
| cache | town | source | licence | fetched | shops | 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` |
## Raw responses (`_raw/`)
The raw Overpass JSON for each town is cached in `_raw/<key>.overpass.json` (wrapped with `fetchedAt`,
`endpoint`, `query`, `bbox`). **Re-runs reuse the raw cache and never re-hit Overpass** — the fetch is
outward-facing network (John-authorized scout, once per town). `--refetch` forces a deliberate re-fetch.
`selfcheck.js` reads only top-level `*.json`, so `_raw/` is never loaded as a cache.
## Names — parody vs factual
Big **trademarked charity/franchise chains** are parodied in `build_towns.py` (Salvos→Slave-ohs,
Vinnies→Sinnies, Anglicare→Tanglecare, Cash Converters→Cashtheists, …) for the game's comic tone.
**Independent shops keep their real OSM name** — factual, publicly-mapped POI data under ODbL. No
private-individual data is carried (name/type/coordinate/suburb only).

View File

@ -0,0 +1,218 @@
{
"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\"](-36.77332,144.26188,-36.74268,144.30012);out center;",
"bbox": {
"minLat": -36.773315315315315,
"minLon": 144.26188381955436,
"maxLat": -36.74268468468469,
"maxLon": 144.30011618044566
},
"elements": [
{
"type": "node",
"id": 7816868915,
"lat": -36.7610632,
"lon": 144.2820458,
"tags": {
"addr:country": "AU",
"addr:housenumber": "293",
"addr:postcode": "3550",
"addr:state": "VIC",
"addr:street": "Lyttleton Terrace",
"addr:suburb": "Bendigo",
"name": "Australian Red Cross Shop",
"shop": "second_hand",
"website": "https://www.redcross.org.au/",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 8040967386,
"lat": -36.7602319,
"lon": 144.2786923,
"tags": {
"brand": "Dymocks",
"brand:wikidata": "Q1280883",
"name": "Dymocks",
"shop": "books",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 8067036585,
"lat": -36.7605894,
"lon": 144.2761859,
"tags": {
"addr:postcode": "3550",
"addr:street": "High Street",
"name": "Bookmark",
"opening_hours": "Mo-Su 10:00-17:00",
"shop": "books"
}
},
{
"type": "node",
"id": 8225415317,
"lat": -36.7589648,
"lon": 144.2774687,
"tags": {
"addr:housenumber": "33",
"addr:postcode": "3550",
"addr:street": "View Street",
"name": "Bat City Music",
"opening_hours": "Mo-Fr 10:00-17:30; Sa 10:00-14:00; PH,Su off",
"phone": "+61 3 5442 3293",
"shop": "music",
"website": "http://batcitymusic.com.au"
}
},
{
"type": "node",
"id": 8649203082,
"lat": -36.7656924,
"lon": 144.2808062,
"tags": {
"brand": "EB Games",
"brand:wikidata": "Q5322604",
"brand:wikipedia": "en:EB Games",
"name": "EB Games",
"shop": "video_games"
}
},
{
"type": "node",
"id": 12421060314,
"lat": -36.7593735,
"lon": 144.2792703,
"tags": {
"addr:housenumber": "18-20",
"addr:postcode": "3550",
"addr:state": "VIC",
"addr:street": "Pall Mall",
"addr:suburb": "Bendigo",
"branch": "Bendigo",
"brand": "Cash Converters",
"brand:wikidata": "Q5048645",
"email": "BendigoVIC@cashconverters.com",
"name": "Cash Converters",
"phone": "+61 3 5444 4260",
"shop": "pawnbroker",
"website": "https://www.cashconverters.com.au/store-locator/bendigovic"
}
},
{
"type": "way",
"id": 329659785,
"center": {
"lat": -36.7557918,
"lon": 144.2629357
},
"nodes": [
3365775841,
3365775839,
13944869958,
3365775842,
3365775836,
9391007061,
9391007062,
13944642149,
13944642156,
13944642151,
13944642155,
13944642150,
13944642154,
13944642152,
13944642153,
13944642148,
9391007063,
9391007060,
3365775849,
3365775841
],
"tags": {
"addr:housenumber": "2-6",
"addr:postcode": "3550",
"addr:state": "VIC",
"addr:street": "Marong Road",
"addr:suburb": "Ironbark",
"branch": "Eaglehawk",
"brand": "Salvos",
"brand:wikidata": "Q120646407",
"building": "retail",
"name": "Salvos",
"operator": "The Salvation Army Australia",
"phone": "+61 3 5446 7928",
"second_hand": "yes",
"shop": "charity"
}
},
{
"type": "way",
"id": 827550052,
"center": {
"lat": -36.7575264,
"lon": 144.2860172
},
"nodes": [
7725901667,
7733874087,
7725901660,
7725901663,
7725901659,
7725901662,
7725901661,
7725901665,
7725901666,
7725901667
],
"tags": {
"addr:country": "AU",
"addr:housenumber": "31",
"addr:postcode": "3550",
"addr:state": "VIC",
"addr:street": "Chapel Street",
"addr:suburb": "Bendigo",
"branch": "Bendigo",
"brand": "Vinnies",
"brand:wikidata": "Q120646672",
"building": "yes",
"name": "Vinnies",
"opening_hours": "Mo-Sa 09:00-17:00; Su 10:00-16:00",
"phone": "+61 3 5443 5052",
"shop": "charity",
"website": "https://www.vinnies.org.au/vic/shops/vinnies-bendigo/"
}
},
{
"type": "way",
"id": 1443367904,
"center": {
"lat": -36.7562608,
"lon": 144.282307
},
"nodes": [
13244868730,
13244868731,
13244868732,
13244868733,
13244868730
],
"tags": {
"addr:housenumber": "1",
"addr:postcode": "3550",
"addr:state": "VIC",
"addr:street": "Farmer Lane",
"addr:suburb": "Bendigo",
"building": "commercial",
"name": "Book Now",
"opening_hours": "We-Su 11:00-17:00",
"phone": "+61 3 5443 8587",
"shop": "books",
"website": "http://www.booknow.net.au/"
}
}
]
}

View File

@ -0,0 +1,99 @@
{
"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;",
"bbox": {
"minLat": -37.077513513513516,
"minLon": 144.2000649826232,
"maxLat": -37.050486486486484,
"maxLon": 144.23393501737684
},
"elements": [
{
"type": "node",
"id": 9231704552,
"lat": -37.0623631,
"lon": 144.2175259,
"tags": {
"addr:housenumber": "244",
"addr:street": "Barker Street",
"name": "Vinnies",
"opening_hours": "Tu-Su 10:00-16:00; Mo closed",
"phone": "+61 3 5472 4419",
"shop": "charity",
"website": "https://www.vinnies.org.au/shops/view/360"
}
},
{
"type": "node",
"id": 11699200337,
"lat": -37.0632013,
"lon": 144.2181634,
"tags": {
"name": "Barrow Boy Traders",
"shop": "antiques"
}
},
{
"type": "node",
"id": 11699208641,
"lat": -37.0616469,
"lon": 144.2172472,
"tags": {
"name": "Mount of Alex Bookshop",
"shop": "books"
}
},
{
"type": "node",
"id": 11699237531,
"lat": -37.0729118,
"lon": 144.2064928,
"tags": {
"name": "XXXX Antiques",
"shop": "antiques"
}
},
{
"type": "node",
"id": 11760538035,
"lat": -37.067129,
"lon": 144.2176252,
"tags": {
"addr:housenumber": "46",
"addr:street": "Forest Street",
"alt_name": "Castlemaine Thrift Shop",
"name": "Salvos",
"opening_hours": "Mo-Sa 10:00-16:00; Su off",
"operator": "The Salvation Army",
"operator:wikidata": "Q188307",
"phone": "+61 3 5470 5636",
"shop": "charity",
"website": "https://www.salvosstores.com.au/stores/vic/3450-castlemaine-5317"
}
},
{
"type": "way",
"id": 315282080,
"center": {
"lat": -37.0657039,
"lon": 144.2196734
},
"nodes": [
3213928211,
3213928212,
3213928213,
3213928216,
3213928214,
3213928215,
3213928211
],
"tags": {
"building": "yes",
"name": "Stonemans Bookroom",
"shop": "books",
"wheelchair": "limited"
}
}
]
}

View File

@ -0,0 +1,354 @@
{
"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\"](-32.06721,115.73418,-32.04379,115.76182);out center;",
"bbox": {
"minLat": -32.067211711711714,
"minLon": 115.73418143183595,
"maxLat": -32.04378828828829,
"maxLon": 115.76181856816406
},
"elements": [
{
"type": "node",
"id": 3144618368,
"lat": -32.0553704,
"lon": 115.7471916,
"tags": {
"check_date": "2025-09-20",
"name": "Elizabeth's",
"opening_hours": "Mo-Fr 10:00-17:00, Sa-Su 09:00-17:00",
"second_hand": "yes",
"shop": "books",
"website": "https://www.elizabethsbookshop.com.au/",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 4601037692,
"lat": -32.0552806,
"lon": 115.744283,
"tags": {
"addr:housenumber": "41",
"addr:street": "High Street",
"name": "New Edition Bookshop",
"opening_hours": "Mo-Su 09:00-21:00",
"phone": "+61 8 9335 2383",
"shop": "books",
"website": "https://newedition.com.au/"
}
},
{
"type": "node",
"id": 4601057991,
"lat": -32.0550425,
"lon": 115.7444055,
"tags": {
"addr:housenumber": "48",
"addr:street": "High Street",
"check_date": "2025-01-04",
"name": "Bill Campbell Books",
"opening_hours": "Mo,We-Fr 10:00-17:00; Tu off; Sa-Su 12:00-17:00",
"shop": "books",
"survey:date": "2024-01-20",
"wheelchair": "limited"
}
},
{
"type": "node",
"id": 5026468221,
"lat": -32.055583,
"lon": 115.7478433,
"tags": {
"addr:housenumber": "36",
"addr:street": "South Terrace",
"name": "Mothballs Vintage",
"shop": "antiques"
}
},
{
"type": "node",
"id": 5885985088,
"lat": -32.0550452,
"lon": 115.7487872,
"tags": {
"brand": "Cash Converters",
"brand:wikidata": "Q5048645",
"name": "Cash Converters",
"shop": "pawnbroker"
}
},
{
"type": "node",
"id": 8153813968,
"lat": -32.0552357,
"lon": 115.7443688,
"tags": {
"addr:housenumber": "43",
"addr:street": "High Street",
"name": "The Record Finder",
"shop": "music",
"survey:date": "2022-07-10"
}
},
{
"type": "node",
"id": 9156620320,
"lat": -32.0656871,
"lon": 115.7525895,
"tags": {
"name": "Save the Children",
"shop": "charity"
}
},
{
"type": "node",
"id": 9794077228,
"lat": -32.065416,
"lon": 115.7525681,
"tags": {
"name": "Adorn Vintage",
"shop": "charity"
}
},
{
"type": "node",
"id": 9795944064,
"lat": -32.056204,
"lon": 115.7447268,
"tags": {
"check_date": "2022-06-05",
"name": "Paper Bird",
"shop": "books"
}
},
{
"type": "node",
"id": 10585254716,
"lat": -32.0533284,
"lon": 115.7496691,
"tags": {
"brand": "Vinnies",
"brand:wikidata": "Q120646672",
"check_date": "2025-09-28",
"name": "Vinnies",
"second_hand": "only",
"shop": "charity"
}
},
{
"type": "node",
"id": 11323015046,
"lat": -32.0556114,
"lon": 115.7483058,
"tags": {
"name": "The Picker Concept",
"shop": "antiques"
}
},
{
"type": "node",
"id": 11337031591,
"lat": -32.05392,
"lon": 115.7498109,
"tags": {
"name": "Nifty in Freo",
"shop": "second_hand"
}
},
{
"type": "node",
"id": 11337040744,
"lat": -32.0539367,
"lon": 115.7460896,
"tags": {
"check_date": "2024-11-01",
"name": "Australian Red Cross Shop",
"shop": "second_hand"
}
},
{
"type": "node",
"id": 12389445377,
"lat": -32.0514063,
"lon": 115.7494672,
"tags": {
"check_date": "2024-12-02",
"name": "St Pats",
"opening_hours": "Mo-Fr 09:00-16:00; Sa-Su off",
"shop": "charity",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 12490289378,
"lat": -32.0522157,
"lon": 115.7492735,
"tags": {
"addr:housenumber": "17",
"addr:street": "Point Street",
"brand": "Warhammer",
"brand:wikidata": "Q587270",
"check_date": "2026-04-19",
"name": "Warhammer",
"shop": "games"
}
},
{
"type": "node",
"id": 12787392414,
"lat": -32.0549882,
"lon": 115.74523,
"tags": {
"addr:housenumber": "61",
"addr:street": "High Street",
"check_date": "2025-04-26",
"name": "Vintage On High",
"name:en": "Ginger Pudding Vintage",
"opening_hours": "Tu-Su 10:00-16:00",
"shop": "second_hand",
"website": "https://www.gingerpuddingvintage.com.au/",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 12878567802,
"lat": -32.0533663,
"lon": 115.7472611,
"tags": {
"check_date": "2025-06-01",
"name": "Atomic Alley",
"opening_hours": "We-Su 10:00-18:00",
"shop": "toys"
}
},
{
"type": "way",
"id": 52013903,
"center": {
"lat": -32.0554336,
"lon": 115.7477488
},
"nodes": [
663126377,
9470885315,
663126379,
3144618388,
3144618389,
663126377
],
"tags": {
"addr:postcode": "6160",
"addr:state": "WA",
"addr:street": "South Terrace",
"addr:suburb": "Fremantle",
"brand": "Timezone",
"brand:wikidata": "Q12521502",
"brand:wikipedia": "en:Timezone (video arcades)",
"building": "yes",
"check_date": "2026-05-03",
"check_date:opening_hours": "2023-06-17",
"leisure": "amusement_arcade",
"name": "Timezone",
"opening_hours": "Mo-Th 10:00-22:00; Fr 10:00-23:30; Sa 09:00-23:30; Su 09:00-22:00",
"payment:cash": "yes",
"payment:credit_cards": "yes",
"payment:debit_cards": "yes",
"phone": "+61 8 7701 9715",
"shop": "video_games",
"website": "https://www.timezonegames.com/en-au/locations/timezone-fremantle",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 310008675,
"center": {
"lat": -32.056734,
"lon": 115.7484504
},
"nodes": [
3153646561,
12088756234,
12088756233,
12088756235,
12088756232,
12088756236,
3153646569,
3153646567,
3153646562,
12088125693,
3153646561
],
"tags": {
"addr:housenumber": "21",
"addr:street": "Essex Street",
"brand": "Salvos",
"brand:wikidata": "Q120646407",
"building": "retail",
"check_date": "2026-05-09",
"name": "Salvos",
"shop": "charity"
}
},
{
"type": "way",
"id": 592578660,
"center": {
"lat": -32.0486758,
"lon": 115.7509014
},
"nodes": [
5655427730,
5655427726,
5655427729,
5655427728,
5655427727,
5655427730
],
"tags": {
"brand": "Elizabeth's Bookstores",
"building": "yes",
"check_date": "2024-03-07",
"name": "Elizabeth's Warehouse",
"second_hand": "yes",
"shop": "books",
"wheelchair": "limited"
}
},
{
"type": "way",
"id": 976912099,
"center": {
"lat": -32.0494787,
"lon": 115.7495694
},
"nodes": [
9040165737,
9040165734,
9040165733,
9040165731,
9040165729,
9040165727,
9040165726,
9040165728,
9040165730,
9040165732,
9040165735,
9040165736,
9040165737
],
"tags": {
"brand": "Toyworld",
"brand:wikidata": "Q95923071",
"building": "yes",
"name": "Toyworld",
"shop": "toys",
"wikidata": "Q130749344"
}
}
]
}

View File

@ -0,0 +1,299 @@
{
"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\"](-33.72801,150.29575,-33.70099,150.32825);out center;",
"bbox": {
"minLat": -33.72801351351352,
"minLon": 150.29575415811652,
"maxLat": -33.700986486486485,
"maxLon": 150.3282458418835
},
"elements": [
{
"type": "node",
"id": 4047416904,
"lat": -33.7167381,
"lon": 150.3109194,
"tags": {
"check_date": "2023-07-16",
"name": "Anglicare",
"opening_hours": "Mo-Fr 09:00-04:45; Sa 10:00-03:45",
"shop": "charity",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 4047416906,
"lat": -33.7174767,
"lon": 150.3118602,
"tags": {
"check_date": "2024-08-25",
"name": "Cat Defence Op Shop",
"opening_hours:signed": "no",
"shop": "charity",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 4047416910,
"lat": -33.7172906,
"lon": 150.3095268,
"tags": {
"name": "Red Shield Family Store",
"opening_hours": "Mo-Fr 09:00-16:00",
"shop": "charity"
}
},
{
"type": "node",
"id": 4047416911,
"lat": -33.7109342,
"lon": 150.3099305,
"tags": {
"name": "Steven Walter",
"shop": "charity"
}
},
{
"type": "node",
"id": 4047416913,
"lat": -33.7174637,
"lon": 150.3117818,
"tags": {
"branch": "Vinnies",
"brand": "Vinnies",
"brand:wikidata": "Q120646672",
"check_date": "2025-07-16",
"name": "Vinnies",
"opening_hours": "Mo-Th 09:00-16:30; Fr 09:00-16:00; Sa 10:00-15:00",
"payment:cash": "yes",
"payment:credit_cards": "yes",
"payment:debit_cards": "yes",
"second_hand": "yes",
"shop": "charity",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 4958417278,
"lat": -33.7149492,
"lon": 150.3112571,
"tags": {
"addr:housenumber": "103",
"addr:street": "Katoomba Street",
"check_date": "2025-07-16",
"name": "Anglicare Op Shops",
"second_hand": "only",
"shop": "charity",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 4958417724,
"lat": -33.7150739,
"lon": 150.3115321,
"tags": {
"addr:housenumber": "140",
"addr:street": "Katoomba Street",
"check_date": "2025-07-16",
"name": "Katoomba Music",
"opening_hours": "Tu,We,Fr 10:00-17:00; Th 10:00-18:00; Sa 10:00-16:00",
"shop": "music",
"wheelchair": "limited"
}
},
{
"type": "node",
"id": 5237464912,
"lat": -33.7137293,
"lon": 150.3114025,
"tags": {
"addr:housenumber": "59-61",
"addr:street": "Katoomba Street",
"name": "Katoomba Vintage Emporium",
"opening_hours": "Mo-Su 10:00-18:00",
"phone": "+61 2 4782 6996",
"shop": "antiques",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 6938379064,
"lat": -33.7122056,
"lon": 150.3106541,
"tags": {
"name": "Katoomba Antique Centre",
"shop": "antiques"
}
},
{
"type": "node",
"id": 6938414604,
"lat": -33.7146541,
"lon": 150.3113164,
"tags": {
"addr:housenumber": "89",
"addr:postcode": "2780",
"addr:state": "NSW",
"addr:street": "Katoomba Street",
"addr:suburb": "Katoomba",
"brand": "Salvos",
"brand:wikidata": "Q120646407",
"check_date": "2025-07-16",
"name": "Salvos",
"second_hand": "only",
"shop": "charity",
"wheelchair": "limited"
}
},
{
"type": "node",
"id": 6938414612,
"lat": -33.7148514,
"lon": 150.3112704,
"tags": {
"brand": "EB Games",
"brand:wikidata": "Q5322604",
"brand:wikipedia": "en:EB Games",
"name": "EB Games",
"opening_hours": "Mo-Sa 09:00-17:30",
"shop": "video_games",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 6938921533,
"lat": -33.7138899,
"lon": 150.3117659,
"tags": {
"addr:housenumber": "84",
"addr:street": "Katoomba Street",
"name": "MacArthur's Arcade",
"opening_hours": "Mo-Su 10:00-17:00",
"shop": "antiques",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 6938921534,
"lat": -33.7139151,
"lon": 150.3117612,
"tags": {
"name": "Mr Pickwick's Bookshop",
"opening_hours": "Mo-Su 10:00-17:00",
"shop": "books",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 7040012373,
"lat": -33.7098056,
"lon": 150.3092784,
"tags": {
"name": "Black Antiques",
"shop": "antiques"
}
},
{
"type": "node",
"id": 7110813637,
"lat": -33.7128108,
"lon": 150.321286,
"tags": {
"addr:housenumber": "58",
"addr:postcode": "2780",
"addr:state": "NSW",
"addr:street": "Megalong Street",
"addr:suburb": "Katoomba",
"check_date:opening_hours": "2025-03-24",
"flickr": "https://www.flickr.com/photos/190060616@N03/50521764803",
"name": "Steven Walter Children's Cancer Foundation Warehouse",
"opening_hours": "We-Sa 10:00-15:00",
"second_hand": "yes",
"shop": "charity"
}
},
{
"type": "node",
"id": 9231634025,
"lat": -33.7163933,
"lon": 150.3108347,
"tags": {
"check_date:opening_hours": "2023-10-06",
"name": "Little Lost Bookshop",
"opening_hours:signed": "no",
"shop": "books"
}
},
{
"type": "node",
"id": 9577859712,
"lat": -33.7142947,
"lon": 150.311173,
"tags": {
"level": "0",
"name": "B'COS Agora Second Hand Bookshop",
"opening_hours": "Mo-Su 10:00-16:00",
"operator": "Blackheath Community Op Shops",
"shop": "books"
}
},
{
"type": "node",
"id": 9791871398,
"lat": -33.7125441,
"lon": 150.3115655,
"tags": {
"check_date:opening_hours": "2023-11-08",
"name": "The Velvet Fog",
"opening_hours": "Tu-Sa 10:00-17:00",
"shop": "music"
}
},
{
"type": "node",
"id": 11242974232,
"lat": -33.7157176,
"lon": 150.31095,
"tags": {
"name": "Sanctus Catholic Books and Gift Shop",
"opening_hours": "Mo-Fr 10:00-17:00; Sa 10:00-14:00",
"shop": "books"
}
},
{
"type": "way",
"id": 753467867,
"center": {
"lat": -33.7075666,
"lon": 150.310861
},
"nodes": [
7040016210,
7040016211,
7040016212,
7040016213,
7040016210
],
"tags": {
"addr:housenumber": "25",
"addr:postcode": "2780",
"addr:state": "NSW",
"addr:street": "Whitton Street",
"addr:suburb": "Katoomba",
"building": "yes",
"building:levels": "1",
"name": "Blue Mountains Cancer Help Op Shop",
"shop": "charity"
}
}
]
}

View File

@ -0,0 +1,338 @@
{
"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\"](-33.91021,151.16489,-33.88679,151.19311);out center;",
"bbox": {
"minLat": -33.91021171171171,
"minLon": 151.1648899610343,
"maxLat": -33.88678828828829,
"maxLon": 151.1931100389657
},
"elements": [
{
"type": "node",
"id": 2365230295,
"lat": -33.897238,
"lon": 151.179525,
"tags": {
"addr:housenumber": "3",
"addr:postcode": "2042",
"addr:street": "Wilson Street",
"addr:suburb": "Newtown",
"name": "Egg Records",
"opening_hours": "Mo-Su 11:00-17:00",
"shop": "music",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 3278485277,
"lat": -33.8875182,
"lon": 151.1707861,
"tags": {
"addr:housenumber": "31\u0430",
"addr:postcode": "2038",
"addr:street": "Parramatta Road",
"addr:suburb": "Annandale",
"check_date:opening_hours": "2026-02-18",
"name": "The Vintage Record",
"opening_hours": "Th-Su 11:00-17:00",
"shop": "music",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 3278488658,
"lat": -33.8877879,
"lon": 151.1713949,
"tags": {
"addr:housenumber": "18",
"addr:postcode": "2048",
"addr:street": "Parramatta Road",
"addr:suburb": "Stanmore",
"name": "Aalders",
"opening_hours": "Mo-Fr 10:00-16:00 \"by appointment\"",
"shop": "antiques",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 5931284422,
"lat": -33.9035515,
"lon": 151.1797675,
"tags": {
"check_date:opening_hours": "2025-07-27",
"name": "Towers Antiques",
"opening_hours:signed": "no",
"shop": "antiques",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 5931284426,
"lat": -33.9032933,
"lon": 151.1796014,
"tags": {
"check_date:opening_hours": "2025-07-17",
"name": "Old Wares",
"opening_hours:signed": "no",
"shop": "antiques",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 6204412995,
"lat": -33.9029178,
"lon": 151.1795869,
"tags": {
"name": "Goulds Books",
"opening_hours": "We-Mo 10:30-18:00",
"shop": "books",
"wheelchair": "yes"
}
},
{
"type": "node",
"id": 6901192228,
"lat": -33.890884,
"lon": 151.1920277,
"tags": {
"name": "Co-op",
"shop": "books"
}
},
{
"type": "node",
"id": 9518337417,
"lat": -33.8878728,
"lon": 151.1664969,
"tags": {
"addr:housenumber": "141",
"addr:street": "Parramatta Road",
"check_date:opening_hours": "2025-03-13",
"name": "Downtown Music",
"opening_hours": "Mo-Th 10:00-17:00; Fr-Su 10:00-16:00",
"phone": "+61 2 9569 2744",
"shop": "music",
"website": "https://downtownmusicsydney.com.au/",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 9888616491,
"lat": -33.8925979,
"lon": 151.1868158,
"tags": {
"name": "Haze Guitars",
"operator": "26 King St, Newtown NSW 2042",
"shop": "music"
}
},
{
"type": "node",
"id": 11984912424,
"lat": -33.9011361,
"lon": 151.178408,
"tags": {
"name": "Thats So Mi",
"opening_hours": "We-Fr 11:00-16:00; Sa,Su 10:00-17:00; Mo,Tu off",
"shop": "antiques",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 12958032466,
"lat": -33.898898,
"lon": 151.1702724,
"tags": {
"name": "St Luke\u2019s Op Shop",
"shop": "charity"
}
},
{
"type": "node",
"id": 13122105023,
"lat": -33.8869187,
"lon": 151.1767126,
"tags": {
"brand": "Savers",
"brand:wikidata": "Q7428188",
"check_date": "2026-02-18",
"check_date:opening_hours": "2026-02-18",
"name": "Savers",
"opening_hours": "Mo-We,Fr 09:00-19:00; Th 09:00-21:00; Sa 09:00-18:00; Su 10:00-18:00",
"shop": "second_hand"
}
},
{
"type": "node",
"id": 13686752819,
"lat": -33.8961303,
"lon": 151.180366,
"tags": {
"email": "books@betterread.com.au",
"fax": "+61 2 9557 8560",
"name": "Better Read Than Dead",
"opening_hours": "Mo-Sa 09:30-21:00; Su 10:00-18:00",
"phone": "+61 2 9557 8700",
"shop": "books",
"website": "https://www.betterread.com.au/"
}
},
{
"type": "node",
"id": 13686752821,
"lat": -33.8959321,
"lon": 151.1807185,
"tags": {
"addr:housenumber": "257",
"addr:postcode": "2042",
"addr:state": "NSW",
"addr:street": "King Street",
"addr:suburb": "Newtown",
"branch": "Newtown",
"name": "Elizabeth's Bookshop",
"opening_hours": "Mo-Fr 10:00-22:30; Sa-Su 09:30-22:30",
"phone": "+61 2 9550 5691",
"shop": "books",
"website": "https://www.elizabethsbookshop.com.au/"
}
},
{
"type": "node",
"id": 13707141472,
"lat": -33.8986661,
"lon": 151.1742068,
"tags": {
"addr:housenumber": "109",
"addr:street": "Enmore Road",
"contact:instagram": "papadisquo.records",
"name": "Papa Disquo",
"shop": "music"
}
},
{
"type": "way",
"id": 291725580,
"center": {
"lat": -33.8869917,
"lon": 151.1768855
},
"nodes": [
2952129488,
2952129489,
2952129490,
5844915688,
2952129491,
5844914079,
2952129488
],
"tags": {
"addr:housenumber": "93-99",
"addr:postcode": "2050",
"addr:street": "Parramatta Road",
"addr:suburb": "Camperdown",
"building": "retail",
"building:levels": "2",
"name": "Savers",
"opening_hours": "Mo-We,Fr 09:00-19:00; Th 09:00-21:00; Sa 09:00-18:00; Su 10:00-18:00",
"roof:shape": "flat",
"shop": "second_hand"
}
},
{
"type": "way",
"id": 291725581,
"center": {
"lat": -33.8870857,
"lon": 151.1764593
},
"nodes": [
2952129492,
5844915703,
5844915702,
5844915701,
2952129493,
5844915700,
2952129494,
2952129495,
2952129492
],
"tags": {
"addr:housenumber": "101",
"addr:postcode": "2050",
"addr:street": "Parramatta Road",
"addr:suburb": "Camperdown",
"brand": "Kidstuff",
"brand:wikidata": "Q117746407",
"building": "retail",
"building:levels": "2",
"check_date": "2025-09",
"name": "Kidstuff",
"opening_hours": "Mo-Sa 09:00-17:30, Su 09:00-17:00",
"phone": "+61 2 9519 4848",
"shop": "toys",
"website": "https://www.kidstuff.com.au/",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 605984545,
"center": {
"lat": -33.9064099,
"lon": 151.1717385
},
"nodes": [
5746331046,
5746331047,
5746331048,
5746331049,
1852656521,
5746076963,
5746331046
],
"tags": {
"brand": "EB Games",
"brand:wikidata": "Q5322604",
"name": "EB Games",
"opening_hours:signed": "no",
"shop": "video_games",
"wheelchair": "yes"
}
},
{
"type": "way",
"id": 605984580,
"center": {
"lat": -33.9073712,
"lon": 151.1724377
},
"nodes": [
5746331119,
9572458386,
5746331037,
5746331120,
5746331030,
5746331029,
5746331163,
5746331028,
3489947034,
5746331118,
5746331119
],
"tags": {
"name": "Toys and Tales",
"shop": "toys",
"wheelchair": "yes"
}
}
]
}

View File

@ -0,0 +1,98 @@
{
"schema": "procity-town-cache/1",
"key": "bendigo_real",
"town": "Bendigo",
"source": "osm",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"generator": "pipeline/build_towns.py",
"fetchedAt": "2026-07-16",
"center": {
"lat": -36.758,
"lon": 144.281
},
"bbox": {
"minLat": -36.773315315315315,
"minLon": 144.26188381955436,
"maxLat": -36.74268468468469,
"maxLon": 144.30011618044566
},
"counts": {
"raw": 9,
"shops": 9
},
"shops": [
{
"id": 329659785,
"name": "Slave-ohs",
"type": "opshop",
"lat": -36.755792,
"lon": 144.262936,
"suburb": "Ironbark"
},
{
"id": 827550052,
"name": "Sinnies",
"type": "opshop",
"lat": -36.757526,
"lon": 144.286017,
"suburb": "Bendigo"
},
{
"id": 1443367904,
"name": "Book Now",
"type": "book",
"lat": -36.756261,
"lon": 144.282307,
"suburb": "Bendigo"
},
{
"id": 7816868915,
"name": "Red Floss Shop",
"type": "opshop",
"lat": -36.761063,
"lon": 144.282046,
"suburb": "Bendigo"
},
{
"id": 8040967386,
"name": "Dymocks",
"type": "book",
"lat": -36.760232,
"lon": 144.278692,
"suburb": "Bendigo"
},
{
"id": 8067036585,
"name": "Bookmark",
"type": "book",
"lat": -36.760589,
"lon": 144.276186,
"suburb": "Bendigo"
},
{
"id": 8225415317,
"name": "Bat City Music",
"type": "record",
"lat": -36.758965,
"lon": 144.277469,
"suburb": "Bendigo"
},
{
"id": 8649203082,
"name": "EB Games",
"type": "video",
"lat": -36.765692,
"lon": 144.280806,
"suburb": "Bendigo"
},
{
"id": 12421060314,
"name": "Cashtheists",
"type": "pawn",
"lat": -36.759374,
"lon": 144.27927,
"suburb": "Bendigo"
}
]
}

View File

@ -0,0 +1,74 @@
{
"schema": "procity-town-cache/1",
"key": "castlemaine_real",
"town": "Castlemaine",
"source": "osm",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"generator": "pipeline/build_towns.py",
"fetchedAt": "2026-07-16",
"center": {
"lat": -37.064,
"lon": 144.217
},
"bbox": {
"minLat": -37.077513513513516,
"minLon": 144.2000649826232,
"maxLat": -37.050486486486484,
"maxLon": 144.23393501737684
},
"counts": {
"raw": 6,
"shops": 6
},
"shops": [
{
"id": 315282080,
"name": "Stonemans Bookroom",
"type": "book",
"lat": -37.065704,
"lon": 144.219673,
"suburb": ""
},
{
"id": 9231704552,
"name": "Sinnies",
"type": "opshop",
"lat": -37.062363,
"lon": 144.217526,
"suburb": ""
},
{
"id": 11699200337,
"name": "Barrow Boy Traders",
"type": "opshop",
"lat": -37.063201,
"lon": 144.218163,
"suburb": ""
},
{
"id": 11699208641,
"name": "Mount of Alex Bookshop",
"type": "book",
"lat": -37.061647,
"lon": 144.217247,
"suburb": ""
},
{
"id": 11699237531,
"name": "XXXX Antiques",
"type": "opshop",
"lat": -37.072912,
"lon": 144.206493,
"suburb": ""
},
{
"id": 11760538035,
"name": "Slave-ohs",
"type": "opshop",
"lat": -37.067129,
"lon": 144.217625,
"suburb": ""
}
]
}

View File

@ -0,0 +1,194 @@
{
"schema": "procity-town-cache/1",
"key": "fremantle_real",
"town": "Fremantle",
"source": "osm",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"generator": "pipeline/build_towns.py",
"fetchedAt": "2026-07-16",
"center": {
"lat": -32.0555,
"lon": 115.748
},
"bbox": {
"minLat": -32.067211711711714,
"minLon": 115.73418143183595,
"maxLat": -32.04378828828829,
"maxLon": 115.76181856816406
},
"counts": {
"raw": 21,
"shops": 21
},
"shops": [
{
"id": 52013903,
"name": "Timezone",
"type": "video",
"lat": -32.055434,
"lon": 115.747749,
"suburb": "Fremantle"
},
{
"id": 310008675,
"name": "Slave-ohs",
"type": "opshop",
"lat": -32.056734,
"lon": 115.74845,
"suburb": "Fremantle"
},
{
"id": 592578660,
"name": "Elizabeth's Warehouse",
"type": "book",
"lat": -32.048676,
"lon": 115.750901,
"suburb": "Fremantle"
},
{
"id": 976912099,
"name": "Toyworld",
"type": "toy",
"lat": -32.049479,
"lon": 115.749569,
"suburb": "Fremantle"
},
{
"id": 3144618368,
"name": "Elizabeth's",
"type": "book",
"lat": -32.05537,
"lon": 115.747192,
"suburb": "Fremantle"
},
{
"id": 4601037692,
"name": "New Edition Bookshop",
"type": "book",
"lat": -32.055281,
"lon": 115.744283,
"suburb": "Fremantle"
},
{
"id": 4601057991,
"name": "Bill Campbell Books",
"type": "book",
"lat": -32.055042,
"lon": 115.744405,
"suburb": "Fremantle"
},
{
"id": 5026468221,
"name": "Mothballs Vintage",
"type": "opshop",
"lat": -32.055583,
"lon": 115.747843,
"suburb": "Fremantle"
},
{
"id": 5885985088,
"name": "Cashtheists",
"type": "pawn",
"lat": -32.055045,
"lon": 115.748787,
"suburb": "Fremantle"
},
{
"id": 8153813968,
"name": "The Record Finder",
"type": "record",
"lat": -32.055236,
"lon": 115.744369,
"suburb": "Fremantle"
},
{
"id": 9156620320,
"name": "Shave the Children",
"type": "opshop",
"lat": -32.065687,
"lon": 115.752589,
"suburb": "Fremantle"
},
{
"id": 9794077228,
"name": "Adorn Vintage",
"type": "opshop",
"lat": -32.065416,
"lon": 115.752568,
"suburb": "Fremantle"
},
{
"id": 9795944064,
"name": "Paper Bird",
"type": "book",
"lat": -32.056204,
"lon": 115.744727,
"suburb": "Fremantle"
},
{
"id": 10585254716,
"name": "Sinnies",
"type": "opshop",
"lat": -32.053328,
"lon": 115.749669,
"suburb": "Fremantle"
},
{
"id": 11323015046,
"name": "The Picker Concept",
"type": "opshop",
"lat": -32.055611,
"lon": 115.748306,
"suburb": "Fremantle"
},
{
"id": 11337031591,
"name": "Nifty in Freo",
"type": "opshop",
"lat": -32.05392,
"lon": 115.749811,
"suburb": "Fremantle"
},
{
"id": 11337040744,
"name": "Red Floss Shop",
"type": "opshop",
"lat": -32.053937,
"lon": 115.74609,
"suburb": "Fremantle"
},
{
"id": 12389445377,
"name": "St Pats",
"type": "opshop",
"lat": -32.051406,
"lon": 115.749467,
"suburb": "Fremantle"
},
{
"id": 12490289378,
"name": "Warhammer",
"type": "toy",
"lat": -32.052216,
"lon": 115.749274,
"suburb": "Fremantle"
},
{
"id": 12787392414,
"name": "Vintage On High",
"type": "opshop",
"lat": -32.054988,
"lon": 115.74523,
"suburb": "Fremantle"
},
{
"id": 12878567802,
"name": "Atomic Alley",
"type": "toy",
"lat": -32.053366,
"lon": 115.747261,
"suburb": "Fremantle"
}
]
}

View File

@ -0,0 +1,186 @@
{
"schema": "procity-town-cache/1",
"key": "katoomba_real",
"town": "Katoomba",
"source": "osm",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"generator": "pipeline/build_towns.py",
"fetchedAt": "2026-07-16",
"center": {
"lat": -33.7145,
"lon": 150.312
},
"bbox": {
"minLat": -33.72801351351352,
"minLon": 150.29575415811652,
"maxLat": -33.700986486486485,
"maxLon": 150.3282458418835
},
"counts": {
"raw": 20,
"shops": 20
},
"shops": [
{
"id": 753467867,
"name": "Blue Mountains Cancer Help Op Shop",
"type": "opshop",
"lat": -33.707567,
"lon": 150.310861,
"suburb": "Katoomba"
},
{
"id": 4047416904,
"name": "Tanglecare",
"type": "opshop",
"lat": -33.716738,
"lon": 150.310919,
"suburb": "Katoomba"
},
{
"id": 4047416906,
"name": "Cat Defence Op Shop",
"type": "opshop",
"lat": -33.717477,
"lon": 150.31186,
"suburb": "Katoomba"
},
{
"id": 4047416910,
"name": "Red Shield Family Store",
"type": "opshop",
"lat": -33.717291,
"lon": 150.309527,
"suburb": "Katoomba"
},
{
"id": 4047416911,
"name": "Steven Walter",
"type": "opshop",
"lat": -33.710934,
"lon": 150.309931,
"suburb": "Katoomba"
},
{
"id": 4047416913,
"name": "Sinnies",
"type": "opshop",
"lat": -33.717464,
"lon": 150.311782,
"suburb": "Katoomba"
},
{
"id": 4958417278,
"name": "Tanglecare Op Shops",
"type": "opshop",
"lat": -33.714949,
"lon": 150.311257,
"suburb": "Katoomba"
},
{
"id": 4958417724,
"name": "Katoomba Music",
"type": "record",
"lat": -33.715074,
"lon": 150.311532,
"suburb": "Katoomba"
},
{
"id": 5237464912,
"name": "Katoomba Vintage Emporium",
"type": "opshop",
"lat": -33.713729,
"lon": 150.311403,
"suburb": "Katoomba"
},
{
"id": 6938379064,
"name": "Katoomba Antique Centre",
"type": "opshop",
"lat": -33.712206,
"lon": 150.310654,
"suburb": "Katoomba"
},
{
"id": 6938414604,
"name": "Slave-ohs",
"type": "opshop",
"lat": -33.714654,
"lon": 150.311316,
"suburb": "Katoomba"
},
{
"id": 6938414612,
"name": "EB Games",
"type": "video",
"lat": -33.714851,
"lon": 150.31127,
"suburb": "Katoomba"
},
{
"id": 6938921533,
"name": "MacArthur's Arcade",
"type": "opshop",
"lat": -33.71389,
"lon": 150.311766,
"suburb": "Katoomba"
},
{
"id": 6938921534,
"name": "Mr Pickwick's Bookshop",
"type": "book",
"lat": -33.713915,
"lon": 150.311761,
"suburb": "Katoomba"
},
{
"id": 7040012373,
"name": "Black Antiques",
"type": "opshop",
"lat": -33.709806,
"lon": 150.309278,
"suburb": "Katoomba"
},
{
"id": 7110813637,
"name": "Steven Walter Children's Cancer Foundation Warehouse",
"type": "opshop",
"lat": -33.712811,
"lon": 150.321286,
"suburb": "Katoomba"
},
{
"id": 9231634025,
"name": "Little Lost Bookshop",
"type": "book",
"lat": -33.716393,
"lon": 150.310835,
"suburb": "Katoomba"
},
{
"id": 9577859712,
"name": "B'COS Agora Second Hand Bookshop",
"type": "book",
"lat": -33.714295,
"lon": 150.311173,
"suburb": "Katoomba"
},
{
"id": 9791871398,
"name": "The Velvet Fog",
"type": "record",
"lat": -33.712544,
"lon": 150.311566,
"suburb": "Katoomba"
},
{
"id": 11242974232,
"name": "Sanctus Catholic Books and Gift Shop",
"type": "book",
"lat": -33.715718,
"lon": 150.31095,
"suburb": "Katoomba"
}
]
}

View File

@ -0,0 +1,170 @@
{
"schema": "procity-town-cache/1",
"key": "newtown_real",
"town": "Newtown",
"source": "osm",
"license": "ODbL 1.0",
"attribution": "\u00a9 OpenStreetMap contributors",
"generator": "pipeline/build_towns.py",
"fetchedAt": "2026-07-16",
"center": {
"lat": -33.8985,
"lon": 151.179
},
"bbox": {
"minLat": -33.91021171171171,
"minLon": 151.1648899610343,
"maxLat": -33.88678828828829,
"maxLon": 151.1931100389657
},
"counts": {
"raw": 19,
"shops": 18
},
"shops": [
{
"id": 291725581,
"name": "Kidstuff",
"type": "toy",
"lat": -33.887086,
"lon": 151.176459,
"suburb": "Camperdown"
},
{
"id": 605984545,
"name": "EB Games",
"type": "video",
"lat": -33.90641,
"lon": 151.171739,
"suburb": "Newtown"
},
{
"id": 605984580,
"name": "Toys and Tales",
"type": "toy",
"lat": -33.907371,
"lon": 151.172438,
"suburb": "Newtown"
},
{
"id": 2365230295,
"name": "Egg Records",
"type": "record",
"lat": -33.897238,
"lon": 151.179525,
"suburb": "Newtown"
},
{
"id": 3278485277,
"name": "The Vintage Record",
"type": "record",
"lat": -33.887518,
"lon": 151.170786,
"suburb": "Annandale"
},
{
"id": 3278488658,
"name": "Aalders",
"type": "opshop",
"lat": -33.887788,
"lon": 151.171395,
"suburb": "Stanmore"
},
{
"id": 5931284422,
"name": "Towers Antiques",
"type": "opshop",
"lat": -33.903551,
"lon": 151.179767,
"suburb": "Newtown"
},
{
"id": 5931284426,
"name": "Old Wares",
"type": "opshop",
"lat": -33.903293,
"lon": 151.179601,
"suburb": "Newtown"
},
{
"id": 6204412995,
"name": "Goulds Books",
"type": "book",
"lat": -33.902918,
"lon": 151.179587,
"suburb": "Newtown"
},
{
"id": 6901192228,
"name": "Co-op",
"type": "book",
"lat": -33.890884,
"lon": 151.192028,
"suburb": "Newtown"
},
{
"id": 9518337417,
"name": "Downtown Music",
"type": "record",
"lat": -33.887873,
"lon": 151.166497,
"suburb": "Annandale"
},
{
"id": 9888616491,
"name": "Haze Guitars",
"type": "record",
"lat": -33.892598,
"lon": 151.186816,
"suburb": "Newtown"
},
{
"id": 11984912424,
"name": "Thats So Mi",
"type": "opshop",
"lat": -33.901136,
"lon": 151.178408,
"suburb": "Newtown"
},
{
"id": 12958032466,
"name": "St Luke\u2019s Op Shop",
"type": "opshop",
"lat": -33.898898,
"lon": 151.170272,
"suburb": "Newtown"
},
{
"id": 13122105023,
"name": "Savers",
"type": "opshop",
"lat": -33.886919,
"lon": 151.176713,
"suburb": "Camperdown"
},
{
"id": 13686752819,
"name": "Better Read Than Dead",
"type": "book",
"lat": -33.89613,
"lon": 151.180366,
"suburb": "Newtown"
},
{
"id": 13686752821,
"name": "Elizabeth's Bookshop",
"type": "book",
"lat": -33.895932,
"lon": 151.180719,
"suburb": "Newtown"
},
{
"id": 13707141472,
"name": "Papa Disquo",
"type": "record",
"lat": -33.898666,
"lon": 151.174207,
"suburb": "Newtown"
}
]
}

View File

@ -111,6 +111,17 @@ const customBands = await loadCustomBands();
let plan; let plan;
try { try {
const citygen = await import('./js/citygen/index.js'); const citygen = await import('./js/citygen/index.js');
// [Lane F R17 — real-town caches] a ?plansrc=osm&town=<key> whose key is NOT a checked-in fixture is one of
// Lane E's real AU town caches (web/assets/towns/<key>.json). Fetch + register it before the plan generates,
// so real towns boot in the engine (the v4 scout). Fail-soft: 404 / invalid cache / ?noassets → plan_osm
// falls back to the default fixture. The scout data is opt-in — never on the default or ?classic path.
if (PLANSRC === 'osm' && TOWN && !NOASSETS && citygen.registerTownCache
&& !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) {
try {
const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' });
if (r.ok) { citygen.registerTownCache(TOWN, await r.json()); console.log(`[procity] real town cache registered: ${TOWN}`); }
} catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); }
}
const gen = citygen.generatePlanFor const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer ? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only) : (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)

View File

@ -16,7 +16,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { rng, shuffle } from '../core/prng.js'; import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js'; import { pickRig, spawnRig, seatedLean } from './rigs.js';
import { makePlaceholder } from './placeholder.js'; import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.js'; import { loadGLB } from '../core/loaders.js';
@ -271,6 +271,7 @@ export class GigCrew {
for (const m of this.members) { for (const m of this.members) {
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix)
const f = m.actor.fig, b = m.base, ph = m.phase; const f = m.actor.fig, b = m.base, ph = m.phase;
if (m.part === 'band') { if (m.part === 'band') {
if (m.seated) { // drummer: seated bob + a little shoulder work, planted if (m.seated) { // drummer: seated bob + a little shoulder work, planted

View File

@ -189,6 +189,7 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s
const mixer = new THREE.AnimationMixer(inner); const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0]; const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen); const act = _action(mixer, inner, chosen);
let seatBone = null;
if (act) { if (act) {
act.time = phase * (act.getClip().duration || 0); act.play(); act.time = phase * (act.getClip().duration || 0); act.play();
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical). // R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
@ -202,20 +203,39 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s
let m = Infinity; let m = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } }); inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
if (isFinite(m)) inner.position.y -= m; if (isFinite(m)) inner.position.y -= m;
// R17 pelvic-lean: cache the mid-spine bone so seatedLean() can tilt the torso forward each frame (the
// sit clip can't — Hips.quaternion is unusable on these rigs). Seated-only; standing rigs get no seatBone.
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
} }
} }
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, dispose }; return { fig, inner, mixer, height, head, seatBone, dispose };
}
// R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets
// the bone's quaternion from the clip every frame, so this is a CONSTANT offset (not an accumulation). Only
// seated figs (drummer, bench-sit loiterers) call it; standing rigs have no seatBone → never leaned.
const _LEAN_AXIS = new THREE.Vector3(1, 0, 0);
const _LEAN_Q = new THREE.Quaternion();
export function seatedLean(spine, angle = 0.34) {
if (spine) spine.quaternion.multiply(_LEAN_Q.setFromAxisAngle(_LEAN_AXIS, angle));
} }
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim // ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ---- // scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}) { export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null; if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight); const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
const mixer = new THREE.AnimationMixer(inner); const mixer = new THREE.AnimationMixer(inner);
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name))); const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name))); const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name)));
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
let seatBone = null;
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
let sitting = false;
let moving = null; // tri-state so the first setMoving always applies let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them // both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); } if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
@ -233,6 +253,32 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); } if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
} }
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
function setSitting(s) {
if (!sitA || s === sitting) return;
sitting = s;
moving = null; // force the next setMoving to re-apply after standing up
if (s) {
if (walkA) walkA.setEffectiveWeight(0);
if (idleA) idleA.setEffectiveWeight(0);
sitA.reset().play().setEffectiveWeight(1);
mixer.update(0); // bake the sit pose before measuring
inner.updateWorldMatrix(true, true);
let w = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); w = Math.min(w, _wp.y); } });
const S = fig.scale.y || 1;
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
fig.userData.procitySeated = true;
} else {
sitA.stop();
inner.position.y = bindPlantY; // restore the standing feet-plant
if (idleA) idleA.setEffectiveWeight(1);
fig.userData.procitySeated = false;
}
}
// seed the clip phase so the crowd isn't in lockstep // seed the clip phase so the crowd isn't in lockstep
function setPhase(p) { function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1); if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
@ -240,6 +286,6 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}
} }
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, head, nominalHeight: nom, setMoving, setPhase, dispose, return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose,
hasClips: !!(walkA && idleA) }; hasClips: !!(walkA && idleA) };
} }

View File

@ -34,6 +34,7 @@ const PATRON_RANGE = 18; // m — a ped ducks into a shop it
const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent) const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent)
const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront
const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day)
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
// near-empty at night). Sampled at 8 control points, linearly interpolated. // near-empty at night). Sampled at 8 control points, linearly interpolated.
@ -91,6 +92,7 @@ class RigPool {
} }
release(actor) { release(actor) {
if (!actor) return; if (!actor) return;
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
let arr = this.free.get(actor.pedIndex); let arr = this.free.get(actor.pedIndex);
if (!arr) this.free.set(actor.pedIndex, arr = []); if (!arr) this.free.set(actor.pedIndex, arr = []);
arr.push(actor); arr.push(actor);
@ -255,6 +257,7 @@ export class CitizenSim {
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0, x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
}; };
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
this._placeOnLane(c); this._placeOnLane(c);
@ -303,6 +306,7 @@ export class CitizenSim {
_acc: 0, _acc: 0,
turn: rng(this.citySeed, 'turn', id), turn: rng(this.citySeed, 'turn', id),
loit: rng(this.citySeed, 'loiter', id), loit: rng(this.citySeed, 'loiter', id),
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
}; };
// assign a real ped type if the fleet is already up (roster can grow after upgrade) // assign a real ped type if the fleet is already up (roster can grow after upgrade)
if (this.mode === 'rig' && this.fleet.ready) { if (this.mode === 'rig' && this.fleet.ready) {
@ -485,7 +489,7 @@ export class CitizenSim {
this._setImpostorLayer(atlas); this._setImpostorLayer(atlas);
this.mode = 'rig'; this.mode = 'rig';
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip }); this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
const assign = c => { const assign = c => {
const pk = pickRig(this.fleet, c.pedRoll); const pk = pickRig(this.fleet, c.pedRoll);
@ -545,7 +549,14 @@ export class CitizenSim {
c.edge = next; c.edge = next;
c.forward = ne.a === node ? 1 : -1; c.forward = ne.a === node ? 1 : -1;
c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop if (c.loit() < 0.10 + c.loiterTend * 0.28) {
c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
// R17 bench-sit: a seeded few of those become a bench-sit. Roll ALWAYS (dedicated stream →
// deterministic, never shifts turn/loit/patron), but only flip c.sit when a sit clip is loaded
// so ?classic (no sit.glb) is inert at the source, not just at the render.
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
}
} }
this._placeOnLane(c); this._placeOnLane(c);
// patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to // patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to
@ -683,7 +694,12 @@ export class CitizenSim {
const a = c.actor; const a = c.actor;
a.fig.position.set(c.x, 0, c.z); a.fig.position.set(c.x, 0, c.z);
a.fig.rotation.y = c.facing; a.fig.rotation.y = c.facing;
a.setMoving?.(c.loiter <= 0); // R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath
// edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null),
// so every non-sitting ped is byte-identical.
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
if (a.setSitting) a.setSitting(wantSit);
if (!wantSit) a.setMoving?.(c.loiter <= 0);
} else if (want === 'mid') { } else if (want === 'mid') {
mid.push(c); mid.push(c);
} }

View File

@ -9,7 +9,7 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
export { shopName, townName, bandName } from './names.js'; export { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; export { generatePlanOSM, osmTownKeys, registerTownCache, validateTownCache, MIN_TOWN_SHOPS } from './plan_osm.js';
export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js'; export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js';
// The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract // The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract
// from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of // from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of

View File

@ -35,9 +35,75 @@ function signOf(name) {
return (s || String(name).slice(0, 15)).toUpperCase(); return (s || String(name).slice(0, 15)).toUpperCase();
} }
// ── the TOWN-CACHE CONTRACT (ROUND17 ledger #6, Lane A owns it) ─────────────────────────────────────
// What `plan_osm` accepts. E's pipeline (build_towns.py) produces one JSON per real town under
// web/assets/towns/<key>.json in THIS shape; plan_osm marches it exactly like the checked-in fixtures —
// the scout proves the EXISTING contract eats real data, no new plan fields, no new geometry.
//
// { schema:"procity-town-cache/1", key, town, source:"osm",
// license, attribution, // ODbL — REQUIRED on shipped real caches (+ SOURCES.md, E)
// center:{lat,lon}, // REQUIRED — equirectangular projection origin
// bbox?, counts?, fetchedAt?, // optional provenance/metadata (geometry ignores these)
// shops:[ { id, name, type, lat, lon, suburb? } ] } // REQUIRED, >= MIN_TOWN_SHOPS
//
// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed —
// same as the fixtures). A blank name defaults to the type label. `suburb` is optional.
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/1';
export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare
export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note)
const isNum = v => typeof v === 'number' && Number.isFinite(v);
// Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not
// boot a valid district; warnings are absorbed (remapped type, defaulted name, missing licence).
export function validateTownCache(cache) {
const errors = [], warnings = [];
if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings };
if (cache.schema && cache.schema !== TOWN_CACHE_SCHEMA) warnings.push(`schema '${cache.schema}' != '${TOWN_CACHE_SCHEMA}'`);
const c = cache.center;
if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers');
if (!Array.isArray(cache.shops)) errors.push('shops must be an array');
else {
if (cache.shops.length < MIN_TOWN_SHOPS) errors.push(`>= ${MIN_TOWN_SHOPS} shops required (got ${cache.shops.length})`);
let badCoord = 0, blankName = 0, unknownType = 0, dupId = 0; const ids = new Set();
for (const s of cache.shops) {
if (!s || !isNum(s.lat) || !isNum(s.lon)) badCoord++;
if (!s || !(typeof s.name === 'string' && s.name.trim())) blankName++;
if (!s || !SHOP_TYPES[s.type]) unknownType++;
if (s) { if (ids.has(s.id)) dupId++; else ids.add(s.id); }
}
if (badCoord) errors.push(`${badCoord} shop(s) with non-finite lat/lon`);
if (blankName) warnings.push(`${blankName} blank shop name(s) → defaulted to type label`);
if (unknownType) warnings.push(`${unknownType} unknown shop type(s) → remapped to opshop`);
if (dupId) warnings.push(`${dupId} duplicate shop id(s)`);
// wide-spread warning: an over-broad Overpass bbox marches into an unusable multi-km strip (the R17
// real-data risk). Metadata only — plan_osm still boots a valid plan; it just isn't a coherent town.
let mnLat = Infinity, mxLat = -Infinity, mnLon = Infinity, mxLon = -Infinity;
for (const s of cache.shops) if (s && isNum(s.lat) && isNum(s.lon)) {
mnLat = Math.min(mnLat, s.lat); mxLat = Math.max(mxLat, s.lat);
mnLon = Math.min(mnLon, s.lon); mxLon = Math.max(mxLon, s.lon);
}
if (mxLat > mnLat) {
const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180);
if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`);
}
}
if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)');
return { ok: errors.length === 0, errors, warnings };
}
// A runtime registry of validated town caches (E's real caches; the selfcheck + shell register them).
// generatePlanOSM resolves a town from opts.cache, then this registry, then the checked-in fixtures.
const TOWN_CACHES = {};
export function registerTownCache(key, cache) {
const v = validateTownCache(cache);
if (!v.ok) throw new Error(`town cache '${key}' invalid: ${v.errors.join('; ')}`);
TOWN_CACHES[key] = cache;
return v;
}
export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0); citySeed = (citySeed >>> 0);
const fx = OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN]; const fx = opts.cache || TOWN_CACHES[town] || OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN];
// Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data // Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data
// "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan. // "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan.
const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' }; const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' };
@ -70,7 +136,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
if (close <= open) close = clampHour(open + 2); if (close <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++; if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name), // real OSM data has the odd unnamed shop — default a blank name to the registry label so the sign
// never renders "undefined" (build_towns.py filters most, but plan_osm must not trust its input).
const nm = (typeof fixtureShop.name === 'string' && fixtureShop.name.trim()) ? fixtureShop.name : (reg.label || type);
if (nm !== fixtureShop.name) norm.namesDefaulted = (norm.namesDefaulted || 0) + 1;
shops.push({ id, lot: lotId, type, name: nm, sign: signOf(nm),
seed, facadeSkin, storeys, hours: [open, close] }); seed, facadeSkin, storeys, hours: [open, close] });
return id; return id;
} }
@ -167,4 +237,4 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
} }
// The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration). // The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration).
export function osmTownKeys() { return Object.keys(OSM_TOWNS); } export function osmTownKeys() { return [...new Set([...Object.keys(OSM_TOWNS), ...Object.keys(TOWN_CACHES)])]; }

View File

@ -4,11 +4,11 @@
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and // The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and
// the generator can never disagree about what "overlap" or "facing" means. // the generator can never disagree about what "overlap" or "facing" means.
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM, osmTownKeys } from './plan_osm.js'; import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS } from './plan_osm.js';
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js'; import { xmur3 } from '../core/prng.js';
@ -352,6 +352,84 @@ for (const seed of GIG_SEEDS) {
districtInvariants(sv, `sweep osm/silverton ${seed}`); districtInvariants(sv, `sweep osm/silverton ${seed}`);
} }
// ── 3e. town-cache contract (ROUND17 ledger #6): validator + real-data hardening + registry seam ──
// A publishes the contract E's build_towns.py builds to; plan_osm must eat real data (dead ends, no
// spine, blank names, unknown types, odd densities) without new plan features. These are synthetic
// adversarial caches — the committed proof of the hardening (real caches load in §3f below).
section('town-cache contract (ROUND17: real data through plan_osm)');
const mkCache = (shops, extra = {}) => ({
schema: 'procity-town-cache/1', key: 'stress', town: 'Stress', source: 'osm',
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.7, lon: 150.3 }, shops, ...extra,
});
const cluster = (n, opt = {}) => Array.from({ length: n }, (_, i) => ({
id: 500 + i,
name: opt.blank && i % 3 === 0 ? '' : `Real Shop ${i}`,
type: opt.unknown && i % 4 === 0 ? 'op_shop_weird' : ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
lat: -33.7 + (opt.samePoint ? 0 : (i % 5) * 0.0008),
lon: 150.3 + (opt.samePoint ? 0 : Math.floor(i / 5) * 0.0008),
suburb: 'Testville',
}));
// (a) VALID-but-messy caches validate ok AND boot a full valid district (structural + gig invariants)
for (const [label, cache] of [
['compact 14-shop town', mkCache(cluster(14))],
['blank names → defaulted', mkCache(cluster(14, { blank: true }))],
['unknown types → opshop', mkCache(cluster(14, { unknown: true }))],
['all shops at one point', mkCache(cluster(14, { samePoint: true }))],
['minimum shop count', mkCache(cluster(MIN_TOWN_SHOPS))],
]) {
const v = validateTownCache(cache);
ok(v.ok, `cache "${label}": validates ok` + (v.ok ? '' : `${v.errors.join('; ')}`));
if (!v.ok) continue;
for (const s of [1, 20261990]) structuralSuite(generatePlanOSM(s, 'stress', { cache }), `stress/${label} ${s}`);
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'stress', cache }), `stress/${label}`);
}
// (b) INVALID caches are REJECTED (they would not boot a valid district)
for (const [label, cache, needle] of [
['too few shops', mkCache(cluster(MIN_TOWN_SHOPS - 1)), 'shops required'],
['non-finite coords', mkCache(cluster(12).map((s, i) => i === 2 ? { ...s, lat: NaN } : s)), 'non-finite'],
['missing center', { schema: 'procity-town-cache/1', shops: cluster(12) }, 'center'],
['shops not an array', { ...mkCache(cluster(12)), shops: 'nope' }, 'array'],
['not an object', 'nope', 'not an object'],
]) {
const v = validateTownCache(cache);
ok(!v.ok && v.errors.some(e => e.includes(needle)), `cache "${label}": rejected` + (v.ok ? ' (WRONGLY ACCEPTED)' : ` — "${v.errors[0]}"`));
}
// (c) a region-wide cache still boots but WARNS (the R17 mega-strip risk: bound the query to one town)
{
const wide = mkCache(cluster(20).map((s, i) => ({ ...s, lat: -33.7 + i * 0.05 })));
const v = validateTownCache(wide);
ok(v.ok && v.warnings.some(w => /span|town/.test(w)), `wide-spread cache: valid but warns (${v.warnings.find(w => /span/.test(w)) || 'NO WARN'})`);
}
// (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it
{
registerTownCache('regtest', mkCache(cluster(14)));
ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`);
ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`);
}
// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ──
// Empty until E's caches land; each is validated, run through the full structural + gig suites, and
// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive.
section('real town caches (web/assets/towns/*.json)');
const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: <key>: 0x… */ };
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json'));
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
for (const f of townFiles) {
const key = f.replace(/\.json$/, '');
let cache; try { cache = JSON.parse(readFileSync(join(TOWNS_DIR, f), 'utf8')); } catch (e) { ok(false, `real/${key}: parses as JSON — ${e.message}`); continue; }
const v = validateTownCache(cache);
ok(v.ok, `real/${key}: valid cache` + (v.ok ? '' : `${v.errors.join('; ')}`));
if (v.warnings.length) console.log(` ⚠ real/${key}: ${v.warnings.join(' · ')}`);
if (!v.ok) continue;
for (const s of OSM_SEEDS) structuralSuite(generatePlanOSM(s, key, { cache }), `real/${key} ${s}`);
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)}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ────────────────── // ── 4. every facade skin referenced by the registry exists on disk ──────────────────
section('assets on disk'); section('assets on disk');
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`); for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);

View File

@ -13,6 +13,7 @@ import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { loadGLB } from '../core/loaders.js'; import { loadGLB } from '../core/loaders.js';
import { rng, frange } from '../core/prng.js'; import { rng, frange } from '../core/prng.js';
import { chunkOf, resolveEdges } from './planutil.js'; import { chunkOf, resolveEdges } from './planutil.js';
import { applyWind } from './wind.js'; // weather-driven vertex sway on the gum-tree billboards (R17)
const FOOT = 3.5; const FOOT = 3.5;
// bus-shelter footprint (from manifest: 2.33 × 1.41 m) + a facing tweak applied to the along-edge // bus-shelter footprint (from manifest: 2.33 × 1.41 m) + a facing tweak applied to the along-edge
@ -145,6 +146,7 @@ function materials() {
novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl
foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }), foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }),
}; };
applyWind(_mats.tree, { topY: 4.4 }); // gum-tree canopy sways when weather is on (amp 0 ⇒ byte-identical off)
return _mats; return _mats;
} }

View File

@ -10,6 +10,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { rng, frange, pick } from '../core/prng.js'; import { rng, frange, pick } from '../core/prng.js';
import { updateWind } from './wind.js'; // R17: weather drives the tree-sway uniform (off ⇒ never called ⇒ calm)
const RAIN_SKIES = ['summer-storm', 'monsoon', 'cold-front']; const RAIN_SKIES = ['summer-storm', 'monsoon', 'cold-front'];
const OVERCAST_SKIES = ['grey-drizzle', 'cold-front', 'high-cirrus']; const OVERCAST_SKIES = ['grey-drizzle', 'cold-front', 'high-cirrus'];
@ -113,6 +114,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu
function update(dt) { function update(dt) {
group.position.copy(camera.position); // the rain box follows the player group.position.copy(camera.position); // the rain box follows the player
if (rainMat) rainMat.uniforms.uTime.value += dt; if (rainMat) rainMat.uniforms.uTime.value += dt;
updateWind(dt, state.intensity); // sway the gum trees — calm breeze → rain gusts (weather-on only)
} }
function dispose() { function dispose() {

55
web/js/world/wind.js Normal file
View File

@ -0,0 +1,55 @@
// PROCITY Lane B — wind.js (round 17, v3.2 — "wind sway ships or dies")
// Weather-driven vertex-shader sway for street foliage. Parked since R7 with the note "touches shared
// materials"; the R16 standard is measurement-over-brief, and the measurement here is: the gum-tree
// material is B-owned street geometry (furniture.js), so this is ENTIRELY Lane B — no C/E material seam.
//
// ONE shared uniform set, referenced by the tree material via applyWind(). Amplitude stays 0 until the
// weather system drives it (updateWind, called from weather.update). With weather OFF — ?weather=0, or
// ?classic=1 where weather is never constructed — updateWind never runs, uWindAmp stays 0, the injected
// displacement is exactly `... * 0.0 == 0.0`, and trees render byte-identical to no-sway (verified). So
// the classic covenant and ?weather=0 both hold by construction.
//
// SCOPE (measured, per R16 discipline): gum-tree BILLBOARDS only. Awnings are rigid posted-verandah box
// slabs (buildings.js) — a structural verandah roof does not flutter, so swaying it would read wrong; it
// is intentionally excluded (not a cross-lane blocker — a physical one). Fabric-canopy flutter, if ever
// wanted, is a separate v3.3 item (the striped canopies share the rigid awning InstancedMesh today).
//
// Cost: ~0 draws — a handful of ALU ops added to the tree InstancedMesh's existing vertex program.
export const windUniforms = {
uWindTime: { value: 0 },
uWindAmp: { value: 0 }, // 0 = dead calm ⇒ zero displacement ⇒ byte-identical (weather off / classic)
};
// Patch a material's vertex program: sway top vertices horizontally (top-weighted, base planted). A
// per-instance phase from world position desyncs neighbouring trees. Guarded for instanced use so the
// same material would still compile if ever drawn non-instanced.
export function applyWind(material, { topY = 4.4 } = {}) {
material.onBeforeCompile = (shader) => {
shader.uniforms.uWindTime = windUniforms.uWindTime;
shader.uniforms.uWindAmp = windUniforms.uWindAmp;
shader.vertexShader = 'uniform float uWindTime;\nuniform float uWindAmp;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
[
'#include <begin_vertex>',
'float wPhase = position.x * 0.1 + position.z * 0.1;',
'#ifdef USE_INSTANCING',
' wPhase = instanceMatrix[3].x * 0.15 + instanceMatrix[3].z * 0.15;',
'#endif',
`float wH = clamp(position.y / ${topY.toFixed(1)}, 0.0, 1.0); wH *= wH;`, // top-weighted, base planted
'float wS = sin(uWindTime + wPhase) + 0.4 * sin(uWindTime * 2.3 + wPhase * 1.7);',
'transformed.x += wS * uWindAmp * wH;',
'transformed.z += cos(uWindTime * 0.8 + wPhase) * uWindAmp * wH * 0.5;',
].join('\n')
);
};
material.needsUpdate = true;
}
// Drive from weather.update(dt): base breeze on any weather-on boot (clear included), gusts scaling with
// rain intensity. NEVER called when weather is absent ⇒ uWindAmp stays 0 (byte-identical, see header).
export function updateWind(dt, intensity = 0) {
windUniforms.uWindTime.value += dt * 1.6;
windUniforms.uWindAmp.value = 0.05 + Math.max(0, intensity) * 0.30; // calm ~0.05 m top sway → gusty ~0.35 m
}