PROCITY/web/js/citizens/sim.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

1105 lines
65 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';
import { snapDoorToFootpath } from './door_snap.js';
import { posturesFor, postureSig, GROUP_OF } from './postures.js';
// [R42 §42.4] Lane B's bench placement rule, imported rather than mirrored (see THE BENCH below).
// STATIC on purpose: the bench-stop check draws three seeded randoms per crossed station, so a
// dynamically-imported station list would draw zero randoms until it landed and "same seed → same
// crowd" would depend on the network — the exact hazard `fleet.clipsRequested` was published
// synchronously to avoid in R41. `chunks.js` already imports this module on every boot (classic and
// ?noassets included), so on the game path this adds no fetch, no module and no behaviour.
import { benchStops } from '../world/furniture.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.
//
// [R42 §42.4] THE MIRROR IS RETIRED. R41 shipped a COPY of Lane B's placement rule here, because
// furniture.js exported no bench enumerator, and filed the seam: "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." B shipped it in §42.5 and the very same round proved why a mirror is not good
// enough — B fixed a 41-round-old defect in the rule itself:
//
// alongYaw = atan2( ux, uz) local +Z runs DOWN THE STREET ← what the mirror copied
// faceYaw = atan2(-uz, ux) local +Z runs ACROSS it ← what a bench actually wants
//
// The bench's front had been square to the road on 60/60 instances while both files' comments said
// "facing the road". A mirror is only as good as the day it was copied, and this one was copied from
// a wrong line — so the fix is not to re-copy it 90° over, it is to stop having a second copy. The
// rule now lives in exactly one function (`benchStationsOnEdge`) that Lane B's placer and Lane B's
// `benchStops(plan)` both call, and `benchStationsFor()` below delegates straight into it.
//
// Two things this does NOT change, both checked rather than assumed:
// • the STATIONS. Same cadence (14, 40, 66, … step 26), same alternating side, same offset, same
// `len 6` end margin, so the same COUNT of stations per edge — which matters because each
// crossed station draws exactly three randoms, and a different count would move every seeded
// identity downstream of it. Positions unchanged to the centimetre; only `yaw` moves.
// • `_seatPose`. B kept the sitter contract explicitly ("a rig front is local Z after the R13
// facing-normalise, so a sitter is still `yaw + π`"), and the seat maths below is written in the
// bench's own local frame, so the corrected yaw carries through it untouched. What changes on
// screen is that the sitter now lands ON the plank facing the road instead of up to 0.38 m off
// it facing along the street (B's un-retouched docs/shots/laneB_r42/bench_seated_after.jpg).
// Gated by `tools/qa/r41_citizens.py` arm 6, which matches these stations against the bench
// InstancedMesh transforms actually standing in Lane B's built scene, position AND yaw, with a 2 m
// offset control. That arm is the reason a drift like this cannot ship silently again.
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.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 };
}
// [R41 §41.3 · rewired R42 §42.4] Lane B's benches on one prepared sim edge
// ({id,kind,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.
// It is now a THIN ADAPTER over `benchStops(plan)`: the sim carries a graph, not a plan, so the edge
// is wrapped back into the two-node plan shape `resolveEdges` wants and handed to Lane B's own rule.
// No arithmetic of its own — that is the whole point (see the block above). Returns B's rows verbatim
// ({edgeId, kind, s, side, x, z, yaw, seatLen}); every caller reads `s`/`side`/`x`/`z`/`yaw`.
// `_benchStations` caches per edge, so this runs once per edge per boot, not per stride.
export function benchStationsFor(e) {
return benchStops({ streets: {
nodes: [{ id: '_a', x: e.A.x, z: e.A.z }, { id: '_b', x: e.B.x, z: e.B.z }],
edges: [{ id: e.id, kind: e.kind, width: e.width || 4, a: '_a', b: '_b' }],
} });
}
// 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
// [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, from B's own placement rule via `benchStops` — see THE BENCH block above for
// why this is no longer a mirror and what gates it. Cached per edge (pure function of the edge, no
// rng), so the adapter's plan-shaped wrapper is built once per edge per boot.
_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.
// [R42 §42.4] Written entirely in the BENCH'S OWN local frame — +Z front, +X plank — which is why
// B's §42.5 yaw correction needed no edit here: the same three lines now put the sitter on the
// plank facing the road instead of 0.38 m off it facing along the street. B also normalised the
// depot bench GLB's geometry (its plank ran along Z, sitter facing X) so a station yaw means one
// thing whichever of the two bench geometries draws, and published `seatLen` on every station: the
// primitive plank is 1.6 m, the depot GLB's 1.216 m, and B measured that ±0.38 fits both.
// `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 (~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;
}
// [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 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;
// [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);
}
}