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>
This commit is contained in:
m3ultra 2026-07-15 10:21:10 +10:00
parent 9c7c65f57c
commit 34145e24a7
6 changed files with 111 additions and 12 deletions

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

@ -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

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

@ -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() {