From 9a65f51a88cff05593b5a63e215e5239a0eb1918 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 09:04:06 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20D=20R14=20(v3.0):=20identity=20continuit?= =?UTF-8?q?y=20=E2=80=94=20the=20ped=20you=20followed=20is=20in=20the=20cr?= =?UTF-8?q?owd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last charter promise. A per-venue tonight roster (sim.js _venueRoster) of who entered a gig venue tonight — surge occupants self-record in _enter, queue admits relay via citizens.recordVenueEntry — is consumed by GigCrew.spawn: the crowd's front slots BECOME the roster identities (same pedIndex, same height), seeded strangers fill the rest. - No golden move: both goldens are plan-gen hashes; each crowd slot is an independently-keyed rng stream, so a roster override touches only that slot and an EMPTY roster is byte-identical to R13 (verified). Roster heights 1.55-1.95m stay inside the no-giants [1.4,2.0] gate. Pure bookkeeping: 0 rng, 0 GPU. - Roster persists across interior exit/re-enter, bounded, auto-clears on the setGig(id,false) transition (per-night). Deduped by stable key. - queue.js: admitOne() returns {pedIndex,height,key} + fires an onAdmit callback (auto-drain AND explicit) so F relays every walked-in punter to the roster; _spawnAt stashes the identity. band.js: _make returns pedIndex; members carry it for F's continuity smoke; crew.spawn is now null-gig-safe. - Verified: surge 8/8 + queue 4/4 real identities land in the crowd; empty-roster == R13; 7-night x 3-venue soak leak geo0/tex0, roster bounded, no identity churn; interior 64 draws <=350 (0 new rigs); ?noassets placeholders, 0 fetch. - F seam in LANE_D_NOTES: crew.spawn({roster}) + queue onAdmit relay + the tonightRoster(venueShopId) accessor. - Debt #2 (sit clip): investigated, deferred to v3.1 — the clip converts+binds (tailnet Sitting_Idle.fbx -> 394KB sit.glb) but a real seated pose is ~1.2m stature (fails F's no-giants floor) and needs a _rotWithHips loader + replant (shared rig-stack) + an F smoke exemption; not a freeze-week change. SEAT_DROP hack kept (reads seated from the money-shot front). Co-Authored-By: Claude Opus 4.8 --- docs/LANES/LANE_D_NOTES.md | 73 ++++++++++++++++++++++++++++++++++++++ web/js/citizens/band.js | 38 +++++++++++++------- web/js/citizens/queue.js | 21 +++++++---- web/js/citizens/sim.js | 38 ++++++++++++++++++-- 4 files changed, 147 insertions(+), 23 deletions(-) diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index b1a61d7..37b678d 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,79 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 14 — v3.0 release: identity continuity (the ped you followed is in the crowd) + +*PROCITY-D, 2026-07-16. The last charter promise. Behind `?gigs=1`; files `sim.js`, `band.js`, `queue.js`. +**No golden moves** — both goldens are plan-gen hashes (independent of runtime crowd identity), and an +empty roster is byte-identical to R13. Verified in-shell against A's 3-venue plan.* + +### The tonight roster (sim.js) — who came to the gig +A per-venue **Map** (`_venueRoster`) of the identities who entered a gig venue +**tonight**, from two sources: +- **Surge occupants** — a streamed ped that patronage steers into a gig venue self-records in `_enter` + (gated `this._gigVenues.has(shopId)`), keyed by the ped's stable `c.id` so re-entry doesn't dup. Unlike + R9 occupancy (which clears on emerge), the roster **persists past the ped's dwell** — "who came tonight". +- **Queue admits** — F relays each `VenueQueue.admitOne()` via `citizens.recordVenueEntry(venueShopId, {pedIndex,height,key})`. + +It's **bounded + per-night**: cleared automatically on the `setGig(id, false)` on→off transition (the gig +ends → the crowd disperses), so it survives interior exit/re-enter (gig stays on) but resets each night. +Pure bookkeeping — **no rng draw, no GPU** (draw/tri delta 0). + +### The crowd consumes it (band.js) — the front slots become the roster +`GigCrew.spawn(roomGroup, { …, roster })`: crowd slot `i` fills from `roster[i]` (its `{pedIndex,height}`), +seeded strangers fill the remainder. This is the release-round's load-bearing safety: each crowd slot is +an **independently-keyed rng stream** (`gig`/`crowd::`), and `_make`'s existing override branches +already skip a slot's own height+pickRig draws when identity is supplied — so a roster override touches +**only that slot**, never another slot / the sway-phase stream / the band / either golden. An **empty +roster is byte-identical to R13** (verified). Roster heights are the sim's 1.55–1.95 m, inside the +no-giants [1.4,2.0] gate. `crew.spawn` returns `{ band, crowd, fromRoster }` and each member carries +`member.pedIndex` (for F's continuity smoke — assert on the seam, not pixels). + +### → Lane F: the seam (three touches; the clear is automatic) +```js +// 1. interior — pass the venue's roster into the crowd (interior_mode.js:239): +crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, + gig: tonight, roster: citizens.tonightRoster(shop.id) }); +// 2. street — relay each queue admit into the roster (index.html per-venue loop ~380-393): +q.spawn(scene, { queueZone, gigId, onAdmit: (id) => citizens.recordVenueEntry(venueShopId, id) }); +// 3. nothing — the roster auto-clears on your existing setGig(id, false) at the gig's end. +``` +Accessor for your smoke: `window.PROCITY.citizens.tonightRoster(venueShopId) → [{pedIndex, height}]` +(insertion-ordered, deduped, bounded). Assert **crowd ⊇ roster ∩ cap** via `crew.crewInfo.fromRoster` + +`member.pedIndex ∈ roster`. `admitOne()` now returns `{pedIndex,height,key}` (was `1`) — truthy, so your +existing `if (q.admitOne())` still holds. Also: `crew.spawn` is now null-`gig`-safe (quiet-night guard). + +### Verified in-shell (seed 20261990, The Exchange Hotel #116) +| check | result | +|---|---| +| roster API — record / dedup-by-key / invalid-pedIndex-ignore / per-venue / clear-on-gig-off | all ✓ | +| **surge continuity** — real peds ducked in → roster → crowd | **8/8** (same pedIndex + height) | +| **queue continuity** — admits relayed via `onAdmit` → roster | **4/4** | +| consume — front slots = roster, tail = seeded strangers unchanged | ✓ | +| **determinism** — empty-roster crowd == R13 baseline (pedIndex + stature per slot) | byte-identical ✓ | +| **soak** — 7 nights × 3 venues, enter/exit + re-spawn | leak **geo Δ0 / tex Δ0**, roster **bounded** (≤ cap, cleared per night), **no identity churn** | +| budget | interior gig **64 draws ≤350**, **0 new rigs** (continuity is bookkeeping) | +| `?noassets=1&gigs=1` | crowd = placeholders using roster heights, **no crash, 0 fetch** | + +### Debt #2 (the drummer sit clip) — INVESTIGATED, deferred to v3.1 (real blockers, not a punt) +The clip **does exist** — `~/Documents/mixamo-fetch/out/` is NOT on m3ultra (Fable's path is stale; the +bank lives on the tailnet source box `johnking@100.91.239.7`). `Sitting_Idle.fbx` there converts cleanly +via Blender to a 394 KB **mesh-free `sit.glb`** that binds (65/65 bones; the fleet's `_canon` keeps the +colon, `mixamorig:Hips`, so raw Mixamo naming is already canonical). **But wiring it is a v3.1 change, not +a release-week one, for two real reasons:** +1. **`_rotOnly` breaks a seated pose** — it drops `Hips.quaternion` (pelvic tilt) + all positions (hip + descent), giving a "levitating L-sit". Correct wiring needs a parallel `_rotWithHips` filter + a + post-pose foot re-plant in `spawnRig` — a change to the **shared** rig stack (keepers/browsers/band/crowd). +2. **A genuinely seated figure is ~1.2 m crown-above-feet**, which **fails F's no-giants stature floor + [1.4,2.0]** (`flags_check.py:695`) — it needs an F smoke exemption for the seated band member. +Both land a qa-red change pending an F handshake, in the freeze round, for a polish item — the drummer +already reads seated from the crowd POV (money shot) via the `SEAT_DROP` hack. **Rides Fable's existing +v3.1 item "bench-sit loiter from the same `sit` clip".** Recipe (for v3.1): scp Sitting_Idle.fbx → Blender +`--background --python` import/export GLB (no bone rename needed) → `web/models/peds/sit.glb` → `_rotWithHips` +loader + `spawnRig` re-plant + F stature exemption. See LANE_D_NOTES wishlist #1. + +--- + ## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge *PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116 diff --git a/web/js/citizens/band.js b/web/js/citizens/band.js index 262ea05..a85365d 100644 --- a/web/js/citizens/band.js +++ b/web/js/citizens/band.js @@ -128,23 +128,25 @@ export class GigCrew { _make(target, key, { x, y, z, ry, height, pedIndex = null }) { const r = rng(this.citySeed, 'gig', key); const h = height != null ? height : 1.6 + r() * 0.3; - let actor, kind; + let actor, kind, usedIndex = null; if (this.fleet && this.fleet.ready) { const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); const rig = pk && this.fleet.all[pk.index]; const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() }); - if (sp) { actor = sp; kind = 'rig'; } + if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; } // usedIndex: the fleet ped shown — F's continuity smoke reads it } if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; } actor.fig.position.set(x, y, z); target.add(actor.fig); - return { actor, kind }; + return { actor, kind, pedIndex: usedIndex }; } - // spawn(roomGroup, { stage, watchPoints, gig }) — the band on the deck + crowd at the watch points. - spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) { + // spawn(roomGroup, { stage, watchPoints, gig, roster }) — the band on the deck + crowd at the watch points. + // roster (R14): [{pedIndex,height}] of the punters who entered this venue tonight (D's citizens.tonightRoster); + // the crowd's front slots become them (continuity), seeded strangers fill the rest. + spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, roster = [], instrumentFor: instFor = instrumentFor } = {}) { this.disposeAll(); - const gid = gig.gigId | 0; + const gid = (gig && gig.gigId) | 0; // null-safe (a quiet-night call would pass no gig) this._gen++; const gen = this._gen; @@ -157,10 +159,10 @@ export class GigCrew { const seated = p.role === 'drums' || !!p.seated; const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser) const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs - const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`, + const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`, { x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null, height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 }); - const member = { actor, kind, part: 'band', role: p.role, dance: false, seated, + const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated, base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] }; const inst = instFor(p.role); if (inst) { actor.fig.add(inst); member.extra.push(inst); } @@ -170,14 +172,24 @@ export class GigCrew { this._addBackline(roomGroup, stage, gen); // amp dressing beside the riser (async, dressing) } - // ── crowd: stand-and-watch or dance at C's watch points ── + // ── crowd: R14 identity continuity — the front slots become tonight's roster (the punters who came in + // via the queue / surge), seeded strangers fill the remainder. The roster override passes {pedIndex,height} + // for that slot only; _make's existing branches then skip that slot's own height+pickRig draws. Because + // every crowd slot is an INDEPENDENTLY keyed stream (`gig`/`crowd::`), this can't shift any + // other slot, the sway-phase stream (`gigp`), or the band — and an EMPTY roster is byte-identical to R13 + // (no override → the exact R13 draws). Roster heights are the sim's 1.55–1.95 m, inside the [1.4,2.0] gate. const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length)); - pts.forEach((w) => { - const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry: w.ry }); - this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, + let fromRoster = 0; + pts.forEach((w, i) => { + const who = roster[i] || null; + const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, + who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height } + : { x: w.x, y: 0, z: w.z, ry: w.ry }); + if (who) fromRoster++; + this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who, base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] }); }); - return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length }; + return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster }; } // Swap a band member's primitive instrument for E's manifest GLB, placeholder-persists style. Fail-soft diff --git a/web/js/citizens/queue.js b/web/js/citizens/queue.js index 75102ba..3a6e41c 100644 --- a/web/js/citizens/queue.js +++ b/web/js/citizens/queue.js @@ -42,11 +42,14 @@ export class VenueQueue { } // Build the seeded line. `count` overrides the seeded 2..6 (F may cap to the room's spare capacity). - spawn(parent, { door = null, queueZone = null, gigId = 0, count = null } = {}) { + // onAdmit (R14): fired with {pedIndex,height,key} on EVERY admit (auto-drain or explicit), so F can relay + // the punter who walked in into the sim's tonight roster (citizens.recordVenueEntry) → they join the crowd. + spawn(parent, { door = null, queueZone = null, gigId = 0, count = null, onAdmit = null } = {}) { this.disposeAll(); this._gen++; this.gid = gigId | 0; this.parent = parent; + this.onAdmit = onAdmit; const anchor = this._anchor(door, queueZone); if (!anchor) return { queue: 0 }; this.anchor = anchor; @@ -81,9 +84,10 @@ export class VenueQueue { const key = `${this.gid}:${slot}`; const r = rng(this.citySeed, 'queueh', key); const h = 1.58 + r() * 0.34; - let actor, kind; + let actor, kind, pedIndex = null; if (this.fleet && this.fleet.ready) { const pk = pickRig(this.fleet, r()); + pedIndex = pk ? pk.index : null; // R14: remember the ped so admitOne can hand it to the crowd const rig = pk && this.fleet.all[pk.index]; const sp = rig && spawnRig(rig, { ry: a.ry, height: h, clip: this.fleet.idleClip, phase: r() }); if (sp) { actor = sp; kind = 'rig'; } @@ -91,8 +95,8 @@ export class VenueQueue { if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'queuebody', key), { height: h }); actor.fig.rotation.y = a.ry; kind = 'placeholder'; } actor.fig.position.set(tx, 0, tz); this.parent.add(actor.fig); - this.members.push({ actor, kind, slot, target: { x: tx, z: tz }, base: { ry: a.ry }, - phase: rng(this.citySeed, 'queuep', key)() * Math.PI * 2 }); + this.members.push({ actor, kind, slot, pedIndex, height: h, key, // key = `${gid}:${slot}` — stable per punter (slot shuffles, key doesn't) + target: { x: tx, z: tz }, base: { ry: a.ry }, phase: rng(this.citySeed, 'queuep', key)() * Math.PI * 2 }); } _gap() { @@ -102,15 +106,17 @@ export class VenueQueue { _nextDrainGap() { return DRAIN_MIN + rng(this.citySeed, 'queuedrain', `${this.gid}:${this.members.length}:${this.t | 0}`)() * (DRAIN_MAX - DRAIN_MIN); } // Admit the head of the line (nearest the door): it walks in, everyone else shuffles up one slot. - // Returns the admitted count so far (F can stop when the room is at watchPoint cap). + // R14: returns the admitted punter's identity {pedIndex, height, key} (or null when empty) and fires + // onAdmit(identity) — F relays it to citizens.recordVenueEntry so the punter joins the interior crowd. admitOne() { - if (!this.members.length) return 0; + if (!this.members.length) return null; // the head = the smallest slot (closest to the anchor) let headIdx = 0; for (let i = 1; i < this.members.length; i++) if (this.members[i].slot < this.members[headIdx].slot) headIdx = i; const head = this.members.splice(headIdx, 1)[0]; if (head.actor.fig.parent) head.actor.fig.parent.remove(head.actor.fig); head.actor.dispose && head.actor.dispose(); + const identity = { pedIndex: head.pedIndex, height: head.height, key: head.key }; // shuffle everyone forward one slot → new target one step closer to the door const a = this.anchor, gap = this._gap(); for (const m of this.members) { @@ -119,7 +125,8 @@ export class VenueQueue { m.target.z = a.z + a.trail.z * gap * m.slot; } this._drainAt = this.t + this._nextDrainGap(); - return 1; + if (this.onAdmit) this.onAdmit(identity); // relay the walked-in punter → the crowd roster (auto or explicit) + return identity; } // idle shuffle + slide up into vacated slots. The seeded auto-drain is a safety so the line empties diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 9005011..d0e00f0 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -126,6 +126,10 @@ export class CitizenSim { this._gigVenues = new Set(); // R13: venueShopIds with a gig on (F sets per-venue via setGig) → multi-venue surge 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._venueRoster = new Map(); // R14 identity continuity: venueShopId → Map — who + // entered a gig venue TONIGHT (surge occupants + F-relayed queue admits). + // GigCrew consumes it so the crowd IS the people who came in. Cleared when + // the gig ends (setGig off). Pure bookkeeping — no rng, no GPU. 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 @@ -164,14 +168,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._occupancy.clear(); + this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear(); this._occupancy.clear(); this._venueRoster.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._occupancy.clear(); this.streamMode = false; + this.chunkRosters.clear(); this._occupancy.clear(); this._venueRoster.clear(); this.streamMode = false; } // rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk @@ -340,7 +344,11 @@ export class CitizenSim { // per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works. setGig(venueShopId, on = true) { if (venueShopId == null) return; - if (on) this._gigVenues.add(venueShopId); else this._gigVenues.delete(venueShopId); + if (on) this._gigVenues.add(venueShopId); + // gig ends (on→off transition) → disperse tonight's roster so the next night starts fresh + it stays + // bounded. Persists across interior exit/re-enter (the gig stays "on" through those) — only the real + // close clears it. .delete() returns true only on the transition, so re-calls while quiet are no-ops. + else if (this._gigVenues.delete(venueShopId)) this._venueRoster.delete(venueShopId); } setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; } @@ -393,8 +401,18 @@ export class CitizenSim { 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; + // R14 continuity: a surge occupant who ducked into a GIG venue joins tonight's roster (the crowd + // becomes the people who came in). The roster persists past this ped's short dwell (unlike occupancy, + // which clears on emerge) — "who came tonight", keyed by the ped's stable id so re-entry doesn't dup. + if (this._gigVenues.has(id)) this._recordRoster(id, c.id, c.pedIndex, c.height); } } + // record one entered identity into a venue's tonight roster (deduped by key, insertion-ordered, no rng). + _recordRoster(venueShopId, key, pedIndex, height) { + let m = this._venueRoster.get(venueShopId); + if (!m) this._venueRoster.set(venueShopId, m = new Map()); + if (!m.has(key)) m.set(key, { pedIndex, height }); // first entry wins + } _emerge(c) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } const w = c._savedWalk; @@ -415,6 +433,20 @@ export class CitizenSim { const occ = this._occupancy.get(shopId) || []; return { count: occ.length, occupants: occ.map(o => ({ ...o })) }; } + // R14 identity continuity — the D→F seam. + // recordVenueEntry: F relays a queue admit (VenueQueue.admitOne() → {pedIndex, height, key}) so the punter + // who walked in the door joins the crowd. Surge occupants self-record in _enter; this is the queue side. + recordVenueEntry(venueShopId, entry) { + if (venueShopId == null || !entry || entry.pedIndex == null) return; + const key = entry.key != null ? `q:${entry.key}` : `q:${entry.pedIndex}:${(+entry.height || 0).toFixed(3)}`; + this._recordRoster(venueShopId, key, entry.pedIndex, entry.height); + } + // tonightRoster: the night's entered identities, for GigCrew.spawn (F passes it in) + F's continuity + // smoke (assert crowd ⊇ roster ∩ cap). Insertion-ordered (entry order), deduped, bounded, pure read. + tonightRoster(venueShopId) { + const m = this._venueRoster.get(venueShopId); + return m ? [...m.values()].map(e => ({ pedIndex: e.pedIndex, height: e.height })) : []; + } // 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() {