// PROCITY Lane D — the citizen simulation (Vuntra's layered LOD idea, at hamlet scale). // // Deterministic per-citizen identity (same seed → same person walks the same beat), walkers pathing // along footpath lanes offset from the street graph, three distance tiers: // • NEAR (<25m): full rig + AnimationMixer (or a placeholder before the fleet loads), walking the // footpath, turning at nodes on a seeded choice, loitering at points of interest. // • MID (25–70m): no mixers ticking — an instanced billboard impostor (impostor.js). // • FAR (>70m): culled entirely. // Promotion/demotion has hysteresis + a hard near-cap so nobody pops and the frame budget holds. // // Built on the house fleet rule: shared base meshes (a small rig pool, reused across citizens of the // same ped type) + one canonical clip bank. Never a per-character rig. import * as THREE from 'three'; import { rng, seedFor } from '../core/prng.js'; import { pickRig, makeActor } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; import { snapDoorToFootpath } from './door_snap.js'; import { posturesFor, postureSig, GROUP_OF } from './postures.js'; // ---- tuning (CITY_SPEC budgets) ---- const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor const MID_ENTER = 68, MID_EXIT = 72; // m, hysteresis band for impostor↔culled const NEAR_MAX = 24; // ≤24 rigged actives (CITY_SPEC) const RIG_CAP = 30; // pooled rig instances (headroom above NEAR_MAX) const NEW_RIG_PER_FRAME = 3; // cap actor creation to avoid clone hitches const MIXER_ALWAYS = 8; // nearest 8 mixers update every frame const MIXER_EXTRA = 4; // ≤4 more mixers per frame (staggered), CITY_SPEC const NEAR_BIAS = 2.0; // currently-near citizens keep priority for a rig slot const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset-free mode const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge const IMPOSTOR_MAX = 220; // instanced billboards ceiling const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent) const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded) const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around // ---- R41 §41.3: the street furniture the clips actually sit and lean on ---- // A REAL bench sit. R17's bench-sit sits a ped down at whatever node it stopped at — upright, on air, // "no bench-position binding" (its own comment). R41 binds it: benches exist, at deterministic // stations, and a ped that walks past one on its own footpath side can stop AT IT. // // The station rule below MIRRORS Lane B's `buildChunkFurniture` (web/js/world/furniture.js:203-209) // exactly — s = 14, 40, 66, …, step 26; side alternating on floor(s/26)%2; offset // (width/2 + FOOT − 0.8) on the furniture perpendicular (−uz, ux); yaw = atan2(ux,uz) + (side>0 ? π : 0). // It is a MIRROR because furniture.js is Lane B's file and exports no bench enumerator (only // `busShelterStops`, its shelter twin). A mirror that drifts is a ped sitting on air again, so it is // GATED, not trusted: `tools/qa/r41_benches.mjs` builds real chunks through Lane B's own // buildChunkFurniture, reads the bench InstancedMesh matrices out of the scene, and requires an exact // 1:1 position/yaw match against this table on four towns. If B ever moves a bench, that gate goes red. // → Filed for Lane B/F in LANE_D_NOTES §41: `export function benchStops(plan)` next to busShelterStops // retires the mirror entirely. One line in B's file; D switches to the import and deletes this. const FURN_FOOT = 3.5; // furniture.js FOOT (verge width used for its offsets) const BENCH_S0 = 14, BENCH_STEP = 26; // furniture.js bench cadence const BENCH_STOP_FRAC = 0.40; // seeded chance a ped passing its own-side bench sits down const BENCH_DWELL = [9, 20]; // s — a sit is a proper stop, not a window-shop pause const BENCH_SEAT_FWD = 0.06; // m toward the bench front, so the seat takes the weight const BENCH_SEAT_SIDE = 0.38; // m along the 1.6 m plank — the bench seats two, side by side const LEAN_RANGE = 5.0; // m — a loiter this close to a shop door can become a lean const LEAN_FRAC = 0.12; // seeded chance a stride check beside a shopfront becomes a lean const LEAN_DWELL = [6, 14]; // s — a lean is a longer stop than a window-shop glance const PAUSE_FRAC = 0.17; // …else a seeded chance they just stop and look in the window const PAUSE_DWELL = [4, 9]; // s — long enough to read the idle, short enough to keep moving const LEAN_WALL_BACK = 0.30; // m further from the road than the raw door point (≈ the facade) const LEAN_SIDE = 1.05; // m along the frontage, so the leaner isn't in the doorway // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h function densityAt(t01) { const x = ((t01 % 1) + 1) % 1 * DAY_CURVE.length; const i = Math.floor(x), f = x - i; return DAY_CURVE[i % DAY_CURVE.length] * (1 - f) + DAY_CURVE[(i + 1) % DAY_CURVE.length] * f; } 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) { const r = rng(citySeed, 'citizen', id); const pedRoll = r(); const height = 1.55 + r() * 0.40; // 1.55–1.95 m (CITY_SPEC height range) const speed = 1.05 + r() * 0.75; // m/s const edge = (r() * edgeCount) | 0; const forward = r() < 0.5 ? 1 : -1; const sFrac = r(); // 0..1 along the edge const loiterTend = r(); const phase = r(); const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0; return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar }; } // [R41 §41.3] Lane B's benches on one prepared sim edge ({A,B,ux,uz,len,width}), as a pure function — // exported so `tools/qa/r41_citizens.py` can check the SAME code the sim uses against the bench // geometry actually standing in the built world, rather than a second copy of the rule. See the // FURN_*/BENCH_* block above for the mirror's provenance and the seam filed to Lane B. export function benchStationsFor(e) { const out = []; const halfRoad = (e.width || 4) / 2; const px = -e.uz, pz = e.ux; // furniture.js perpendicular const yaw = Math.atan2(e.ux, e.uz); for (let s = BENCH_S0; s < e.len - 6; s += BENCH_STEP) { const side = (Math.floor(s / BENCH_STEP) % 2) ? 1 : -1; const off = (halfRoad + FURN_FOOT - 0.8) * side; out.push({ s, side, x: e.A.x + e.ux * s + px * off, z: e.A.z + e.uz * s + pz * off, yaw: yaw + (side > 0 ? Math.PI : 0) }); } return out; } // stable signature string for one citizen (pedIndex assigned later, once the fleet is known) export function signatureOf(id, idn, pedIndex) { return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`; } // ---- a small pool of rig actors, reused across citizens of the same ped type ---- class RigPool { constructor(fleet, clips) { this.fleet = fleet; this.clips = clips; this.free = new Map(); this.total = 0; } acquire(pedIndex) { const list = this.free.get(pedIndex); if (list && list.length) return list.pop(); if (this.total >= RIG_CAP) { // evict one free actor of another type for (const [k, arr] of this.free) { if (arr.length) { arr.pop().dispose(); this.total--; break; } } if (this.total >= RIG_CAP) return null; // everything in use — caller falls back to impostor } const rig = this.fleet.all[pedIndex]; if (!rig) return null; const actor = makeActor(rig, this.clips); if (actor) { actor.pedIndex = pedIndex; this.total++; } return actor; } release(actor) { if (!actor) return; if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant if (actor.setLooking) actor.setLooking(false, 0); // R29: nor mid-glance — snap back to idle before pooling let arr = this.free.get(actor.pedIndex); if (!arr) this.free.set(actor.pedIndex, arr = []); arr.push(actor); } dispose() { for (const arr of this.free.values()) arr.forEach(a => a.dispose()); this.free.clear(); this.total = 0; } } export class CitizenSim { constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null, chunkStream = null }) { this.renderer = renderer; this.scene = scene; this.camera = camera; this.citySeed = citySeed >>> 0; this.fleet = fleet; this.group = group || new THREE.Group(); this.group.name = 'citizens'; scene.add(this.group); this._setGraph(graph); 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._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], shopId }] door points this.patronageOn = true; 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 this.debugTiers = false; this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig' this.rigPool = null; this.paused = false; // app sets this on a visibilitychange → hidden (pauses mixers) this._exposure = null; // shell's live renderer.toneMappingExposure (day/night) → impostor match this._mixerCursor = 0; // round-robin cursor for the staggered mixer budget this._nearList = []; // reused scratch this._midList = []; this._frustum = new THREE.Frustum(); this._pv = new THREE.Matrix4(); this.stats = { active: 0, rigged: 0, mid: 0, far: 0, mixerMs: 0, poolTotal: 0, mode: this.mode }; // bake the placeholder impostor atlas up front so the mid tier works from frame one this._buildPlaceholderImpostors(); // 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. // perChunk default 16 = full density. Affordable since Lane E's R7 merge made each ped ONE draw // (was 8 sub-meshes / ~7 draws): the worst continuous-walk street view now sits well under the 300 // budget even with a dense street of near-rigs. This is the shipping default for the R7 roster flip. 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._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._venueRoster.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) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } 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), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched // [R41 §41.3] this citizen's four standing postures — a PURE function of (citySeed, id), decided // here once and never re-rolled, so lazy clip loading can change when a posture shows but never // which one it is. Two more freshly-keyed streams for the new street stops; like R17/R29 they // are drawn independently and cannot shift turn/loiter/patron/benchsit/glance. posture: posturesFor(this.citySeed, id), bench: null, benchRng: rng(this.citySeed, 'benchstop', id), lean: null, leanRng: rng(this.citySeed, 'leanstop', 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); } // ================= R41 §41.3: the motion library ================= get bank() { return (this.fleet && this.fleet.bank) || null; } // The GATE question — "is the motion library on for this boot" — answered synchronously from frame // zero (rigs.js sets it before the dynamic import starts). Every roll that can change a citizen's // POSITION is gated on this, never on `bank`, so the number of randoms drawn per boot cannot depend // on when a fetch landed. `bank` answers the different question of what is resident right now, and // is only ever used to pick WHICH clip plays — with the R2/R16/R29 base clip as the fallback. get clipsOn() { return !!(this.fleet && this.fleet.clipsRequested); } // ask for a group; harmless (and free) when there is no bank. Called at the moment of INTENT, so a // group is fetched the first time the town actually wants it, never at boot "just in case". _wantGroup(file) { const b = this.bank; if (b && file) b.ensureGroup(file); } _wantClip(id) { const b = this.bank; if (b && id && GROUP_OF[id]) b.ensureGroup(GROUP_OF[id]); } // the assigned clip if it is resident, else null ⇒ the caller's pre-R41 base clip _clip(id) { const b = this.bank; return b ? b.get(id) : null; } // The determinism artefact for postures: one line per ACTIVE citizen, sorted, pure identity — // never live state, so it holds while the crowd walks. Two runs must produce byte-equal output // (tools/qa/r41_postures.mjs asserts it, and re-derives the same lines from postures.js alone). postureSignature() { return this._activeList.map((c) => postureSig(c.id, c.posture || posturesFor(this.citySeed, c.id))).sort(); } // what is actually resident right now (the memory ledger the round asks to be stated) clipStats() { const b = this.bank; return b ? b.stats() : { groups: 0, clips: 0, bytes: 0, manifest: false, catalogue: 0 }; } // Lane B's benches, enumerated from B's own placement rule — see the FURN_* block above for why // this is a mirror and what gates it. Cached per edge (pure function of the edge, no rng). _benchStations(ei) { const e = this.edges[ei]; return e._benches || (e._benches = benchStationsFor(e)); } // The bench's local +Z is its FRONT (furniture.js's template puts the backrest at z=−0.2 and the // seat at y=0.45); rig fronts are local −Z after the R13 facing-normalise, so a sitter facing the // same way as the bench is `yaw + π`. Nudged BENCH_SEAT_FWD off the backrest onto the seat. // `seat` is ±1: the bench's seat plank is 1.6 m of local X, so it takes TWO. Measured need — with // benches 26 m apart per side, two peds picking the same station within one dwell is uncommon but // real (seen on the first soak: -1,-6#3 and -1,-6#5 co-located to the centimetre). A seeded ± puts // them side by side like people instead of inside each other. Residual: both can still draw the // same side; that reads as one ped, not a glitch, and it is bounded by the same 26 m cadence. _seatPose(st, seat) { const fx = Math.sin(st.yaw), fz = Math.cos(st.yaw); // bench local +Z (its front) const rx = Math.cos(st.yaw), rz = -Math.sin(st.yaw); // bench local +X (along the plank) return { x: st.x + fx * BENCH_SEAT_FWD + rx * BENCH_SEAT_SIDE * seat, z: st.z + fz * BENCH_SEAT_FWD + rz * BENCH_SEAT_SIDE * seat, facing: st.yaw + Math.PI }; } // A shopfront to put a back against. `shop._raw` is the door point BEFORE R40's footpath clamp, // i.e. the shell's `lot centre + front normal · (d/2 + 0.6)` — 0.6 m off the facade, which is the // wall we want. `n` is the unit outward normal of the nearest street (centreline → door), so the // building is at +n and the road at −n: stand LEAN_WALL_BACK further along +n, offset LEAN_SIDE // along the frontage so the leaner isn't blocking the doorway, and face −n (the road). // Pure geometry over the sim's own edges — no lots, no plan, no rng beyond the caller's roll. // O(1): the reference street is the one the LEANER IS WALKING ON, not a scan of every edge in // town. The ped is inside LEAN_RANGE (5 m) of that door, on that street's footpath, so the shop // fronts that street in every case but a corner — and a corner's residue is a facing, not a // position. The scan version cost 30 986 iterations per lean event on adelaide_real. _leanPose(shop, side, e) { const base = shop._raw || shop; if (!e) return null; const ax = e.A.x, az = e.A.z, dx = e.B.x - ax, dz = e.B.z - az; const L2 = dx * dx + dz * dz; let t = L2 < 1e-9 ? 0 : ((base.x - ax) * dx + (base.z - az) * dz) / L2; t = t < 0 ? 0 : t > 1 ? 1 : t; const qx = ax + t * dx, qz = az + t * dz; const bd = Math.hypot(base.x - qx, base.z - qz); if (!isFinite(bd) || bd < 1e-6) return null; const nx = (base.x - qx) / bd, nz = (base.z - qz) / bd; // outward: road → building const tx = -nz, tz = nx; // along the frontage return { x: base.x + nx * LEAN_WALL_BACK + tx * LEAN_SIDE * side, z: base.z + nz * LEAN_WALL_BACK + tz * LEAN_SIDE * side, facing: Math.atan2(nx, nz), // rig front −Z looks along (−nx,−nz) }; } activeCitizens() { return this._activeList; } streamEncountered() { return [...this._encountered].sort(); } // hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes // these from the plan (openLate shop's lot → chunkKey, + neighbours) and passes them — the sim is // graph-only so it can't derive them itself. Empty = every chunk thins uniformly by the day curve. setNightLivelyChunks(keys) { this._livelyChunks = new Set(keys); } chunkKeyAt(x, z) { return chunkKey(chunkCoord(x), chunkCoord(z)); } // ---- graph → footpath lanes ---- _setGraph(graph) { this.nodes = graph.nodes; this.edges = graph.edges.map(e => { const A = graph.nodes.find(n => n.id === e.a), B = graph.nodes.find(n => n.id === e.b); const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1e-3; const ux = dx / len, uz = dz / len; const off = (e.width || 4) * 0.5 + FOOTPATH_MARGIN; return { ...e, A, B, ux, uz, len, off }; }); // adjacency: node id → incident edge indices this.adj = new Map(); this.nodes.forEach(n => this.adj.set(n.id, [])); this.edges.forEach((e, i) => { this.adj.get(e.a).push(i); this.adj.get(e.b).push(i); }); } // ---- deterministic identity ---- _makeCitizen(id) { const idn = identityOf(this.citySeed, this.edges.length, id); 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: idn.edge, forward0: idn.forward, // immutable spawn beat (for the determinism signature) // live state (mutates as they walk + turn at nodes) edge: idn.edge, forward: idn.forward, s: idn.sFrac * this.edges[idn.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), sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched // [R41 §41.3] this citizen's four standing postures — a PURE function of (citySeed, id), decided // here once and never re-rolled, so lazy clip loading can change when a posture shows but never // which one it is. Two more freshly-keyed streams for the new street stops; like R17/R29 they // are drawn independently and cannot shift turn/loiter/patron/benchsit/glance. posture: posturesFor(this.citySeed, id), bench: null, benchRng: rng(this.citySeed, 'benchstop', id), lean: null, leanRng: rng(this.citySeed, 'leanstop', id), }; // assign a real ped type if the fleet is already up (roster can grow after upgrade) 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; } _ensureRoster(n) { while (this.roster.length < n) this.roster.push(this._makeCitizen(this.roster.length)); } // ---- public controls ---- setPopulation(n) { this.target = Math.max(0, n | 0); this._ensureRoster(this.target); } setTimeOfDay(t01) { this.timeOfDay = t01; } // Match mid-tier billboards to the shell's day/night exposure. The impostor atlas is baked at a // fixed exposure and self-tone-maps (toneMapped:false), so as the shell animates // renderer.toneMappingExposure across day segments the billboards would drift brighter/darker than // the near rigs. Call this each frame with the live exposure. Stored so an atlas re-bake (the // placeholder→rig upgrade) inherits it. (Exposure only — the atlas can't track per-segment sun/hemi // colour; that would need a re-bake. Close enough for the LOD swap.) setExposure(e) { this._exposure = e; if (this.impostor) this.impostor.setExposure(e); } setDebugTiers(on) { this.debugTiers = !!on; if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff); } // ---- R8 patronage + weather ---- // shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only). // map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert. // [Lane D R40 §40.5] Each door point is clamped into the footpath band of its nearest street // (door_snap.js — registry's vergeBand over our own edges, capped at 6 m so plaza-front stalls // stay put). The R8 claim is "peds duck into shops they pass"; the residue after B's R39 heading // fix was door points a metre or two off the walkable strip — peds steering to the kerb gutter or // a corner sliver. Points keep the shell's chunk keys (moves ≤ cap ≪ CHUNK; patronage is already // chunk-blind by covenant, sim.js:401). Gated by tools/qa/door_footpath_check.mjs — ≥95% of door // points on walkable front ground on all four measured towns, fix-only (breaks 0). setShops(shopsByChunk) { if (shopsByChunk && this.edges) { const snapped = new Map(); for (const [key, list] of shopsByChunk) { snapped.set(key, list.map((s) => { const p = snapDoorToFootpath(s.x, s.z, this.edges); // [R41] keep the PRE-clamp point as `_raw`. The clamp deliberately drags the door onto the // walkable strip (that is its whole job), but a leaner wants the facade the door was // derived from, not the kerb the ped walks on — see _leanPose. Non-enumerable-ish extra // field only; every existing reader takes x/z/hours/shopId and is untouched. return p.moved ? { ...s, x: p.x, z: p.z, _raw: { x: s.x, z: s.z } } : s; })); } shopsByChunk = snapped; } this.shopsByChunk = shopsByChunk; } setPatronage(on) { this.patronageOn = !!on; } // Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street // converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants // drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it). // R13 the district: per-venue, so several concurrent gigs each pull their own block. F calls this once // 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; // [R41 §41.3] gig night is the venue clips' cue. F flips this from the STREET when the doors open, // long before the player walks in, so venue.glb (526 KB) is resident by the time GigCrew spawns — // lazy, one fetch, and never paid by a town that has no gig on. GigCrew re-asks anyway and heals // itself if this never ran (band.js), so the ordering is an optimisation, not a dependency. if (on) this._wantGroup('venue.glb'); 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 }; } _openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; } // rain thins the crowd (~40–60%), overcast a little; clear = v1. Applied to each chunk's active count. _weatherDensityMult() { const w = this.weather; if (w.state === 'rain') return 0.6 - 0.2 * w.intensity; // 0.4–0.6 if (w.state === 'overcast') return 1 - 0.15 * w.intensity; // ~0.9 return 1; } _speedMult() { return this.weather.state === 'rain' ? 1.0 + 0.18 * this.weather.intensity : 1; } // chance (per in-range check) a ped ducks into an open shop. Rain → shelter-seeking. As the streets // empty (night), bump it so the few peds out concentrate at the one open shop (the video shop draws // the night crowd instead of a dead street with a lone visitor). _patronChance() { let base = this.weather.state === 'rain' ? 0.22 + 0.25 * this.weather.intensity : 0.16; const day = densityAt(this.timeOfDay); if (day < 0.35) base = Math.min(0.6, base + (0.35 - day) * 1.3); return base; } // nearest OPEN shop the ped is currently passing, else null. Hours-aware — at night only the openLate // video shop qualifies, so its block draws the night crowd. // // [R23] The carried v4.x fix — GIG_RANGE (34 m) is a RADIUS, but this read only the ped's own 64 m chunk, // so a gig venue genuinely in range, just across a chunk edge, was invisible. A's R21 cluster bias made // that WORSE, not better: venues now sit INSIDE the retail cluster where peds converge from every side, // so 42.6% of in-gig-range peds were chunk-blind (up from the 8.7% I filed at the old isolated pub). // // The fix is scoped to the GIG path on purpose. Extending the neighbour scan to ORDINARY shops as well // was the literal brief, but measured it breaks the same brief's "byte-identical flags-off" clause: on // the synthetic town (what ?classic=1 boots) 127 of 855 finds — 14.9% — would newly come from a // neighbour chunk, i.e. the frozen v2 crowd would start shopping ~15% more. So: // · ORDINARY shops: own chunk only — v2 semantics, frozen. A shopfront is noticed from its own block. // · GIG venues: the full 34 m radius, honest across chunk edges — the gig is the "follow the sound" // pull that is SUPPOSED to reach further; that is the whole point of GIG_RANGE > PATRON_RANGE. // With no gig on, `_gigVenues` is empty and the neighbour loop is skipped entirely ⇒ ?classic and every // flags-off boot take the identical code path AND identical cost to v2. Byte-identical by construction. // Both ranges are < CHUNK (64), so a 3×3 sweep is exact, not an approximation. Fixed dz/dx order ⇒ // exact-distance ties resolve deterministically, as the old single-list scan did. // Carried: PATRON_RANGE keeps v2's own chunk-blindness. Revisit only if the covenant is ever relaxed. _nearestOpenShop(c) { if (!this.shopsByChunk) return null; const cx = chunkCoord(c.x), cz = chunkCoord(c.z); let best = null, bd = PATRON_RANGE; let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary const own = this.shopsByChunk.get(chunkKey(cx, cz)); // shops (follow the sound), and across venues. if (own) for (const s of own) { if (!this._openAt(s.hours)) continue; const d = Math.hypot(s.x - c.x, s.z - c.z); if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; } if (d < bd) { bd = d; best = s; } } if (this._gigVenues.size) { // gig on → the 34 m pull reaches across chunk edges (gigs only) for (let dz = -1; dz <= 1; dz++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dz === 0) continue; // own chunk already scanned above const list = this.shopsByChunk.get(chunkKey(cx + dx, cz + dz)); if (!list) continue; for (const s of list) { if (!this._gigVenues.has(s.shopId)) continue; // neighbours contribute gig venues ONLY if (!this._openAt(s.hours)) continue; const d = Math.hypot(s.x - c.x, s.z - c.z); if (d < gbd) { gbd = d; gigBest = s; } } } } } return gigBest || best; } // [R41 §41.3] the nearest shop FRONT within `range` — open or shut; a leaner doesn't need the door // to work, just a wall. Separate from `_nearestOpenShop` on purpose: that function's own-chunk // blindness is a frozen v2 semantic the R23 note explains at length, and this is new behaviour with // no covenant to keep, so it scans the honest 3×3 (range ≪ CHUNK, so the sweep is exact). Only ever // called at the instant a loiter begins, and only when the bank exists ⇒ ?classic never runs it. _nearestShopPoint(c, range) { if (!this.shopsByChunk) return null; const cx = chunkCoord(c.x), cz = chunkCoord(c.z); let best = null, bd = range; for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) { const list = this.shopsByChunk.get(chunkKey(cx + dx, cz + dz)); if (!list) continue; for (const s of list) { const d = Math.hypot(s.x - c.x, s.z - c.z); if (d < bd) { bd = d; best = s; } } } return best; } _beginVisit(c, shop) { c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out c.patronTarget = shop; c.patron = 'going'; } _enter(c) { 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; // 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; 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 })) }; } // 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() { return this._activeList.map(c => this._sig(c)); } // ---- impostor atlases ---- _buildPlaceholderImpostors() { const subjects = []; for (let v = 0; v < PLACEHOLDER_VARIANTS; v++) { const ph = makePlaceholder(rng(this.citySeed, 'pvar', v), { height: 1.9 }); subjects.push({ object3D: ph.fig, height: 1.9 }); } const atlas = bakeImpostorAtlas(this.renderer, subjects, { yaws: 4, cell: 128, environment: this.scene.environment }); subjects.forEach(s => s.object3D.traverse(o => { if (o.isMesh) o.material.dispose?.(); })); this._setImpostorLayer(atlas); } _upgradeToRigs() { // bake one atlas cell-set per ped type, from a representative mid-stride pose const temps = [], subjects = []; for (let i = 0; i < this.fleet.all.length; i++) { const rig = this.fleet.all[i]; const spawned = makeActor(rig, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, nominalHeight: 1.9 }); if (!spawned) continue; spawned.setMoving(true, 0); spawned.mixer.update(0.35 + i * 0.017); // desynced mid-stride so the atlas isn't all one pose temps.push(spawned); subjects.push({ object3D: spawned.fig, height: 1.9, pedIndex: i }); } if (!subjects.length) return; const atlas = bakeImpostorAtlas(this.renderer, subjects, { yaws: 4, cell: 128, environment: this.scene.environment }); temps.forEach(t => t.dispose()); this._setImpostorLayer(atlas); this.mode = 'rig'; this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip, lookClip: this.fleet.lookClip }); // sit/lookClip null under ?classic → bench-sit + glance inert // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs 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) { if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); } this.impostor = new ImpostorLayer(atlas, { maxInstances: IMPOSTOR_MAX }); this.group.add(this.impostor.mesh); if (this.debugTiers) this.impostor.setTint(tierColor.mid); if (this._exposure != null) this.impostor.setExposure(this._exposure); // survive the upgrade re-bake } // ---- lane math ---- _placeOnLane(c) { const e = this.edges[c.edge]; const start = c.forward > 0 ? e.A : e.B; const tdx = c.forward * e.ux, tdz = c.forward * e.uz; // travel direction // right-perpendicular (dz,-dx): opposing walkers take opposite footpaths const px = tdz, pz = -tdx; c.x = start.x + tdx * c.s + px * e.off; c.z = start.z + tdz * c.s + pz * e.off; c.facing = Math.atan2(-tdx, -tdz); // rig front = local -Z } _advance(c, dt) { // ---- R8 patronage state machine (streamed citizens only; c.patron is undefined for v1) ---- if (c.patron === 'inside') { c.patronTimer -= dt; if (c.patronTimer <= 0) this._emerge(c); return; } if (c.patron === 'going') { // steer straight to the shop door const dx = c.patronTarget.x - c.x, dz = c.patronTarget.z - c.z; const dist = Math.hypot(dx, dz); if (dist < 1.8) { this._enter(c); return; } const step = c.speed * this._speedMult() * dt; c.x += (dx / dist) * step; c.z += (dz / dist) * step; c.facing = Math.atan2(-dx / dist, -dz / dist); return; } if (c.loiter > 0) { c.loiter -= dt; // [R41] a bench sit / shopfront lean is a POSITIONED stop: the ped was moved off its lane to the // furniture. When the stop expires, drop the binding and put them back on the footpath in one // step, so the next frame walks from the lane and not from the seat. if (c.loiter <= 0 && (c.bench || c.lean)) { c.bench = null; c.lean = null; this._placeOnLane(c); } return; } const e = this.edges[c.edge]; const adv = c.speed * this._speedMult() * dt; const s0 = c.s; c.s += adv; // [R41 §41.3] THE BENCH. Lane B puts benches at fixed stations along every edge, on alternating // sides. A ped walks the footpath on the side given by its travel direction, so it can only use a // bench on ITS side: the sim's lane perpendicular is (forward·uz, −forward·ux) = −forward × the // furniture perpendicular, hence `side === −forward`. Crossing that station's arc-length this // frame is the trigger; a dedicated stream decides. Gated on `clipsOn` (the boot-stable "is the // library on" answer), NOT on residency — so ?classic / ?noassets never run a line of it, and a // boot that has it on draws the identical randoms from frame zero whatever the network does. All // three draws are unconditional; the POSE falls back to R16's sit.glb until the variant lands. if (c.benchRng && this.clipsOn && !c.patron) { for (const st of this._benchStations(c.edge)) { if (st.side !== -c.forward) continue; if (!(s0 < st.s && c.s >= st.s)) continue; const wantsBench = c.benchRng() < BENCH_STOP_FRAC; const seat = c.benchRng() < 0.5 ? -1 : 1; // drawn always — see clipsOn const dwell = c.benchRng(); if (!wantsBench) continue; this._wantClip(c.posture && c.posture.sit); // first sit intent fetches sitlean.glb const p = this._seatPose(st, seat); c.bench = st; c.lean = null; c.sit = false; c.glance = false; c.s = st.s; // resume from the bench, not past it c.loiter = BENCH_DWELL[0] + dwell * (BENCH_DWELL[1] - BENCH_DWELL[0]); c.x = p.x; c.z = p.z; c.facing = p.facing; return; // the POSE falls back to R16's sit.glb } // until this citizen's variant lands } if (c.s >= e.len) { // arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter const node = c.forward > 0 ? e.b : e.a; const inc = this.adj.get(node); let choices = inc.filter(i => i !== c.edge); if (!choices.length) choices = inc; // dead-end → U-turn const next = choices[(c.turn() * choices.length) | 0]; const ne = this.edges[next]; c.edge = next; c.forward = ne.a === node ? 1 : -1; c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance if (c.loit() < 0.10 + c.loiterTend * 0.28) { c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop // R17 bench-sit: a seeded few of those become a bench-sit. Roll ALWAYS (dedicated stream → // deterministic, never shifts turn/loit/patron), but only flip c.sit when a sit clip is loaded // so ?classic (no sit.glb) is inert at the source, not just at the render. const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false; c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip); // [R29 Spike 1] a standing glance — the other way to spend a window-shop stop. Same discipline as // the sit roll: draw ALWAYS from a dedicated stream (deterministic; never shifts turn/loit/patron), // but only flip the flag when a clip exists ⇒ ?classic inert at the source. Mutually exclusive with // the sit: you can't bench-sit and stand glancing, so the sit wins and the glance takes the rest. const wantsGlance = c.glanceRng ? (c.glanceRng() < GLANCE_FRAC) : false; c.glance = !c.sit && wantsGlance && !!(this.fleet && this.fleet.lookClip); } } this._placeOnLane(c); // patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to // duck in (proximity-gated so the roll isn't wasted mid-block — this is what makes the sparse // night crowd reliably cluster at the open-late video shop). if (this.patronageOn && this.shopsByChunk && c.patronRng) { c._patronDist = (c._patronDist || 0) + adv; if (c._patronDist >= PATRON_STRIDE) { c._patronDist = 0; const shop = this._nearestOpenShop(c); const chance = (shop && this._gigVenues.has(shop.shopId)) ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance(); if (shop && c.patronRng() < chance) this._beginVisit(c, shop); // [R41 §41.3] THE SHOPFRONT LEAN — the ped that walked past a shop and DIDN'T go in. // This rides the patronage stride check on purpose. R17/R29's window-shop stop fires at a // graph NODE, i.e. at an intersection, where there is rarely a shopfront to lean on (measured: // 0 leans in a 9 s run when it was wired there). The stride check is the moment the sim // already asks "is there a shop beside me", which is exactly when a leaner is beside a wall. // Strictly downstream of the patron decision, on its own stream, and gated on `clipsOn` — so // with the library off (?classic / ?noassets / ?clips=0) not one line of it runs. // …and THE WINDOW PAUSE, which is what finally makes the 10-idle pool visible. Measured, and // it changed the design: with the pool wired only to R17/R29's node loiter, a census of the // live crowd found 0.8% of citizens stopped at any instant (the loiter fires at a graph NODE, // and edges are long) — so nine of ten new idles were assigned, deterministic, and never // seen. A ped who walks past a shop and neither goes in nor leans on it now sometimes just // STOPS and looks at the window, in their own seeded idle. No reposition, no new clip: the // actor's resting action is already this citizen's idle (setIdleClip at acquire). // All four draws are unconditional so the stream position cannot depend on load timing. else if (c.leanRng && this.clipsOn && !c.bench) { const wantsLean = c.leanRng() < LEAN_FRAC; const side = c.leanRng() < 0.5 ? 1 : -1; const wantsPause = c.leanRng() < PAUSE_FRAC; const dwell = c.leanRng(); const wall = (wantsLean || wantsPause) ? this._nearestShopPoint(c, LEAN_RANGE) : null; if (wall && wantsLean) { this._wantClip(c.posture && c.posture.lean); // first lean intent fetches sitlean.glb const p = this._leanPose(wall, side, this.edges[c.edge]); // the ped's CURRENT edge // (a node arrival above may have moved it) if (p) { c.lean = wall; c.sit = false; c.glance = false; c.loiter = LEAN_DWELL[0] + dwell * (LEAN_DWELL[1] - LEAN_DWELL[0]); c.x = p.x; c.z = p.z; c.facing = p.facing; // pose falls back to R29's look.glb } // until this citizen's variant lands } else if (wall && wantsPause) { c.sit = false; c.glance = false; // plain stop ⇒ the seeded idle plays c.loiter = PAUSE_DWELL[0] + dwell * (PAUSE_DWELL[1] - PAUSE_DWELL[0]); } } } } } // ---- actor lifecycle ---- _acquireActor(c) { if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) { const a = this.rigPool.acquire(c.pedIndex); if (a) { // [R41 §41.3] THE HEADLINE. Pooled actors are shared between citizens of the same ped type, so // the per-citizen posture is installed here, on acquire, not baked at construction: the same // citizen always stands the same way, and the street stops being one person copy-pasted. // Both calls are inert when the bank is absent or the clip has not landed (they fall back to // the base walk/idle actions) ⇒ ?classic / ?noassets / pre-load are the R40 actor exactly. if (this.bank && c.posture) { a.setIdleClip?.(this._clip(c.posture.idle)); a.setWalkClip?.(this._clip(c.posture.walk)); } a.setPhase(c.phase); a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose) a.fig.scale.setScalar(c.height / (a.nominalHeight || 1.75)); this.group.add(a.fig); c.actor = a; c.actorKind = 'rig'; return true; } return false; // pool exhausted this frame → stay an impostor } // placeholder mode (asset-free) — build a unique-coloured figure const ph = makePlaceholder(rng(this.citySeed, 'body', c.id), { height: c.height }); this.group.add(ph.fig); c.actor = ph; c.actorKind = 'placeholder'; return true; } _releaseActor(c) { if (!c.actor) return; this.group.remove(c.actor.fig); if (c.actorKind === 'rig' && this.rigPool) this.rigPool.release(c.actor); else c.actor.dispose?.(); c.actor = null; c.actorKind = null; c._acc = 0; } 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) { const haveLively = this._livelyChunks.size > 0; const wm = this._weatherDensityMult(); // rain/overcast thins the crowd on top of the day curve for (const [key, arr] of this.chunkRosters) { // hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block) // keep a night floor so that street never goes dead while its shop is still open. const base = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density; const d = base * wm; const activeCount = Math.round(arr.length * d); 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 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 (const c of activeCitizens) { this._advance(c, dt); const ddx = c.x - camX, ddz = c.z - camZ; c._d = Math.hypot(ddx, ddz); cand.push(c); } // choose the near set: hysteresis eligibility, then nearest-first up to the cap const eligible = []; for (const c of cand) { if (c.patron === 'inside') continue; // ducked into a shop → not rendered const wasNear = c.tier === 'near'; if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c); } eligible.sort((a, b) => (a._d - (a.tier === 'near' ? NEAR_BIAS : 0)) - (b._d - (b.tier === 'near' ? NEAR_BIAS : 0))); const nearSet = new Set(eligible.slice(0, NEAR_MAX)); // assign tiers + representations const mid = this._midList; mid.length = 0; let newRigs = 0; for (const c of cand) { let want; if (c.patron === 'inside') want = 'far'; // inside a shop → hidden (actor released below) else if (nearSet.has(c)) want = 'near'; else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid'; else want = 'far'; if (want === 'near') { // acquire an actor (budgeted); if it fails this frame, ride as an impostor instead if (!c.actor) { if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid'; } else if (c.actorKind === 'placeholder' && this.mode === 'rig') { this._releaseActor(c); // upgrade path: swap placeholder → rig if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid'; } } if (want !== 'near' && c.actor) this._releaseActor(c); if (want === 'near' && c.actor) { near.push(c); const a = c.actor; a.fig.position.set(c.x, 0, c.z); a.fig.rotation.y = c.facing; // R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath // edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null), // so every non-sitting ped is byte-identical. // [R41 §41.3] `c.bench` is a real bench-bound sit (the ped was moved onto Lane B's furniture); // it and the R17 free sit both come out here, the bench one carrying this citizen's own seeded // sitlean variant. `sitClip` null ⇒ setSitting falls back to R16's sit.glb; both null ⇒ inert. const benchSit = !!(c.bench && c.loiter > 0); const wantSit = !!((benchSit || c.sit) && c.loiter > 0 && a.setSitting); if (a.setSitting) a.setSitting(wantSit, benchSit ? this._clip(c.posture && c.posture.sit) : null); // [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles. // No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical. // [R41] …and now also leans: same standing-posed-clip seam, this citizen's own lean variant. const wantLean = !wantSit && !!(c.lean && c.loiter > 0 && a.setLooking); const wantLook = !wantSit && !wantLean && !!(c.glance && c.loiter > 0 && a.setLooking); if (a.setLooking) a.setLooking(wantSit ? false : (wantLean || wantLook), 0.3, wantLean ? this._clip(c.posture && c.posture.lean) : null); if (!wantSit && !wantLean && !wantLook) a.setMoving?.(c.loiter <= 0); } else if (want === 'mid') { mid.push(c); } c.tier = want; } // near animation: rigs on a staggered mixer budget; placeholders tick cheaply every frame near.sort((a, b) => a._d - b._d); const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); const extra = []; for (let i = 0; i < near.length; i++) { const c = near[i], a = c.actor; c._acc += dt; if (a.mixer) { // [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's // post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`. if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance || c.lean) a.plantFeet?.(); } else extra.push(c); } else { a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder } } // round-robin the mixers beyond the nearest 8, ≤MIXER_EXTRA per frame if (extra.length) { if (this._mixerCursor >= extra.length) this._mixerCursor = 0; for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) { const c = extra[(this._mixerCursor + k) % extra.length]; c.actor.mixer.update(c._acc); c._acc = 0; if (c.glance || c.lean) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran (R41: leaners too) } this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length; } const mixerMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0; // mid tier: one instanced draw call if (this.impostor) { const list = mid.length > IMPOSTOR_MAX ? mid.slice(0, IMPOSTOR_MAX) : mid; this.impostor.update(list, cam); } // 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); } }