Compare commits

...

6 Commits

Author SHA1 Message Date
m3ultra
34145e24a7 Lane D round-9: interior presence — occupancy truth + browser rigs (C->D->F seam)
Follow a ped into a shop and find them browsing. D owns occupancy truth; C owns browse points;
F wires the handoff. Validated end-to-end in-shell (?stock=real). qa --strict GREEN; v1 untouched.

Occupancy (D1): patronage records who's inside which shop by shopId. New
citizens.occupancyOf(shopId) -> {count, occupants:[{seed,enteredAt,pedIndex}]} (pedIndex = the
exact ped who ducked in, for identity continuity). Cleaned on emerge/chunk-drop/stream-toggle.
Deterministic: 17 shops + occupants byte-equal across two runs (fixed a stale-occupancy leak on
stream toggle).

Browsers: keepers.js gained browse:true (faces the shelf via ry, no player-greet) + pedIndex +
seedKey. Each = a merged ped ~1 draw. 12 enter/exit cycles: count == min(occupancy,3) 12/12,
worst 61 draws <=350 (C's headroom holds), leak-free (0 GPU delta after warmup), disposed every
exit via keepers.disposeAll().

Consistency (D2): inside peds hidden on the street (0 rendered), re-emerge at the door.

F handoff verified by running F's exact interior_mode wiring (enter -> occupancyOf ->
browsePoints.slice(0,min(count,3)) -> keepers.spawn). index.html setShops door points need
shopId:s.id added. Closing-time handled by the dwell model (pending A's explicit ruling).

Evidence: docs/shots/laneD/r9_browsers_in_shop.jpg. Changed sim.js + keepers.js + test page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:21:10 +10:00
m3ultra
9c7c65f57c Lane C round-9: draw-headroom note for D's browser rigs (F1 handoff)
Measured worst interior with 3 two-draw rig proxies at browse points:
68 draws (opshop/hall) in the ?stock=real path — browsers add ≤6, ample
headroom. Flags the one tight spot for D/F: pre-existing GLB-on opshop/
hall is 344/350, so keep rigs impostor-cheap if ever stacked on GLB
hero-props there; F's sweep-with-browsers confirms ≤350.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:57:55 +10:00
m3ultra
c68bba8f32 Lane C round-9 C2: buy-anywhere — pull-and-buy on book spines + toy boxes
Book/toy shelves get pull-and-buy over the SAME wallet; records keep the
deep dig. New stockpack.buildBuyableShelf(): a real shelf is merged,
per-item-ADDRESSABLE meshes — one per atlas (all covers drawn from one
seeded atlas → one draw/shelf), tagged noBatch so batch.js leaves them
intact. Aim a spine/box → pull card (title/author-or-maker/$price/band
from the pack index) → BUY debits the wallet, adds to inventory, and
collapseBuyItem() zero-areas the item's quad in place (removed from the
shelf, no geometry churn). Parody stock → no buy mesh → no card. Fail-
soft per pack; atlasPool() keeps each shelf single-atlas (one draw).

Test page: E interacts (bin→dig, shelf→card) + a #shelfBuy pull card;
shelfBuySoak() buys one item per book/toy seed. Validated: raycast→card
→BUY end-to-end (quad collapses to centre, cash/inventory update),
buy-soak 6/6 collapseOk leak geo0/tex0, ?stock=real draws record 41/
book 58/toy 57 ≤350, drawSweep GLB-off 168/on 344 unchanged, placement
determinism 0 fails, qa --strict GREEN 5/5. Shot: browse_buy_r9.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:56:21 +10:00
m3ultra
5ae635a1e9 Lane C round-9 C1: seeded browse points (room.browsePoints) for Lane D rigs
layout.js placeBrowsePoints(): 1–3 deterministic floor poses in front of
browsable floor fittings, facing the goods. Guarantees (all asserted):
walkable (occ-free, not reserved), reachable from spawn (door→counter
flood-fill), ≥0.6m apart + clear of keeper stand, every archetype (rich
candidate ring — 4 sides×2 gaps + diagonals — plus a nearest-free-cell
fallback so tight/mismatched rooms still yield ≥1). Pure data: no meshes,
no draws; ≤350 law untouched. Exposed as room.browsePoints[]
{x,z,ry,atKind,slotIndex}, same coord frame + ry convention as
counter.stand (contract in LANE_C_NOTES).

Validated headless: 180 type×archetype×seed combos → 0 empties (177×3,
3×1 via fallback), determinism 0 fails, min-sep 0.7m, 0 keeper clashes.
Test page: cyan browse-post markers on the 'path' overlay + HUD count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:36:26 +10:00
m3ultra
5529d62263 Lane C round-9 handshake: browse-points interface (C→D→F seam)
Publishes decision #1's contract FIRST (before code) so D and F build
in parallel: room.browsePoints[] (0..3, deterministic per shop.seed) —
{x,z,ry,atKind,slotIndex}, same coord frame + ry convention as
counter.stand. Guarantees: walkable, reachable (door→counter flood-
fill), ≥0.6m spaced, every archetype. C adds no meshes/draws; D's rigs
ride the ≤350 interior budget. Includes the D→F→C handoff (occupancy
count → browsePoints.slice(0,min(count,3)) → one rig per point).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:27:12 +10:00
m3ultra
e8f92a0da2 Fable (integrator): round-9 lane instructions — the v2.0 ship round (interior presence, buy-anywhere, tram, tour, docs freeze)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:16:59 +10:00
16 changed files with 742 additions and 38 deletions

View File

@ -3,7 +3,43 @@
*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-15 (round 8) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake +
working code; C2 widens buying beyond the dig.
**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes
`room.browsePoints[]` — 13 deterministic floor poses in front of browsable fittings, facing the goods,
where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as
`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law
is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed
first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via
fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
`path` overlay + a HUD count.
**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**;
records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged,
per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a
pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one
draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack.
Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 ·
book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg).
**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first
`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
---

View File

@ -1,5 +1,27 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.
Built the D side of the one cross-lane seam (C browse points → D occupancy+browsers → F handoff).
Follow a ped into a shop and find them browsing. Full numbers + F wiring in `LANE_D_NOTES.md`.
- **Occupancy truth (D1)**: patronage records who's inside which shop by shopId. New
`citizens.occupancyOf(shopId) → {count, occupants:[{seed,enteredAt,pedIndex}]}`. Cleaned on
emerge/chunk-drop/stream-toggle. **Deterministic** (17 shops + occupants byte-equal across two runs).
- **Browser rigs**: `keepers.js` gained `browse` (faces shelf, no greet) + `pedIndex` (the browser IS
the ped who ducked in) + `seedKey`. Validated 12 enter/exit cycles: count == min(occupancy,3) **12/12**,
worst **61 draws ≤350**, **leak-free** (0 GPU delta), disposed every exit. Merged peds = ~1 draw each.
- **Consistency (D2)**: inside peds hidden on the street (0 rendered), re-emerge at the door.
- **F handoff** (verified by running F's exact wiring): enter → `occupancyOf` → `browsePoints.slice(0,
min(count,3))` → `keepers.spawn({...browse:true, pedIndex})`. `web/index.html` `setShops` door points
need `shopId: s.id` added. Closing-time handled by the dwell model (pending A's explicit ruling).
- Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg`. v1 path untouched.
Changed `web/js/citizens/sim.js` (occupancy) + `keepers.js` (browser mode) + `citizens_test.html`
(shopId in synthetic shops).
---
## 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

View File

@ -1,5 +1,86 @@
# LANE C — cross-lane notes (PROCITY-C)
## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live)
**Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref
`lane-c/round9-browse`). The contract below is final — build against it. It is the seam the whole round
hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.**
**F smoke (warn):** enter a `?stock=real` book/toy/record shop, assert `room.browsePoints.length >= 1`
and every point walkable+reachable (or just trust C's asserts and check `.length` + that D's rig count ==
`min(occupancy,3)`). Test page: the `path` overlay draws each point as a cyan post + facing nose.
**Draw headroom for D's rigs** (I measured with 3 two-draw rig proxies standing at the points): worst
interior in the `?stock=real` path is **68 draws** (opshop/hall) — browsers add ≤6, tons of room. The one
tight spot is the *pre-existing* GLB-on worst (opshop/hall **344/350**, unrelated to browsers): if D's rigs
are ever on TOP of GLB hero-props in that room, keep them impostor-cheap (≤2 draws each, the keeper pattern)
and F's sweep-with-browsers should confirm ≤350. Everywhere else there's ample headroom.
### What C exposes on the room API (live once C1 commits)
```jsonc
room.browsePoints: BrowsePoint[] // 0..3 entries, DETERMINISTIC per shop.seed, stably ordered
BrowsePoint = {
x, z, // room-local floor position (metres). Origin = room centre, +Z = door/street side,
// Z = back — the SAME frame as room.spawn / room.counter.stand.
ry, // facing yaw, oriented to look AT the shelf. SAME convention as room.counter.stand.ry
// (rig-front = local Z, i.e. the value keepers.js already consumes). Drop your
// browser rig in with rotation.y = ry and it faces the goods.
atKind, // string — the fitting kind being browsed ('bin' | 'shelf' | 'rack' | 'table' | 'case' …).
// Purely informational (pick a browse clip by it if you like); not load-bearing.
slotIndex, // 0-based, == array position. Stable across rebuilds of the same seed.
}
```
**Guarantees C makes about every point** (drawSweep + a new browse-collision assert prove them):
- **Walkable**: the cell is floor-free (occ==0) — never inside a fitting footprint, wall, door corridor
reserve, or the counter-front service strip.
- **Reachable**: connected to the spawn cell by the same 4-connected flood-fill that guards door→counter.
- **Spaced**: points are ≥0.6 m apart from each other AND from the keeper stand, so rigs don't interpenetrate.
- **Deterministic**: pure function of `shop.seed`; same seed → same points, same order.
- **Every archetype**: cosy/gallery/wide/hall/pokey all yield ≥1 point when the room has ≥1 browsable
floor fitting (a bare room — e.g. a tiny pokey with only a counter — may yield 0; treat 0 as "no browsers").
C adds **no meshes and no draws** for browse points — they are pure poses. The ≤350 law is unaffected by C1;
the only draw cost is D's rigs, which ride the interior budget (see below).
### The D → F → C handoff (who calls whom)
1. **D owns occupancy truth.** Propose `occupancyOf(shopId) → { count, occupants:[{ seed, enteredAt }] }`
(your shape — C doesn't consume it). Count is however many peds patronage says are inside, pre-cap.
2. **F wires it at interior build.** On enter, F reads D's occupancy, then:
`const pts = room.browsePoints.slice(0, Math.min(occ.count, 3));` and asks D's fleet to stand one
browser rig per `pt` at `(pt.x, pt.z)`, `rotation.y = pt.ry`. **Cap 3** is enforced by there being ≤3
points — F just clamps count. Identity: pair `occupants[i].seed` with `pts[i]` so the same ped is the
same rig deterministically.
3. **Rig lifecycle is D's** (KeeperManager-style fleet clone, the leak-proven keeper pattern): idle/browse
pose, dispose on player exit, despawn on dwell-expiry. Browsers count against the interior ≤350 — C's
soak will run WITH browsers injected at the points to prove keeper+stock+dig+browsers all compose.
**Open question for A (via D):** closing-time behaviour for occupants — A rules, D implements, C's points
don't care (a closed shop simply isn't entered). No blocker on C.
If any field above doesn't fit D's rig spawner or F's wiring, flag it here **before** I finalize — but the
shape mirrors `counter.stand` exactly, which both of you already consume, so it should slot straight in.
## → Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn)
Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a
merged, per-item-addressable mesh: `mesh.userData.buyMesh === true`, `mesh.userData.buyItems = [{item,
center, vStart}]`, one draw per shelf (drawSweep with `?stock=real`: record 41 · book 58 · toy 57, ≤350).
Smoke recipe (headless — no raycast needed):
```js
const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' });
let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; });
assert(mesh); // real book/toy shop has ≥1 buy mesh
const it = mesh.userData.buyItems[0]; // {item:{title,artist,price,price_band}, center, vStart}
// buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it);
// assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free
```
Test page exposes `shelfBuySoak()` (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and
`shelfUnderAim()/showShelfCard()/buyShelfOffer()` for the live raycast path (E key: bin→dig, shelf→card).
Parody stock (no pack) has **no** buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack.
## → 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:

View File

@ -6,6 +6,56 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below)
**Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points;
F wires the handoff. Validated end-to-end in-shell (`?stock=real`). `qa.sh --strict` GREEN; v1
(`?roster=v1`) path untouched.
### D owns occupancy truth ✅ (the C→D→F seam)
Patronage now records who's inside which shop, keyed by `shopId` (from the door point). New API:
```js
citizens.occupancyOf(shopId) → { count, occupants: [{ seed, enteredAt, pedIndex }] }
```
`count` = peds patronage currently has inside (pre-cap); `pedIndex` lets a browser BE the exact ped who
ducked in off the street (identity continuity). Occupancy is cleaned on emerge, on chunk-drop, and on
stream toggle. **Deterministic**: same seed + same update sequence → identical occupied shops AND
occupants (verified: 17 shops, seed:pedIndex byte-equal across two runs).
- **Consistency (D2)**: an `inside` ped is hidden on the street (not in the rendered set — verified 0
inside peds rendered) and re-emerges at the door. No double-presence.
### Browser rigs ✅ (KeeperManager, the leak-proven pattern)
`keepers.js` gained `browse:true` (faces the shelf via `ry`, **no player-greet**) + `pedIndex`
(pick the exact fleet ped) + `seedKey` (distinct browsers per shop). Each browser is a **merged ped
= ~1 draw**. Validated over **12 enter/exit cycles**: browser count == `min(occupancy, 3)` **12/12**,
worst interior **61 draws ≤350** (C's headroom holds), **leak-free (0 GPU delta after warmup)**,
disposed on every exit (`keepers.disposeAll()` in interior_mode).
### → Lane F: the handoff in `web/js/world/interior_mode.js` (F owns it) — verified working
Two-line wiring at interior build (F already has `keepers` + `current.browsePoints` from C):
```js
// on enter(shop), after buildInterior:
const occ = citizens.occupancyOf(shop.id);
const pts = (current.browsePoints || []).slice(0, Math.min(occ.count, 3)); // cap 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[i] ? occ.occupants[i].pedIndex : null,
seedKey: `${shop.id}#${i}`,
}));
// exit() already calls keepers.disposeAll() → browsers freed with the room. Leak-proven.
```
And **`web/index.html`**: `citizens.setShops(...)` door points must carry `shopId: s.id` (add it to the
R8 recipe — one field). Everything else (patronage, weather) is unchanged.
**Closing-time (pending A's ruling):** my dwell model already handles it sensibly — `_openAt(hours)`
stops *new* entries once a shop closes, and existing occupants clear within their short seeded dwell
(520 s, tiny vs the hour-long closing window), so no one is stuck inside a shut shop. I'll adopt A's
explicit ruling when it lands if it differs.
Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg` (two browsers in a toy shop among the priced stock).
---
## 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

View File

@ -1,5 +1,9 @@
# PROCITY — Round 8 lane instructions (from Fable, integrator)
> **⚠ SUPERSEDED by [ROUND9_INSTRUCTIONS.md](ROUND9_INSTRUCTIONS.md) (2026-07-14).** Round 8 is
> complete with NO slips — weather, Katoomba, 3 stock packs, buy loop, patronage all live;
> v2.0-rc tagged (`9cc00ad`) and pushed. Historical reference only.
Date: 2026-07-14 · Written after the Fable round-7 review (`16d4f2b`) and the **v2.0-beta
tag** (`4feba02`). You are an Opus 4.8 agent assigned ONE lane (read your session prompt).
Read this file, your lane spec, your `<X>-progress.md`, and NOTES addressed to you first.

View File

@ -0,0 +1,201 @@
# PROCITY — Round 9 lane instructions (from Fable, integrator)
Date: 2026-07-14 · Written after the Fable round-8 review. You are an Opus 4.8 agent
assigned ONE lane (read your session prompt). Read this file, your lane spec, your
`<X>-progress.md`, and NOTES addressed to you first.
Repo state: `main` at `9cc00ad` = `v2.0-rc`, **fully pushed** (origin synced, all five
version tags live). `tools/qa.sh --strict` GREEN 5/5 with **zero warnings** — every smoke
incl. the warn-level ones (buy, patronage, book/toy stock, katoomba) is green. Round 8 had
**no slips**: weather, Katoomba, three real-stock packs (record 350 / book 311 / toy 273),
buy loop, patronage all live. Goldens: synthetic `0x3fa36874` · melbourne `0x34cfdec0` ·
katoomba `0x0f652510`. fal ~$21.36.
## Theme: ship v2.0
The rc is healthy and warning-free. Round 9 hardens it into **`v2.0`**: graduate every
smoke to strict, make the crowd visible *inside* shops, widen buying beyond the dig,
re-shoot the tour so it shows the living town, and freeze the docs. One new toy (B's tram)
rides along but does NOT block the tag.
What v2.0 means when tagged: seed → real-or-synthetic AU town → streamed streets with
weather and a living crowd that shops → every door opens → real browsable stock you can
buy with a wallet → all of it deterministic, leak-free, budget-clean, and enforced by a
strict harness.
## Prime law (unchanged post-amendment)
Default boot = streamed roster + patronage at the pinned baseline; plan goldens frozen;
new features behind default-off flags; flags compose; qa strict green at every commit.
Baseline re-pins only by the one-commit protocol.
## Integrator decisions (so nobody blocks)
1. **Interior presence (the round's one cross-lane seam — C↔D, F wires).** Peds who duck
into a shop should be *there* when the player follows. Contract: D tracks per-shop
occupancy (who's inside + their identity seeds, already implied by patronage state);
C exposes 13 **browse points** per room in the room API (aisle spots, like the keeper's
counter spot); F passes occupancy → interior build so browser rigs (KeeperManager-style
fleet clones, idle/browse pose) stand at browse points. Cap 3/room; ≤350 draws holds;
leak-free like keepers (proven pattern). D owns occupancy truth, C owns points + room
fit, F owns the handoff. Agree the two interfaces in NOTES before code (half-day
handshake rule).
2. **Buy-anywhere scope (C):** book/toy shelves get pull-and-buy (aim at a real-stock
spine/box → pull-out card with title/price → BUY via the same wallet). No new dig
mechanics — reuse the pull-panel pattern. Record digs stay the deep interaction.
3. **Tram (B): now a real task, but explicitly NON-BLOCKING for v2.0** (no-hostage rule
pre-applied). `?tram=1`: one instanced tram on a seeded main-street loop over the
bus_shelter stops, door-pause at each, budget-neutral (≤2 draws). If it lands, it
enters the harness at warn and the tour gets a shot.
4. **Audio stays parked** — STANDING ASK TO JOHN (third round running): one decision line
("generate ambience freely" / "use library X" / "skip for v2") unlocks street ambience,
rain-on-roof, and the milk-bar door bell. Until then no lane touches audio.
5. **fal: no new spends** (broken-prop clause stands).
6. **Tag `v2.0`** (F, end of round) when: all round-8 warn smokes strict + green ·
interior presence live + smoked · buy-anywhere live + smoked · v2 tour re-shot ·
full-defaults soak green · docs frozen. Tram/third-town slippage does not block.
This is the epoch tag — after it, Fable + John pick the v3 epoch (editor/overrides vs
venue district vs multiplayer presence — V2_IDEAS).
## Process rules (unchanged)
- ONE shared working tree; exact-path staging; options before `--`; new files `add`ed
first; durable lane refs; qa green at every commit; progress docs + NOTES.
- F owns `web/index.html` + `tools/`; document call sites for F.
- Slip rule: partial commit + cut-line note, never silent.
- `procityprogress.txt` / `settings.local.json` untracked; staging dirs gitignored.
- Repo `/Users/m3ultra/Documents/procity`; dev `:8130`.
## Sequencing
```
C1 (browse points) ──┬──→ D1 (occupancy + browsers) ──→ F1 (wire handoff + smoke)
└──→ C2 (buy-anywhere)
B1 (tram, non-blocking) · A1 (contract freeze + docs) · E1 (VRAM audit) — parallel
all land ──→ F2 (graduate smokes strict) ──→ F3 (tour + soak) ──→ F4 (tag v2.0)
```
C starts first (both seams hang off the room API). F1 needs C1+D1.
---
## Lane A (Citygen) — freeze the contract, polish the docs
Small round — you're the most "done" lane; make it official.
1. **CITY_SPEC v2 contract freeze**: document the final CityPlan producer contract as
built — both sources, the town-key mechanism, the goldens table (synthetic/melbourne/
katoomba + how to pin a new one), the normalization rules the osm importer applies,
the openLate/hours/storeys laws. This becomes the reference for the v3 epoch (the
editor/override layer reads THIS contract).
2. **Recipe sanity**: dry-run your own "add a town" recipe against the doc (don't ship a
third town unless it falls out for free — if it does, pin its golden and tell F).
3. **On-call** for D's patronage/occupancy questions (shop doors, hours edge cases —
e.g. what happens to occupants at closing time; give D a ruling and document it).
**Acceptance**: CITY_SPEC v2 section complete + matches reality (F will diff claims vs
code in review); closing-time ruling documented; qa green.
## Lane B (Streetscape) — the tram (non-blocking) + tour poses
1. **`?tram=1`** (decision #3): one instanced tram/small bus, seeded main-street loop
over your bus_shelter stops, door-pause dwell at each stop, bell-ready (no audio yet).
Deterministic (same seed → same schedule), budget ≤2 draws, composes with weather/
night (headlights at night if cheap). Flag-off identical. It slipping does NOT block
v2.0 — commit partial + cut line if needed.
2. **v2 tour bookmarks** (F shoots this round — do early): add named poses for the new
life — `rain_verandah` (rain through a lit winmap window), `patronage_door` (peds at a
shop door), `dig_real` (a bin of real covers), `katoomba_main` (2nd town), `tram_stop`
(if tram lands), plus your judgment for 23 more. Verify via `shots.py`.
3. **Weather polish debt** (only if quick): any visual nits from your own R8 notes.
**Acceptance**: tram loops deterministically under budget (or documented cut); tour poses
live + un-letterboxed; qa green.
## Lane C (Interiors) — browse points + buy-anywhere
1. **C1 — browse points (DO FIRST — D and F build on it).** Room API: 13 seeded browse
points per room (position + facing, aisle-adjacent, never blocking the door or the
counter), exposed like the keeper's counter spot. Deterministic per shop.seed; works
in every archetype (drawSweep proves no collisions with fittings). Document the
interface in LANE_C_NOTES *first* (decision #1 handshake).
2. **C2 — buy-anywhere (decision #2):** aiming at a real-stock spine/box on book/toy
shelves raises the pull-out card (title/artist-or-maker/price from the pack index);
BUY debits the wallet, removes the item from the shelf (your batched-stock
addressability from R6 — same trick as dug bins), adds to inventory. Parody stock =
not buyable (card shows "not for sale" or simply no card — your call, document it).
Fail-soft, deterministic, leak-free, ≤350 draws; extend the soak to buy 1 shelf item
per room type.
3. **Support F1**: when browsers spawn at your points, verify keeper/stock/dig all still
compose in a room WITH browsers (the soak covers it).
**Acceptance**: browse points in every archetype, documented, D/F consuming; shelf
buying works end-to-end with the wallet; soaks green; ≤350; qa green.
## Lane D (Citizens) — occupancy truth + browsers inside
1. **D1 — occupancy + browser rigs (after C1's interface note).** Track per-shop
occupancy from patronage (who ducked in: identity seed + entry time + seeded dwell).
When the player enters an occupied shop, browser rigs (fleet clones, KeeperManager-
style lifecycle — the leak-proven pattern) stand/idle at C's browse points, matching
occupancy count (cap 3, decision #1). They exit on dwell-expiry (walk-out tell if
cheap, despawn otherwise); at closing time follow A's ruling. Deterministic: same
seed + same time → same occupants. Leak-free over N enter/exit cycles (extend your
soak). Coordinate the F-facing handoff shape (occupancy query per shopId) in NOTES.
2. **D2 — street↔interior consistency tells**: the ped who ducked in shouldn't ALSO be
visible on the street (your despawn already handles it); re-emergence spawns at the
door. Verify with patronage + occupancy both on.
3. Budget: browsers ride the interior ≤350 law (C's sweep with browsers on proves it).
**Acceptance**: follow a ped into a shop and find them browsing; count matches
occupancy; deterministic + leak-free + ≤350 with browsers; closing-time behavior per A's
ruling; qa green.
## Lane E (Assets) — the VRAM/texture audit + on-call
1. **E1 — memory audit (the ship-round due diligence).** Nobody has measured total
texture/VRAM weight since the packs landed. Audit: all atlases (3 stock packs), skins,
facade atlas, ped fleet, GLB textures — sizes on disk + estimated GPU memory + what's
resident in a default boot vs `?stock=real` boot vs worst case (all flags). Numbers in
AUDIT.md + a one-para verdict (fine / needs compression pass / needs lazy-load).
If a cheap win exists (e.g. a pack atlas that should be WebP/half-res), take it —
bigger restructuring gets flagged for v3, not done now.
2. **E2 — depot + manifest hygiene**: `validate --depot` + pack-QA + provenance-drift all
green post-R8 (they should be — confirm); prune any orphaned depot files your audit
finds (list first in NOTES, delete only your own procity_* uploads).
3. **On-call**: B may want a tram texture/GLB (procedural/primitive first — a box tram
with the right silhouette beats a heavy model; flux/local gen only if it truly reads
wrong); C may want a "browse pose" check on fleet clips (that's D's domain — redirect).
**Acceptance**: audit published with numbers + verdict; cheap wins taken; depot hygiene
confirmed; qa green.
## Lane F (Integration) — graduate, shoot, freeze, tag
1. **F1 — interior-presence wiring (after C1+D1):** occupancy query → interior build →
browser spawn at browse points; smoke at warn (enter occupied shop, assert browser
count > 0 matching occupancy, 0 errors, dispose clean on exit).
2. **F2 — graduate round-8 smokes to strict**: buy-v0, patronage, book/toy stock,
katoomba golden. New this round at warn: interior presence, shelf-buy, tram (if B
lands). Everything green before F3.
3. **F3 — the v2 tour + full-defaults soak**: re-shoot `docs/shots/v2_tour/` (12 shots:
B's new poses + the classics re-taken on current defaults — busy street, rain money
shot, dig with real covers, browsers in a shop, katoomba, night video-shop crowd,
tram if landed) + contact sheet. Full soak on pure defaults (no flags) + the all-on
combo; both leak-free, 0 errors.
4. **F4 — docs freeze + tag `v2.0`** (decision #6): runbook gate table updated to the v2
reality; flags table final (what's default, what's flagged, what graduated);
README/CITY_SPEC claims match code (diff A's contract freeze vs reality as part of
review); F-progress round-9 section. Tag with a proper epoch summary. Local tag —
John pushes (the push pattern is proven now).
5. **Marshal**: C1 is the round's single point of failure (two lanes build on it) —
check for its commit early; if C stalls, escalate in F-progress immediately.
**Acceptance**: presence wired + smoked; all r8 smokes strict; tour + soaks green;
docs frozen; `v2.0` tagged on an all-green tree.
---
*— Fable (integrator). Nine rounds from empty repo to this: a seed becomes a town where
it rains, the crowd shops, and you can follow someone into a record store, flip past
three hundred real sleeves, and buy one. Round 9 makes that official. Commit atomically,
pin your refs, leave it green.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

View File

@ -191,7 +191,7 @@ if (STREAM && PATRONAGE) {
[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' };
const s = { x, z, shopId: `e${i}s${side}`, 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 }));

View File

@ -72,6 +72,7 @@ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.j
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';
import { collapseBuyItem } from './js/interiors/stockpack.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';
@ -171,6 +172,7 @@ function updateHUD() {
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
`places ${current.places.length} · counter ${current.recipe.counterPos}` +
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '') +
`\nbrowse points ${(current.browsePoints || []).length} (Lane D rigs)` +
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
}
function glbCount() {
@ -223,6 +225,17 @@ function buildPathHelper() {
noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry;
grp.add(post, noseGrp);
}
// browse points (R9) — where Lane D stands browser rigs. Cyan post + white nose = facing the goods.
for (const bp of (current.browsePoints || [])) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
new THREE.MeshBasicMaterial({ color: '#2ec8d6', transparent: true, opacity: 0.6 }));
post.position.set(bp.x, 0.8, bp.z);
const noseGrp = new THREE.Group();
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' }));
nose.position.set(0, 0, -0.4); // local Z = facing (same convention as keeper)
noseGrp.add(nose); noseGrp.position.set(bp.x, 1.4, bp.z); noseGrp.rotation.y = bp.ry;
grp.add(post, noseGrp);
}
pathHelper = grp; scene.add(grp);
}
function applyWire() {
@ -408,6 +421,107 @@ async function digSoak(N = 15) {
return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
}
// ── buy-anywhere (round 9 C2): aim at a real-stock book spine / toy box → pull card → BUY ──
// Records keep the deep dig; shelves get a light pull-and-buy over the SAME wallet. Reuses the
// batched-but-addressable buy meshes from stockpack.buildBuyableShelf. Parody stock has no buy mesh
// → no card ("not for sale"). UI is test-page-local; the reusable pieces are the mesh data + wallet.
const _buyRay = new THREE.Raycaster();
const _lp = new THREE.Vector3();
const shelfPanel = document.createElement('div'); shelfPanel.id = 'shelfBuy';
const shelfCss = document.createElement('style'); shelfCss.textContent = `
#shelfBuy{position:fixed;top:50%;right:26px;transform:translateY(-50%);z-index:72;width:230px;background:rgba(18,15,10,.96);border:1px solid #b58b3a;border-radius:10px;padding:14px 15px;font:12px "Courier New",monospace;color:#e8e2d4;display:none}
#shelfBuy h3{margin:0 0 2px;font-size:15px;color:#ffd75e}
#shelfBuy .sub{color:#b8b0a0;margin-bottom:8px}
#shelfBuy .price{font-size:20px;color:#3dff8b;margin-bottom:10px}
#shelfBuy button{width:100%;padding:9px;background:#1b2436;border:1px solid #3d5273;color:#cfe0ff;font:700 13px "Courier New",monospace;border-radius:6px;cursor:pointer}
#shelfBuy button:disabled{opacity:.45;cursor:not-allowed}
#shelfBuy .x{position:absolute;top:8px;right:11px;color:#8a8272;cursor:pointer}`;
document.head.appendChild(shelfCss); document.body.appendChild(shelfPanel);
let shelfOffer = null; // { mesh, item } currently on the card
// Collect buy meshes in the live room, raycast from crosshair, return the nearest unsold item.
function shelfUnderAim(maxDist = 3.0) {
if (!current) return null;
const meshes = []; current.group.traverse(o => { if (o.userData && o.userData.buyMesh && o.userData.buyItems.length) meshes.push(o); });
if (!meshes.length) return null;
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
const hit = _buyRay.intersectObjects(meshes, false)[0];
if (!hit || hit.distance > maxDist) return null;
const mesh = hit.object;
_lp.copy(hit.point); mesh.worldToLocal(_lp); // hit → mesh-local (== item centre space)
let best = null, bd = Infinity;
for (const it of mesh.userData.buyItems) { 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; } }
return best ? { mesh, item: best } : null;
}
const priceOf = (it) => it.price || (it.price_band === 'grail' ? 60 : it.price_band === 'collector' ? 30 : 8);
function showShelfCard(offer) {
shelfOffer = offer; const it = offer.item.item;
const price = priceOf(it), afford = wallet.canBuy(price);
shelfPanel.style.display = 'block';
shelfPanel.innerHTML =
`<span class="x">×</span><h3>${it.title || 'Untitled'}</h3>` +
`<div class="sub">${it.artist ? it.artist + ' · ' : ''}${(it.price_band || 'stock')}</div>` +
`<div class="price">$${price}</div>` +
`<button ${afford ? '' : 'disabled'}>${afford ? 'BUY IT' : 'NOT ENOUGH CASH'}</button>`;
shelfPanel.querySelector('.x').onclick = closeShelfCard;
shelfPanel.querySelector('button').onclick = () => buyShelfOffer();
}
function closeShelfCard() { shelfPanel.style.display = 'none'; shelfOffer = null; }
// Buy the carded item: debit wallet, collapse its quad (removed from the shelf), drop it from the pool.
function buyShelfOffer() {
if (!shelfOffer) return false;
const { mesh, item } = shelfOffer, it = item.item, price = priceOf(it);
if (!wallet.buy({ t: it.title, a: it.artist, price, s: it.price_band })) return false;
collapseBuyItem(mesh, item);
const arr = mesh.userData.buyItems; const i = arr.indexOf(item); if (i >= 0) arr.splice(i, 1);
closeShelfCard();
return true;
}
// E interacts: a record bin opens the dig; a real shelf raises the buy card. Works with the dig's E too.
if (STOCK_REAL || DIG_ON) {
addEventListener('keydown', e => {
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
if (dig && dig.active) return;
if (DIG_ON) { const b = binUnderAim(); if (b) { openDigOn(b); return; } } // bins → dig first
const off = shelfUnderAim(); if (off) showShelfCard(off); // shelves → buy card
});
document.getElementById('hint').innerHTML += ' · <b>E</b> bin/shelf · <b>I</b> bag';
}
// Buy-anywhere soak: for each shelf shop type, enter, buy one shelf item, assert wallet+collapse+leak-free.
async function shelfBuySoak() {
const richWallet = createWallet(7);
const realBuy = (o) => richWallet.buy(o);
// warmup: build+render+dispose one book & toy room so the SHARED pack atlases are resident (and thus
// counted in the baseline) — else they'd read as a false texture "leak" on first render (like soak()).
for (const t of ['book', 'toy']) { const w = buildInterior({ id: 'warm', type: t, seed: 5 }, THREE, { stock: 'real' }); scene.add(w.group); renderer.render(scene, camera); scene.remove(w.group); w.dispose(); }
await new Promise(r => setTimeout(r, 0));
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
const out = { types: {}, bought: 0, broke: 0, noMesh: 0, collapseOk: true };
for (const type of ['book', 'toy']) {
for (const s of [1990, 7, 42]) {
const room = buildInterior({ id: type, type, seed: s }, THREE, { stock: 'real' });
scene.add(room.group); renderer.render(scene, camera);
// find a buy mesh + an item; buy it directly (headless — no raycast needed)
let mesh = null; room.group.traverse(o => { if (!mesh && o.userData && o.userData.buyMesh && o.userData.buyItems.length) mesh = o; });
if (!mesh) { out.noMesh++; out.types[type + '/' + s] = 'NO_BUY_MESH'; scene.remove(room.group); room.dispose(); continue; }
const before = mesh.userData.buyItems.length; const item = mesh.userData.buyItems[0]; const it = item.item;
const cashBefore = richWallet.cash(); const price = Math.min(priceOf(it), cashBefore); // never starve the soak
const posBefore = mesh.geometry.attributes.position.getX(item.vStart);
if (realBuy({ t: it.title, a: it.artist, price, s: it.price_band })) {
collapseBuyItem(mesh, item); mesh.userData.buyItems.splice(0, 1);
const pa = mesh.geometry.attributes.position; // all 4 verts must collapse to the centre (zero area)
for (let k = 0; k < 4; k++) if (Math.abs(pa.getX(item.vStart + k) - item.center.x) > 1e-4 || Math.abs(pa.getY(item.vStart + k) - item.center.y) > 1e-4) out.collapseOk = false;
out.bought++;
out.types[type + '/' + s] = { items: before, afterBuy: mesh.userData.buyItems.length, cashΔ: cashBefore - richWallet.cash() };
} else { out.broke++; out.types[type + '/' + s] = 'BROKE'; }
scene.remove(room.group); room.dispose();
}
}
renderer.render(scene, camera);
out.leakGeo = renderer.info.memory.geometries - bG; out.leakTex = renderer.info.memory.textures - bT;
return out;
}
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function frame() {
@ -419,7 +533,8 @@ 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, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter };
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak };
rebuild();
frame();

View File

@ -25,25 +25,29 @@ export class KeeperManager {
this._p = new THREE.Vector3();
}
// spawn(target, { x, z, ry, shopId, type }) → keeper handle. `ry` = the counter's outward facing.
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop' } = {}) {
const r = rng(this.citySeed, 'keeper', shopId);
// spawn(target, { x, z, ry, shopId, type, browse, pedIndex, seedKey }) → keeper handle.
// ry = the counter's outward facing (keeper) / the browse point's shelf-facing yaw (browser).
// browse = R9 interior-presence: a browser rig at a C browse point — faces the goods, no greet.
// pedIndex = pick this exact fleet ped (so a browser IS the ped who ducked in off the street).
// seedKey = per-instance seeding key (e.g. `${shopId}#${slot}`) so browsers at one shop differ.
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null } = {}) {
const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId);
const height = 1.58 + r() * 0.34;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index;
const rig = idx != null && this.fleet.all[idx];
const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() });
if (spawned) { actor = spawned; kind = 'rig'; }
}
if (!actor) { // asset-free fallback
const ph = makePlaceholder(rng(this.citySeed, 'keeper-body', shopId), { height });
const ph = makePlaceholder(rng(this.citySeed, browse ? 'browser-body' : 'keeper-body', seedKey || shopId), { height });
ph.fig.rotation.y = ry;
actor = ph; kind = 'placeholder';
}
actor.fig.position.set(x, 0, z);
target.add(actor.fig);
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type };
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse };
this.keepers.push(k);
return k;
}
@ -67,6 +71,7 @@ export class KeeperManager {
if (a.mixer) a.mixer.update(dt); // rig idle
else a.tick?.(dt, false); // placeholder idle
if (k.browse) continue; // browsers face the goods (ry) — no greet turn
if (pp) {
const dx = pp.x - a.fig.position.x, dz = pp.z - a.fig.position.z;
const dist = Math.hypot(dx, dz);

View File

@ -119,9 +119,10 @@ export class CitizenSim {
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.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
this.patronageOn = true;
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
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
@ -160,14 +161,14 @@ export class CitizenSim {
if (this.streamMode) return;
this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster
if (!this.chunkEdges) this._buildChunkIndex();
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear();
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear(); this._occupancy.clear();
this.streamRadius = radius; this.streamPerChunk = perChunk;
this.streamMode = true; this._hookDriven = false;
}
disableStream() {
if (!this.streamMode) return;
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
this.chunkRosters.clear(); this.streamMode = false;
this.chunkRosters.clear(); this._occupancy.clear(); this.streamMode = false;
}
// rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk
@ -220,7 +221,7 @@ export class CitizenSim {
dropChunk(key) {
const arr = this.chunkRosters.get(key);
if (!arr) return;
for (const c of arr) this._releaseActor(c);
for (const c of arr) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } this._releaseActor(c); }
this.chunkRosters.delete(key);
}
@ -372,13 +373,34 @@ export class CitizenSim {
c.patron = 'inside';
c.patronTimer = 5 + c.patronRng() * 15; // seeded dwell 520s
c.x = c.patronTarget.x; c.z = c.patronTarget.z; // parked at the door while inside (hidden)
// R9 occupancy truth: record who's inside which shop (F reads this to stand browser rigs)
const id = c.patronTarget.shopId;
if (id != null) {
let occ = this._occupancy.get(id); if (!occ) this._occupancy.set(id, occ = []);
occ.push({ seed: c.id, enteredAt: this.timeOfDay, pedIndex: c.pedIndex });
c._occShop = id;
}
}
_emerge(c) {
if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; }
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
}
_removeOccupant(shopId, seed) {
const occ = this._occupancy.get(shopId); if (!occ) return;
const i = occ.findIndex(o => o.seed === seed);
if (i >= 0) occ.splice(i, 1);
if (!occ.length) this._occupancy.delete(shopId);
}
// D's occupancy truth (the C→D→F seam): how many streamed peds patronage currently has inside this
// shop, and who (seed + entry time + ped type). F reads this on interior build, then stands one
// browser rig per browse point for min(count, 3). Count is pre-cap.
occupancyOf(shopId) {
const occ = this._occupancy.get(shopId) || [];
return { count: occ.length, occupants: occ.map(o => ({ ...o })) };
}
// 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() {

View File

@ -17,6 +17,8 @@
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
// // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand)
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@ -119,6 +121,7 @@ export function buildInterior(shop, THREE, opts) {
exits: shell.exits,
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,

View File

@ -124,6 +124,91 @@ function keeperStand(cx, cz, front, W, D) {
return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) };
}
// ── browse points (round 9): where Lane D stands browser rigs ─────────────────────────
// 13 seeded floor poses, aisle-adjacent, in front of a browsable fitting, facing the goods. Pure
// data (no meshes, no draws) — mirrors counter.stand so keepers.js/fleet clones drop straight in.
// Every point is walkable (occ-free, not reserved), reachable from spawn (same flood-fill as
// door→counter), ≥MIN_SEP from each other AND the keeper stand. Deterministic per shop.seed.
const MIN_SEP = 0.6; // min spacing between rigs (m)
const STAND_GAP = 0.55; // how far in front of a fitting a browser stands (m)
function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
// reachable floor cells from spawn (adjust off an occupied spawn cell like reaches() does)
let start = spawnCell;
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
const reach = floodFill(grid, start);
// browsable = surviving floor fittings (not the counter, not wall-mounted). Prefer ones that actually
// carry interactable stock (bins/cases have `places`) so browsers cluster at the goods, then bigger
// fittings, then a seeded tiebreak — all pure functions of seed/geometry (determinism holds).
const cand = placed
.filter(p => !p.removed && !p.noStamp && p.kind !== 'counter')
.map(p => ({ p, jitter: r() })) // draw seeded keys up front so ordering is seed-stable
.sort((a, b) => {
const ap = (a.p.fitting.places || []).length ? 1 : 0, bp = (b.p.fitting.places || []).length ? 1 : 0;
if (ap !== bp) return bp - ap;
const aa = a.p.hw * a.p.hd, ba = b.p.hw * b.p.hd;
if (Math.abs(aa - ba) > 0.05) return ba - aa;
return a.jitter - b.jitter;
})
.map(o => o.p);
const pts = [];
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
// Is (x,z) a valid, unclaimed stand? (walkable free floor · reachable · clear of walls/keeper/other rigs)
const validStand = (x, z) => {
const [cx, cz] = grid.cellOf(x, z);
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
const idx = cz * grid.cols + cx;
if (grid.occ[idx] !== 0 || grid.reserved[idx]) return false;
if (!reach.has(idx)) return false;
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) return false;
if (near(x, z, pts, MIN_SEP)) return false;
return true;
};
const push = (x, z, p) => {
const dx = p.x - x, dz = p.z - z; // stand → fitting; face it (counter.stand ry convention)
pts.push({ x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)), atKind: p.kind, slotIndex: pts.length });
};
// Stand candidates around a fitting: 4 sides at two gaps + 4 diagonals, tried toward-room-centre
// first (the open aisle) so a browser faces OUT of a wall-hugging shelf, never into the wall. Richer
// than a single ring so a valid cell survives even in a tight/pokey room where one side is blocked.
const standsAround = (p) => {
const tX = p.x > 0 ? -1 : 1, gW = p.hw + STAND_GAP, gW2 = p.hw + STAND_GAP + 0.35, gD = p.hd + STAND_GAP;
return [
{ x: p.x + tX * gW, z: p.z }, { x: p.x, z: p.z + gD },
{ x: p.x + tX * gW, z: p.z + 0.5 }, { x: p.x + tX * gW, z: p.z - 0.5 },
{ x: p.x + tX * gW2, z: p.z }, { x: p.x, z: p.z - gD },
{ x: p.x - tX * gW, z: p.z }, { x: p.x - tX * gW, z: p.z + 0.5 },
];
};
for (const p of cand) {
if (pts.length >= 3) break;
for (const s of standsAround(p)) {
if (!validStand(s.x, s.z)) continue;
push(s.x, s.z, p); break; // one point per fitting
}
}
// Fallback: a room with browsable goods but no point yet (all fitting sides boxed in — tight pokey/
// mismatched archetypes) still deserves ≥1 browser. Scan the reachable free floor for the cell nearest
// a browsable fitting and stand there, facing that fitting. Deterministic (grid + cand order are seeded).
if (pts.length === 0 && cand.length) {
let best = null;
for (const idx of reach) {
if (grid.occ[idx] !== 0 || grid.reserved[idx]) continue;
const cx = idx % grid.cols, cz = (idx / grid.cols) | 0;
const x = (cx + 0.5) * grid.cw - grid.W / 2, z = (cz + 0.5) * grid.cd - grid.D / 2;
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) continue;
for (const p of cand) {
const d = Math.hypot(p.x - x, p.z - z);
if (d < 0.5) continue; // not on top of the fitting
if (!best || d < best.d) best = { x, z, p, d };
}
}
if (best) push(best.x, best.z, best.p);
}
return pts;
}
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
function makeWallSlots(W, D, r) {
const slots = [];
@ -240,6 +325,11 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
// 6) BROWSE POINTS (round 9) — 13 seeded floor poses for Lane D's browser rigs, in front of the
// goods, reachable + non-blocking. Pure data (no geometry). Computed AFTER the path guarantee so
// every point sits on a survivor's open side and on a cell reachable from the door.
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
@ -249,7 +339,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
return {
placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved,
spawnCell, targetCell, placement,
spawnCell, targetCell, placement, browsePoints,
counter: {
mesh: counter.fitting.group,
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself

View File

@ -15,7 +15,7 @@
// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
import { pick } from './context.js';
import { realSleeveMesh } from './stockpack.js';
import { realSleeveMesh, buildBuyableShelf } from './stockpack.js';
// Muted 90s stock palette (cardboard, plastic, faded card).
const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
@ -165,19 +165,24 @@ 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++) {
if (pack) { // real book spines → one buyable, addressable mesh
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw
const pls = [];
for (let i = 0; i < n; i++) {
const bh = clear * (0.8 + r() * 0.2);
const x = slot.x - run / 2 + (i + 0.5) * step;
const rz = (slot.lean && i > n - 3) ? 0.18 : 0;
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: step * 0.86, h: bh, rz });
}
const meshes = buildBuyableShelf(ctx, pack, pls);
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
}
for (let i = 0; i < n; i++) { // procedural spines (no pack / merge failed)
const bh = clear * (0.8 + r() * 0.2);
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;
}
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
const 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
}
return;
@ -199,20 +204,25 @@ function shelfLine(ctx, parent, slot, opts, r) {
// multi-material box cost one draw PER face (6×), the biggest draw-count offender pre-round-6.
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
const step = run / n;
for (let i = 0; i < n; i++) {
if (pack) { // real toy boxes → one buyable, addressable mesh
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw
const pls = [];
for (let i = 0; i < n; i++) {
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const x = slot.x - run / 2 + (i + 0.5) * step;
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: bw * 0.88, h: bh });
}
const meshes = buildBuyableShelf(ctx, pack, pls);
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
}
for (let i = 0; i < n; i++) { // procedural cartons (no pack / merge failed)
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
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;
}
const m = ctx.mat('#ffffff', 0.8);
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
const mesh = ctx.box(bw * 0.88, bh, bd, m, x, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
mesh.userData.isStock = true;
}
}

View File

@ -13,6 +13,7 @@
// Fail-soft: index/atlas missing → getStockPack returns null → callers fall back to parody canvas.
import * as THREE_NS from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const _texLoader = new THREE_NS.TextureLoader();
const _packs = new Map(); // type → Promise<pack|null>
@ -44,11 +45,21 @@ async function buildPack(type, idx, base, THREE) {
materials[name] = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.72, map: tex || null });
}));
const first = names[0];
// Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a
// single mesh (one draw), instead of fragmenting into one mesh per atlas the random picks happened to hit.
const byAtlas = {};
for (const name of names) byAtlas[name] = [];
for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it);
const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length);
return {
type,
items: idx.items,
atlases: nonEmpty.length ? nonEmpty : names,
itemsByAtlas: byAtlas,
// shared material for an item's atlas (batching groups sleeves by this)
material(item) { return materials[item.atlas] || materials[first]; },
// items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw)
atlasPool(r) { const a = (this.atlases.length ? this.atlases : names); const name = a[(r() * a.length) | 0]; return byAtlas[name] && byAtlas[name].length ? byAtlas[name] : idx.items; },
// WebGL-flipped cover rect [u0, v0, u1, v1] (origin bottom-left) for a plane's UV
uvRect(item) { const u = item.uv; return [u[0], 1 - u[3], u[2], 1 - u[1]]; },
};
@ -79,3 +90,57 @@ export function realSleeveMesh(ctx, pack, item, w, h) {
mesh.userData.isStock = true;
return mesh;
}
// One quad geometry for a placement, UV-baked to its atlas rect and transformed into fitting-local
// space (so the merged mesh sits at identity). Shared shape with realSleeveMesh, minus the Mesh.
function itemQuad(THREE, pack, pl) {
const g = new THREE.PlaneGeometry(pl.w, pl.h);
const [u0, v0, u1, v1] = pack.uvRect(pl.item);
const a = g.attributes.uv;
a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0);
if (pl.rz) g.rotateZ(pl.rz);
if (pl.rx) g.rotateX(pl.rx);
g.translate(pl.x, pl.y, pl.z);
return g;
}
// BUY-ANYWHERE (round 9): build a shelf of real covers as merged, per-item-ADDRESSABLE meshes — one
// mesh per atlas bucket (so all share a material and draw once), tagged `noBatch` so batch.js leaves
// them intact (they're already one draw each). Each mesh carries `buyItems:[{item,center,vStart}]` and
// a `sold` list; collapseBuyItem() zero-areas a bought item's quad in place. Records stay the dig; this
// is book spines + toy boxes. Returns [] on merge failure → caller falls back to plain realSleeveMesh.
// The merged geometries are ctx-tracked → freed with the room (leak-free).
export function buildBuyableShelf(ctx, pack, placements) {
const THREE = ctx.THREE;
const byAtlas = new Map(); // material.uuid → { material, pls:[] }
for (const pl of placements) {
const mat = pack.material(pl.item); if (!mat) continue;
let b = byAtlas.get(mat.uuid); if (!b) { b = { material: mat, pls: [] }; byAtlas.set(mat.uuid, b); }
b.pls.push(pl);
}
const out = [];
for (const b of byAtlas.values()) {
const geos = b.pls.map(pl => itemQuad(THREE, pack, pl));
let merged; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
geos.forEach(g => g.dispose());
if (!merged) continue;
ctx._geometries.add(merged); // freed at room dispose
const mesh = new THREE.Mesh(merged, b.material);
mesh.userData.noBatch = true; // already one draw — batch.js skips it
mesh.userData.buyMesh = true;
mesh.userData.buyItems = b.pls.map((pl, i) => ({ item: pl.item, center: { x: pl.x, y: pl.y, z: pl.z }, vStart: i * 4 }));
mesh.userData.sold = [];
out.push(mesh);
}
return out;
}
// Zero-area a bought item's quad (collapse its 4 verts to the item centre → invisible), in place, in
// the merged buyable mesh. Leak-free (no geometry churn). `it` is an entry from mesh.userData.buyItems.
export function collapseBuyItem(mesh, it) {
const pos = mesh.geometry.attributes.position;
for (let k = 0; k < 4; k++) pos.setXYZ(it.vStart + k, it.center.x, it.center.y, it.center.z);
pos.needsUpdate = true;
mesh.geometry.computeBoundingSphere();
if (mesh.userData.sold) mesh.userData.sold.push(it.vStart);
}