Compare commits
7 Commits
b6404ff7a2
...
e4a49b5bc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a49b5bc8 | ||
|
|
64ded4ea01 | ||
|
|
483a4b75c6 | ||
|
|
6671cf08f4 | ||
|
|
6fffd19043 | ||
|
|
ee16545941 | ||
|
|
abcadcba13 |
@ -3,7 +3,28 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-16 (round 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 (69–216 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
|
||||
|
||||
The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default.
|
||||
|
||||
@ -74,6 +74,7 @@ master off:
|
||||
| `?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 |
|
||||
| `?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 |
|
||||
|
||||
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on
|
||||
|
||||
@ -1,10 +1,38 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 2–16 closed** (see below).
|
||||
v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden
|
||||
`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.*
|
||||
*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 2–17 closed** (see below).
|
||||
v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs
|
||||
2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).*
|
||||
*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)
|
||||
|
||||
Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot
|
||||
|
||||
@ -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`
|
||||
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)
|
||||
|
||||
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
|
||||
|
||||
@ -2,6 +2,48 @@
|
||||
|
||||
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)
|
||||
|
||||
### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)
|
||||
|
||||
@ -1,5 +1,46 @@
|
||||
# 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
|
||||
|
||||
Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are
|
||||
|
||||
@ -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**: 6–9 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
|
||||
|
||||
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,
|
||||
|
||||
@ -1,5 +1,42 @@
|
||||
# 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⟩
|
||||
|
||||
> 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 |
|
||||
| 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) |
|
||||
| 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:
|
||||
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).
|
||||
|
||||
@ -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.
|
||||
- **`?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.
|
||||
|
||||
---
|
||||
|
||||
## 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
193
pipeline/build_towns.py
Normal 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()
|
||||
@ -126,32 +126,40 @@ def main():
|
||||
|
||||
|
||||
def drift_check(record):
|
||||
"""After publishing, compare _published.json against the live depot procity_* list; warn on
|
||||
drift (a published file missing from the record, or a recorded file absent from the depot)."""
|
||||
"""Compare _published.json (the published-name RECORD) against the depot's actual file list.
|
||||
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:
|
||||
live = fetch_depot_procity()
|
||||
live = fetch_depot_files()
|
||||
except Exception as e:
|
||||
print(f" (drift check skipped — depot list unreachable: {e})")
|
||||
return
|
||||
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 → deleted/never landed
|
||||
if missing_from_record:
|
||||
print(f" DRIFT: {len(missing_from_record)} on depot not in _published.json: "
|
||||
f"{sorted(missing_from_record)[:5]}")
|
||||
missing_from_depot = rec - live # recorded but not live → real drift
|
||||
unrecorded = {f for f in live if f.startswith("procity_")} - rec # our namespace live but unrecorded
|
||||
if unrecorded:
|
||||
print(f" DRIFT: {len(unrecorded)} procity_ on depot not in _published.json: "
|
||||
f"{sorted(unrecorded)[:5]}")
|
||||
if missing_from_depot:
|
||||
print(f" DRIFT: {len(missing_from_depot)} in _published.json not live on depot: "
|
||||
f"{sorted(missing_from_depot)[:5]}")
|
||||
if not missing_from_record and not missing_from_depot:
|
||||
print(f" ✓ _published.json matches the live depot ({len(live)} procity assets).")
|
||||
if not unrecorded and not missing_from_depot:
|
||||
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
|
||||
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"))
|
||||
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__":
|
||||
|
||||
14
tools/qa.sh
14
tools/qa.sh
@ -126,6 +126,20 @@ if [ "$SOAK_RUN" = 1 ]; then
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
hr
|
||||
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
169
tools/qa/town_matrix.py
Normal 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()
|
||||
56
web/assets/towns/README.md
Normal file
56
web/assets/towns/README.md
Normal 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).
|
||||
40
web/assets/towns/SOURCES.md
Normal file
40
web/assets/towns/SOURCES.md
Normal 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).
|
||||
218
web/assets/towns/_raw/bendigo_real.overpass.json
Normal file
218
web/assets/towns/_raw/bendigo_real.overpass.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
99
web/assets/towns/_raw/castlemaine_real.overpass.json
Normal file
99
web/assets/towns/_raw/castlemaine_real.overpass.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
354
web/assets/towns/_raw/fremantle_real.overpass.json
Normal file
354
web/assets/towns/_raw/fremantle_real.overpass.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
299
web/assets/towns/_raw/katoomba_real.overpass.json
Normal file
299
web/assets/towns/_raw/katoomba_real.overpass.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
338
web/assets/towns/_raw/newtown_real.overpass.json
Normal file
338
web/assets/towns/_raw/newtown_real.overpass.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
98
web/assets/towns/bendigo_real.json
Normal file
98
web/assets/towns/bendigo_real.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
74
web/assets/towns/castlemaine_real.json
Normal file
74
web/assets/towns/castlemaine_real.json
Normal 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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
194
web/assets/towns/fremantle_real.json
Normal file
194
web/assets/towns/fremantle_real.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
186
web/assets/towns/katoomba_real.json
Normal file
186
web/assets/towns/katoomba_real.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
170
web/assets/towns/newtown_real.json
Normal file
170
web/assets/towns/newtown_real.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -111,6 +111,17 @@ const customBands = await loadCustomBands();
|
||||
let plan;
|
||||
try {
|
||||
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
|
||||
? (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)
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
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 { loadGLB } from '../core/loaders.js';
|
||||
|
||||
@ -271,6 +271,7 @@ export class GigCrew {
|
||||
for (const m of this.members) {
|
||||
if (m.actor.mixer) m.actor.mixer.update(dt); // rig 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;
|
||||
if (m.part === 'band') {
|
||||
if (m.seated) { // drummer: seated bob + a little shoulder work, planted
|
||||
|
||||
@ -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 chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
|
||||
const act = _action(mixer, inner, chosen);
|
||||
let seatBone = null;
|
||||
if (act) {
|
||||
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).
|
||||
@ -202,20 +203,39 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s
|
||||
let m = Infinity;
|
||||
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
|
||||
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); }
|
||||
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
|
||||
// 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;
|
||||
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 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)));
|
||||
// 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
|
||||
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||
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); }
|
||||
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
|
||||
function setPhase(p) {
|
||||
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); }
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
@ -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 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 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,
|
||||
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||
@ -91,6 +92,7 @@ class RigPool {
|
||||
}
|
||||
release(actor) {
|
||||
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);
|
||||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||||
arr.push(actor);
|
||||
@ -255,6 +257,7 @@ export class CitizenSim {
|
||||
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),
|
||||
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; } }
|
||||
this._placeOnLane(c);
|
||||
@ -303,6 +306,7 @@ export class CitizenSim {
|
||||
_acc: 0,
|
||||
turn: rng(this.citySeed, 'turn', 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)
|
||||
if (this.mode === 'rig' && this.fleet.ready) {
|
||||
@ -485,7 +489,7 @@ export class CitizenSim {
|
||||
this._setImpostorLayer(atlas);
|
||||
|
||||
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
|
||||
const assign = c => {
|
||||
const pk = pickRig(this.fleet, c.pedRoll);
|
||||
@ -545,7 +549,14 @@ export class CitizenSim {
|
||||
c.edge = next;
|
||||
c.forward = ne.a === node ? 1 : -1;
|
||||
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);
|
||||
// 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;
|
||||
a.fig.position.set(c.x, 0, c.z);
|
||||
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') {
|
||||
mid.push(c);
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.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';
|
||||
// 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
|
||||
|
||||
@ -35,9 +35,75 @@ function signOf(name) {
|
||||
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 = {}) {
|
||||
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
|
||||
// "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' };
|
||||
@ -70,7 +136,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
|
||||
if (close <= open) close = clampHour(open + 2);
|
||||
if (close > LATE_HOUR - 1) norm.hoursClamped++;
|
||||
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
|
||||
shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name),
|
||||
// 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] });
|
||||
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).
|
||||
export function osmTownKeys() { return Object.keys(OSM_TOWNS); }
|
||||
export function osmTownKeys() { return [...new Set([...Object.keys(OSM_TOWNS), ...Object.keys(TOWN_CACHES)])]; }
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
// 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.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
|
||||
import { generatePlanOSM, 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 { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
|
||||
import { xmur3 } from '../core/prng.js';
|
||||
@ -352,6 +352,84 @@ for (const seed of GIG_SEEDS) {
|
||||
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 ──────────────────
|
||||
section('assets on disk');
|
||||
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);
|
||||
|
||||
@ -13,6 +13,7 @@ import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { loadGLB } from '../core/loaders.js';
|
||||
import { rng, frange } from '../core/prng.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;
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
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 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) {
|
||||
group.position.copy(camera.position); // the rain box follows the player
|
||||
if (rainMat) rainMat.uniforms.uTime.value += dt;
|
||||
updateWind(dt, state.intensity); // sway the gum trees — calm breeze → rain gusts (weather-on only)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
|
||||
55
web/js/world/wind.js
Normal file
55
web/js/world/wind.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user