Compare commits
4 Commits
900b274a9a
...
3daf8df3ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3daf8df3ec | ||
|
|
fbc8ffa1c8 | ||
|
|
f952f513d0 | ||
|
|
a667283cf3 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -13,3 +13,6 @@ pipeline/.peds_orig/
|
||||
pipeline/.props_orig/
|
||||
pipeline/.peds_r6/
|
||||
pipeline/_stockpack/
|
||||
pipeline/_records.tsv
|
||||
pipeline/_books.tsv
|
||||
pipeline/_toys.tsv
|
||||
|
||||
@ -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 ~$60–200 (`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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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%** (40–60%
|
||||
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.
|
||||
|
||||
@ -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 ~150–250-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)
|
||||
|
||||
@ -1,8 +1,32 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (1362/1362); rounds 2–6 closed** (see below).
|
||||
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 2–8 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
|
||||
|
||||
|
||||
@ -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 15–150 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
|
||||
|
||||
@ -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 ~$60–200 (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 4–6 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:
|
||||
|
||||
@ -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 5–20s 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
|
||||
40–60% 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
|
||||
|
||||
@ -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}`,
|
||||
|
||||
BIN
docs/shots/laneA/osm-katoomba.png
Normal file
BIN
docs/shots/laneA/osm-katoomba.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/shots/laneC/buyloop_r8.jpg
Normal file
BIN
docs/shots/laneC/buyloop_r8.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@ -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.
|
||||
|
||||
@ -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
@ -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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 (~40–60%), 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.4–0.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 5–20s
|
||||
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';
|
||||
|
||||
|
||||
@ -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 B–F 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 B–F 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.
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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}: 2–4 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}: 2–4 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 ──────────────────
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
33
web/js/interiors/wallet.js
Normal file
33
web/js/interiors/wallet.js
Normal 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 ~$60–200
|
||||
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); },
|
||||
};
|
||||
}
|
||||
@ -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={};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user