Lane D round-5: chunk-streamed roster (v2) behind ?roster=stream

Implements the R3 design note (was design-only). Default-off; the v1 whole-graph path is
byte-identical when the flag is off (shell citizen-0 signature matches the R4 golden exactly).

- chunk-local identity keyed (seed, chunkKey, i) — same seed + chunk -> same residents,
  independent of town size / visit order; home edge picked from that chunk's edges.
- windowed spawn/despawn: hook-driven via Lane B's onChunkBuilt/onChunkDisposed (sim auto-
  detects and stops polling) + a camera-poll fallback that needs zero Lane B changes.
- global near-cap + mixer stagger + rig pool + impostor layer unchanged, run across live chunks.
- owner-chunk despawn (no cross-boundary re-keying); per-chunk time-of-day thinning (hours-aware).

Proven on a 9x9 fixture grid: constant far-field density (~58-129 within 70m at spawn AND 150m
out, vs v1's uniform ~12-15); deterministic (507 chunk-keyed identities re-derive from seed);
leak-free (2 full grid walks / ~380 chunk cycles -> 0 geom/tex delta, pool capped 30); budget
unchanged (near-cap 24 + 1 impostor draw, 2.31ms/frame). qa.sh --strict GREEN.

Only web/js/citizens/sim.js + web/citizens_test.html changed. Flag + shell call-site documented
in LANE_D_NOTES for Lane F to wire web/index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 21:07:10 +10:00
parent 3a831fe467
commit 93a3168a99
5 changed files with 295 additions and 52 deletions

View File

@ -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 — ~58129 citizens within 70m
at spawn AND 150m out, vs v1's uniform ~1215 (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).

View File

@ -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 ~1215 everywhere (spread too thin). Stream is ~510× 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@ -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(); } };
</script>
</body>

View File

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