Compare commits

...

4 Commits

Author SHA1 Message Date
m3ultra
3daf8df3ec Lane D round-8: shop patronage v0 + weather reaction
The crowd comes alive. Default-on for the streamed roster (behind ?patronage=0); v1 path
(?roster=v1) untouched — no patron fields, golden identity holds. qa --strict GREEN.

Patronage (D1): per-ped state machine walking -> going (steer to a nearby open shop's door,
seeded ~every 10m within 18m) -> inside (hidden, 5-20s seeded dwell) -> emerge (resumes walk).
Hours-aware: noon = all types (up to ~38 inside, 7 types); night = video-only (the openLate shop
draws the crowd, roll ramps up as streets empty). Deterministic (150 chunk-keyed ids), leak-free
(0 GPU delta over the churn), budget-neutral (inside peds hidden -> draws -1).

Weather (D2): reads Lane B's PROCITY.weather contract (no import). setWeather({state,intensity}).
Rain -> density -56% (40-60% target), walk speed +14%, more sheltering (inside 6->25); overcast
-10%; clear = v1.

Also this session: R7 post-flip shell verification (no-flag boot streams, determinism, leak-free,
?roster=v1 restores golden roster) and pushed main + v1.0/v1.1/v2.0-alpha/v2.0-beta to origin.

F wiring (setShops door-point recipe, setWeather, ?patronage=0) documented in LANE_D_NOTES.
Only web/js/citizens/sim.js + web/citizens_test.html changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:42:34 +10:00
m3ultra
fbc8ffa1c8 Lane C round-8: buy loop v0 + book/toy real stock (?stock=real)
C1 — buy loop v0 (new wallet.js): runtime-only economy — seeded cash
(~$60–200), buy()/broke-gate/in-memory bag, onChange for a cash chip.
World gen untouched (purchases never re-enter the plan/seeded build).
Test page: #cashChip HUD + I-toggle inventory + dig BUY IT button
(pull → bag, cash down, bin depletes); digSoak buys ≤2/visit.

C2 — book spines + toy boxes wired to ?stock=real. stockpack.js keys
each pack to a slot (record→sleeve, book→spine, toy→box); stock.js
shelfLine builds real atlas-UV cover planes for book/toy shelves
(static, no dig), fail-soft per pack. E's packs live: record 350/6,
book 311/4, toy 273/5 (items/atlases).

C3 — validated: draws ≤350 (record 41, book 42, toy 51), determinism
0 fails, leak-free (0 geo/tex delta ×20; buy-soak 12 visits/5 bought,
leakGeo0/tex0), reload resets wallet, flag-off + ?noassets untouched,
qa.sh --strict GREEN 5/5. Shot: docs/shots/laneC/buyloop_r8.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:33:00 +10:00
m3ultra
f952f513d0 Lane A round 8: OSM to parity + a second town (Katoomba) — carried R7 brief, full
The carried brief (slipped R7) lands complete — all four items, qa.sh --strict GREEN.

1. Selfcheck PARITY: extracted structuralSuite(plan,label) and run the FULL invariant suite
   on every (source, town) — synthetic + both OSM towns get identical coverage (storeys,
   one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism,
   exactly-one-openLate). ALL GREEN 1727/1727 (was 1362; OSM went subset → full ×2 towns).
2. SECOND TOWN: Katoomba (Blue Mountains, 19 shops, op-shop/book heavy) — regional contrast
   to inner-Melbourne. osm_fixture.js → OSM_TOWNS{melbourne,katoomba}+DEFAULT_TOWN;
   generatePlanFor(seed, source, {town}); ?plansrc=osm&town=katoomba. Golden 0x0f652510.
   Melbourne 0x34cfdec0 + synthetic 0x3fa36874 UNCHANGED (town key kept off the plan).
3. "Add a town" recipe in LANE_A_NOTES (R8): extract → OSM_TOWNS → pin golden → tell F.
4. Normalization + LOG: importer bends real data to the contracts (unknown kind→opshop,
   hours-clamp so only one shop is openLate, video-preferred landmark w/ fallback) and logs
   it via {report}; selfcheck prints per-town (e.g. melbourne hoursClamped:5, openLate:video).

For F: three goldens to pin, gate keyed (seed, plansrc, town); selector gained {town} — see
LANE_A_NOTES R8. Screenshots docs/shots/laneA/osm-{melbourne,katoomba}.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:15:12 +10:00
m3ultra
a667283cf3 Lane E round 8: REAL stock packs (record 350 / book 311 / toy 273) live on depot
John unblocked GODVERSE DB access (m1ultra native-Postgres clones, read-only SELECT,
peer auth). All 3 packs rebuilt from real data:
- record 350: discogs_full.inventory (real store stock) join release/release_image,
  real Discogs cover art.
- book 311 + toy 273: dealgod.products by category_path, real product photos + prices.
Metadata parody-transformed (deterministic, no source-token leak -> no real trademarks
in pack text); cover images are real release/product art (read-only, non-economy,
decision #2); book prices seeded (source is info-only, John's call). 15 atlases
published to the depot; indexes committed (the C contract) + staged for ?localdepot;
all pass the pack-QA gate. build_stock_pack.py gained --from-tsv (extract-on-DB-box ->
build-here), portrait 176x256 book cell, seeded-price fallback. qa --strict GREEN 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:09:14 +10:00
27 changed files with 13079 additions and 826 deletions

3
.gitignore vendored
View File

@ -13,3 +13,6 @@ pipeline/.peds_orig/
pipeline/.props_orig/
pipeline/.peds_r6/
pipeline/_stockpack/
pipeline/_records.tsv
pipeline/_books.tsv
pipeline/_toys.tsv

View File

@ -3,7 +3,49 @@
*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-14 (round 7) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 8) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock
Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.
**C1 — buy loop v0** (new `web/js/interiors/wallet.js`). A shallow, **runtime-only** economy: seeded
starting cash, purchases, an in-memory bag. Reload resets; world generation is **untouched** (purchases
never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never
writes back into gen).
- `createWallet(seed)``{ cash, start, canBuy, buy(offer), inventory, count, onChange }`. Start is
seeded ~$60200 (`mulberry32`). `buy` deducts + records on success, **returns false unchanged if broke**.
`onChange` lets F's street shell subscribe the same wallet to a cash chip.
- **Test page** (`interior_test.html`): a `#cashChip` HUD (`$163 · 🛍 N`, street + interior) + an
inventory panel toggled with **I**. The dig's pull card gets a **BUY IT** button (price + band); buying
pulls the sleeve into the bag, decrements cash, updates the chip, and **depletes that bin** (bought bins
offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy). `digSoak` now buys up to 2/visit.
- Offer shape is the dig offer (`{a,t,price,s}`) OR a pack item (`{title,artist,price,price_band}`) — one
`buy()` eats both.
**C2 — book spines + toy boxes wired to `?stock=real`.** E shipped the generalized packs
(`stock_book_*` 311 items / 4 atlases, `stock_toy_*` 273 items / 5 atlases, alongside `stock_record_*`
350/6). `stockpack.js` now keys each pack to its slot via `SLOT_FOR = {record:'sleeve', book:'spine',
toy:'box'}`; `stock.js shelfLine` consults the adapter and builds real cover planes for book spines
(bookshelves) and toy boxes (cube shelves) — **static shelf stock, no dig** for those, fail-soft per pack.
Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one
draw per atlas.
**C3 — validated** (all three packs live, `?stock=real`):
- **Draws ≤350** (the law): record **41**, book **42**, toy **51** worst over seeds {1990,7,42(,101)}.
- **Determinism**: same seed → identical draw calls + mesh count, all three types (0 fails).
- **Leak-free**: 0 geometry/texture delta over 20 build/dispose cycles; **buy-soak** 12 record-dig visits,
5 bought (wallet depletes to broke → exercises the can't-buy path), `leakGeo 0 / leakTex 0`; resting geo
back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed.
- **Reload resets** the wallet (fresh boot → seeded start again); **flag-off + `?noassets` untouched**.
- Shot: [buyloop_r8.jpg](docs/shots/laneC/buyloop_r8.jpg) — toy boxes, book spines, record dig → BUY card.
**→ F**: two new warn-level smokes (hooks in [LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md)): (1) **buy loop**
`createWallet` deduct/broke/inventory is pure and headless-testable; (2) **book/toy packs** — enter book or
toy shop with `?stock=real`, assert `isStock` meshes on a shared atlas material (drawSweep ≤350). Buy UI is
test-page-local; the reusable piece for the street shell is `wallet.js` + `onChange`.
---

View File

@ -1,5 +1,25 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.
Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main
1a20501→900b274; v1.0/v1.1/v2.0-alpha/v2.0-beta all on origin).
- **Post-flip verify** ✅ (real shell): no-flag boot → streamMode true, determinism (188 ids), leak-free
(0 delta), setNightLivelyChunks wired; `?roster=v1` → fixed roster, golden identity byte-identical.
- **Shop patronage v0 (D1)** ✅: streamed peds duck into open shops they pass (going→inside→emerge, hidden
while inside), hours-aware — noon variety (up to ~38 inside, 7 types), **night video-only** (the
open-late shop draws the crowd). Deterministic (150 chunk-keyed ids), leak-free (0 GPU delta over the
churn), **budget-neutral** (draws 1, inside peds hidden). Behind `?patronage=0`, default-on for stream.
- **Weather reaction (D2)** ✅: reads B's `PROCITY.weather` (no import). Rain → density **56%** (4060%
target), walk speed +~14%, more sheltering (inside 6→25); overcast 10%; clear = v1.
- v1 path untouched (no patron fields, golden identity). Full numbers + F's `setShops`/`setWeather`/
`?patronage=0` wiring in `LANE_D_NOTES.md`.
Only `web/js/citizens/sim.js` + `web/citizens_test.html` (synthetic-shop demo + flags) changed.
---
## Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.
Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the **GO** for the roster flip.

View File

@ -16,9 +16,15 @@ other lanes' files was touched.*
qa** — proven (inverted-UV+empty-title → exit 1). qa `--strict` GREEN 5/5.
- **Tags pushed** (John-delegated): origin/main = HEAD (1a20501) + v1.0/v1.1/v2.0-alpha/v2.0-beta all
live on origin — verified.
- **Blocker (flagged to Fable):** real ~150250-item book/toy (+ record) packs need `GODVERSE_DSN`
(dealgod Postgres, up on :5432, read from env). One `--kind X --from-db` command each once set.
Current packs are synthetic placeholders (real index format). No fal spend.
- **REAL packs LIVE (John unblocked DB access).** All 3 packs rebuilt from GODVERSE (m1ultra
Postgres clones, read-only SELECT, peer auth): **record 350** (`discogs_full.inventory` — John's
real store stock + real Discogs covers), **book 311** + **toy 273** (`dealgod.products` by
category, real product photos + prices). Metadata parody-transformed (no real trademarks); covers
are real art (decision #2); book prices seeded (info-only source). 15 atlases published to the
depot, indexes committed to `pipeline/packs/` + staged, all pass the pack-QA gate. `build_stock_pack.py`
gained `--from-tsv` (extract on the DB box → build here) + portrait book cell + seeded-price
fallback. Access path: deployment DB was Docker-internal/SSH-denied → used the m1ultra clones per
John. No fal spend.
- **On-call** (B rain-streak/puddle, D door-swing tell): none requested.
## Round 7 (Fable) — the two feeders (ped merge + stock pack)

View File

@ -1,8 +1,32 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1362/1362); rounds 26 closed** (see below).
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 28 closed** (see below).
`qa.sh --strict` GREEN.*
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
## Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)
The carried brief (slipped R7) is done — all four items, not a partial.
1. **Selfcheck parity**: extracted the full invariant suite into `structuralSuite(plan, label)` and
run it on **every (source, town)** — synthetic + both OSM towns now get identical coverage
(storeys, one-shop-per-lot, solid-use, facing, within/cross-block overlap, corridor coverage,
determinism, exactly-one-openLate). **ALL GREEN 1727/1727** (was 1362 — OSM went from a subset to
the full suite ×2 towns).
2. **Second town**: **Katoomba** (Blue Mountains, 19 shops, op-shop/book heavy) — a small-regional
contrast to inner-Melbourne (95, book heavy). `?plansrc=osm&town=katoomba`. `osm_fixture.js` is now
`OSM_TOWNS { melbourne, katoomba }` + `DEFAULT_TOWN`; `generatePlanFor(seed, source, { town })`.
Golden pinned `0x0f652510`. **Melbourne golden `0x34cfdec0` and synthetic `0x3fa36874` unchanged.**
3. **"Add a town" recipe** documented in `LANE_A_NOTES.md` (R8) — extraction → `OSM_TOWNS` entry →
pin golden → tell F. Mechanical.
4. **Normalization + logging**: importer normalizes real data to the contracts (unknown kind→opshop,
hours clamp so only one shop is openLate, video-preferred landmark w/ fallback) and **logs** what it
changed via `{ report }` — selfcheck prints e.g. `melbourne: 95 shops · normalized {typesRemapped:0,
hoursClamped:5, openLate:video}`. The contract never bends; the data does.
Extraction one-liner (Katoomba, reusable — see recipe): cell 0.05 around (-33.714, 150.312), same
KIND map + dedupe + sort-by-id as Melbourne (cell 0.06 around -37.803, 144.967). Screenshots:
`docs/shots/laneA/osm-{melbourne,katoomba}.png`. `qa.sh --strict` GREEN.
## Round 6 (2026-07-14) — the second plan source (`?plansrc=osm`), FULLY LANDED

View File

@ -2,6 +2,53 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 8 (2026-07-15)
### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg.
- **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every
(source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot,
facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …).
`node selfcheck.js`**ALL GREEN 1727/1727**.
- **Selector gained a town arg** (`web/js/citygen/index.js`):
`generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through —
`generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference
impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`).
`osmTownKeys()` lists the available towns for a UI picker.
- **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already:
| source / town | golden |
|--------------------|--------------|
| synthetic | `0x3fa36874` |
| osm / melbourne | `0x34cfdec0` |
| **osm / katoomba** | `0x0f652510` |
The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are
**unchanged** from round 6.
- Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast
to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`.
### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4)
Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is
**logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`):
- unknown OSM `shop=` kind → `opshop` (`typesRemapped`);
- a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`);
- openLate landmark prefers a **video**; a town with no video falls back to another non-stall type
and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`).
### RECIPE — add another OSM town (mechanical, ~10 min)
1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell
size that captures one coherent town (aim 15150 on-theme shops). The extraction one-liner used
for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`.
2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`:
`key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops
by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book,
music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn).
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`, read the printed
`osm/<key>: fingerprint 0x…`, add `<key>: 0x…` to `OSM_GOLDENS` in `selfcheck.js`. Re-run → green.
4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the
full parity suite covers it automatically.
## Round 6 (2026-07-14)
### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin

View File

@ -1,5 +1,31 @@
# LANE C — cross-lane notes (PROCITY-C)
## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed)
**Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM:
```js
import { createWallet } from './web/js/interiors/wallet.js';
const w = createWallet(1990);
const start = w.start(); // seeded ~$60200 (deterministic per seed)
w.buy({ title:'X', artist:'Y', price: 25 }); // true; cash -= 25; bag +1
w.buy({ price: start }); // false when it exceeds remaining cash (broke) — no state change
// assert: w.count() === 1 && w.cash() === start - 25 && w.canBuy(w.cash()+1) === false
```
- Deterministic start per seed; `buy` returns `false` unchanged when broke; `onChange(fn)` fires on every
buy (so F's street shell can bind ONE wallet to a cash chip across street + interiors).
- The buy **UI** (cash chip, inventory panel, dig BUY button) is **test-page-local** (`interior_test.html`).
The reusable economy is `wallet.js`; render it wherever. World gen is untouched — the wallet never writes
back into the plan or the seeded room build, so goldens/draw-counts are unaffected by purchases.
**Book/toy packs** now feed `?stock=real` too (E shipped `stock_book_*` / `stock_toy_*`). Smoke:
- Boot `?stock=real`, enter a **book** or **toy** shop, assert real stock present: same test as records —
`let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0`. Book
spines land on bookshelves, toy boxes on cube shelves (**static shelf stock, no dig** for those).
- **Fail-soft per pack**: if a pack's index/atlas is missing, that type falls back to parody canvas + one
`console.warn`; records/other types unaffected. `?noassets` untouched.
- **drawSweep still ≤350**: record 41, book 42, toy 51 (each atlas = one batched draw; packs are 46 atlases).
## → Lane F: `?stock=real` smoke for the strict harness (round-7 C1 landed)
`?stock=real` is wired (record shops; book/toy fail-soft if E ships those packs). Smoke recipe:

View File

@ -6,6 +6,57 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below)
**The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the
open-late video shop draws visitors; rain thins + shelters the crowd.** Default-on for the streamed
roster (post-flip), behind `?patronage=0` off-switch. Verified in-shell (real plan shops) + test page.
`qa.sh --strict` GREEN. v1 (`?roster=v1`) path untouched (no patron fields, golden identity holds).
### Patronage (D1) ✅
State machine per streamed ped: walking → (every ~10m, if a nearby OPEN shop is within 18m, a seeded
roll) → **going** (steer to the door) → **inside** (hidden, seeded 520s dwell) → **emerge** (resumes
its footpath walk). Hours-aware via the shop's `hours`.
- **Day-long**: peds visibly enter/leave shops of all types (noon: up to ~38 inside near a shopfront,
7 types visited).
- **Night**: at 22:48 only the video shop (`hours[1]≥22`) is open → patronage is **video-only**
(`onlyVideo:true`), and its block stays lively (the R6 night floor). The patronage roll ramps up as
the streets empty so the few peds out concentrate at the one open shop.
- **Deterministic** (chunk-keyed identities unchanged: 150 re-derive from seed), **leak-free** (0 GPU
delta over a 2-walk patronage churn — enter/emerge reuses the existing actor lifecycle), and
**budget-neutral / positive** (inside peds are hidden → draws 1 vs patronage-off at the same spot).
### Weather reaction (D2) ✅ — reads Lane B's `PROCITY.weather` contract (does NOT import weather.js)
`sim.setWeather({state,intensity})` each frame. **Rain** (intensity 0.8): density **56%** (in the
4060% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured
inside 6→25). **Overcast**: ~10%. **Clear**: v1 behaviour. Seed 20261990 rolls `clear` (v1-identical);
use `?weather=rain` to force it.
### → Lane F: wiring in `web/index.html` (F owns the shell)
1. **Shop door points** — build once from the plan, feed the sim:
```js
const CH = 64, shopsByChunk = new Map();
for (const s of plan.shops) {
const l = plan.lots.find(x => x.id === s.lot); if (!l) continue;
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront
const k = citizens.chunkKeyAt(x, z);
(shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours });
}
if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
```
(PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the `+0.6`.)
2. **Weather** — in the street branch each frame: `citizens.setWeather(window.PROCITY.weather);`
(B's contract is always a valid `{state,intensity}`; F sets `{clear,0}` when `?weather` is off.)
3. `setNightLivelyChunks` (openLate block) — already wired in F1; unchanged.
**Baseline note (flip protocol):** patronage default-on may move the flags-off draw baseline a hair —
in the direction of *fewer* draws (inside peds hidden), and the default spawn view has ~0 near-rigs so
it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak).
---
## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready
**Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed

View File

@ -1,6 +1,19 @@
# LANE E — cross-lane notes
## Round 8
## Round 8 — REAL packs live (update)
**→ Lane C: all 3 stock packs are now REAL** (GODVERSE data, John unblocked DB access). Same schema
you wired — just richer data, no code change needed:
- **record** — 350 real vinyl (John's store stock, real Discogs covers), 6 atlases, cell 256×256.
- **book** — 311 real book covers (`dealgod.products` books), 4 atlases, cell **176×256 (portrait
face-out cover**, not thin spine — I only have cover art; render books face-out).
- **toy** — 273 real toy/collectible photos, 5 atlases, cell 256×256.
Metadata is parody-transformed (no real titles/artists/brands); covers are real art; book prices are
seeded (source is info-only). Indexes committed to `pipeline/packs/` + staged (`assets/models/`, your
`?localdepot` path); atlases published to the depot. All pass the pack-QA gate. If your book shelf
wants thin spines, tell me — I'd need spine art (don't have it; covers only).
## Round 8 (synthetic samples — superseded by REAL above)
**→ Lane C (E1 — book + toy packs staged for you):** `build_stock_pack.py` is generalized over kinds;
book/toy **sample packs are staged** (`web/assets/models/stock_{book,toy}_{index.json,atlas_00.webp}`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -328,3 +328,26 @@ hunt). One command each once set: `build_stock_pack.py --kind book --from-db --c
current staged packs are synthetic placeholders (real index format); provenance of the real packs =
GODVERSE metadata (OpenLibrary/toygod) **parody-transformed** (no real titles/authors/brands ship) +
real cover art (read-only, non-economy). No fal spend this round.
## Round 8 (cont.) — REAL stock packs live (GODVERSE, John-authorized DB access)
The synthetic samples are replaced by **real** packs, built from GODVERSE via peer-auth on m1ultra
(`johnking@100.91.239.7`, native Postgres, `psql` at `/opt/homebrew/Cellar/libpq/18.4/bin`). Access
resolved after the deployment DB proved Docker-internal (port closed, SSH denied) — John pointed to
the m1ultra clones. All extraction was **read-only SELECT**; no creds guessed, no writes.
| pack | items | source (m1ultra) | covers | prices |
|---|---|---|---|---|
| **record** | 350 | `discogs_full`: `inventory` (John's real store stock) ⋈ `release``release_image` | real Discogs cover art (`monsterrobot.party` CDN, from `local_url`) | real store prices |
| **book** | 311 | `dealgod.products` WHERE `category_path LIKE 'books%'` | real product photos (`image_url`, portrait 176×256) | real `current_price` |
| **toy** | 273 | `dealgod.products` WHERE `category_path LIKE 'collectables-hobbies-toys%'` | real product photos (`image_url`) | real `current_price` |
Build: `build_stock_pack.py --kind <k> --from-tsv` (rows extracted to a gitignored TSV, covers fetched
at build time, resized into atlas cells). **Metadata is parody-transformed** (titles/artists/authors/
brands → house word banks, deterministic, shares no source token — "Dark Side of the Moon"/"Pink Floyd"
→ "Quiet Milk Bar"/"The Galahs") so **no real trademark ships in the pack text**; the **cover images are
real release/product art** (read-only, non-economy, per decision #2 — John's own store inventory + AU
marketplace listings). Books are info-only in the source → **prices seeded deterministically** (John's
call). 15 atlases + 3 indexes: atlases published to the depot (`/api/upload`), indexes committed to
`pipeline/packs/` (the C contract) + staged for `?localdepot`. All 3 pass the pack-QA gate. Toy filter
is broad (some comics/coins among the collectibles) — acceptable for a collectibles shop; tighten later.

View File

@ -54,8 +54,16 @@ BANKS = {
"Wattle", "Billabong", "Outback", "Reef City"],
band_b=["Toys", "Playsystems", "Games", "Fun Co.", "Novelties", "Workshop", "Mfg.", "Play Lab"]),
}
CELL = {"record": (256, 256), "book": (96, 256), "toy": (256, 256)} # book = tall-thin spine
GRID = {"record": 8, "book": 16, "toy": 8} # columns per atlas row
CELL = {"record": (256, 256), "book": (176, 256), "toy": (256, 256)} # book = portrait cover (2:3-ish)
GRID = {"record": 8, "book": 10, "toy": 8} # columns per atlas row
def seeded_price(kind, title, artist):
"""Some sources carry no price (books are info-only) — mint a deterministic, kind-plausible
price so the buy loop + price bands work. Same item same price every build."""
s = _seed("price", kind, title or "", artist or "")
lo, hi = {"book": (3, 40), "toy": (4, 35), "record": (8, 60)}.get(kind, (5, 40))
return lo + s % (hi - lo + 1)
def _seed(*p):
@ -115,6 +123,29 @@ def rows_from_db(kind, count):
return out
def rows_from_tsv(path):
"""Rows from a `artist\\ttitle\\tprice\\tcover_url` TSV extracted from GODVERSE (discogs_full
inventory JOIN release JOIN release_image John's real store stock + saved Discogs covers).
Covers are fetched from the URL at build time; metadata is parody-transformed downstream."""
out = []
for line in open(path, encoding="utf-8"):
parts = line.rstrip("\n").split("\t")
if len(parts) < 4 or not parts[3]: # need a cover URL
continue
col0, col1, price, cover = parts[0], parts[1], parts[2], parts[3]
# record source is (artist, title); single-field sources (books/toys from products) put the
# title in col0 and leave col1 empty — carry the real title through so parody seeds off it.
if col1:
artist, title = col0, col1
elif col0:
artist, title = "", col0
else:
continue
out.append({"artist": artist, "title": title,
"price": int(price) if str(price).lstrip("-").isdigit() else None, "img": cover})
return out
def rows_sample(kind, n):
"""Synthetic rows (solid-colour cells at the kind's aspect) — proves the pipeline with no DB."""
from PIL import Image, ImageDraw
@ -154,8 +185,9 @@ def build_pack(kind, rows):
sheet.paste(cov, (gx * cw, gy * ch))
ptitle, partist = parody(kind, r["title"], r["artist"])
u0, v0 = gx * cw / W, gy * ch / H
price = r["price"] if r.get("price") else seeded_price(kind, r["title"], r["artist"])
item = {"id": f"{kind[:3]}_{len(items):04d}", "title": ptitle, "artist": partist,
"price": int(r["price"] or 4), "price_band": price_band(kind, r["price"]),
"price": int(price), "price_band": price_band(kind, price),
"atlas": aname, "uv": [round(u0, 4), round(v0, 4),
round(u0 + cw / W, 4), round(v0 + ch / H, 4)]}
items.append({k: item[k] for k in INDEX_FIELDS})
@ -189,7 +221,9 @@ def main():
f".webp atlases + *_index.json via /api/upload.")
return
kind = a[a.index("--kind") + 1] if "--kind" in a else "record"
if "--sample" in a:
if "--from-tsv" in a:
rows = rows_from_tsv(a[a.index("--from-tsv") + 1])
elif "--sample" in a:
rows = rows_sample(kind, int(a[a.index("--sample") + 1]))
elif "--from-db" in a:
rows = rows_from_db(kind, int(a[a.index("--count") + 1]) if "--count" in a else 250)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,8 @@ const NOASSETS = qs.get('noassets') === '1';
// R7 flip: the chunk-streamed roster is now the DEFAULT. `?roster=v1` is the escape hatch back to the
// fixed v1 roster; `?roster=stream` is still accepted (explicit). Mirrors the shell wiring F lands in F1.
const STREAM = qs.get('roster') !== 'v1';
const PATRONAGE = qs.get('patronage') !== '0'; // R8: peds duck into open shops (default-on for stream)
const WEATHER = qs.get('weather'); // R8 demo: ?weather=rain|overcast (thins + redirects the crowd)
// ---------- renderer / scene ----------
const app = document.getElementById('app');
@ -175,7 +177,33 @@ for (const n of nodes) {
// ---------- fleet + sim ----------
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 10 (street draw budget) — see sim.js
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 16 (full density post-merge) — see sim.js
// ---------- R8 patronage demo (synthetic shops on the fixture; the shell feeds REAL plan shops — see
// LANE_D_NOTES for F's setShops door-point recipe). Peds duck into open shops they pass; one openLate. ----
if (STREAM && PATRONAGE) {
const shopsByChunk = new Map();
const cell = (x, z) => { const k = sim.chunkKeyAt(x, z); let a = shopsByChunk.get(k); if (!a) shopsByChunk.set(k, a = []); return a; };
let vid = null;
edges.forEach((e, i) => {
const A = nodeById(e.a), B = nodeById(e.b), mx = (A.x + B.x) / 2, mz = (A.z + B.z) / 2;
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1, px = dz / len, pz = -dx / len;
[1, -1].forEach((side) => {
const x = mx + px * side * 7, z = mz + pz * side * 7;
const openLate = i === 0 && side === 1; // one openLate "video" shop
const s = { x, z, hours: openLate ? [11, 23] : [9, 17], type: openLate ? 'video' : 'shop' };
cell(x, z).push(s); if (openLate) vid = s;
const box = new THREE.Mesh(new THREE.BoxGeometry(3.4, 3, 3.4),
new THREE.MeshStandardMaterial({ color: openLate ? 0x3a5c8c : 0x54493c, roughness: 0.9 }));
box.position.set(x, 1.5, z); scene.add(box);
});
});
sim.setShops(shopsByChunk);
if (vid) { const [cx, cz] = sim.chunkKeyAt(vid.x, vid.z).split(',').map(Number); const lv = new Set();
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) lv.add(`${cx + a},${cz + b}`); sim.setNightLivelyChunks(lv); }
}
if (STREAM && WEATHER === 'rain') sim.setWeather({ state: 'rain', intensity: 0.8 });
else if (STREAM && WEATHER === 'overcast') sim.setWeather({ state: 'overcast', intensity: 0.5 });
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),

View File

@ -71,12 +71,35 @@ import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js';
import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type
const realAdapterFor = (type) => (STOCK_REAL ? makeStockAdapter(getStockPack(type)) : null);
// ── buy loop v0: in-memory wallet + cash HUD + inventory (I). F wires the same wallet on the street. ──
const wallet = createWallet(1990);
const boughtByBin = {}; // bin key → count sold, so a re-dug bin depletes
const cashChip = document.createElement('div'); cashChip.id = 'cashChip';
const invPanel = document.createElement('div'); invPanel.id = 'invPanel';
const walletCss = document.createElement('style'); walletCss.textContent = `
#cashChip{position:fixed;top:12px;right:14px;z-index:70;background:rgba(24,20,14,.92);border:1px solid #6a5a38;border-radius:8px;padding:8px 13px;font:700 15px "Courier New",monospace;color:#3dff8b}
#invPanel{position:fixed;top:52px;right:14px;z-index:70;width:238px;max-height:62vh;overflow:auto;background:rgba(24,20,14,.95);border:1px solid #4a4030;border-radius:8px;padding:10px 12px;font:12px "Courier New",monospace;color:#d8d8e0;display:none}
#invPanel h4{margin:0 0 6px;color:#ffd75e}
#invPanel .row{display:flex;justify-content:space-between;gap:8px;padding:2px 0;border-bottom:1px solid #2a2418}`;
document.head.appendChild(walletCss); document.body.append(cashChip, invPanel);
function updateCash() { cashChip.textContent = `$${wallet.cash()} · 🛍 ${wallet.count()}`; }
function renderInv() {
const inv = wallet.inventory();
invPanel.innerHTML = `<h4>INVENTORY (${inv.length})</h4>` + (inv.length
? inv.map(it => `<div class="row"><span>${it.artist ? it.artist + ' — ' : ''}${it.title}</span><span>$${it.price}</span></div>`).join('')
: '<div style="color:#8a8a9a">empty — walk to a bin and press E, then BUY</div>');
}
wallet.onChange(() => { updateCash(); if (invPanel.style.display === 'block') renderInv(); });
updateCash();
addEventListener('keydown', e => { if (e.code === 'KeyI' && !e.target.matches('input,select')) { const on = invPanel.style.display === 'block'; invPanel.style.display = on ? 'none' : 'block'; if (!on) renderInv(); } });
// ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
@ -347,21 +370,42 @@ function openDigOn(bin) {
if (controls.isLocked) controls.unlock();
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic position)
dig.open({ seed: binSeed(curSeed, key), count: 16, shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type), getCash: () => 50, onBuy: () => true, onClose: () => {} });
const sold = boughtByBin[key] || 0; // the bin depletes as you buy from it
dig.open({ seed: binSeed(curSeed, key), count: Math.max(3, 16 - sold), shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type),
getCash: wallet.cash,
onBuy: (o) => { const ok = wallet.buy(o); if (ok) boughtByBin[key] = (boughtByBin[key] || 0) + 1; return ok; },
onClose: () => {} });
}
if (DIG_ON) {
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });
document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin';
document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin · <b>I</b> bag';
}
// enter+leave a dig per record room, assert leak-free (round-5 acceptance)
// enter+leave a dig per record room, BUYING 2 items per visit, assert leak-free (round-8 buy loop)
async function digSoak(N = 15) {
if (!dig) dig = createDig(THREE, renderer);
dig.open({ seed: 1, count: 16 }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
const richWallet = createWallet(7); // fresh rich-ish wallet so buys land during the soak
let topUps = 0;
const buyer = (o) => { if (o.price > richWallet.cash()) { topUps++; return false; } return richWallet.buy(o); };
dig.open({ seed: 1, count: 16, getCash: richWallet.cash, onBuy: buyer }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
for (let i = 0; i < N; i++) { dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9) }); renderer.render(dig.scene, dig.camera); dig.close(); }
const cv = renderer.domElement;
let bought = 0;
for (let i = 0; i < N; i++) {
dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9), getCash: richWallet.cash, onBuy: buyer });
for (let k = 0; k < 8; k++) dig.update(0.03);
for (let b = 0; b < 2; b++) { // buy 2 per visit via the real pull+BUY path
cv.dispatchEvent(new PointerEvent('pointerdown', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
cv.dispatchEvent(new PointerEvent('pointerup', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
for (let k = 0; k < 4; k++) dig.update(0.03);
const btn = document.querySelector('.pcdg-panel button');
if (btn && !btn.disabled) { const before = richWallet.count(); btn.click(); if (richWallet.count() > before) bought++; }
for (let k = 0; k < 4; k++) dig.update(0.03);
}
renderer.render(dig.scene, dig.camera); dig.close();
}
renderer.render(scene, camera);
return { rooms: N, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
}
// ── loop ───────────────────────────────────────────────────────────────────────
@ -375,7 +419,7 @@ function frame() {
// expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
DIG_ON, digSoak, openDigOn, binUnderAim, get dig() { return dig; } };
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter };
rebuild();
frame();

View File

@ -30,6 +30,8 @@ const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset
const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge
const IMPOSTOR_MAX = 220; // instanced billboards ceiling
const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night
const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this
const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent)
// 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.
@ -116,6 +118,10 @@ export class CitizenSim {
this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling
this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block)
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close] }] door points
this.patronageOn = true;
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._dropKeys = []; // scratch: chunks to drop this frame
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
this.timeOfDay = 0.5; // noon
@ -240,6 +246,7 @@ export class CitizenSim {
edge, forward: idn.forward, s: idn.sFrac * this.edges[edge].len, loiter: 0,
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
};
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);
@ -316,6 +323,62 @@ export class CitizenSim {
this.debugTiers = !!on;
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
}
// ---- R8 patronage + weather ----
// shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only).
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; }
setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; }
_openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; }
// rain thins the crowd (~4060%), overcast a little; clear = v1. Applied to each chunk's active count.
_weatherDensityMult() {
const w = this.weather;
if (w.state === 'rain') return 0.6 - 0.2 * w.intensity; // 0.40.6
if (w.state === 'overcast') return 1 - 0.15 * w.intensity; // ~0.9
return 1;
}
_speedMult() { return this.weather.state === 'rain' ? 1.0 + 0.18 * this.weather.intensity : 1; }
// chance (per in-range check) a ped ducks into an open shop. Rain → shelter-seeking. As the streets
// empty (night), bump it so the few peds out concentrate at the one open shop (the video shop draws
// the night crowd instead of a dead street with a lone visitor).
_patronChance() {
let base = this.weather.state === 'rain' ? 0.22 + 0.25 * this.weather.intensity : 0.16;
const day = densityAt(this.timeOfDay);
if (day < 0.35) base = Math.min(0.6, base + (0.35 - day) * 1.3);
return base;
}
// nearest OPEN shop the ped is currently passing (current chunk, ≤PATRON_RANGE), else null.
// Hours-aware — at night only the openLate video shop qualifies, so its block draws the night crowd.
_nearestOpenShop(c) {
if (!this.shopsByChunk) return null;
const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z));
if (!list || !list.length) return null;
let best = null, bd = PATRON_RANGE;
for (const s of list) {
if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z);
if (d < bd) { bd = d; best = s; }
}
return best;
}
_beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
c.patronTarget = shop; c.patron = 'going';
}
_enter(c) {
c.patron = 'inside';
c.patronTimer = 5 + c.patronRng() * 15; // seeded dwell 520s
c.x = c.patronTarget.x; c.z = c.patronTarget.z; // parked at the door while inside (hidden)
}
_emerge(c) {
const w = c._savedWalk;
if (w) { c.edge = w.edge; c.forward = w.forward; c.s = w.s; }
c.patron = null; c.patronTarget = null; c._savedWalk = null;
this._placeOnLane(c); // back on the footpath, resumes the walk
}
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
// holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes.
identitySignature() {
@ -388,11 +451,23 @@ export class CitizenSim {
}
_advance(c, dt) {
// ---- R8 patronage state machine (streamed citizens only; c.patron is undefined for v1) ----
if (c.patron === 'inside') { c.patronTimer -= dt; if (c.patronTimer <= 0) this._emerge(c); return; }
if (c.patron === 'going') { // steer straight to the shop door
const dx = c.patronTarget.x - c.x, dz = c.patronTarget.z - c.z;
const dist = Math.hypot(dx, dz);
if (dist < 1.8) { this._enter(c); return; }
const step = c.speed * this._speedMult() * dt;
c.x += (dx / dist) * step; c.z += (dz / dist) * step;
c.facing = Math.atan2(-dx / dist, -dz / dist);
return;
}
if (c.loiter > 0) { c.loiter -= dt; return; }
const e = this.edges[c.edge];
c.s += c.speed * dt;
const adv = c.speed * this._speedMult() * dt;
c.s += adv;
if (c.s >= e.len) {
// arrived at the far node — pick the next edge (seeded), maybe loiter
// arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter
const node = c.forward > 0 ? e.b : e.a;
const inc = this.adj.get(node);
let choices = inc.filter(i => i !== c.edge);
@ -405,6 +480,17 @@ export class CitizenSim {
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
}
this._placeOnLane(c);
// patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to
// duck in (proximity-gated so the roll isn't wasted mid-block — this is what makes the sparse
// night crowd reliably cluster at the open-late video shop).
if (this.patronageOn && this.shopsByChunk && c.patronRng) {
c._patronDist = (c._patronDist || 0) + adv;
if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0;
const shop = this._nearestOpenShop(c);
if (shop && c.patronRng() < this._patronChance()) this._beginVisit(c, shop);
}
}
}
// ---- actor lifecycle ----
@ -445,10 +531,12 @@ export class CitizenSim {
const density = densityAt(this.timeOfDay);
if (this.streamMode) {
const haveLively = this._livelyChunks.size > 0;
const wm = this._weatherDensityMult(); // rain/overcast thins the crowd on top of the day curve
for (const [key, arr] of this.chunkRosters) {
// hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block)
// keep a night floor so that street never goes dead while its shop is still open.
const d = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const base = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const d = base * wm;
const activeCount = Math.round(arr.length * d);
for (let i = 0; i < arr.length; i++) {
if (i < activeCount) list.push(arr[i]);
@ -491,6 +579,7 @@ export class CitizenSim {
// choose the near set: hysteresis eligibility, then nearest-first up to the cap
const eligible = [];
for (const c of cand) {
if (c.patron === 'inside') continue; // ducked into a shop → not rendered
const wasNear = c.tier === 'near';
if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c);
}
@ -502,7 +591,8 @@ export class CitizenSim {
let newRigs = 0;
for (const c of cand) {
let want;
if (nearSet.has(c)) want = 'near';
if (c.patron === 'inside') want = 'far'; // inside a shop → hidden (actor released below)
else if (nearSet.has(c)) want = 'near';
else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid';
else want = 'far';

View File

@ -9,15 +9,16 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js';
export { shopName, townName } from './names.js';
export { generatePlanOSM } from './plan_osm.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.js';
// planSource selector (round 6). Default 'synthetic' → the byte-identical golden-hash generator;
// 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm to the `source` arg here so the
// shell bootstrap picks the producer without BF caring which one ran. Unknown source → synthetic.
// planSource selector (round 6; multi-town round 8). Default 'synthetic' → the byte-identical
// golden-hash generator; 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm (+ optional
// &town=<key>) to these args so the shell bootstrap picks the producer/town without BF caring which
// ran. Unknown source → synthetic; unknown town → the default town.
import { generatePlan as _synth } from './plan.js';
import { generatePlanOSM as _osm } from './plan_osm.js';
export function generatePlanFor(seed, source = 'synthetic') {
return source === 'osm' ? _osm(seed) : _synth(seed);
export function generatePlanFor(seed, source = 'synthetic', opts = {}) {
return source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed);
}
// Convenience default so `citygen.default` also resolves to the (synthetic) generator.

View File

@ -1,110 +1,141 @@
// PROCITY OSM plan-source fixture — Lane A (round 6). REAL secondhand/charity/music/book/
// toy/antique shops, one compact inner-Melbourne cluster, extracted from thriftgod city_source.json
// (an Overpass cache; committed here so the game makes ZERO network calls — CITY_SPEC law).
// Generated deterministically (sorted by OSM node id). Checked in, hand-editable, byte-stable.
// PROCITY OSM plan-source fixtures — Lane A. REAL AU secondhand/charity/music/book/toy/antique
// shops extracted deterministically from thriftgod city_source.json (an Overpass cache), committed
// here so the game makes ZERO network calls (CITY_SPEC law). Each town is one compact real cluster.
//
// Each shop: { name (real), type (PROCITY registry id), lat, lon, suburb }. The OSM importer
// (plan_osm.js) projects lat/lon → local metres and lays a valid CityPlan; see LANE_A_NOTES.
// Multi-town (round 8): OSM_TOWNS[key] → { town, source, center{lat,lon}, shops[{id,name,type,lat,lon,suburb}] }.
// Select with ?plansrc=osm&town=<key>. To ADD a town see the recipe in docs/LANES/LANE_A_NOTES.md.
export const OSM_FIXTURE = {
town: "Melbourne",
source: "thriftgod/city_source.json (OSM Overpass cache, inner Melbourne)",
center: { lat: -37.803, lon: 144.967 },
shops: [
{"id":582845633,"name":"Jesper Junior","type":"toy","lat":-37.799331,"lon":144.97814,"suburb":""},
{"id":589473054,"name":"Central Catholic Bookshop","type":"book","lat":-37.811707,"lon":144.962939,"suburb":"Melbourne"},
{"id":598109860,"name":"Readings","type":"book","lat":-37.798052,"lon":144.967187,"suburb":""},
{"id":612755480,"name":"The Haunted Bookshop","type":"book","lat":-37.815426,"lon":144.961705,"suburb":"Melbourne"},
{"id":612771027,"name":"Hill of Content","type":"book","lat":-37.81148,"lon":144.97199,"suburb":""},
{"id":632244266,"name":"Goldmine Records","type":"record","lat":-37.791222,"lon":144.97579,"suburb":""},
{"id":635708269,"name":"Dymocks","type":"book","lat":-37.815462,"lon":144.96572,"suburb":"Melbourne"},
{"id":642325730,"name":"The Paperback","type":"book","lat":-37.811675,"lon":144.971313,"suburb":"Melbourne"},
{"id":663937314,"name":"Pop Mart","type":"toy","lat":-37.814031,"lon":144.964538,"suburb":""},
{"id":699437448,"name":"Big Dreams","type":"toy","lat":-37.799152,"lon":144.984398,"suburb":""},
{"id":699461941,"name":"Red Cross Shop","type":"opshop","lat":-37.799348,"lon":144.984349,"suburb":""},
{"id":803089441,"name":"Kay Craddock Antiquarian Bookseller","type":"book","lat":-37.814775,"lon":144.968242,"suburb":""},
{"id":990674449,"name":"Happy Valley","type":"book","lat":-37.800458,"lon":144.98416,"suburb":"Collingwood"},
{"id":990676685,"name":"St Mark's Recycled","type":"opshop","lat":-37.80039,"lon":144.984176,"suburb":""},
{"id":990860527,"name":"Villain","type":"toy","lat":-37.798355,"lon":144.977178,"suburb":""},
{"id":997422797,"name":"Alice & Co.","type":"book","lat":-37.80279,"lon":144.977551,"suburb":""},
{"id":1070219314,"name":"SOUNDMERCH","type":"record","lat":-37.805386,"lon":144.984819,"suburb":"Collingwood"},
{"id":1072618081,"name":"White Rabbit Record Bar","type":"record","lat":-37.793645,"lon":144.93006,"suburb":""},
{"id":1074085246,"name":"Northside Records","type":"record","lat":-37.80642,"lon":144.982482,"suburb":""},
{"id":1104149817,"name":"Hot Potatoes Super $2 Shop","type":"opshop","lat":-37.802722,"lon":144.983783,"suburb":""},
{"id":1379457331,"name":"Used Pty Ltd - The Stage","type":"pawn","lat":-37.799431,"lon":144.989961,"suburb":""},
{"id":1671557044,"name":"Other Goods & Services","type":"opshop","lat":-37.7742,"lon":144.971388,"suburb":"Brunswick East"},
{"id":2011953059,"name":"AllBooks4Less","type":"book","lat":-37.8105,"lon":144.966688,"suburb":""},
{"id":2056959150,"name":"Tabernacle Games","type":"toy","lat":-37.78028,"lon":144.986264,"suburb":"Fitzroy North"},
{"id":2090596140,"name":"Fitzroy Toy Library","type":"toy","lat":-37.803392,"lon":144.980385,"suburb":""},
{"id":2105173577,"name":"Book Grocer","type":"book","lat":-37.815225,"lon":144.960413,"suburb":""},
{"id":2108114139,"name":"Used Pty Ltd","type":"pawn","lat":-37.799008,"lon":144.983676,"suburb":""},
{"id":2139774534,"name":"Plug Seven Records","type":"record","lat":-37.799668,"lon":144.984007,"suburb":"Fitzroy"},
{"id":2139774821,"name":"Smith Street Bazaar","type":"pawn","lat":-37.799836,"lon":144.983957,"suburb":"Fitzroy"},
{"id":2160049170,"name":"New International Book Shop","type":"book","lat":-37.806832,"lon":144.966259,"suburb":""},
{"id":2164686254,"name":"Embiggen Books","type":"book","lat":-37.810103,"lon":144.966151,"suburb":"Melbourne"},
{"id":2402316117,"name":"Already Read Bookshop","type":"book","lat":-37.783229,"lon":144.982101,"suburb":"Fitzroy North"},
{"id":2677671238,"name":"Etruria Antiques Gallery","type":"pawn","lat":-37.799412,"lon":144.989808,"suburb":"Collingwood"},
{"id":2688014250,"name":"The History","type":"pawn","lat":-37.802586,"lon":144.986922,"suburb":"Collingwood"},
{"id":3412569256,"name":"Flight Experience","type":"video","lat":-37.810466,"lon":144.965145,"suburb":"Melbourne"},
{"id":3418896122,"name":"Social Club Books","type":"book","lat":-37.796822,"lon":144.985198,"suburb":""},
{"id":3788338576,"name":"Hylands Bookshop","type":"book","lat":-37.811456,"lon":144.966498,"suburb":"Melbourne"},
{"id":3949641000,"name":"Goldmine Records","type":"record","lat":-37.779472,"lon":144.986947,"suburb":"Fitzroy North"},
{"id":3949687621,"name":"Poison City","type":"record","lat":-37.795207,"lon":144.979159,"suburb":"Fitzroy"},
{"id":4972218985,"name":"Retro Wolf","type":"toy","lat":-37.794166,"lon":144.949683,"suburb":""},
{"id":5406503614,"name":"Readings","type":"book","lat":-37.809718,"lon":144.964589,"suburb":""},
{"id":6228833108,"name":"Gregory's Antiques and Lights","type":"pawn","lat":-37.785753,"lon":144.981522,"suburb":""},
{"id":6237887803,"name":"Readings","type":"book","lat":-37.809443,"lon":144.966332,"suburb":""},
{"id":6507557579,"name":"Record Paradise","type":"record","lat":-37.773631,"lon":144.960868,"suburb":"Brunswick"},
{"id":6649578474,"name":"Readings Kids","type":"book","lat":-37.79792,"lon":144.967205,"suburb":""},
{"id":6680206192,"name":"Brotherhood of St Laurence Op Shop","type":"opshop","lat":-37.773636,"lon":144.971215,"suburb":""},
{"id":6687064305,"name":"Vinnies","type":"opshop","lat":-37.774974,"lon":144.960631,"suburb":""},
{"id":6702087933,"name":"Legacy","type":"opshop","lat":-37.811229,"lon":144.964332,"suburb":""},
{"id":7202622331,"name":"Retro Depot","type":"opshop","lat":-37.782348,"lon":144.977928,"suburb":""},
{"id":7273662704,"name":"Bookshop","type":"book","lat":-37.774882,"lon":144.960955,"suburb":""},
{"id":7391506777,"name":"Ace Antiques","type":"pawn","lat":-37.803155,"lon":144.94892,"suburb":""},
{"id":9097507475,"name":"Vinyl Space","type":"record","lat":-37.799535,"lon":144.988755,"suburb":""},
{"id":9336124056,"name":"World Food Books","type":"book","lat":-37.816635,"lon":144.966746,"suburb":""},
{"id":10048390715,"name":"EB Games","type":"video","lat":-37.816891,"lon":144.964598,"suburb":""},
{"id":10598284186,"name":"Mui Karaoke Rooms","type":"record","lat":-37.811449,"lon":144.96435,"suburb":""},
{"id":10611335971,"name":"Mind Games","type":"toy","lat":-37.814339,"lon":144.965791,"suburb":""},
{"id":10713580252,"name":"City Of Vintage","type":"opshop","lat":-37.773999,"lon":144.960805,"suburb":""},
{"id":10729013736,"name":"Epic Vintage","type":"opshop","lat":-37.776826,"lon":144.960351,"suburb":""},
{"id":11017168508,"name":"Mary Martin Bookshop","type":"book","lat":-37.807847,"lon":144.957651,"suburb":""},
{"id":11031787378,"name":"The Book Grocer","type":"book","lat":-37.815477,"lon":144.9514,"suburb":""},
{"id":11118777232,"name":"Elite Office Machines Co.","type":"pawn","lat":-37.797065,"lon":144.968274,"suburb":""},
{"id":11129101658,"name":"F.J. Rocca","type":"pawn","lat":-37.797543,"lon":144.969696,"suburb":""},
{"id":11133299408,"name":"Brotherhood of St Lawrence","type":"opshop","lat":-37.814546,"lon":144.964109,"suburb":""},
{"id":11135649030,"name":"Warhammer","type":"video","lat":-37.813084,"lon":144.970498,"suburb":""},
{"id":11140063962,"name":"The Salvation Army","type":"opshop","lat":-37.817091,"lon":144.953989,"suburb":""},
{"id":11162131777,"name":"Mary Martin Bookshop","type":"book","lat":-37.807745,"lon":144.95666,"suburb":""},
{"id":11185870784,"name":"EB Games","type":"video","lat":-37.812749,"lon":144.937428,"suburb":"Docklands"},
{"id":11244890775,"name":"North Melbourne Books","type":"book","lat":-37.802823,"lon":144.949112,"suburb":""},
{"id":11287631726,"name":"Plenty of Games","type":"video","lat":-37.81572,"lon":144.964811,"suburb":""},
{"id":11287631727,"name":"The Gaming Verse","type":"video","lat":-37.817709,"lon":144.961688,"suburb":""},
{"id":11555994364,"name":"Strange World Records","type":"record","lat":-37.77301,"lon":144.971585,"suburb":""},
{"id":11660848323,"name":"Melbourne Book Market","type":"book","lat":-37.806808,"lon":144.956428,"suburb":""},
{"id":11957731033,"name":"Archie Brothers STRIKE","type":"video","lat":-37.811779,"lon":144.936627,"suburb":""},
{"id":12175749075,"name":"I Heart Snacks","type":"video","lat":-37.811059,"lon":144.963269,"suburb":""},
{"id":12175767221,"name":"Pop Mart","type":"toy","lat":-37.810856,"lon":144.963047,"suburb":""},
{"id":12175767225,"name":"Heydays","type":"toy","lat":-37.810779,"lon":144.963015,"suburb":""},
{"id":12175767227,"name":"I Am","type":"toy","lat":-37.810815,"lon":144.963167,"suburb":""},
{"id":12175779440,"name":"Merry Seasons","type":"toy","lat":-37.810464,"lon":144.962038,"suburb":""},
{"id":12320608201,"name":"Amplify Bookstore","type":"book","lat":-37.806902,"lon":144.955544,"suburb":""},
{"id":12418141339,"name":"The Little Bookroom","type":"book","lat":-37.771822,"lon":144.978527,"suburb":""},
{"id":12432795349,"name":"One Stop Anime","type":"video","lat":-37.815261,"lon":144.965727,"suburb":""},
{"id":12437671918,"name":"Mooii","type":"toy","lat":-37.810519,"lon":144.96303,"suburb":""},
{"id":12511070907,"name":"Rotart Charity Books","type":"opshop","lat":-37.812736,"lon":144.938173,"suburb":""},
{"id":12511070911,"name":"Books Gifts Station","type":"book","lat":-37.813078,"lon":144.938613,"suburb":""},
{"id":12511098032,"name":"Popclutcha","type":"video","lat":-37.813266,"lon":144.93777,"suburb":""},
{"id":12511098647,"name":"Toyworld","type":"toy","lat":-37.813905,"lon":144.938006,"suburb":""},
{"id":12611273300,"name":"Dungeon of Magic","type":"video","lat":-37.814504,"lon":144.964088,"suburb":""},
{"id":12611672199,"name":"All Star Comics","type":"book","lat":-37.817545,"lon":144.961612,"suburb":"Melbourne"},
{"id":12686552001,"name":"City Basement Books","type":"book","lat":-37.818532,"lon":144.963194,"suburb":""},
{"id":12686552101,"name":"Metropolis Bookshop","type":"book","lat":-37.812039,"lon":144.965298,"suburb":""},
{"id":12695960455,"name":"Bookshop by Uro","type":"book","lat":-37.799835,"lon":144.985564,"suburb":""},
{"id":13075851874,"name":"Licorice Pie","type":"record","lat":-37.799856,"lon":144.985715,"suburb":""},
{"id":13206134926,"name":"Sticky Institute","type":"book","lat":-37.806746,"lon":144.960086,"suburb":""},
{"id":13229707300,"name":"Readings","type":"book","lat":-37.81289,"lon":144.963426,"suburb":""},
{"id":13967406755,"name":"Law Bookshop","type":"book","lat":-37.815692,"lon":144.958884,"suburb":"Melbourne"}
],
export const OSM_TOWNS = {
melbourne: {
town: "Melbourne",
source: "thriftgod/city_source.json (OSM Overpass cache, inner Melbourne)",
center: {"lat":-37.803,"lon":144.967},
shops: [
{"id":582845633,"name":"Jesper Junior","type":"toy","lat":-37.799331,"lon":144.97814,"suburb":""},
{"id":589473054,"name":"Central Catholic Bookshop","type":"book","lat":-37.811707,"lon":144.962939,"suburb":"Melbourne"},
{"id":598109860,"name":"Readings","type":"book","lat":-37.798052,"lon":144.967187,"suburb":""},
{"id":612755480,"name":"The Haunted Bookshop","type":"book","lat":-37.815426,"lon":144.961705,"suburb":"Melbourne"},
{"id":612771027,"name":"Hill of Content","type":"book","lat":-37.81148,"lon":144.97199,"suburb":""},
{"id":632244266,"name":"Goldmine Records","type":"record","lat":-37.791222,"lon":144.97579,"suburb":""},
{"id":635708269,"name":"Dymocks","type":"book","lat":-37.815462,"lon":144.96572,"suburb":"Melbourne"},
{"id":642325730,"name":"The Paperback","type":"book","lat":-37.811675,"lon":144.971313,"suburb":"Melbourne"},
{"id":663937314,"name":"Pop Mart","type":"toy","lat":-37.814031,"lon":144.964538,"suburb":""},
{"id":699437448,"name":"Big Dreams","type":"toy","lat":-37.799152,"lon":144.984398,"suburb":""},
{"id":699461941,"name":"Red Cross Shop","type":"opshop","lat":-37.799348,"lon":144.984349,"suburb":""},
{"id":803089441,"name":"Kay Craddock Antiquarian Bookseller","type":"book","lat":-37.814775,"lon":144.968242,"suburb":""},
{"id":990674449,"name":"Happy Valley","type":"book","lat":-37.800458,"lon":144.98416,"suburb":"Collingwood"},
{"id":990676685,"name":"St Mark's Recycled","type":"opshop","lat":-37.80039,"lon":144.984176,"suburb":""},
{"id":990860527,"name":"Villain","type":"toy","lat":-37.798355,"lon":144.977178,"suburb":""},
{"id":997422797,"name":"Alice & Co.","type":"book","lat":-37.80279,"lon":144.977551,"suburb":""},
{"id":1070219314,"name":"SOUNDMERCH","type":"record","lat":-37.805386,"lon":144.984819,"suburb":"Collingwood"},
{"id":1072618081,"name":"White Rabbit Record Bar","type":"record","lat":-37.793645,"lon":144.93006,"suburb":""},
{"id":1074085246,"name":"Northside Records","type":"record","lat":-37.80642,"lon":144.982482,"suburb":""},
{"id":1104149817,"name":"Hot Potatoes Super $2 Shop","type":"opshop","lat":-37.802722,"lon":144.983783,"suburb":""},
{"id":1379457331,"name":"Used Pty Ltd - The Stage","type":"pawn","lat":-37.799431,"lon":144.989961,"suburb":""},
{"id":1671557044,"name":"Other Goods & Services","type":"opshop","lat":-37.7742,"lon":144.971388,"suburb":"Brunswick East"},
{"id":2011953059,"name":"AllBooks4Less","type":"book","lat":-37.8105,"lon":144.966688,"suburb":""},
{"id":2056959150,"name":"Tabernacle Games","type":"toy","lat":-37.78028,"lon":144.986264,"suburb":"Fitzroy North"},
{"id":2090596140,"name":"Fitzroy Toy Library","type":"toy","lat":-37.803392,"lon":144.980385,"suburb":""},
{"id":2105173577,"name":"Book Grocer","type":"book","lat":-37.815225,"lon":144.960413,"suburb":""},
{"id":2108114139,"name":"Used Pty Ltd","type":"pawn","lat":-37.799008,"lon":144.983676,"suburb":""},
{"id":2139774534,"name":"Plug Seven Records","type":"record","lat":-37.799668,"lon":144.984007,"suburb":"Fitzroy"},
{"id":2139774821,"name":"Smith Street Bazaar","type":"pawn","lat":-37.799836,"lon":144.983957,"suburb":"Fitzroy"},
{"id":2160049170,"name":"New International Book Shop","type":"book","lat":-37.806832,"lon":144.966259,"suburb":""},
{"id":2164686254,"name":"Embiggen Books","type":"book","lat":-37.810103,"lon":144.966151,"suburb":"Melbourne"},
{"id":2402316117,"name":"Already Read Bookshop","type":"book","lat":-37.783229,"lon":144.982101,"suburb":"Fitzroy North"},
{"id":2677671238,"name":"Etruria Antiques Gallery","type":"pawn","lat":-37.799412,"lon":144.989808,"suburb":"Collingwood"},
{"id":2688014250,"name":"The History","type":"pawn","lat":-37.802586,"lon":144.986922,"suburb":"Collingwood"},
{"id":3412569256,"name":"Flight Experience","type":"video","lat":-37.810466,"lon":144.965145,"suburb":"Melbourne"},
{"id":3418896122,"name":"Social Club Books","type":"book","lat":-37.796822,"lon":144.985198,"suburb":""},
{"id":3788338576,"name":"Hylands Bookshop","type":"book","lat":-37.811456,"lon":144.966498,"suburb":"Melbourne"},
{"id":3949641000,"name":"Goldmine Records","type":"record","lat":-37.779472,"lon":144.986947,"suburb":"Fitzroy North"},
{"id":3949687621,"name":"Poison City","type":"record","lat":-37.795207,"lon":144.979159,"suburb":"Fitzroy"},
{"id":4972218985,"name":"Retro Wolf","type":"toy","lat":-37.794166,"lon":144.949683,"suburb":""},
{"id":5406503614,"name":"Readings","type":"book","lat":-37.809718,"lon":144.964589,"suburb":""},
{"id":6228833108,"name":"Gregory's Antiques and Lights","type":"pawn","lat":-37.785753,"lon":144.981522,"suburb":""},
{"id":6237887803,"name":"Readings","type":"book","lat":-37.809443,"lon":144.966332,"suburb":""},
{"id":6507557579,"name":"Record Paradise","type":"record","lat":-37.773631,"lon":144.960868,"suburb":"Brunswick"},
{"id":6649578474,"name":"Readings Kids","type":"book","lat":-37.79792,"lon":144.967205,"suburb":""},
{"id":6680206192,"name":"Brotherhood of St Laurence Op Shop","type":"opshop","lat":-37.773636,"lon":144.971215,"suburb":""},
{"id":6687064305,"name":"Vinnies","type":"opshop","lat":-37.774974,"lon":144.960631,"suburb":""},
{"id":6702087933,"name":"Legacy","type":"opshop","lat":-37.811229,"lon":144.964332,"suburb":""},
{"id":7202622331,"name":"Retro Depot","type":"opshop","lat":-37.782348,"lon":144.977928,"suburb":""},
{"id":7273662704,"name":"Bookshop","type":"book","lat":-37.774882,"lon":144.960955,"suburb":""},
{"id":7391506777,"name":"Ace Antiques","type":"pawn","lat":-37.803155,"lon":144.94892,"suburb":""},
{"id":9097507475,"name":"Vinyl Space","type":"record","lat":-37.799535,"lon":144.988755,"suburb":""},
{"id":9336124056,"name":"World Food Books","type":"book","lat":-37.816635,"lon":144.966746,"suburb":""},
{"id":10048390715,"name":"EB Games","type":"video","lat":-37.816891,"lon":144.964598,"suburb":""},
{"id":10598284186,"name":"Mui Karaoke Rooms","type":"record","lat":-37.811449,"lon":144.96435,"suburb":""},
{"id":10611335971,"name":"Mind Games","type":"toy","lat":-37.814339,"lon":144.965791,"suburb":""},
{"id":10713580252,"name":"City Of Vintage","type":"opshop","lat":-37.773999,"lon":144.960805,"suburb":""},
{"id":10729013736,"name":"Epic Vintage","type":"opshop","lat":-37.776826,"lon":144.960351,"suburb":""},
{"id":11017168508,"name":"Mary Martin Bookshop","type":"book","lat":-37.807847,"lon":144.957651,"suburb":""},
{"id":11031787378,"name":"The Book Grocer","type":"book","lat":-37.815477,"lon":144.9514,"suburb":""},
{"id":11118777232,"name":"Elite Office Machines Co.","type":"pawn","lat":-37.797065,"lon":144.968274,"suburb":""},
{"id":11129101658,"name":"F.J. Rocca","type":"pawn","lat":-37.797543,"lon":144.969696,"suburb":""},
{"id":11133299408,"name":"Brotherhood of St Lawrence","type":"opshop","lat":-37.814546,"lon":144.964109,"suburb":""},
{"id":11135649030,"name":"Warhammer","type":"video","lat":-37.813084,"lon":144.970498,"suburb":""},
{"id":11140063962,"name":"The Salvation Army","type":"opshop","lat":-37.817091,"lon":144.953989,"suburb":""},
{"id":11162131777,"name":"Mary Martin Bookshop","type":"book","lat":-37.807745,"lon":144.95666,"suburb":""},
{"id":11185870784,"name":"EB Games","type":"video","lat":-37.812749,"lon":144.937428,"suburb":"Docklands"},
{"id":11244890775,"name":"North Melbourne Books","type":"book","lat":-37.802823,"lon":144.949112,"suburb":""},
{"id":11287631726,"name":"Plenty of Games","type":"video","lat":-37.81572,"lon":144.964811,"suburb":""},
{"id":11287631727,"name":"The Gaming Verse","type":"video","lat":-37.817709,"lon":144.961688,"suburb":""},
{"id":11555994364,"name":"Strange World Records","type":"record","lat":-37.77301,"lon":144.971585,"suburb":""},
{"id":11660848323,"name":"Melbourne Book Market","type":"book","lat":-37.806808,"lon":144.956428,"suburb":""},
{"id":11957731033,"name":"Archie Brothers STRIKE","type":"video","lat":-37.811779,"lon":144.936627,"suburb":""},
{"id":12175749075,"name":"I Heart Snacks","type":"video","lat":-37.811059,"lon":144.963269,"suburb":""},
{"id":12175767221,"name":"Pop Mart","type":"toy","lat":-37.810856,"lon":144.963047,"suburb":""},
{"id":12175767225,"name":"Heydays","type":"toy","lat":-37.810779,"lon":144.963015,"suburb":""},
{"id":12175767227,"name":"I Am","type":"toy","lat":-37.810815,"lon":144.963167,"suburb":""},
{"id":12175779440,"name":"Merry Seasons","type":"toy","lat":-37.810464,"lon":144.962038,"suburb":""},
{"id":12320608201,"name":"Amplify Bookstore","type":"book","lat":-37.806902,"lon":144.955544,"suburb":""},
{"id":12418141339,"name":"The Little Bookroom","type":"book","lat":-37.771822,"lon":144.978527,"suburb":""},
{"id":12432795349,"name":"One Stop Anime","type":"video","lat":-37.815261,"lon":144.965727,"suburb":""},
{"id":12437671918,"name":"Mooii","type":"toy","lat":-37.810519,"lon":144.96303,"suburb":""},
{"id":12511070907,"name":"Rotart Charity Books","type":"opshop","lat":-37.812736,"lon":144.938173,"suburb":""},
{"id":12511070911,"name":"Books Gifts Station","type":"book","lat":-37.813078,"lon":144.938613,"suburb":""},
{"id":12511098032,"name":"Popclutcha","type":"video","lat":-37.813266,"lon":144.93777,"suburb":""},
{"id":12511098647,"name":"Toyworld","type":"toy","lat":-37.813905,"lon":144.938006,"suburb":""},
{"id":12611273300,"name":"Dungeon of Magic","type":"video","lat":-37.814504,"lon":144.964088,"suburb":""},
{"id":12611672199,"name":"All Star Comics","type":"book","lat":-37.817545,"lon":144.961612,"suburb":"Melbourne"},
{"id":12686552001,"name":"City Basement Books","type":"book","lat":-37.818532,"lon":144.963194,"suburb":""},
{"id":12686552101,"name":"Metropolis Bookshop","type":"book","lat":-37.812039,"lon":144.965298,"suburb":""},
{"id":12695960455,"name":"Bookshop by Uro","type":"book","lat":-37.799835,"lon":144.985564,"suburb":""},
{"id":13075851874,"name":"Licorice Pie","type":"record","lat":-37.799856,"lon":144.985715,"suburb":""},
{"id":13206134926,"name":"Sticky Institute","type":"book","lat":-37.806746,"lon":144.960086,"suburb":""},
{"id":13229707300,"name":"Readings","type":"book","lat":-37.81289,"lon":144.963426,"suburb":""},
{"id":13967406755,"name":"Law Bookshop","type":"book","lat":-37.815692,"lon":144.958884,"suburb":"Melbourne"}
],
},
katoomba: {
town: "Katoomba",
source: "thriftgod/city_source.json (OSM Overpass cache, Katoomba / Blue Mountains)",
center: {"lat":-33.714,"lon":150.312},
shops: [
{"id":4047416904,"name":"Anglicare","type":"opshop","lat":-33.716738,"lon":150.310919,"suburb":""},
{"id":4047416906,"name":"Cat Defence Op Shop","type":"opshop","lat":-33.717477,"lon":150.31186,"suburb":""},
{"id":4047416910,"name":"Red Shield Family Store","type":"opshop","lat":-33.717291,"lon":150.309527,"suburb":""},
{"id":4047416911,"name":"Steven Walter","type":"opshop","lat":-33.710934,"lon":150.309931,"suburb":""},
{"id":4047416913,"name":"Vinnies","type":"opshop","lat":-33.717464,"lon":150.311782,"suburb":""},
{"id":4958417278,"name":"Anglicare Op Shops","type":"opshop","lat":-33.714949,"lon":150.311257,"suburb":""},
{"id":4958417724,"name":"Katoomba Music","type":"record","lat":-33.715074,"lon":150.311532,"suburb":""},
{"id":5237464912,"name":"Katoomba Vintage Emporium","type":"pawn","lat":-33.713729,"lon":150.311403,"suburb":""},
{"id":6938379064,"name":"Katoomba Antique Centre","type":"pawn","lat":-33.712206,"lon":150.310654,"suburb":""},
{"id":6938414604,"name":"Salvos","type":"opshop","lat":-33.714654,"lon":150.311316,"suburb":"Katoomba"},
{"id":6938414612,"name":"EB Games","type":"video","lat":-33.714851,"lon":150.31127,"suburb":""},
{"id":6938921533,"name":"MacArthur's Arcade","type":"pawn","lat":-33.71389,"lon":150.311766,"suburb":""},
{"id":6938921534,"name":"Mr Pickwick's Bookshop","type":"book","lat":-33.713915,"lon":150.311761,"suburb":""},
{"id":7040012373,"name":"Black Antiques","type":"pawn","lat":-33.709806,"lon":150.309278,"suburb":""},
{"id":7110813637,"name":"Steven Walter Children's Cancer Foundation","type":"opshop","lat":-33.712811,"lon":150.321286,"suburb":"Katoomba"},
{"id":9231634025,"name":"Little Lost Bookshop","type":"book","lat":-33.716393,"lon":150.310835,"suburb":""},
{"id":9577859712,"name":"B'COS Agora Second Hand Bookshop","type":"book","lat":-33.714295,"lon":150.311173,"suburb":""},
{"id":9791871398,"name":"The Velvet Fog","type":"record","lat":-33.712544,"lon":150.311566,"suburb":""},
{"id":11242974232,"name":"Sanctus Catholic Books and Gift Shop","type":"book","lat":-33.715718,"lon":150.31095,"suburb":""}
],
},
};
export const DEFAULT_TOWN = "melbourne";
// Back-compat single-town export (pre-round-8 callers).
export const OSM_FIXTURE = OSM_TOWNS[DEFAULT_TOWN];

View File

@ -13,7 +13,7 @@
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { OSM_FIXTURE } from './osm_fixture.js';
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
@ -35,9 +35,12 @@ function signOf(name) {
return (s || String(name).slice(0, 15)).toUpperCase();
}
export function generatePlanOSM(citySeed) {
export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0);
const fx = OSM_FIXTURE;
const fx = OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN];
// Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data
// "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan.
const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' };
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
@ -55,6 +58,7 @@ export function generatePlanOSM(citySeed) {
function addShop(lotId, fixtureShop) {
const id = sid++;
const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop';
if (type !== fixtureShop.type) norm.typesRemapped++; // unknown OSM kind → opshop (normalization)
const reg = SHOP_TYPES[type];
const seed = seedFor(citySeed, 'osmshop', fixtureShop.id);
const sr = mulberry32(seed);
@ -64,6 +68,7 @@ export function generatePlanOSM(citySeed) {
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name),
seed, facadeSkin, storeys, hours: [open, close] });
@ -120,6 +125,7 @@ export function generatePlanOSM(citySeed) {
if (shops.length) {
const videos = shops.filter(s => s.type === 'video');
const pool = videos.length ? videos : shops.filter(s => s.type !== 'stall');
norm.openLate = videos.length ? 'video' : (pool.length ? pool[0].type + ' (no video in town)' : 'none');
if (pool.length) {
const lr = rng(citySeed, 'osm-openlate', 0);
const chosen = pick(lr, pool);
@ -144,11 +150,13 @@ export function generatePlanOSM(citySeed) {
const M = 40;
const size = { w: Math.max(256, Math.ceil((maxx - minx) + 2 * M)), d: Math.max(256, Math.ceil((maxz - minz) + 2 * M)) };
if (opts.report) Object.assign(opts.report, norm); // caller-supplied log sink (selfcheck prints it)
return {
version: 1,
citySeed,
source: 'osm',
name: fx.town || 'Melbourne',
source: 'osm', // town key stays OFF the plan (keeps the Melbourne golden frozen); the
name: fx.town || 'Melbourne', // display name + the selector arg convey which town this is.
size,
districts,
streets: { nodes, edges },
@ -157,3 +165,6 @@ export function generatePlanOSM(citySeed) {
shops,
};
}
// The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration).
export function osmTownKeys() { return Object.keys(OSM_TOWNS); }

View File

@ -8,7 +8,7 @@ import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM } from './plan_osm.js';
import { generatePlanOSM, osmTownKeys } from './plan_osm.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
@ -30,6 +30,113 @@ function nearestOnSeg(px, pz, ax, az, bx, bz) {
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
return [ax + dx * t, az + dz * t];
}
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
// The FULL structural invariant suite for ANY CityPlan — synthetic OR osm (round-8 parity: both
// sources get identical coverage). `label` prefixes each check. Synthetic-only "brief" checks
// (market/arcade/dept/milkbar) live at the call site, guarded, since a real town has none.
function structuralSuite(plan, label) {
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
const blockIds = new Set(plan.blocks.map(b => b.id));
const lotIds = new Set(plan.lots.map(l => l.id));
const districtIds = new Set(plan.districts.map(d => d.id));
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `${label}: node coords finite`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `${label}: lot numbers finite`);
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `${label}: block poly finite`);
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `${label}: every edge references real nodes`);
ok(plan.blocks.every(b => districtIds.has(b.district)), `${label}: every block in a real district`);
ok(plan.lots.every(l => blockIds.has(l.block)), `${label}: every lot in a real block`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `${label}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `${label}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `${label}: every shop has a real lot`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `${label}: every shop has a known type`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `${label}: every facade is in its type's pool`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `${label}: every shop has sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign), `${label}: every shop is named`);
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `${label}: no unresolved tokens / undefined in names`);
ok(plan.shops.every(sh => {
const [mn, mx] = SHOP_TYPES[sh.type].storeys; const pmax = mx >= 2 ? Math.min(3, mx + 1) : mx;
return sh.storeys >= mn && sh.storeys <= pmax;
}), `${label}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey never boosted)`);
const lotShopCounts = {};
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
let backwards = null;
for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; }
}
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
let inBlock = null;
for (const arr of Object.values(byBlock)) {
const cq = arr.map(lotCorners);
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
if (obbOverlap(cq[i], cq[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
if (inBlock) break;
}
ok(!inBlock, `${label}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
const solids = plan.lots.filter(l => SOLID.has(l.use));
const cs = solids.map(lotCorners);
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
let crossBlock = null;
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
if (solids[i].block === solids[j].block) continue;
const A = aabb[i], B = aabb[j];
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue;
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
}
ok(!crossBlock, `${label}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
const idx = chunkIndex(plan);
const covered = new Set();
for (const cellB of Object.values(idx.chunks)) {
for (const id of cellB.lots) covered.add(id);
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)), `${label}: chunk buckets reference real ids`);
}
ok(plan.lots.every(l => covered.has(l.id)), `${label}: chunkIndex covers every lot`);
const cell = v => Math.floor(v / CHUNK);
let edgeGap = null;
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw;
const steps = Math.max(1, Math.ceil(len / 2));
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) {
for (let k = 0; k <= steps && !edgeGap; k++) {
const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f;
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)];
}
if (edgeGap) break;
}
if (edgeGap) break;
}
ok(!edgeGap, `${label}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `${label}: JSON round-trip lossless`);
// exactly one late-night landmark; openLate field ⟺ hours[1]≥22 (same shop); it's the video (or,
// for a town with no video at all, the fallback landmark). Holds for synthetic AND osm.
const late = plan.shops.filter(sh => sh.openLate);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1, `${label}: exactly one open-late shop (${late.length})`);
ok(ge22.length === 1, `${label}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `${label}: openLate field ⟺ hours[1]≥22 (same shop)`);
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `${label}: open-late shop closes ≥22:00 and isn't a stall`);
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `${label}: the open-late shop is the video rental (or fallback if no video)`);
}
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)');
@ -56,191 +163,42 @@ for (const s of SEEDS) {
ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`);
}
// ── 3. structural invariants over every seed ────────────────────────────────────────
section('structural invariants');
// ── 3. structural invariants — SYNTHETIC source (full suite) + synthetic-only brief checks ──
section('structural invariants — synthetic');
const facadeSet = new Set();
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
for (const s of SEEDS) {
const plan = generatePlan(s);
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
const blockIds = new Set(plan.blocks.map(b => b.id));
const lotIds = new Set(plan.lots.map(l => l.id));
const districtIds = new Set(plan.districts.map(d => d.id));
// every number finite (JSON would silently turn NaN/Infinity into null)
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `seed ${s}: node coords finite`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `seed ${s}: lot numbers finite`);
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `seed ${s}: block poly finite`);
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `seed ${s}: every edge references real nodes`);
ok(plan.blocks.every(b => districtIds.has(b.district)), `seed ${s}: every block in a real district`);
ok(plan.lots.every(l => blockIds.has(l.block)), `seed ${s}: every lot in a real block`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `seed ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `seed ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `seed ${s}: every shop has a real lot`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `seed ${s}: every shop has a known type`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `seed ${s}: every facade is in its type's pool`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `seed ${s}: every shop has sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign), `seed ${s}: every shop is named`);
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `seed ${s}: no unresolved tokens / undefined in names`);
// STOREYS strictly within the registry range, corner-boost included (Lane F finding #5 class): the
// occasional 3-storey corner anchor adds ≤1 storey capped at 3, and ONLY for tall-capable types
// (registry max ≥ 2) — so single-storey types (video/milkbar/stall, [1,1]) must NEVER exceed 1.
// (The old `<= Math.max(mx,3)` was too lax — it would have passed a video at 3 storeys.)
ok(plan.shops.every(sh => {
const [mn, mx] = SHOP_TYPES[sh.type].storeys;
const pmax = mx >= 2 ? Math.min(3, mx + 1) : mx;
return sh.storeys >= mn && sh.storeys <= pmax;
}), `seed ${s}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey types never boosted)`);
// one lot ↔ at most one shop
const lotShopCounts = {};
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
ok(Object.values(lotShopCounts).every(c => c === 1), `seed ${s}: no lot has two shops`);
// a shop only ever sits on a solid lot use
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `seed ${s}: every shop sits on a solid lot`);
// FACING: every lot's facade normal points at (not away from) its frontEdge (the review's D1 class)
let backwards = null;
for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge);
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z);
const tox = nx - l.x, toz = nz - l.z; // vector from lot to its street
const [fx, fz] = facing(l);
if (tox * fx + toz * fz < -0.01) { backwards = l.id; break; } // facing away from the street it fronts
}
ok(backwards === null, `seed ${s}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
// OVERLAP within a block (all lots) — strips are sequential ⇒ disjoint by construction
const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
let inBlock = null;
for (const arr of Object.values(byBlock)) {
const cs = arr.map(lotCorners);
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
if (obbOverlap(cs[i], cs[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
if (inBlock) break;
}
ok(!inBlock, `seed ${s}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
// OVERLAP across blocks (building lots) — the corner-deconfliction guarantee (review D2)
const solids = plan.lots.filter(l => SOLID.has(l.use));
const cs = solids.map(lotCorners);
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
let crossBlock = null;
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
if (solids[i].block === solids[j].block) continue;
const A = aabb[i], B = aabb[j];
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue; // AABB reject
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
}
ok(!crossBlock, `seed ${s}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
// CHUNKINDEX: covers every lot; buckets reference real ids
const idx = chunkIndex(plan);
const covered = new Set();
for (const cellB of Object.values(idx.chunks)) {
for (const id of cellB.lots) covered.add(id);
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)),
`seed ${s}: chunk buckets reference real ids`);
}
ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`);
// CHUNKINDEX corridor coverage: every chunk the road CORRIDOR passes through lists the edge — not
// just the centreline. Samples densely along the edge AND across the ±width/2 verge band (incl.
// offsets BETWEEN the chunkIndex rails, so this genuinely tests band interior, not just the rails).
// Guards the Lane B furniture-drop class: verge props must never fall in a chunk that omits their edge.
const cell = v => Math.floor(v / CHUNK);
let edgeGap = null;
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw;
const steps = Math.max(1, Math.ceil(len / 2)); // 2m dense along-edge samples
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band
for (let k = 0; k <= steps && !edgeGap; k++) {
const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f;
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)];
}
if (edgeGap) break;
}
if (edgeGap) break;
}
ok(!edgeGap, `seed ${s}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
// JSON round-trip is lossless (and carries no stray non-schema keys)
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`);
// design brief presence
ok(plan.streets.edges.some(e => e.kind === 'main'), `seed ${s}: has a main-street spine`);
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `seed ${s}: has an arcade`);
ok(plan.districts.some(d => d.kind === 'market'), `seed ${s}: has a market district`);
ok(plan.shops.some(sh => sh.type === 'stall'), `seed ${s}: market has stalls`);
ok(plan.shops.some(sh => sh.type === 'dept'), `seed ${s}: has a department-store anchor`);
structuralSuite(plan, `syn ${s}`);
// synthetic-only brief presence (a real OSM town has none of these)
ok(plan.streets.edges.some(e => e.kind === 'main'), `syn ${s}: has a main-street spine`);
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `syn ${s}: has an arcade`);
ok(plan.districts.some(d => d.kind === 'market'), `syn ${s}: has a market district`);
ok(plan.shops.some(sh => sh.type === 'stall'), `syn ${s}: market has stalls`);
ok(plan.shops.some(sh => sh.type === 'dept'), `syn ${s}: has a department-store anchor`);
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 24 corner milk bars (${milkbars})`);
// exactly one deterministic late-night landmark, and the `openLate` field ⟺ `hours[1] >= 22`
// (Lane F's night gate may key off EITHER — they identify the same single shop).
const late = plan.shops.filter(sh => sh.openLate);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`);
ok(ge22.length === 1, `seed ${s}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `seed ${s}: openLate field ⟺ hours[1]≥22 (same shop)`);
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`);
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `seed ${s}: the open-late shop is the video rental`);
ok(milkbars >= 2 && milkbars <= 4, `syn ${s}: 24 corner milk bars (${milkbars})`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
// ── 3b. OSM plan source (?plansrc=osm): same structural invariants + its own golden ────
// The synthetic golden (0x3fa36874) is untouched above; this parameterizes the harness by source.
// Brief checks (market/arcade/dept/milkbar) are synthetic-only and deliberately NOT asserted here —
// a real-data town has no synthetic market square. Structural invariants must hold identically.
section('OSM plan source (fixture-driven, zero-network)');
const OSM_GOLDEN = { seed: 20261990, hash: 0x34cfdec0 };
// ── 3b. OSM plan-source PARITY: every town runs the identical structuralSuite + its own golden ──
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
section('OSM plan source parity (fixture-driven, zero-network)');
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510 };
const OSM_SEEDS = [20261990, 1, 42, 777];
for (const s of OSM_SEEDS) {
const plan = generatePlanOSM(s);
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const lotIds = new Set(plan.lots.map(l => l.id));
ok(plan.source === 'osm', `osm ${s}: source tagged 'osm'`);
ok(JSON.stringify(generatePlanOSM(s)) === JSON.stringify(plan), `osm ${s}: deterministic (same fixture+seed)`);
ok(plan.lots.length > 0 && plan.shops.length > 0, `osm ${s}: non-empty (${plan.shops.length} real shops)`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `osm ${s}: lot numbers finite`);
ok(plan.streets.edges.every(e => nodeById.has(e.a) && nodeById.has(e.b)), `osm ${s}: edges reference real nodes`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `osm ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `osm ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot) && SHOP_TYPES[sh.type] && SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `osm ${s}: shops valid (lot + type + facade-in-pool)`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `osm ${s}: sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign && !/[{}]|undefined/.test(sh.name + sh.sign)), `osm ${s}: every shop named (real OSM names)`);
let back = null;
for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b);
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { back = l.id; break; }
for (const town of osmTownKeys()) {
const report = {};
for (const s of OSM_SEEDS) {
const plan = generatePlanOSM(s, town, s === 20261990 ? { report } : {});
ok(plan.source === 'osm', `osm/${town} ${s}: source tagged 'osm'`);
ok(JSON.stringify(generatePlanOSM(s, town)) === JSON.stringify(plan), `osm/${town} ${s}: deterministic (same fixture+seed)`);
structuralSuite(plan, `osm/${town} ${s}`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
ok(back === null, `osm ${s}: every lot faces its frontEdge` + (back !== null ? ` (lot ${back})` : ''));
const cs = plan.lots.map(lotCorners); let ov = null;
for (let i = 0; i < plan.lots.length && !ov; i++) for (let j = i + 1; j < plan.lots.length; j++)
if (obbOverlap(cs[i], cs[j])) { ov = [plan.lots[i].id, plan.lots[j].id]; break; }
ok(!ov, `osm ${s}: no overlapping lots (within or across blocks)` + (ov ? ` (${ov})` : ''));
const idx = chunkIndex(plan); const cov = new Set();
for (const c of Object.values(idx.chunks)) for (const id of c.lots) cov.add(id);
ok(plan.lots.every(l => cov.has(l.id)), `osm ${s}: chunkIndex covers every lot`);
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `osm ${s}: JSON round-trip lossless`);
const late = plan.shops.filter(sh => sh.openLate), ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0] && late[0].type === 'video', `osm ${s}: exactly one open-late video`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
{
const hash = xmur3(JSON.stringify(generatePlanOSM(OSM_GOLDEN.seed)))() >>> 0;
ok(hash === OSM_GOLDEN.hash, `osm ${OSM_GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(OSM_GOLDEN.hash >>> 0).toString(16)}`);
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, town)))() >>> 0;
const want = OSM_GOLDENS[town];
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches 0x${((want ?? 0) >>> 0).toString(16)}`);
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────

View File

@ -145,6 +145,9 @@ function shelfLine(ctx, parent, slot, opts, r) {
const scatter = slot.scatter;
const n = Math.max(1, slot.count || 4);
const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r);
// ?stock=real: book spines / toy boxes pull real covers from the pack (shared atlas material → batched).
const src = !scatter && opts.adapter && opts.adapter(opts.shop, kind);
const pack = src && src.real ? src.pack : null;
if (scatter) { // tables / pegboards: random small goods
for (let i = 0; i < n; i++) {
@ -163,11 +166,19 @@ function shelfLine(ctx, parent, slot, opts, r) {
if (kind === 'spine') { // books / VHS: thin spines edge-out
const step = (run - 0.04) / n;
for (let i = 0; i < n; i++) {
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
const bh = clear * (0.8 + r() * 0.2);
const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent);
const x = slot.x - run / 2 + (i + 0.5) * step;
let b;
if (pack) { // real book spine: cover plane at the shelf front
b = realSleeveMesh(ctx, pack, pick(r, pack.items), step * 0.86, bh);
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
parent.add(b);
} else {
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
b = ctx.box(step * 0.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent);
b.userData.isStock = true;
}
if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
b.userData.isStock = true;
}
return;
}
@ -191,11 +202,17 @@ function shelfLine(ctx, parent, slot, opts, r) {
for (let i = 0; i < n; i++) {
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
const m = ctx.mat('#ffffff', 0.8);
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
const mesh = ctx.box(bw * 0.88, bh, bd, m,
slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
mesh.userData.isStock = true;
const x = slot.x - run / 2 + (i + 0.5) * step;
if (pack) { // real toy box: cover plane at the carton front
const b = realSleeveMesh(ctx, pack, pick(r, pack.items), bw * 0.88, bh);
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
parent.add(b);
} else {
const m = ctx.mat('#ffffff', 0.8);
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
const mesh = ctx.box(bw * 0.88, bh, bd, m, x, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
mesh.userData.isStock = true;
}
}
}

View File

@ -54,11 +54,16 @@ async function buildPack(type, idx, base, THREE) {
};
}
// The stockAdapter for ?stock=real. Returns the pack POOL for a sleeve slot; consumers (stock.binFan /
// dig.js) pick a seeded subset per bin. Non-sleeve kinds → null (fall through to procedural).
// Each pack type feeds exactly one stock slot kind so a record shop's pack never lands on a bric-a-brac
// shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves).
const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box' };
// The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers
// (stock.binFan / stock.shelfLine / dig.js) pick a seeded subset. Other kinds → null → procedural.
export function makeStockAdapter(pack) {
if (!pack || !pack.items || !pack.items.length) return null;
return (shop, slotKind) => (slotKind === 'sleeve' ? { real: true, pack } : null);
const want = SLOT_FOR[pack.type] || 'sleeve';
return (shop, slotKind) => (slotKind === want ? { real: true, pack } : null);
}
// Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so

View File

@ -0,0 +1,33 @@
// PROCITY Lane C — buy loop v0 (round 8). In-memory wallet: seeded starting cash, purchases, inventory.
// RUNTIME state only — reload resets, world generation is untouched (purchases never enter the plan or
// the seeded room build). One wallet is shared across dig sessions + shops so F's shell can render the
// same cash chip on the street. Economy is deliberately shallow (no persistence, no backend) — v0.
import { mulberry32 } from '../core/prng.js';
export function createWallet(seed = 1) {
const r = mulberry32(seed >>> 0);
const start = 60 + ((r() * 140) | 0); // seeded starting cash ~$60200
let cash = start;
const inv = [];
const listeners = new Set();
const notify = () => listeners.forEach(fn => { try { fn(); } catch (e) {} });
return {
cash: () => cash,
start: () => start,
canBuy: (price) => (price || 0) <= cash,
// buy(offer) — offer is a dig offer ({a:artist, t:title, price, ...}) or a pack item ({title,artist,price}).
// Returns false (no state change) if broke. Deducts + records on success.
buy(o) {
const price = (o && o.price) || 0;
if (price > cash) return false;
cash -= price;
inv.push({ title: o.t || o.title || '?', artist: o.a || o.artist || '', price, band: (o.s || o.price_band || '') });
notify();
return true;
},
inventory: () => inv.slice(),
count: () => inv.length,
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
};
}

View File

@ -87,8 +87,10 @@
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module">
import { generatePlanFor, chunkIndex, CHUNK } from './js/citygen/index.js';
// plan source: default 'synthetic'; ?plansrc=osm renders the real-data fixture town.
const PLANSRC = new URLSearchParams(location.search).get('plansrc') === 'osm' ? 'osm' : 'synthetic';
// plan source: default 'synthetic'; ?plansrc=osm renders a real-data fixture town, ?town=<key> picks it.
const _q = new URLSearchParams(location.search);
const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = _q.get('town') || undefined;
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
@ -294,7 +296,7 @@ const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlanFor(s, PLANSRC); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
plan = generatePlanFor(s, PLANSRC, { town: TOWN }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};