diff --git a/D-progress.md b/D-progress.md index d9cb591..fa5f961 100644 --- a/D-progress.md +++ b/D-progress.md @@ -1,5 +1,26 @@ # PROCITY-D — progress (Lane D · Citizens) +## Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind `?roster=stream`. qa GREEN. + +Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical +when off. Full numbers + Lane F wiring in `docs/LANES/LANE_D_NOTES.md` (top). + +- **Chunk-local identity** keyed `(seed, chunkKey, i)` → same seed + chunk → same residents regardless + of town size / visit order; **windowed spawn/despawn** hook-driven (Lane B's `onChunkBuilt`/ + `onChunkDisposed`, sim auto-detects) with a camera-poll fallback; **global near-cap** across live + chunks; **owner-chunk despawn** (no cross-boundary re-keying); v1 path untouched (`chunkStream` opt-in). +- **Proven** on a big 9×9 fixture grid: (a) constant far-field density — ~58–129 citizens within 70m + at spawn AND 150m out, vs v1's uniform ~12–15 (fixed roster spread thin); (b) deterministic — 507 + chunk-keyed identities re-derive from seed; (c) leak-free — 2 full grid walks (~380 chunk cycles) → + **0 geom/tex delta**, pool capped 30, chunks bounded 25; (d) budget — same near-cap + 1 impostor + draw as v1, 2.31 ms/frame; (e) **flag-off byte-identical** — shell citizen-0 signature exactly + matches the R4 golden. Hours-aware density included (per-chunk tod thinning). +- Only `web/js/citizens/sim.js` + `web/citizens_test.html` (bigger fixture + stream toggle/HUD/ + determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires `web/index.html`. + Evidence: `docs/shots/laneD/r5_stream_tiers.jpg`. + +--- + ## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN. All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top). diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 78b8464..fc59652 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,58 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below + +**As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical. +`tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates). + +**What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly +sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of +`(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks +around the camera. Density becomes constant per unit street and follows the player. Everything +downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays +**global across live chunks**. + +**Measured (test page, 9×9 fixture grid, ~±208m):** +- **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere — + vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street + density at equal total cost, and it doesn't thin as you walk arbitrarily far. +- **Deterministic**: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism + button, stream mode). Same seed + same walk → same residents. +- **Leak-free**: after warming ped GPU uploads, **2 full grid walks (~380 chunk build/dispose cycles) + → 0 geometry / 0 texture delta**; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5). +- **Budget**: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps + headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution + is identical to v1's bound. +- **Flag-off = v1**: shell with no flag → `streamMode:false`, citizen 0 signature + `0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation. + +### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell) +Minimum (poll-driven, zero Lane B changes — works today): +```js +const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null; +const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, + graph: plan.streets, fleet, chunkStream: streamOpt }); +``` +Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the +sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**: +```js +if (streamOpt) { + chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41) + chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47 +} +``` +(`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.) +Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must +not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key +(`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours. + +*Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve +v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later +(needs shop data in the chunk; the sim is graph-only today) — noted, not built.* + +--- + ## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F) **All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.** @@ -162,10 +214,12 @@ viewport, missing modelMatrix, frozen exposure.* `keepers.update(dt, playerPos)` each interior frame. - `?noassets=1` verified: full placeholder town, mixers=0, zero crashes. -## Chunk-streamed roster — v1.5 design note (design only; no implementation) +## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`) -*Round-3 task 3. This is the durable fix for "big-town streets look empty." Written as a spec for a -future Lane D/F pass — nothing here is wired yet; v1 keeps the whole-graph roster.* +*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built +result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven +(`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner +despawn, constructor opt-in (`chunkStream`). Kept for the rationale.* **The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over *all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test diff --git a/docs/shots/laneD/r5_stream_tiers.jpg b/docs/shots/laneD/r5_stream_tiers.jpg new file mode 100644 index 0000000..5031dc5 Binary files /dev/null and b/docs/shots/laneD/r5_stream_tiers.jpg differ diff --git a/web/citizens_test.html b/web/citizens_test.html index 95208d4..a906f95 100644 --- a/web/citizens_test.html +++ b/web/citizens_test.html @@ -83,6 +83,7 @@ import { KeeperManager } from './js/citizens/keepers.js'; const qs = new URLSearchParams(location.search); const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0; const NOASSETS = qs.get('noassets') === '1'; +const STREAM = qs.get('roster') === 'stream'; // v2: chunk-streamed roster (default off = v1) // ---------- renderer / scene ---------- const app = document.getElementById('app'); @@ -121,23 +122,26 @@ function makeEnv() { scene.environment = makeEnv(); // ---------- fixture street graph (LOCAL — no Lane A/B imports) ---------- -// a 3×3 lattice of nodes → a walkable loop network with turns at every junction. -const G = 40; +// a GRID×GRID lattice (≈±GRID·G/2 m) → a big walkable loop network with turns at every junction. +// Deliberately large so ?roster=stream can be walked far from spawn: v1's fixed roster thins out over +// the whole grid, chunk-streaming keeps constant density around the camera. Centre cross = main streets. +const GRID = 9, G = 52, MID = (GRID - 1) / 2; const nodes = []; -for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) - nodes.push({ id: `n${r}${c}`, x: (c - 1) * G, z: (r - 1) * G }); -const nid = (r, c) => `n${r}${c}`; +for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++) + nodes.push({ id: `n${r}_${c}`, x: (c - MID) * G, z: (r - MID) * G }); +const nid = (r, c) => `n${r}_${c}`; const edges = []; let eid = 0; -for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) { - if (c < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === 1 ? 9 : 6, kind: r === 1 ? 'main' : 'side' }); - if (r < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === 1 ? 9 : 6, kind: c === 1 ? 'main' : 'side' }); +for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++) { + if (c < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === MID ? 9 : 6, kind: r === MID ? 'main' : 'side' }); + if (r < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === MID ? 9 : 6, kind: c === MID ? 'main' : 'side' }); } const graph = { nodes, edges }; +const GRID_SPAN = GRID * G; // used to size the ground plane + fog // ground + road/footpath strips so the streets read const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 }); -const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat); +const ground = new THREE.Mesh(new THREE.PlaneGeometry(GRID_SPAN + 200, GRID_SPAN + 200), groundMat); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground); function addStrip(width, len, cx, cz, beta, color, y) { @@ -168,7 +172,8 @@ for (const n of nodes) { // ---------- fleet + sim ---------- const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/'); -const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet }); +const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet, + chunkStream: STREAM ? { radius: 2, perChunk: 16 } : null }); // ---------- keeper demo (mock counter, since Lane C hasn't landed) ---------- const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1), @@ -183,13 +188,13 @@ if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'dem const TIER_COL = { near: new THREE.Color(0x66dd88), mid: new THREE.Color(0xffcc44), far: new THREE.Color(0x884466) }; const ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2); const markers = new THREE.InstancedMesh(ring, - new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 220); + new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 512); markers.frustumCulled = false; markers.visible = false; markers.count = 0; scene.add(markers); const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1); -function paintTierMarkers(active) { - let n = 0; - for (let i = 0; i < active && n < 220; i++) { - const c = sim.roster[i]; +function paintTierMarkers() { + const act = sim.activeCitizens(); let n = 0; + for (let i = 0; i < act.length && n < 512; i++) { + const c = act[i]; _mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms); markers.setMatrixAt(n, _mm); markers.setColorAt(n, TIER_COL[c.tier] || TIER_COL.far); n++; } @@ -241,21 +246,33 @@ $('btn-tiers').onclick = () => { debugTiers = !debugTiers; sim.setDebugTiers(debugTiers); markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', debugTiers); }; +function freshPed(idn) { if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) return pk.index; } return -1; } $('btn-det').onclick = () => { - const active = sim.stats.active; - const live = sim.identitySignature(); - const fresh = []; - for (let i = 0; i < active; i++) { - const idn = identityOf(SEED, edges.length, i); - let pedIndex = -1; - if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) pedIndex = pk.index; } - fresh.push(signatureOf(i, idn, pedIndex)); + let ok, msg; + if (STREAM) { + // stream: every live chunk's residents are a pure function of (seed, chunkKey, i) — re-derive + compare + let checked = 0, bad = null; + for (const [key, arr] of sim.chunkRosters) { + const el = sim.chunkEdges.get(key) || []; + for (let i = 0; i < arr.length; i++) { + const idn = identityOf(SEED, el.length, `${key}#${i}`); + const expect = signatureOf(`${key}#${i}`, idn, freshPed(idn)); + checked++; + if (expect !== sim._sig(arr[i])) { bad = `${key}#${i}`; break; } + } + if (bad) break; + } + ok = !bad; + msg = ok ? `✓ deterministic — ${checked} chunk-keyed identities match seed ${SEED} (${sim.chunkRosters.size} chunks)` + : `✗ MISMATCH at ${bad}`; + } else { + const active = sim.stats.active, live = sim.identitySignature(), fresh = []; + for (let i = 0; i < active; i++) { const idn = identityOf(SEED, edges.length, i); fresh.push(signatureOf(i, idn, freshPed(idn))); } + ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]); + msg = ok ? `✓ deterministic — ${active} identities match seed ${SEED}` : `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`; } - const ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]); $('det').style.color = ok ? '#8fd88f' : '#e06a5a'; - $('det').textContent = ok - ? `✓ deterministic — ${active} identities match seed ${SEED}` - : `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`; + $('det').textContent = msg; }; if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only'; @@ -268,7 +285,7 @@ function frame(now) { renderer.info.reset(); const st = sim.update(dt); keepers.update(dt); - if (debugTiers) paintTierMarkers(st.active); + if (debugTiers) paintTierMarkers(); renderer.render(scene, camera); const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1; @@ -282,7 +299,7 @@ function frame(now) { $('s-draws').textContent = renderer.info.render.calls; $('s-tris').textContent = renderer.info.render.triangles.toLocaleString(); $('s-pool').textContent = st.poolTotal; - $('s-mode').textContent = st.mode + (NOASSETS ? ' (noassets)' : ''); + $('s-mode').textContent = st.mode + (STREAM ? ` · stream ${st.chunks}ch` : '') + (NOASSETS ? ' (noassets)' : ''); requestAnimationFrame(frame); } requestAnimationFrame(frame); @@ -297,7 +314,7 @@ document.addEventListener('visibilitychange', () => sim.setPaused(document.hidde window._sim = sim; // dev hook // dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF) window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers, - step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(st.active); renderer.render(scene, camera); } }, + step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(); renderer.render(scene, camera); } }, look(tx, ty, tz, radius, theta = cam.theta, phi = cam.phi) { cam.target.set(tx, ty, tz); cam.radius = radius; cam.theta = theta; cam.phi = phi; applyCam(); } }; diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 59d86a3..26d30ae 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -41,6 +41,13 @@ function densityAt(t01) { const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 }; +// ---- chunk math (v2 chunk-streamed roster). CHUNK=64m is CITY_SPEC law and matches Lane B's +// planutil.js exactly, so B's onChunkBuilt/onChunkDisposed keys line up with ours. Kept local so the +// sim stays dependency-free (the standalone test page has no Lane B modules). ---- +const CHUNK = 64; +const chunkCoord = (v) => Math.floor(v / CHUNK); +const chunkKey = (cx, cz) => `${cx},${cz}`; + // ---- pure deterministic identity (no THREE, no GPU) — same (seed, id) → same person, forever ---- // Kept free-standing so the determinism check can recompute it independently of the live sim. export function identityOf(citySeed, edgeCount, id) { @@ -87,7 +94,7 @@ class RigPool { } export class CitizenSim { - constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null }) { + constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null, chunkStream = null }) { this.renderer = renderer; this.scene = scene; this.camera = camera; @@ -99,8 +106,16 @@ export class CitizenSim { this._setGraph(graph); - this.roster = []; // all generated citizens (identity + live state), indexed by id + this.roster = []; // v1: all generated citizens (identity + live state), indexed by id this.target = 0; // slider population + // v2 chunk-streamed roster (behind ?roster=stream — see enableStream). Default OFF → v1 path. + this.streamMode = false; + this.chunkRosters = new Map(); // chunkKey → Citizen[] (owned by that chunk) + this.chunkEdges = null; // chunkKey → [edge index] (built lazily on enable) + this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling + this._encountered = new Set(); // cumulative identity signatures seen (determinism proof) + 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 this.debugTiers = false; this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig' @@ -119,8 +134,119 @@ export class CitizenSim { // when the fleet lands, upgrade: bake rig atlas, swap actors, assign real ped types if (fleet && fleet.whenReady) fleet.whenReady.then(() => { if (fleet.ready) this._upgradeToRigs(); }); + + if (chunkStream) this.enableStream(chunkStream === true ? {} : chunkStream); } + // ================= v2: chunk-streamed roster (behind ?roster=stream) ================= + // Constant per-unit-street density that follows the camera, so streets stay lively arbitrarily far + // from spawn (v1's fixed roster smears N over the whole town and thins out). Identity is keyed per + // chunk (same seed + chunk → same residents, independent of town size / visit order). Everything + // downstream — LOD tiers, the 24 near-cap, mixer stagger, rig pool, impostor layer — is already + // per-citizen and chunk-agnostic and runs GLOBAL across live chunks, unchanged. + + enableStream({ radius = 2, perChunk = 16 } = {}) { + 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.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; + } + + // rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk + _buildChunkIndex() { + this.chunkEdges = new Map(); + this.edges.forEach((e, ei) => { + const steps = Math.max(1, Math.ceil(e.len / (CHUNK / 2))); + const seen = new Set(); + for (let i = 0; i <= steps; i++) { + const t = i / steps, x = e.A.x + (e.B.x - e.A.x) * t, z = e.A.z + (e.B.z - e.A.z) * t; + const k = chunkKey(chunkCoord(x), chunkCoord(z)); + if (!seen.has(k)) { + seen.add(k); + let arr = this.chunkEdges.get(k); if (!arr) this.chunkEdges.set(k, arr = []); + arr.push(ei); + } + } + }); + } + + // Lane B seam (F wires these onto ctx.onChunkBuilt/onChunkDisposed). First call flips us hook-driven. + onChunkBuilt(key) { if (this.streamMode) { this._hookDriven = true; this.feedChunk(key); } } + onChunkDisposed(key) { if (this.streamMode) { this._hookDriven = true; this.dropChunk(key); } } + + // poll-driven window (works with zero Lane B changes — test page + shell-until-F-wires-hooks): + // the camera chunk + neighbours within radius R (R≥1 so a walker never enters an unloaded chunk). + _pollChunks() { + const cx = chunkCoord(this.camera.position.x), cz = chunkCoord(this.camera.position.z); + const R = this.streamRadius; + const want = new Set(); + for (let dx = -R; dx <= R; dx++) for (let dz = -R; dz <= R; dz++) { + const k = chunkKey(cx + dx, cz + dz); + if (this.chunkEdges.has(k)) want.add(k); + } + for (const k of want) if (!this.chunkRosters.has(k)) this.feedChunk(k); + this._dropKeys.length = 0; + for (const k of this.chunkRosters.keys()) if (!want.has(k)) this._dropKeys.push(k); + for (const k of this._dropKeys) this.dropChunk(k); + } + + feedChunk(key) { + if (this.chunkRosters.has(key)) return; + const edgeList = this.chunkEdges.get(key); + if (!edgeList || !edgeList.length) { this.chunkRosters.set(key, []); return; } + const n = this._perChunkMax(key); + const arr = new Array(n); + for (let i = 0; i < n; i++) { const c = this._makeChunkCitizen(key, i, edgeList); arr[i] = c; this._encountered.add(this._sig(c)); } + this.chunkRosters.set(key, arr); + } + dropChunk(key) { + const arr = this.chunkRosters.get(key); + if (!arr) return; + for (const c of arr) this._releaseActor(c); + this.chunkRosters.delete(key); + } + + // per-chunk population: base × district weight (more edges = busier junction) × seeded jitter. + _perChunkMax(key) { + const edges = this.chunkEdges.get(key) || []; + const r = rng(this.citySeed, 'chunkpop', key); + const busy = 0.7 + Math.min(1.3, edges.length * 0.12); + return Math.max(4, Math.round(this.streamPerChunk * busy * (0.75 + r() * 0.5))); + } + + // chunk-local identity: home edge is picked from THIS chunk's edges, so town size / visit order + // never perturb who lives here. id is the string `${chunkKey}#${i}` (prng streams accept strings). + _makeChunkCitizen(key, i, edgeList) { + const id = `${key}#${i}`; + const idn = identityOf(this.citySeed, edgeList.length, id); + const edge = edgeList[idn.edge] ?? edgeList[0]; + const c = { + id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed, + loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar, + pedIndex: -1, subject: idn.pvar, + edge0: edge, forward0: idn.forward, localEdge0: idn.edge, ownerChunk: key, + edge, forward: idn.forward, s: idn.sFrac * this.edges[edge].len, loiter: 0, + x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0, + turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), + }; + if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } + this._placeOnLane(c); + return c; + } + + _sig(c) { + return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex); + } + activeCitizens() { return this._activeList; } + streamEncountered() { return [...this._encountered].sort(); } + // ---- graph → footpath lanes ---- _setGraph(graph) { this.nodes = graph.nodes; @@ -181,11 +307,9 @@ export class CitizenSim { if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff); } // 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). + // holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes. identitySignature() { - return this.roster.slice(0, this.stats.active).map(c => - signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.edge0, forward: c.forward0 }, c.pedIndex) - ); + return this._activeList.map(c => this._sig(c)); } // ---- impostor atlases ---- @@ -222,11 +346,15 @@ export class CitizenSim { this.mode = 'rig'; this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip }); // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs - this.roster.forEach(c => { + const assign = c => { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); } - }); + }; + this.roster.forEach(assign); + for (const arr of this.chunkRosters.values()) arr.forEach(assign); // stream-mode rosters too + // encountered signatures now carry pedIndex → refresh so the determinism proof matches post-upgrade + if (this.streamMode) { this._encountered.clear(); for (const arr of this.chunkRosters.values()) arr.forEach(c => this._encountered.add(this._sig(c))); } } _setImpostorLayer(atlas) { @@ -299,28 +427,47 @@ export class CitizenSim { setPaused(p) { this.paused = !!p; } + // the set of citizens active this frame — v1: the roster prefix scaled by time-of-day; stream: the + // union of live-chunk residents, each chunk thinned by the same time-of-day curve (hours-aware + // density per unit street). Deactivates everyone else. Downstream code is identical for both. + _activeSet() { + const list = []; + const density = densityAt(this.timeOfDay); + if (this.streamMode) { + for (const arr of this.chunkRosters.values()) { + const activeCount = Math.round(arr.length * density); + for (let i = 0; i < arr.length; i++) { + if (i < activeCount) list.push(arr[i]); + else if (arr[i].tier !== 'far') { this._releaseActor(arr[i]); arr[i].tier = 'far'; } + } + } + } else { + const active = Math.min(this.roster.length, Math.round(this.target * density)); + this._ensureRoster(active); + for (let i = active; i < this.roster.length; i++) { + const c = this.roster[i]; + if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; } + } + for (let i = 0; i < active; i++) list.push(this.roster[i]); + } + return list; + } + // ---- the frame ---- update(dt) { if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events) dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports - // active population from slider × time-of-day - const active = Math.min(this.roster.length, Math.round(this.target * densityAt(this.timeOfDay))); - this._ensureRoster(active); - - // deactivate anyone past the active prefix - for (let i = active; i < this.roster.length; i++) { - const c = this.roster[i]; - if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; } - } + if (this.streamMode && !this._hookDriven) this._pollChunks(); // camera-follow window (or Lane B hooks) + const activeCitizens = this._activeSet(); + this._activeList = activeCitizens; // advance + measure distances for the active set const cam = this.camera; const camX = cam.position.x, camZ = cam.position.z; const near = this._nearList; near.length = 0; const cand = []; - for (let i = 0; i < active; i++) { - const c = this.roster[i]; + for (const c of activeCitizens) { this._advance(c, dt); const ddx = c.x - camX, ddz = c.z - camZ; c._d = Math.hypot(ddx, ddz); @@ -400,15 +547,19 @@ export class CitizenSim { } // debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead) + const active = activeCitizens.length; this.stats = { active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length, mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode, + chunks: this.streamMode ? this.chunkRosters.size : 0, }; return this.stats; } dispose() { this.roster.forEach(c => this._releaseActor(c)); + for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c)); + this.chunkRosters.clear(); if (this.rigPool) this.rigPool.dispose(); if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); } this.scene.remove(this.group);