PROCITY/web/js/citizens/sim.js
m3ultra 10025d4082 Lane D R29 (v6 Spike 1): look.glb bound and wired — peds glance, feet planted (wave 0)
The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound
through the fleet gate; street peds now glance around during window-shop stops. `lean`
is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside
every plan hash; the synthetic anchor stays R27's amended 0x5f76e76.

WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit
gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have
breached the zero-fetch-delta covenant — E flagged it, the gate is the answer).
makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped
like sit, since idle->idle-variant reads naturally and there's no replant for a fade to
fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but
only flipped when a clip exists => classic inert at the source. Mutually exclusive with
bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads
lookClip; release snaps the glance off so no rig is pooled mid-look.

THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a
STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's
alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground
(-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting
through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for
rig-height independence, so the clip's authored weight-shift dip can't move the hips and
comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per
frame.

FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure
posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never
incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip
relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones
cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites
— the drummer's post-mix lean taught that coupling rule).

PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so
the glance still animates; ?classic — no look fetch, no Look action on any rig, glance
never flips (0); identity signature unchanged, no glance term; sit AND glance never
simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged
seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm.

D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped
glancing on the footpath, head sweeping, feet planted.
Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not
new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact
placement is the only genuinely new part.
FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays
0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but
the same species as the epoch's five holds, so: flagged, not touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:44:07 +10:00

809 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (2570m): 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';
// ---- 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
// 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.551.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 };
}
// 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<key,{pedIndex,height}> — 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
};
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(); }
// 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
};
// 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.
setShops(shopsByChunk) { 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;
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 (~4060%), 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.40.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;
}
_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 520s
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; return; }
const e = this.edges[c.edge];
const adv = c.speed * this._speedMult() * dt;
c.s += adv;
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);
}
}
}
// ---- actor lifecycle ----
_acquireActor(c) {
if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) {
const a = this.rigPool.acquire(c.pedIndex);
if (a) {
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.
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
if (a.setSitting) a.setSitting(wantSit);
// [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.
const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking);
if (a.setLooking) a.setLooking(wantLook);
if (!wantSit && !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) 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.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran
}
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);
}
}