diff --git a/D-progress.md b/D-progress.md index 1364b75..18fa6ec 100644 --- a/D-progress.md +++ b/D-progress.md @@ -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 diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 2e86358..d512f9b 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -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 diff --git a/docs/shots/laneD/r9_browsers_in_shop.jpg b/docs/shots/laneD/r9_browsers_in_shop.jpg new file mode 100644 index 0000000..3912106 Binary files /dev/null and b/docs/shots/laneD/r9_browsers_in_shop.jpg differ diff --git a/web/citizens_test.html b/web/citizens_test.html index 2d285c7..a1774e7 100644 --- a/web/citizens_test.html +++ b/web/citizens_test.html @@ -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 })); diff --git a/web/js/citizens/keepers.js b/web/js/citizens/keepers.js index 0698c12..09aa111 100644 --- a/web/js/citizens/keepers.js +++ b/web/js/citizens/keepers.js @@ -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); diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 2bcfe7e..3274378 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -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() {