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:
parent
9c7c65f57c
commit
34145e24a7
@ -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
|
||||
|
||||
@ -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
|
||||
(5–20 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
|
||||
|
||||
BIN
docs/shots/laneD/r9_browsers_in_shop.jpg
Normal file
BIN
docs/shots/laneD/r9_browsers_in_shop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 866 KiB |
@ -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 }));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 5–20s
|
||||
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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user