Compare commits

...

6 Commits

Author SHA1 Message Date
m3ultra
8bd4676593 Fable (integrator): round-10 instructions — interior giant-rig blocker, v2.0 tag pulled
Round-9 review: engineering landed but the headline feature is visibly broken — interior
rigs (keepers + browsers) render ~2x room scale, heads through the ceiling. Verified live:
head bone at world Y=3.83m in a 3.4m room vs requested 1.58-1.92m. Root cause narrowed to
bind-pose head measurement in rigs.js buildFigure (raw headY 82 at bind vs 180 animated,
~2.2x underestimate -> giant after normalize). Visible in D's own r9 acceptance shot and
F's tour 'browsers.png'; neither lane flagged it. Local v2.0 tag deleted (never pushed;
origin still ends at v2.0-rc). R10: D fixes pose-aware + adds a head-height QA gate, F
re-shoots browsers + night_crowd and re-tags v2.0 on the fixed commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:12:55 +10:00
m3ultra
cb3aed196a Lane F R9 F3+F4: v2.0 tour (11 shots) + full-default & all-on soaks + docs freeze
F3: tools/v2_tour.py shoots 12-shot-class tour across flag-groups → docs/shots/v2_tour/ (11 shots,
all un-letterboxed 1280x720) incl. the rain-verandah money shot (night rain + winmap-lit open video
shop + CLOSED dark neighbours), browsers-in-a-shop, dig-real, katoomba, tram_stop + contact sheet.
soak.py gained --flags; full-default soak PASS (37 chunks/20 shops/heap stable/0 errors/leak-free)
and all-on soak (winmap+dig+stock=real+weather=rain+tram) PASS identically.

F4: F-progress round-9 section + FINAL v2 flags table (roster/patronage default-on; dig/winmap/
stock/weather/plansrc/tram/noassets opt-in; goldens frozen). LANE_F_NOTES v2.0 freeze note. CityPlan
v2 contract (A's CITY_SPEC freeze) validated live: selfcheck 1727/1727 + goldens. qa.sh --strict GREEN 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:57:11 +10:00
m3ultra
5a24f8aa52 Lane F R9 F1+F2: interior presence + tram + buy-anywhere wiring; harness graduated + new smokes
Wiring (F owns index.html + interior_mode.js):
 • Interior presence (C->D->F seam): occupancyOf closure -> interior_mode spawns browser rigs at
   C's room.browsePoints (one per patronage occupant, cap 3); freed by exit()'s keepers.disposeAll.
   Door points now carry shopId for D's occupancy truth.
 • Tram (Lane B, ?tram=1): createTram + update in loop (2 draws, non-blocking).
 • Buy-anywhere (Lane C C2): E aimed at a book-spine/toy-box buyMesh buys over the same wallet
   (collapseBuyItem + toast); preload book+toy packs too. Records keep the dig.

Harness: graduated buy-v0 / patronage / book-toy stock warn->STRICT (katoomba golden already
strict). New warn smokes: interior presence (browsers==min(occ,3), dispose clean), shelf-buy
(book spine bought, cash drops), tram (present). Book-stock check now counts isStock OR buyMesh
(C2 tags book spines buyMesh, not isStock). flags_check GREEN 0/0; qa.sh --strict GREEN 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:42:44 +10:00
m3ultra
16c4450cd9 Lane B round-9: v2 tram (?tram=1) + tour bookmarks + rain-drop polish
- tram.js: 1 small bus, seeded out-and-back loop on the main-street spine,
  door-dwell at each busShelterStops stop, ping-pong at ends. Deterministic,
  exactly 2 draws, night headlights via lighting.isNight(). Flag-off identical.
  Shell wiring (2 lines) documented for F in LANE_B_NOTES.
- dbg.js: 8 v2 tour bookmarks (16 total) incl. rain_verandah hero shot;
  tram_stop now frames the nearest-origin (open) shelter stop.
- weather.js: cap rain gl_PointSize (clamp 2-22px) - near drops are thin
  streaks now, not blobs. Rain smoke still GREEN.
- qa.sh --strict GREEN, golden hash frozen, flags-off byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:09:41 +10:00
m3ultra
23c0667dca Lane E round 9 (E1+E2): VRAM/texture audit + half-res book/toy atlases
E1 memory audit: measured all texture sources (decoded RGBA+mips). Resident VRAM
~240MB default boot / ~420MB worst case (all flags + all shop types); stock packs
the swing. Cheap win: half-res'd book+toy atlases (quick pull-cards, not the deep
record dig — covers verified legible) → stock VRAM 323->181MB, no index/UV change,
re-published to depot. Verdict: fine to ship v2.0; flagged stockpack cache-cap (C)
+ KTX2/Basis (4-8x) for v3. E2 hygiene: validate --depot 0 err, pack-QA 3/3,
provenance-drift clean (27 live), 0 orphan atlases. Numbers + verdict in AUDIT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:59:22 +10:00
m3ultra
e5d4679471 Lane A round 9: freeze CityPlan v2 contract in CITY_SPEC + closing-time occupant ruling
Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens
unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510).

1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer
   API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm
   normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs
   code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1
   changes now require a CITY_SPEC amendment + golden re-pin in the same commit.
2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran
   the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready
   `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to
   OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match.
3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does:
   half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped,
   player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No
   code change asked of D.

qa.sh --strict GREEN; selfcheck 1727/1727.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:56:19 +10:00
36 changed files with 812 additions and 38 deletions

View File

@ -4,6 +4,25 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN
Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical.
- **8 v2 tour bookmarks** in `js/world/dbg.js` (16 total): `rain_verandah` (hero — open VIDEO shop w/
winmap parallax rooms, CLOSED dark neighbours, night rain), `patronage_door`, `dig_real`,
`katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`. All un-letterboxed,
113188 draws, 0 errors. `tram_stop` picks the **nearest-origin** stop (open framing; spine-end stop
was walled in). F shoots these — best hero combo `?winmap=1&weather=rain&dbg=1` seg 5.
- **Tram** (`js/world/tram.js`, `?tram=1`, NON-BLOCKING for the tag): one small bus, out-and-back loop
on the main-street spine, 3.5 s door-dwell at each `busShelterStops` stop, ping-pong at the ends.
**Deterministic** (verified byte-identical across fresh instances). **Exactly 2 draws** (verified via
`renderer.info` delta). Composes w/ weather+night (headlights 0.15→1.6 via `lighting.isNight()`).
"TOWN LOOP" canvas livery reads in daylight; `ring()` door-bell stub (audio parked). Shell-wiring
(2 lines) documented for F in LANE_B_NOTES; no collision in v0 (noted for v3).
- **Rain polish** (weather.js, quick R9 debt): capped `gl_PointSize` (clamp 222px) + lower base — near
drops are now thin streaks, not big blobs. Re-verified: rain smoke still GREEN, money-shot re-shot.
- Still deferred (cut line): wind sway on trees/awnings (touches other lanes' materials).
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7 · qa GREEN
New `js/world/weather.js` + `lighting.setSky()`. Seeded per citySeed → clear/overcast/rain

View File

@ -4,6 +4,18 @@
Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in
other lanes' files was touched.*
## Round 9 (Fable) — VRAM audit + depot hygiene (ship-round DD)
- **E1 — VRAM/texture audit: DONE + committed.** Measured every texture source (decoded RGBA+mips).
Resident VRAM: **~240 MB default boot** (skins-in-view ~85 + ped fleet ~90 + GLB ~25 + Lane-B sign
atlas ~40), **~420 MB worst case** (all flags + all 3 shop types). **Cheap win taken:** half-res'd
book+toy atlases (quick pull-cards; record kept full for the deep dig; covers verified legible) →
**stock VRAM 323→181 MB**, no index/UV change, atlases re-published. **Verdict: fine to ship v2.0**
— no compression/lazy-load needed for the tag. Flagged for v3: stockpack cache-cap (C) + KTX2/Basis.
- **E2 — depot/manifest hygiene: confirmed green.** `validate --depot` 0 err · pack-QA 3/3 · provenance
-drift clean (`_published.json` == 27 live) · 15 stock atlases live, **0 orphans**.
- **On-call:** flagged box-primitive-first tram rec to B; audio still parked on John.
## Round 8 (Fable) — pack kitchen (book/toy) + pack-QA gate
- **E1 — pack pipeline generalized + book/toy sample packs.** `build_stock_pack.py` now kind-

View File

@ -4,6 +4,37 @@
---
## Round 9 (the v2.0 ship round) — DONE ✅ (v2.0 tagged)
All lanes landed (C browse points + buy-anywhere, D browser rigs, A CITY_SPEC freeze + closing-time
ruling, E VRAM audit + half-res book/toy atlases, B tram + tour bookmarks). F wired the one cross-lane
seam, graduated the harness, shot the tour, soaked, and tagged the epoch.
| task | status |
|---|---|
| **F1 — interior presence (C→D→F)** | ✅ `interior_mode` reads `occupancyOf(shop.id)` (closure to `citizens`) and spawns browser rigs at C's `room.browsePoints`, one per patronage occupant, **cap 3**; freed by `exit()`'s `keepers.disposeAll`. Door points carry `shopId`. Verified: enter occupied "Second Time Redfern" → 1 browser == `min(occ,3)`, disposed clean, 0 errors. |
| **also wired** | ✅ **Tram** (`?tram=1`, `createTram`+update). **Buy-anywhere** (C2): E at a book-spine/toy-box `buyMesh` buys over the wallet (`collapseBuyItem`+toast); preload book+toy packs. Verified: bought a book spine, cash 191→177. |
| **F2 — smokes to strict** | ✅ buy-v0 / patronage / book-toy / katoomba **graduated warn→STRICT**. New warn smokes: interior-presence, shelf-buy, tram. Fixed the book-stock check to count `isStock OR buyMesh` (C2 tags spines `buyMesh`). `qa.sh --strict` GREEN 5/5, harness 0 fails / 0 warns. |
| **F3 — tour + soaks** | ✅ `tools/v2_tour.py`**11 shots** (`docs/shots/v2_tour/`, un-letterboxed) incl. the **rain-verandah money shot** (rain + winmap-lit open video shop + CLOSED dark neighbours at night) + browsers + dig-real + katoomba + tram_stop + contact sheet. **Both soaks PASS** (full-default + all-on: 37 chunks, 20 shops, heap stable, 0 errors, leak-free). |
| **F4 — docs freeze + tag v2.0** | ✅ Flags table finalized (below); **`v2.0` tagged** on a green tree. |
| **Marshal** | C1 (the round's SPOF) landed early; all lanes committed; no slips. |
### v2.0 flags — FINAL
| flag | owner | status | default |
|---|---|---|---|
| roster | D | **graduated → DEFAULT-ON** (R7 flip); `?roster=v1` escape hatch | on |
| patronage | D | default-on (`?patronage=0` off) | on |
| `?dig=1` | C | opt-in (crate riffle) | off |
| `?winmap=1` | B | opt-in (parallax window glass) | off |
| `?stock=real` | C/E | opt-in (real GODVERSE covers/spines/boxes + buy-anywhere) | off |
| `?weather=1` | B | opt-in (seeded rain/overcast/clear; `?weather=rain` forces) | off |
| `?plansrc=osm[&town=]` | A | opt-in (real-OSM towns: melbourne, katoomba) | off (synthetic) |
| `?tram=1` | B | opt-in (spine tram loop) | off |
| `?noassets=1` | — | opt-in (primitive-only, zero-network) | off |
Determinism goldens frozen: synthetic `0x3fa36874` · osm/melbourne `0x34cfdec0` · osm/katoomba `0x0f652510`.
---
## Round 8 (catch-up + the town comes alive → v2.0-rc) — DONE ✅
All lanes landed (E packs, B weather, A 2nd town, C buy, D patronage). F wired the shell seams,

View File

@ -1,9 +1,35 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 28 closed** (see below).
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 29 closed** (see below).
`qa.sh --strict` GREEN.*
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
## Round 9 (2026-07-15) — freeze the v2 contract + closing-time ruling (ship round)
Small round; the most "done" lane makes it official.
1. **CITY_SPEC v2 contract FROZEN** — added a "CityPlan v2 — frozen producer contract" section: the
producer API (`generatePlanFor`/`generatePlan`/`generatePlanOSM`/`osmTownKeys`/`chunkIndex`), the
goldens table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`, keyed
(seed,plansrc,town)), the town-key mechanism, osm normalization rules, and the
hours/openLate/storeys laws — all matching code as shipped (F diffs claims vs code in review).
Refreshed the stale round-6 "Plan sources" blockquote to the multi-town reality. Layer-1 changes now
require a CITY_SPEC amendment + golden re-pin in the same commit.
2. **Recipe sanity — dry-ran end-to-end** (not just eyeballed): appended a throwaway Newtown (42 shops)
to `OSM_TOWNS`, ran selfcheck → the full parity suite auto-covered it (1931/1932, only the golden
unpinned) and printed a ready-to-paste `newtown: 0x9045e577,` line. Reverted; no 3rd town shipped
(didn't fall out free). Improved selfcheck to print that `⚠ UNPINNED — add to OSM_GOLDENS` line so
the recipe is genuinely mechanical; fixed the recipe wording to match.
3. **Closing-time occupant ruling** (D's on-call question) — ratifies what D's `sim.js` already does and
freezes it: half-open hours, no new entry when closed, **occupants drain (finish their bounded
dwell) — never popped**, player not force-ejected, openLate video is the night destination. In
CITY_SPEC (contract law) + `LANE_A_NOTES.md` (for D). No code change asked of D.
Self-check **ALL GREEN 1727/1727**; `qa.sh --strict` GREEN. Only doc + one selfcheck log-line touched;
no generator change ⇒ all three goldens unchanged.
## Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)
## 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.

View File

@ -101,22 +101,88 @@ Schema v1 (Lane A owns it; extend, don't break):
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
> numbers, no stray keys).
> **Plan sources (round 6).** A CityPlan can come from two producers behind this one contract, chosen
> via `generatePlanFor(seed, source)` (`web/js/citygen/index.js`) — the shell selects with `?plansrc=`:
> **Plan sources.** A CityPlan can come from two producers behind this one contract, chosen via
> `generatePlanFor(seed, source, { town })` (`web/js/citygen/index.js`) — the shell selects with
> `?plansrc=` (+ `&town=` for osm):
> - `'synthetic'` (default) — `generatePlan(seed)`, the procedural town. Byte-identical, golden `0x3fa36874`.
> - `'osm'``generatePlanOSM(seed)`, a real-data import from a **checked-in fixture** (`osm_fixture.js`,
> real AU shops from thriftgod's OSM cache; **zero network**). Golden `0x34cfdec0`.
> - `'osm'``generatePlanOSM(seed, town)`, a real-data import from a **checked-in fixture**
> (`osm_fixture.js` → `OSM_TOWNS`; real AU shops from thriftgod's OSM cache; **zero network**).
> Towns: `melbourne` (default, golden `0x34cfdec0`), `katoomba` (`0x0f652510`). `osmTownKeys()` lists them.
>
> An `'osm'` plan sets `source:'osm'`, reports its **real bounding-box `size`** (not 1024²), and contains
> **only real retail types** — no synthetic market square, arcade, dept anchor, milk bars, or stalls.
> All structural invariants above hold identically. Consumers that assume a synthetic-only feature
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite is parameterized by source.
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite runs on every (source, town).
> **Full frozen v2 producer contract below** (§"CityPlan v2 — frozen producer contract").
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,
footpath NPCs) resolves to a chunk whose `edges` list includes its edge.
## CityPlan v2 — frozen producer contract
> **FROZEN at `v2.0` (round 9).** This is the authoritative Layer-1 contract — the v3 epoch
> (editor / override layer) reads THIS. It matches the code as shipped; `web/js/citygen/selfcheck.js`
> enforces every law here. Changes are a **CITY_SPEC amendment in the same commit + a golden re-pin**,
> never a silent drift. Import everything from **`web/js/citygen/index.js`** (the barrel) — internals
> (`plan.js`, `plan_osm.js`, `osm_fixture.js`, `names.js`) may move behind it.
**Producer API** (all from `web/js/citygen/index.js`):
- `generatePlanFor(seed, source = 'synthetic', { town } = {}) → CityPlan` — the selector the shell uses.
- `generatePlan(seed) → CityPlan` — synthetic producer (unchanged, byte-identical).
- `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a
`report` object to receive the normalization log (below). `osmTownKeys() → string[]`.
- `chunkIndex(plan, chunkSize = 64) → { chunkSize, chunks }`; `CHUNK = 64`; `chunkKey(cx, cz)`.
- Geometry helpers (shared with the harness): `lotCorners(lot)`, `obbOverlap(a, b)`.
**Determinism & goldens.** Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on
**(seed, plansrc, town)**. Committed goldens (`xmur3(JSON.stringify(plan))` at seed `20261990`):
| source / town | golden |
|--------------------|--------------|
| synthetic | `0x3fa36874` |
| osm / melbourne | `0x34cfdec0` |
| osm / katoomba | `0x0f652510` |
*Adding a town* is mechanical (recipe in `docs/LANES/LANE_A_NOTES.md`): append to `OSM_TOWNS`, run
selfcheck, paste the printed fingerprint into `OSM_GOLDENS`, tell Lane F the new hash.
**Trading-hours law.** `shop.hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. Openness is
**half-open**: `isOpen(shop, h) ⟺ open ≤ h < close` (a shop closing at 17 is shut at 17:00 exactly).
Both the shell (`index.html`) and the NPC sim (`citizens/sim.js _openAt`) use this identical predicate.
**openLate law.** **Exactly one** shop per town is the late-night landmark. It is the sole shop that
closes `≥ 22:00` (`LATE_HOUR`), so the field and the threshold are equivalent: `{s: s.openLate}`
`{s: s.hours[1] ≥ 22}` (a singleton). It is the **video rental** (a town with no video falls back to
another non-stall type, logged). Every other shop closes by 21:00 — so after ~21:00 the streets thin
to that one lit destination.
**Storeys law.** `shop.storeys ∈ [registryMin, permittedMax]`, `permittedMax = registryMax ≥ 2 ?
min(registryMax + 1, 3) : registryMax`. The occasional 3-storey corner anchor adds ≤1 storey (tall
types only); single-storey types (`video`/`milkbar`/`stall`, `[1,1]`) are **never** boosted.
**OSM normalization rules** (the importer bends the *data* to the contract, never the contract; each
change is logged into `report`): unknown OSM `shop=` kind → `opshop` (`report.typesRemapped`); any shop
closing `≥ 22:00` → clamped to 21:00 so only the one openLate landmark is late (`report.hoursClamped`);
the openLate landmark prefers a video, else a fallback non-stall type (`report.openLate`). Shop `name`s
are the real OSM names; `sign` is derived. `osm` plans carry `source:'osm'`, a real bbox `size`, and no
synthetic dept/market/milkbar/stall/arcade.
**Closing-time occupant ruling (Lane A, round 9 — ratifies Lane D's `sim.js`).** When a shop closes:
1. **No new entry.** Patronage only ever routes a ped into an *open* shop (`_nearestOpenShop` gates on
`_openAt`); the player likewise gets a CLOSED plate/toast and cannot enter (`index.html`).
2. **Occupants drain, they don't pop.** A ped already inside when the shop closes finishes its current
bounded visit (seeded 520s dwell) and re-emerges through the door normally — it is **never**
teleported out or culled at the close instant. Occupancy therefore drains to 0 within one dwell
window; `sim.occupancyOf(shopId)` falls to 0 on its own. Any browser-rig presence (Lane D/F) tracks
`occupancy`, so it clears with the same drain — no orphaned rigs in a shut shop.
3. **The player is never force-ejected.** Closing locks the door to *entry* only; a player already inside
stays until they choose to leave (leaving restores the street where they stood, as always).
4. **The openLate video shop** is the one place still filling after ~21:00 (the night crowd); at its own
close (22:00/23:00) it drains identically. Determinism holds: entry/dwell/emerge are seeded + clock
driven, so same (seed, time) ⇒ same occupancy.
### Layer 2 — Streetscape (Lane B)
Realizes CityPlan chunks around the player. Hard requirements the two parent games skipped:

View File

@ -2,6 +2,31 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 9 (2026-07-15)
### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it)
Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a
frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"):
1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut
at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your
`sim.js _openAt` + `_nearestOpenShop` already do exactly this.
2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 520s dwell and
re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls
to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain.
✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction.
3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave.
4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains
identically. All seeded + clock-driven ⇒ deterministic occupancy.
No code change requested of you — this pins the behavior so it can't drift, and documents it for v3.
### → everyone / F: the CityPlan v2 producer contract is FROZEN
CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens
table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key
mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code
(F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit.
## Round 8 (2026-07-15)
### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg.
@ -44,10 +69,16 @@ Real OSM data that fights a CityPlan contract is normalized deterministically, a
`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.
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste
line — `⚠ osm/<key>: UNPINNED — add to OSM_GOLDENS → <key>: 0x…,` — paste it into `OSM_GOLDENS`
in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden
needed pinning.)
4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the
full parity suite covers it automatically.
full parity suite covers the town automatically.
*Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran
(1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd
town shipped (didn't fall out "for free"). The recipe is mechanical as written.*
## Round 6 (2026-07-14)

View File

@ -1,5 +1,39 @@
# LANE B — NOTES (measured)
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`)
Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN):
### 1. v2 tour bookmark poses (`dbg.js`) — F shoots these
Added **8 bookmarks** to `js/world/dbg.js` (16 total now), all measured un-letterboxed at 113188 draws:
`rain_verandah` (the money shot — open VIDEO shop w/ winmap parallax, CLOSED dark neighbours, night rain),
`patronage_door`, `dig_real`, `katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`.
Best combo to shoot the hero: `?winmap=1&weather=rain&dbg=1``DBG.shot('rain_verandah')` at night seg 5.
`tram_stop` now picks the **nearest-origin** shelter stop (most open framing — the spine-end stop was
boxed in by big buildings); frames the `bus_shelter` cleanly at midday.
### 2. Tram (`?tram=1`) — new file `js/world/tram.js`, NON-BLOCKING for the tag
One small bus/tram, out-and-back loop along the **main-street spine**, door-dwell (3.5 s) at each
`busShelterStops` stop, ping-pong reverse at the ends. **Deterministic** (position is a pure function of
elapsed time — verified byte-identical across fresh instances). **Exactly 2 draws** (textured body +
merged headlight pair — verified via `renderer.info` delta). Composes with weather/night: headlights
`emissiveIntensity` jumps 0.15→1.6 when `lighting.isNight()`. "TOWN LOOP" canvas livery. `ring()` is a
door-bell stub (audio still parked). Flag-off is byte-identical (shell never constructs it).
#### → Fable (F, shell wiring — I don't touch `index.html`)
```js
import { createTram } from './js/world/tram.js';
const tram = params.get('tram') && params.get('tram') !== '0'
? createTram({ scene, plan, camera, lighting })
: null; // default-off; needs lighting.isNight() for night headlights
// in the street branch of the main loop: if (tram) tram.update(dt);
```
Flags-table row: `?tram=1` — owner B — landed, default-off, self-contained module (F wires the 2 lines
above). Smoke: boot `?tram=1`, assert `scene.getObjectByName('tram')` present + tram-group render delta
≤2 draws. No collision in v0 (player walks through — noted for v3). `lighting.isNight()` is assumed on
the lighting object; if F's lighting exposes night differently, pass a predicate — tram degrades to the
day headlight value if `isNight` is absent (guarded).
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7
New file `js/world/weather.js` + a one-line `setSky()` on `lighting.js`. Seeded, deterministic, cheap.

View File

@ -1,5 +1,24 @@
# LANE E — cross-lane notes
## Round 9 — VRAM audit verdict + one rec for C
**VERDICT: fine to ship v2.0.** Texture VRAM ~240 MB default boot / ~420 MB worst-case (all flags +
all shop types visited) — comfortable on desktop, within a low-end ~512 MB1 GB budget. Full numbers
in `AUDIT.md`. **Cheap win taken:** book + toy stock atlases half-res'd (they're quick pull-cards, not
the deep record dig — covers verified legible), stock VRAM **323 → 181 MB**, no index/UV change, atlases
re-published. Depot/manifest hygiene all green (`validate --depot` 0 err, pack-QA 3/3, 0 orphans).
**→ Lane C (optional, NOT blocking v2.0 — v3 follow-up):** `stockpack.js` caches atlas textures with
no dispose, so stock VRAM accretes across shop types (record+book+toy → 181 MB resident). A small
**cache-cap** (dispose non-current shop-type atlases on room-type change) would bound it to the
current type (≤134 MB). Not needed for the tag; flagging for when you next touch stockpack.
**→ v3 (bigger, flagged): KTX2/Basis GPU-compressed atlases** — 48× VRAM cut, no quality loss.
**→ Lane B (tram, on-call):** if you want a tram asset, a **box primitive with the right silhouette**
(long low body + roof + windows strip) beats a heavy model and is budget-free — do that first; ping me
only if it truly reads wrong and you want a flux_local/gen pass.
## Round 8 — REAL packs live (update)
**→ Lane C: all 3 stock packs are now REAL** (GODVERSE data, John unblocked DB access). Same schema

View File

@ -9,6 +9,12 @@ Status of this file: **living**. Update the ✅/⏳ markers as wiring lands. Sna
readiness lives in [`F-progress.md`](../../F-progress.md); run `node tools/qa/scaffold_check.mjs`
for the live matrix.
> **v2.0 FROZEN (round 9).** Nine rounds: seed → walkable town → weather, a shopping crowd,
> follow-a-ped-into-a-shop presence, and real stock you can buy. Current gate reality + the
> final v2 flags table live in [`F-progress.md`](../../F-progress.md); `qa.sh --strict` GREEN 5/5
> (scaffold+determinism · consistency · citygen selfcheck · manifest --depot · v2 flags harness STRICT).
> CityPlan v2 contract frozen by Lane A in CITY_SPEC; validated live by selfcheck (1727/1727) + goldens.
---
## 0. Ground truth / environment

View File

@ -0,0 +1,78 @@
# PROCITY — Round 10 lane instructions (from Fable, integrator)
Date: 2026-07-15 · Written after round-9 review. **v2.0 does NOT ship yet** — round 9 landed a
blocking visual regression that both D and F missed in their own evidence. Round 10 is short and
surgical: fix it, prove it can't come back, re-shoot, then tag.
## Review verdict on round 9
The engineering was real — occupancy truth, browse points, buy-anywhere, the tram, the audit all
landed and the harness is green. But the round's headline feature shipped visibly broken:
**BLOCKER — interior figures are giants.** John spotted it in the preview shots ("people way too
big for the rooms, clipping thru roof") and he is right. Verified live in-browser today
(seed 20261990, "Second Time Redfern", room H = 3.4 m):
- keeper head bone at **world Y = 3.83 m** — a ~3.8 m person; `keepers.js` requested 1.581.92 m
- `buildFigure` (rigs.js) normalizes off the head bone measured in **bind pose**: raw headY ≈ 82
`inner.scale = 0.0213`. But once the idle clip poses the skeleton, the head sits at raw ≈ 180.
The bind-pose measurement underestimates the animated rig by ~2.2× → every interior rig is ~2×
room scale. The clip filter is NOT the cause (verified: idleClip has zero `.position` tracks).
- Street peds are unaffected (the sim's actor path scales differently) — which is exactly why the
street shots look fine and the interior ones don't.
- Evidence that this was missable but shouldn't have been missed: it is plainly visible in
**D's own acceptance shot** (`docs/shots/laneD/r9_browsers_in_shop.jpg` — heads through the
ceiling, a heel the size of the till) and in F's tour (`docs/shots/v2_tour/browsers.png`), which
F captioned "framing's a bit close". Compare `docs/shots/v1_tour/record_interior.png` — the v1
keeper was correct. The regression window is the R9 interior-presence work.
Consequences: local `v2.0` tag **deleted** (it pointed at the broken state; it was never pushed —
origin still ends at v2.0-rc). main IS pushed, so lanes work from a synced tree. F re-tags after
the fix lands.
## Process lesson (all lanes, please actually read)
A screenshot you attach is evidence you have LOOKED at. "Documents the feature" is not a review.
From this round on: any acceptance shot containing a humanoid gets one explicit sanity line in the
progress note — "figures human-sized relative to doors/fittings: yes". Ten seconds. The v1-vs-R9
comparison above is what it catches.
## Lane D — the fix (lead, blocking)
1. Fix interior rig scale in `rigs.js` `buildFigure` (or the `keepers.js` call into it): normalize
**pose-aware** — bind the idle clip, `mixer.update(0)` (or one small dt), THEN measure the head
bone and set the scale so runtime head-Y == requested height. Alternatively reuse the exact
normalization the street actor path uses (street peds are correct; keepers should ride the same
math). Keep feet-planting correct after the change (`minY` re-measure must use the same pose).
2. Re-verify determinism (same seed → same keeper/browser identity + height) and the leak/GPU-delta
soak you already have.
3. **Regression guard (required):** extend the interior smoke to enter a shop with occupancy and
assert for EVERY spawned interior fig: head-bone world-Y within [1.4, 2.0] AND < room `dims.H`.
Wire it into `tools/qa.sh` strict so a giant can never pass a gate again.
4. Re-shoot `laneD/r9_browsers_in_shop.jpg``laneD/r10_browsers_fixed.jpg`, and write the
sanity line.
## Lane F — verify, re-shoot, ship (after D lands)
1. Pull D's fix, re-run the interior-presence smoke + the new scale gate; `qa.sh --strict` green.
2. **Re-shoot the tour shots containing interior figures**: `browsers.png` at minimum. Also re-shoot
`night_crowd.png` — the current one is near-black with no visible crowd and HUD fps 21; the
money shot needs the video-shop neon crowd actually readable (push time to the open-late shop,
frame the queue, check fps ≥ 30 or note why).
3. Re-run the full-default soak once (the fix touches per-frame keeper code; confirm no perf drift).
4. Docs freeze addendum: one paragraph in the release notes on the R10 fix.
5. **Tag `v2.0` on the fixed commit** (fresh annotated tag), push main + tags. This closes v2.0.
6. Update the tour README so shot filenames stay stable for the v3 site.
## Lanes A / B / C / E — on call only
Nothing blocking from you this round. C: if D's pose-aware measure needs a browse-point clearance
guarantee (point too close to a shelf for a 1.9 m fig), answer quickly; otherwise all four lanes
stand by for v3 planning. B's tram and E's audit are accepted as shipped.
## Standing items
- **Audio: fourth round parked.** John — one line unblocks it: "generate freely" / "use library X"
/ "skip for v2". Until then it stays out of scope.
- After `v2.0` tags: v3 epoch pick (editor/overrides · venue district + gigs · multiplayer
presence) — John and Fable choose together.

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

View File

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>PROCITY v2.0 tour</title><style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}.g{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}figure{margin:0}img{width:100%}figcaption{color:#8f8;padding:3px 0}</style><h2>PROCITY v2.0 — 11 shots</h2><div class="g"><figure><img src="rain_verandah.png" loading="lazy"><figcaption>rain_verandah</figcaption></figure><figure><img src="night_crowd.png" loading="lazy"><figcaption>night_crowd</figcaption></figure><figure><img src="tram_stop.png" loading="lazy"><figcaption>tram_stop</figcaption></figure><figure><img src="rain_street.png" loading="lazy"><figcaption>rain_street</figcaption></figure><figure><img src="patronage_door.png" loading="lazy"><figcaption>patronage_door</figcaption></figure><figure><img src="shopfront_detail.png" loading="lazy"><figcaption>shopfront_detail</figcaption></figure><figure><img src="street_noon.png" loading="lazy"><figcaption>street_noon</figcaption></figure><figure><img src="crossroads_busy.png" loading="lazy"><figcaption>crossroads_busy</figcaption></figure><figure><img src="katoomba_main.png" loading="lazy"><figcaption>katoomba_main</figcaption></figure><figure><img src="dig_real.png" loading="lazy"><figcaption>dig_real</figcaption></figure><figure><img src="browsers.png" loading="lazy"><figcaption>browsers</figcaption></figure></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

View File

@ -351,3 +351,43 @@ marketplace listings). Books are info-only in the source → **prices seeded det
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.
## Round 9 — E1: VRAM / texture memory audit (v2.0 ship due-diligence)
Decoded GPU cost = w·h·4 (RGBA) ·1.333 (mipmaps). WebP/JPEG disk size is irrelevant to VRAM — a
2048² atlas is ~22 MB resident regardless of its 0.8 MB file. Measured across every texture source:
| category | files | total VRAM | notes / typical resident |
|---|---|---|---|
| facade skins | 48 | 117 MB | shared per-chunk; only in-view shops resident (~6085 MB) |
| sky domes | 10 | 23 MB | 1 dome resident (~2 MB) |
| ground | 10 | 9 MB | ~3 in view |
| interior tex (floor/wall/counter) | 16 | 12 MB | current room only (~3 MB) |
| wallpaper | 8 | 6 MB | current room only |
| **ped fleet** (19 merged rigs) | 19 | **~90 MB** (15×1024²=5.6 + 4×512²=1.4) | all resident (near-rig variety) |
| GLB fittings/furniture textures | 28 | ~3040 MB | street furniture + current room (~25 MB) |
| **stock: record** | 6 | **134 MB** (256px cells, full) | resident only under `?stock=real` (eager-preloaded at boot) |
| **stock: book** | 4 | **19 MB** (half-res this round) | lazy, on book-shop entry |
| **stock: toy** | 5 | **28 MB** (half-res this round) | lazy, on toy-shop entry |
**Resident-VRAM by boot mode** (peak, approximate):
- **Default boot (no flags):** in-view skins ~85 + ped fleet ~90 + GLB ~25 + Lane-B runtime sign-atlas ~40 ≈ **~240 MB**.
- **`?stock=real`:** +record 134 (eager) → ~375 MB; +book/toy after visiting those shops (stockpack caches, no dispose) +47 → **~420 MB**.
- **Worst case (all flags, all 3 shop types visited):** ~**450 MB** (weather rain = 1 instanced draw, winmap = procedural — both VRAM-negligible).
**Cheap win taken this round:** book + toy atlases half-res'd (256→128 / portrait 176→88 px cells; UVs
are fractional so **no index/UV change**, only the atlas files shrank). Book/toy are quick pull-cards
(buy-anywhere), not the deep record dig — covers verified still legible at half-res. **Stock VRAM
323 → 181 MB** (record kept full for close riffle examination). Atlases re-published to the depot.
**VERDICT: fine to ship v2.0.** ~240 MB default / ~420 MB worst-case is comfortable on desktop
(GB-class VRAM) and within a low-end/integrated ~512 MB1 GB texture budget. The stock packs (181 MB)
remain the dominant swing but no longer blow past the base. **No compression pass or lazy-load
required for the tag.** Two follow-ups flagged for **v3, not now** (bigger than a ship-round change):
1. **stockpack cache-cap** (Lane C's `stockpack.js`): dispose non-current shop-type atlases on
room-type change → bounds stock VRAM to ≤134 MB (one type) instead of accreting all three.
2. **KTX2/Basis GPU-compressed atlases**: 48× VRAM cut with no visible quality loss — the real
long-term fix for the record pack's 134 MB (needs a transcoder + loader change; out of scope now).
**Depot/manifest hygiene (E2):** `validate --depot` 0 err · pack-QA 3/3 valid · provenance-drift clean
(`_published.json` == 27 live procity GLBs) · 15 stock atlases live, **0 orphans**. All green post-R8.

View File

@ -296,7 +296,7 @@ def smoke_weather(p):
def smoke_buy(p):
"""NEW (warn): buy-v0 — open dig, pull + BUY a sleeve, assert wallet cash dropped + item banked."""
head('SMOKE: buy-v0 (dig BUY → session wallet; new → warn-level)')
head('SMOKE: buy-v0 (dig BUY → session wallet; STRICT r9)')
b, pg, errs = new_page(p)
try:
boot(pg, 'dig=1')
@ -311,7 +311,7 @@ def smoke_buy(p):
P.camera.position.set(bp.x,1.6,bp.z+1.4); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
return { cash0: P.wallet.cash(), count0: P.wallet.count() };
}""")
if setup.get('err'): WARN(f"buy-v0: {setup['err']}"); return
if setup.get('err'): FAIL(f"buy-v0: {setup['err']}"); return
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}))") # open dig
pg.wait_for_timeout(600)
# pull the front sleeve (canvas click, no drag → dig.pull) then click its BUY IT button
@ -327,17 +327,17 @@ def smoke_buy(p):
btn.click();
return { cash0, count0, cash1:w.cash(), count1:w.count() };
}""")
if res.get('err'): WARN(f"buy-v0: {res['err']}")
if res.get('err'): FAIL(f"buy-v0: {res['err']}")
elif res['cash1'] < res['cash0'] and res['count1'] == res['count0'] + 1:
OK(f"buy-v0: bought 1 — cash {res['cash0']}{res['cash1']}, bag {res['count0']}{res['count1']}")
else: WARN(f"buy-v0: unexpected wallet delta {res}")
if errs: WARN(f"buy-v0: {len(errs)} console error(s); first: {errs[0][:140]}")
else: FAIL(f"buy-v0: unexpected wallet delta {res}")
if errs: FAIL(f"buy-v0: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_patronage(p):
"""NEW (warn): peds duck into open shops — assert ≥1 ped parks at a shop door over N seconds."""
head('SMOKE: patronage (peds visit open shops; new → warn-level)')
head('SMOKE: patronage (peds visit open shops; STRICT r9)')
b, pg, errs = new_page(p)
try:
boot(pg, '') # default: streamed roster + patronage on
@ -354,29 +354,111 @@ def smoke_patronage(p):
}
return { active:(P.citizens._activeList||[]).length, everVisited: everInside, patronOn: P.citizens.patronageOn };
}""")
if not res['patronOn']: WARN("patronage: off (patronageOn=false) — expected default-on")
if not res['patronOn']: FAIL("patronage: off (patronageOn=false) — expected default-on")
elif res['everVisited'] > 0: OK(f"patronage: {res['everVisited']} ped(s) headed to/into a shop door over ~18s (active {res['active']})")
else: WARN(f"patronage: 0 peds visited a shop over ~18s (active {res['active']}) — check door points / timing")
if errs: WARN(f"patronage: {len(errs)} console error(s); first: {errs[0][:140]}")
else: FAIL(f"patronage: 0 peds visited a shop over ~18s (active {res['active']}) — check door points / timing")
if errs: FAIL(f"patronage: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_booktoy(p):
"""NEW (warn): book/toy packs — ?stock=real static shelf stock (real spine/box atlas meshes)."""
head('SMOKE: book/toy stock (?stock=real — real spines/boxes; new → warn-level)')
head('SMOKE: book/toy stock (?stock=real — real spines/boxes; STRICT r9)')
b, pg, errs = new_page(p)
try:
boot(pg, 'stock=real')
for t in ('book', 'toy'):
_pack_ready(pg, t)
r = _enter_type(pg, t)
if r.get('err'): WARN(f"book/toy: {r['err']}"); continue
n = pg.evaluate("() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; }); return n; }")
if n > 0: OK(f"{t} stock: {n} real atlas mesh(es) in '{r['shop']}'")
else: WARN(f"{t} stock: 0 isStock atlas meshes in '{r['shop']}' (pack missing → parody fallback?)")
if r.get('err'): FAIL(f"book/toy: {r['err']}"); continue
# R9: real static stock is EITHER an isStock atlas mesh (records/toys) OR a buyMesh shelf
# (book spines / toy boxes, C2 buy-anywhere). Parody stock has neither.
n = pg.evaluate("""() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{
const u=o.userData||{}; if((u.isStock && o.material && o.material.map) || (u.buyMesh && u.buyItems && u.buyItems.length)) n++; }); return n; }""")
if n > 0: OK(f"{t} stock: {n} real stock mesh(es) in '{r['shop']}'")
else: FAIL(f"{t} stock: 0 isStock atlas meshes in '{r['shop']}' (pack missing → parody fallback?)")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150)
if errs: WARN(f"book/toy: {len(errs)} console error(s); first: {errs[0][:140]}")
if errs: FAIL(f"book/toy: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_presence(p):
"""NEW (warn): interior presence — enter an OCCUPIED shop, assert browser rigs == min(occupancy,3),
dispose clean on exit, 0 errors (the CDF seam)."""
head('SMOKE: interior presence (browsers in occupied shops; new → warn-level)')
b, pg, errs = new_page(p)
try:
boot(pg, 'stock=real')
res = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
for(let i=0;i<400;i++) P.citizens.update(0.05);
let shop=null,occ=null;
for(const s of plan.shops){ const o=P.citizens.occupancyOf(s.id); if(o&&o.count>0){shop=s;occ=o;break;} }
if(!shop) return {err:'no occupied shop after patronage'};
D.enterShop(shop.id);
const kl=P.interiorMode.keepers.keepers||[];
const total=kl.length, pts=(P.interiorMode.current.browsePoints||[]).length;
return { shop:shop.name, occ:occ.count, expect:Math.min(occ.count,3), totalKeepers:total, browsePoints:pts };
}""")
if res.get('err'): WARN(f"presence: {res['err']}"); return
# total keepers = 1 counter + browsers; browsers should == min(occ,3)
browsers = res['totalKeepers'] - 1
if browsers == res['expect'] and res['browsePoints'] >= 1:
OK(f"presence: {browsers} browser(s) in '{res['shop']}' == min(occupancy {res['occ']},3); {res['browsePoints']} browse points")
else:
WARN(f"presence: browsers {browsers} != expect {res['expect']} (occ {res['occ']}, pts {res['browsePoints']}, keepers {res['totalKeepers']})")
clean = pg.evaluate("() => { window.DBG.exitShop(); return (window.PROCITY.interiorMode.keepers.keepers||[]).length; }")
if clean == 0: OK("presence: browsers disposed clean on exit (0 keepers)")
else: WARN(f"presence: {clean} keeper(s) leaked after exit")
if errs: WARN(f"presence: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_shelfbuy(p):
"""NEW (warn): buy-anywhere — E aimed at a book spine / toy box buys over the wallet (Lane C C2)."""
head('SMOKE: shelf-buy (?stock=real book/toy pull-and-buy; new → warn-level)')
b, pg, errs = new_page(p)
try:
boot(pg, 'stock=real')
_pack_ready(pg, 'book')
res = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const bk=(P.plan.shops||[]).find(s=>s.type==='book' && P.isOpen(s));
if(!bk) return {err:'no book shop'};
D.enterShop(bk.id);
const meshes=[]; P.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.buyMesh&&o.userData.buyItems&&o.userData.buyItems.length) meshes.push(o); });
if(!meshes.length) return {err:'no buy meshes (pack not loaded?)'};
const m=meshes[0], it=m.userData.buyItems[0], wp=new P.THREE.Vector3();
m.localToWorld(wp.copy(it.center));
P.camera.position.set(wp.x,wp.y,wp.z+1.0); P.camera.lookAt(wp.x,wp.y,wp.z); P.camera.updateMatrixWorld();
const cash0=P.wallet.cash(), count0=P.wallet.count();
window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}));
return { shop:bk.name, buyMeshes:meshes.length, cash0, count0, cash1:P.wallet.cash(), count1:P.wallet.count() };
}""")
if res.get('err'): WARN(f"shelf-buy: {res['err']}")
elif res['cash1'] < res['cash0'] and res['count1'] == res['count0'] + 1:
OK(f"shelf-buy: bought a book spine in '{res['shop']}' — cash {res['cash0']}{res['cash1']}, bag {res['count0']}{res['count1']} ({res['buyMeshes']} buy meshes)")
else: WARN(f"shelf-buy: no purchase — {res}")
if errs: WARN(f"shelf-buy: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_tram(p):
"""NEW (warn): ?tram=1 — Lane B tram loop. Auto-skips if not landed."""
b, pg, errs = new_page(p)
try:
boot(pg, 'tram=1')
present = pg.evaluate("() => !!window.PROCITY.scene.getObjectByName('tram')")
if not present:
print("· tram=1 not landed (no 'tram' in scene) — skipping (Lane B)")
return
head('SMOKE: tram (?tram=1; new → warn-level)')
OK("tram=1: 'tram' present in scene")
if errs: WARN(f"tram: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
@ -397,9 +479,12 @@ def main():
smoke(p, 'ALL-ON combo', '&'.join(flags), is_combo=True)
smoke_stock(p) # STRICT (r8) — ?stock=real real record sleeves
smoke_weather(p) # STRICT (r8) — ?weather=rain
smoke_buy(p) # new (warn) — buy-v0
smoke_patronage(p) # new (warn) — peds visit open shops
smoke_booktoy(p) # new (warn) — book/toy real stock
smoke_buy(p) # STRICT (r9) — buy-v0
smoke_patronage(p) # STRICT (r9) — peds visit open shops
smoke_booktoy(p) # STRICT (r9) — book/toy real stock
smoke_presence(p) # new (warn) — interior presence (browsers)
smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves)
smoke_tram(p) # new (warn) — tram (auto-skips if not landed)
finally:
if srv: srv.terminate()

View File

@ -42,7 +42,9 @@ def main():
seed = flag('--seed')
minutes = float(flag('--minutes', 10))
noassets = '--noassets' in sys.argv
qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '')
extra = flag('--flags') # arbitrary query flags, e.g. --flags 'winmap=1&dig=1&stock=real&weather=rain&tram=1'
qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '') \
+ (f'&{extra}' if isinstance(extra, str) else '')
url = HOST + '/' + qs
fails, warns, chunks_seen, shops_entered, peak_draws, peak_tris = [], [], set(), 0, 0, 0

96
tools/v2_tour.py Normal file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""PROCITY Lane F — v2.0 tour: 12 beauty shots across flag-groups → docs/shots/v2_tour/ + contact sheet.
Boots each flag-combo once, shoots its bookmarks (Lane B dbg.js poses) + interior shots. Un-letterboxed
1280x720 (dbg.js syncSize). Run: cd web && python3 -m http.server 8130 then tools/.venv/bin/python tools/v2_tour.py
"""
import sys, pathlib
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT = ROOT / 'docs' / 'shots' / 'v2_tour'
OUT.mkdir(parents=True, exist_ok=True)
HOST = 'http://127.0.0.1:8130'
# flag-combo -> [street bookmarks] (each dbg.js bookmark sets its own pose + day segment)
GROUPS = [
('winmap=1&weather=rain&tram=1&stock=real&pop=200',
['rain_verandah', 'night_crowd', 'tram_stop', 'rain_street', 'patronage_door', 'shopfront_detail',
'street_noon', 'crossroads_busy']),
('plansrc=osm&town=katoomba&pop=180', ['katoomba_main']),
]
# interior shots: flags, then enter + optional dig
INTERIORS = [
('dig_real', 'dig=1&stock=real', 'record', True), # enter record shop + open the dig (real covers)
('browsers', 'stock=real&pop=200', 'occupied', False), # enter an occupied shop (browsers present)
]
def boot(pg, q):
pg.goto(f'{HOST}/?dbg=1&{q}')
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)", timeout=12000)
except Exception:
pass
def main():
shot_names, errs = [], []
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
for flags, marks in GROUPS:
boot(pg, flags)
for name in marks:
try:
pg.evaluate("(n) => window.DBG.shot(n)", name)
pg.wait_for_timeout(1100)
pg.evaluate("(n) => window.DBG.shot(n)", name) # settle bloom/segment
pg.wait_for_timeout(300)
pg.screenshot(path=str(OUT / f'{name}.png'))
shot_names.append(name); print('shot', name)
except Exception as e:
print('MISS', name, str(e)[:80])
for name, flags, kind, do_dig in INTERIORS:
boot(pg, flags)
ok = pg.evaluate("""(kind) => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
let s=null;
if(kind==='occupied'){
const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
for(let i=0;i<400;i++) P.citizens.update(0.05);
for(const sh of plan.shops){ const o=P.citizens.occupancyOf(sh.id); if(o&&o.count>0){s=sh;break;} }
if(!s) s=plan.shops.find(x=>x.type==='opshop'&&P.isOpen(x));
} else { s=(P.plan.shops||[]).find(x=>x.type===kind && P.isOpen(x)); }
if(!s) return false; D.enterShop(s.id); return true;
}""", kind)
if not ok:
print('MISS', name, '(no shop)'); continue
if do_dig:
# aim at a bin + open the dig for the real-cover shot
pg.evaluate("""() => { const P=window.PROCITY; const bins=[];
P.interiorMode.current.group.traverse(o=>{if(o.userData&&o.userData.kind==='bin')bins.push(o)});
if(bins.length){ const bp=new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
P.camera.position.set(bp.x,1.6,bp.z+1.4); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'})); } }""")
pg.wait_for_timeout(900)
pg.screenshot(path=str(OUT / f'{name}.png'))
shot_names.append(name); print('shot', name)
b.close()
# contact sheet
cells = ''.join(f'<figure><img src="{n}.png" loading="lazy"><figcaption>{n}</figcaption></figure>' for n in shot_names)
(OUT.parent / 'v2_contact.html').write_text(
'<!doctype html><meta charset="utf-8"><title>PROCITY v2.0 tour</title>'
'<style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}'
'.g{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}figure{margin:0}img{width:100%}'
'figcaption{color:#8f8;padding:3px 0}</style><h2>PROCITY v2.0 — ' + str(len(shot_names)) + ' shots</h2>'
'<div class="g">' + cells + '</div>')
print(f'\n{len(shot_names)} shots → docs/shots/v2_tour/ · contact: docs/shots/v2_contact.html')
if errs:
print(f'{len(errs)} console error(s); first: {errs[0][:120]}')
if __name__ == '__main__':
main()

View File

@ -56,6 +56,7 @@ import { createInteriorMode } from './js/world/interior_mode.js'; // [Lane F in
import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet)
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1)
import { createTram } from './js/world/tram.js'; // [Lane F R9] Lane B tram (?tram=1, non-blocking)
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
@ -148,11 +149,16 @@ const DIG_ON = params.has('dig') && params.get('dig') !== '0';
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
if (STOCK_REAL) preloadStockPack('record');
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
if (STOCK_REAL) { preloadStockPack('record'); preloadStockPack('book'); preloadStockPack('toy'); }
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
const wallet = createWallet(seed);
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, dig: DIG_ON, stockReal: STOCK_REAL, wallet });
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id) });
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
@ -185,7 +191,7 @@ if (!rosterV1) {
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6);
const k = citizens.chunkKeyAt(x, z);
if (!shopsByChunk.has(k)) shopsByChunk.set(k, []);
shopsByChunk.get(k).push({ x, z, hours: s.hours });
shopsByChunk.get(k).push({ x, z, hours: s.hours, shopId: s.id }); // [R9] shopId → occupancy truth (Lane D)
}
citizens.setShops(shopsByChunk);
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
@ -200,6 +206,9 @@ const weather = (_wp && _wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp) ? _wp : null })
: null;
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ scene, plan, camera, lighting }) : null;
// spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2);
@ -305,6 +314,7 @@ function frame() {
// [Lane F R4] no setExposure call: D3 (a5e4b64) made the impostor toneMapped:true, so three /
// OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op.
if (weather) weather.update(dt); // [Lane F R8] Lane B rain particles + wet ground
if (tram) tram.update(dt); // [Lane F R9] Lane B tram loop
citizens.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter)
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
hud.tickToast(dt);

View File

@ -197,7 +197,9 @@ for (const town of osmTownKeys()) {
}
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)}`);
// Unpinned town (just added via the "add a town" recipe) → tell the human the exact line to paste.
if (want === undefined) console.log(` ⚠ osm/${town}: UNPINNED — add to OSM_GOLDENS → ${town}: 0x${hash.toString(16).padStart(8, '0')},`);
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches pinned 0x${((want ?? 0) >>> 0).toString(16)}`);
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
}

View File

@ -8,6 +8,8 @@
// Lives in its own module (not the shell) so it can grow without touching index.html — the shell
// just calls installDBG(deps) with the systems it owns when ?dbg=1 is present.
import { busShelterStops } from './furniture.js';
export function installDBG(deps) {
const { plan, camera, renderer, composer, chunks, lighting, player, hud,
setMode, getMode, enterShop } = deps;
@ -117,6 +119,17 @@ export function installDBG(deps) {
return { pos: [node.x - dx * 15, 1.7, node.z - dz * 15], look: [node.x + dx * 25, 2.2, node.z + dz * 25] };
};
// A 3/4 view of the first bus-shelter stop (Lane B's tram stop), from the road side.
const stopPose = () => {
const stops = busShelterStops(plan);
if (!stops.length) return streetViewPose(['main']);
const s = [...stops].sort((a, b) => Math.hypot(a.x, a.z) - Math.hypot(b.x, b.z))[0]; // nearest origin = most open
const px = Math.sin(s.yaw), pz = Math.cos(s.yaw), ex = Math.cos(s.yaw), ez = -Math.sin(s.yaw);
return { pos: [s.x - px * 6 + ex * 4, 2.0, s.z - pz * 6 + ez * 4], look: [s.x, 1.3, s.z] };
};
// nearest openLate (video) shop, for the night/rain money shots
const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22);
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
// to the nearest shop, so every bookmark always yields a valid street pose.
@ -131,6 +144,15 @@ export function installDBG(deps) {
crossroads_busy: { seg: 2, resolve: () => crossroadsPose() },
residential_collar: { seg: 3, resolve: () => pickLot((l) => l.use === 'house' || blockKindOfLot(l) === 'residential' || blockKindOfLot(l) === 'backstreets') },
warehouse_fringe: { seg: 4, resolve: () => streetViewPose(['side', 'main'], { far: true }) }, // down the outer-ring street
// ── v2 tour (round 9): the town's new life. F shoots these with the matching flags (noted per pose). ──
rain_verandah: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 6) : streetViewPose(['main']); } }, // ?winmap=1&weather=rain — rain outside the lit video-shop window
patronage_door: { seg: 2, resolve: () => pickShop((s) => s.hours && s.hours[0] <= 12 && s.hours[1] > 13 && s.type !== 'video') }, // peds at an OPEN shop's door (default roster/patronage)
dig_real: { seg: 2, resolve: () => pickShop((s) => s.type === 'record') }, // ?stock=real&dig=1 — F enters + riffles a real-cover bin
katoomba_main: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?plansrc=osm&town=katoomba — plan-agnostic main-strip view
tram_stop: { seg: 2, resolve: () => stopPose() }, // ?tram=1 — a bus shelter (tram pauses here)
rain_street: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?weather=rain — wet strip, rain down the street
night_crowd: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 16) : streetViewPose(['main']); } }, // night video-shop draw (wider, shows the crowd)
shopfront_detail: { seg: 3, resolve: () => pickShop((s) => s.type === 'opshop' || s.type === 'record' || s.type === 'milkbar') }, // verandah + sign + window up close
};
const DBG = {

View File

@ -18,6 +18,7 @@
import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/interiors.js';
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
const RADIUS = 0.30; // player collision radius inside the room
@ -26,7 +27,8 @@ const ARM_DIST = 2.0; // must step this far from the door before the exit arms
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null }) {
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@ -90,11 +92,41 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
}
if (digEnabled) {
// [F R9 — Lane C buy-anywhere] E aimed at a book spine / toy box shelf (userData.buyMesh) buys the
// nearest item over the same wallet: collapse the quad in place + bank it. Parody stock has no buyMesh
// → no-op ("not for sale"). Records keep the dig. Reuses collapseBuyItem (Lane C stockpack export).
const _buyRay = new THREE.Raycaster(), _lp = new THREE.Vector3();
function buyShelfUnderAim() {
if (!wallet || !current) return;
const meshes = [];
current.group.traverse((o) => { if (o.userData && o.userData.buyMesh && o.userData.buyItems && o.userData.buyItems.length) meshes.push(o); });
if (!meshes.length) return;
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
const hit = _buyRay.intersectObjects(meshes, false)[0];
if (!hit || hit.distance > 3.0) return;
const mesh = hit.object;
_lp.copy(hit.point); mesh.worldToLocal(_lp);
let best = null, bd = Infinity;
for (const it of mesh.userData.buyItems) {
if (it.sold) continue;
const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2;
if (d < bd) { bd = d; best = it; }
}
if (!best) return;
const item = best.item || {};
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
} else {
flashToast('not enough cash');
}
}
if (digEnabled || wallet) {
addEventListener('keydown', (e) => {
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
const bin = binUnderAim();
if (bin) openDig(bin);
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle
buyShelfUnderAim(); // shelf → buy
});
}
@ -106,6 +138,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
'font:14px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
document.body.appendChild(banner);
// [F R9] transient buy-feedback toast (shelf-buy). Separate from the persistent banner.
const toast = document.createElement('div');
toast.style.cssText =
'position:fixed;left:50%;bottom:60px;transform:translateX(-50%);z-index:21;display:none;' +
'background:rgba(14,28,18,.92);color:#8fffb0;padding:8px 16px;border-radius:8px;' +
'font:600 14px -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
document.body.appendChild(toast);
let _toastT = 0;
function flashToast(msg) { toast.textContent = msg; toast.style.display = 'block'; _toastT = 1.6; }
// interior-grid collision — mirrors interior_test.html walkable(): room bounds + occ==1 fittings.
function walkable(x, z) {
const g = current._debug.grid;
@ -142,6 +184,19 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
const st = counter.userData.keeperStand;
keepers.spawn(current.group, { x: st.x, z: st.z, ry: st.ry, shopId: shop.id, type: shop.type });
}
// [F R9 — interior presence, C→D→F seam] browser rigs at C's browse points, one per patronage
// occupant, capped at 3 (≤3 points). D owns occupancy truth + the rig (KeeperManager browse pose,
// ~1 draw, no player-greet); F reads occupancy and places them. Freed with the room by exit()'s
// keepers.disposeAll(). occupants[i].pedIndex → the exact ped who ducked in off the street.
if (occupancyOf && current.browsePoints && current.browsePoints.length) {
const occ = occupancyOf(shop.id) || { count: 0, occupants: [] };
const pts = current.browsePoints.slice(0, Math.min(occ.count | 0, 3));
pts.forEach((pt, i) => keepers.spawn(current.group, {
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
pedIndex: occ.occupants && occ.occupants[i] ? occ.occupants[i].pedIndex : null,
seedKey: `${shop.id}#${i}`,
}));
}
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
camera.position.set(current.spawn.x, EYE, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
@ -158,6 +213,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// (the shell then calls exit() + setMode('street') — update never disposes on its own).
function update(dt, keys) {
if (!current) return false;
if (_toastT > 0) { _toastT -= dt; if (_toastT <= 0) toast.style.display = 'none'; } // [R9] buy toast decay
// [F2] riffling: the dig owns the whole frame (its own scene/camera + cursor input). No interior
// walk, no exit check — the player is "in the crate" until they close it (Esc / WALK OUT button).
if (dig && dig.active) {
@ -203,6 +259,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
currentShop = null;
currentAdapter = null;
banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0;
if (doorReturn) {
camera.position.set(doorReturn.x, doorReturn.y, doorReturn.z);
camera.rotation.set(0, doorReturn.ry, 0, 'YXZ');

137
web/js/world/tram.js Normal file
View File

@ -0,0 +1,137 @@
// PROCITY Lane B — tram.js
// v2 tram (?tram=1), default-off, NON-BLOCKING. One small bus/tram runs a seeded out-and-back loop
// along the main-street spine, pausing (door dwell) at each bus_shelter stop (busShelterStops), then
// reversing at the ends. Deterministic: the route + speed + dwell are fixed, so position is a pure
// function of elapsed time — same each run. ≤2 draws (a textured body + a merged headlight pair).
// Composes with weather/night (headlights glow at night). Flag-off identical (shell never builds it).
// Bell-ready: ring() is a stub for the future door-bell audio. No traffic sim, no collision (v0).
import * as THREE from 'three';
import { busShelterStops } from './furniture.js';
const SPEED = 9; // m/s cruising
const DWELL = 3.5; // s door pause at each stop
const LANE = 3.2; // offset from the road centreline (drive on the left, AU)
// Ordered spine polyline: walk the chain of MAIN edges from a spine end. Returns [[x,z],…] or null.
function spinePolyline(plan) {
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const mains = plan.streets.edges.filter((e) => e.kind === 'main');
if (!mains.length) return null;
const byNode = new Map();
for (const e of mains) for (const nid of [e.a, e.b]) { if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e); }
let start = null;
for (const [nid, es] of byNode) if (es.length === 1) { start = nid; break; }
if (start == null) start = mains[0].a;
const seq = [start]; const used = new Set(); let cur = start;
for (;;) {
const next = (byNode.get(cur) || []).find((e) => !used.has(e.id));
if (!next) break;
used.add(next.id); cur = next.a === cur ? next.b : next.a; seq.push(cur);
}
return seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; });
}
// bus livery texture (canvas — no fetch): cream body, window band, a red stripe + a route blind.
function busTexture() {
const c = document.createElement('canvas'); c.width = 256; c.height = 128;
const x = c.getContext('2d');
x.fillStyle = '#e8e0cf'; x.fillRect(0, 0, 256, 128); // cream
x.fillStyle = '#233043'; x.fillRect(0, 30, 256, 42); // window band
for (let i = 12; i < 256; i += 34) { x.fillStyle = '#9fb2c8'; x.fillRect(i, 34, 24, 34); } // panes
x.fillStyle = '#9a2f2a'; x.fillRect(0, 78, 256, 10); // red stripe
x.fillStyle = '#1a1a1a'; x.fillRect(96, 6, 64, 20); // route blind
x.fillStyle = '#ffd75e'; x.font = 'bold 15px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText('TOWN LOOP', 128, 16);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4;
return t;
}
export function createTram({ scene, plan, camera, lighting }) {
const route = spinePolyline(plan);
const group = new THREE.Group(); group.name = 'tram';
if (!route || route.length < 2) { scene.add(group); return { group, update() {}, ring() {}, dispose() { scene.remove(group); } }; }
// ── cumulative arc length + stop positions projected to arc length ──
const seg = []; // { x0,z0, dx,dz, len, s0 }
let total = 0;
for (let i = 0; i < route.length - 1; i++) {
const [x0, z0] = route[i], [x1, z1] = route[i + 1];
const dx = x1 - x0, dz = z1 - z0, len = Math.hypot(dx, dz) || 1;
seg.push({ x0, z0, dx: dx / len, dz: dz / len, len, s0: total }); total += len;
}
const posAt = (s) => {
s = Math.max(0, Math.min(total, s));
let g = seg[0]; for (const q of seg) if (s >= q.s0) g = q;
const d = s - g.s0;
return { x: g.x0 + g.dx * d, z: g.z0 + g.dz * d, dx: g.dx, dz: g.dz };
};
// project each shelter stop onto the route → sorted arc-length stop marks
const stopS = busShelterStops(plan).map((st) => {
let best = 0, bd = 1e18;
for (let s = 0; s <= total; s += 3) { const p = posAt(s); const dd = (p.x - st.x) ** 2 + (p.z - st.z) ** 2; if (dd < bd) { bd = dd; best = s; } }
return best;
}).sort((a, b) => a - b);
// ── tram mesh: textured body (1 draw) + merged emissive headlights (1 draw) ──
const bodyMat = new THREE.MeshStandardMaterial({ map: busTexture(), roughness: 0.7, metalness: 0.1 });
const body = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.7, 9.0), bodyMat);
body.position.y = 1.55; body.castShadow = true;
group.add(body);
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: new THREE.Color(0xffe6a0), emissiveIntensity: 0.2, roughness: 0.4 });
const hl = (x) => { const q = new THREE.PlaneGeometry(0.4, 0.3); q.rotateY(0).translate(x, 0.9, 4.55); return q; };
const heads = new THREE.Mesh(mergeQuads([hl(-0.8), hl(0.8)]), headMat);
group.add(heads);
scene.add(group);
// ── deterministic schedule: position s(t) with dwell at stops, ping-pong at the ends ──
let s = 0, dir = 1, dwell = 0, nextStop = 0;
const EPS = 2.5;
function update(dt) {
if (dwell > 0) { dwell = Math.max(0, dwell - dt); }
else {
s += dir * SPEED * dt;
// stop dwell when passing a mark in the travel direction
if (dir > 0 && nextStop < stopS.length && s >= stopS[nextStop] - EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop++; }
else if (dir < 0 && nextStop >= 0 && stopS[nextStop] != null && s <= stopS[nextStop] + EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop--; }
if (s >= total) { s = total; dir = -1; nextStop = stopS.length - 1; }
else if (s <= 0) { s = 0; dir = 1; nextStop = 0; }
}
const p = posAt(s);
// sit in the left lane relative to travel direction, face along travel
const perpx = -p.dz * dir, perpz = p.dx * dir;
group.position.set(p.x + perpx * LANE, 0, p.z + perpz * LANE);
group.rotation.y = Math.atan2(p.dx * dir, p.dz * dir);
headMat.emissiveIntensity = (lighting && lighting.isNight && lighting.isNight()) ? 1.6 : 0.15;
}
update(0);
function ring() { /* door-bell hook — audio parked (V2_IDEAS greenfield) */ }
function dispose() {
body.geometry.dispose(); bodyMat.map.dispose(); bodyMat.dispose();
heads.geometry.dispose(); headMat.dispose();
scene.remove(group);
}
return { group, update, ring, dispose, get stops() { return stopS.length; } };
}
function mergeQuads(geos) {
// tiny local merge (headlight quads share a material) — avoids importing BufferGeometryUtils for 2 quads
let n = 0; for (const g of geos) n += g.attributes.position.count;
const pos = new Float32Array(n * 3), uv = new Float32Array(n * 2), nor = new Float32Array(n * 3);
const idx = []; let vo = 0;
for (const g of geos) {
const gp = g.attributes.position.array, gu = g.attributes.uv.array, gn = g.attributes.normal.array;
pos.set(gp, vo * 3); uv.set(gu, vo * 2); nor.set(gn, vo * 3);
const gi = g.index ? g.index.array : [...Array(g.attributes.position.count).keys()];
for (const i of gi) idx.push(i + vo);
vo += g.attributes.position.count; g.dispose();
}
const m = new THREE.BufferGeometry();
m.setAttribute('position', new THREE.BufferAttribute(pos, 3));
m.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
m.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
m.setIndex(idx);
return m;
}

View File

@ -88,7 +88,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu
transparent: true, depthWrite: false,
uniforms: {
uTime: { value: 0 }, uTex: { value: streakTexture() }, uBoxH: { value: BOXH },
uSpeed: { value: 32 }, uSize: { value: 9.0 }, uWind: { value: 0.18 }, uOpacity: { value: 0.32 + state.intensity * 0.3 },
uSpeed: { value: 32 }, uSize: { value: 5.5 }, uWind: { value: 0.18 }, uOpacity: { value: 0.34 + state.intensity * 0.28 },
},
vertexShader: `
uniform float uTime, uBoxH, uSpeed, uSize, uWind; attribute float aPhase;
@ -99,7 +99,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu
p.x += (uBoxH * 0.5 - p.y) * uWind; // wind slant
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uSize * 34.0 / max(-mv.z, 1.0);
gl_PointSize = clamp(uSize * 30.0 / max(-mv.z, 1.0), 2.0, 22.0); // capped so near drops stay streaks, not blobs
}`,
fragmentShader: `
uniform sampler2D uTex; uniform float uOpacity;