PROCITY/web/js/citizens/clipbank.js
m3ultra 78f49f7113 Lane D R41 §41.3: the town stops walking — 99.3% to 78.1%, at zero draws
THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that
turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean
0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20.
The town was 99.3% people walking because standing still had nowhere to happen.

Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic
idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a
shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on
shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per
(shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record
keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired
state machine, filed to R42.

Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta
+3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips
— not even clipbank.js (dynamic import). No shell edit needed.

DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square
94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly.

DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1
differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of
(citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys.

TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29
node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in
a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted:
14/14 derived stations coincide with real instanced geometry within 2 cm, and the control
(same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the
mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road,
contradicting its own comment.

Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76.

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

131 lines
7.0 KiB
JavaScript

// PROCITY Lane D — R41 §41.3: THE CLIP BANK. Lane E's 46-clip motion library, loaded by group,
// lazily, fail-soft, and byte-shaped exactly like the eight clips `loadPedFleet` already loads.
//
// The contract (Lane E, `web/assets/motion_manifest.json`, E-progress §41.1 ¶4):
// groups[<file>] one GLB under web/models/clips/ = ONE fetch, N named animations
// clips[<clipId>] { group, category, duration, loopable, loopSeamDeg, source, mixamo, … }
// a group GLB's gltf.animations[] are NAMED BY clipId ⇒ `animations.find(a => a.name === id)`
//
// Verified before wiring, not assumed: `python3 pipeline/clips_verify.py` → GREEN 0/0 on today's
// tree, 46 clips / 6 groups / 3 498 124 B, every group 0 meshes / 0 materials / 0 images / 66 nodes.
//
// This module is DYNAMICALLY imported by rigs.js, and only when the clip gate is on. That is not
// cosmetic: under `?classic=1` / `?noassets=1` the browser never fetches clipbank.js itself, so the
// zero-fetch-delta covenant holds for the JS surface as well as the asset surface.
//
// Loading is per GROUP and lazy by design (the fetch ledger is in LANE_D_NOTES §41):
// boot idles.glb + locomotion.glb + the manifest (3 fetches, 1 258 784 B)
// lazy sitlean.glb on the first sit/lean intent · browse.glb on the first interior browser ·
// venue.glb on the first gig / pub / record-shop keeper
// never social.glb — no two-person state machine yet (R42)
// A clip that has not landed yet is simply absent: every consumer falls back to the pre-R41 8-clip
// action, so lazy loading can never change WHICH posture a citizen was assigned (postures.js), only
// how soon it looks like it.
import { loadGLB } from '../core/loaders.js';
import { canonName, rotOnlyClip } from './rigs.js';
export const CLIP_BASE = 'models/clips/';
export const MANIFEST_URL = 'assets/motion_manifest.json';
// Above this measured first-key→last-key seam a clip does not close, so repeating it pops. E measures
// `loopSeamDeg` per clip; we ping-pong those instead of dropping them — which is why `browse_pick_up`
// (92.15°) reads as crate-digging (down, up, down) rather than a teleport back to the start.
export const LOOP_SEAM_DEG = 25;
export class ClipBank {
constructor({ clipBase = CLIP_BASE, manifestUrl = MANIFEST_URL } = {}) {
this.clipBase = clipBase;
this.manifestUrl = manifestUrl;
this.manifest = null;
this.clips = new Map(); // clipId → AnimationClip, already _canon'd + _rotOnly'd
this.groups = new Map(); // group file → Promise<boolean> (promise-cached: one fetch each)
this.loaded = new Set(); // group files whose animations are indexed
this.bytes = 0; // manifest-declared bytes of the groups actually resident
this._mp = null;
}
// boot(groups) — the manifest and the eager groups IN PARALLEL. Deliberately not serialised:
// the group filenames are static (postures.js BOOT_GROUPS), so waiting for a 16 KB JSON before
// starting a 1 MB GLB would add a round trip to the boot for nothing.
boot(groups = []) {
return Promise.all([this.manifestP(), ...groups.map((g) => this.ensureGroup(g))]).then(() => this);
}
manifestP() {
return (this._mp ||= fetch(this.manifestUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)
.then((m) => { this.manifest = m || null; return this.manifest; }));
}
// ensureGroup(file) → Promise<boolean>. Promise-cached per file (and loadGLB is URL-cached under
// that), so N callers racing on the first interior cost exactly one fetch. Fail-soft: a missing or
// broken group resolves false and every consumer keeps its fallback clip.
ensureGroup(file) {
if (!file) return Promise.resolve(false);
let p = this.groups.get(file);
if (p) return p;
p = loadGLB(this.clipBase + file).then((g) => {
const anims = (g && g.animations) || [];
if (!anims.length) return false;
for (const a of anims) {
if (this.clips.has(a.name)) continue;
// the same two-step every shared clip in this project rides: fold mixamorigN: → mixamorig:
// so any clip binds to any character, then keep rotations only (rigs.js `_rotOnly` — drops
// every position/scale track AND Hips.quaternion). E measured 65 raw tracks → 64 after, on
// all 46; asserted again below so a future re-pack that breaks it fails loudly here.
a.tracks.forEach((t) => { t.name = canonName(t.name); });
const c = rotOnlyClip(a);
c.name = a.name;
if (!c.tracks.length) { console.warn('[clipbank] clip empty after _rotOnly, skipped:', a.name); continue; }
this.clips.set(a.name, c);
}
this.loaded.add(file);
const gm = this.manifest && this.manifest.groups && this.manifest.groups[file];
if (gm && gm.bytes) this.bytes += gm.bytes;
return true;
});
this.groups.set(file, p);
return p;
}
// get(id) → AnimationClip | null. Null is a first-class answer: "assigned, not resident yet".
get(id) {
if (!id || id[0] === '@') return null; // '@…' are the pre-R41 base-asset sentinels
const c = this.clips.get(id);
if (!c) return null;
// loop mode is decided ONCE, and only when the manifest is actually there to decide it from —
// otherwise a clip that resolved before the JSON would be frozen on the default forever.
if (c._pcLoop === undefined && this.manifest && this.manifest.clips) {
const m = this.manifest.clips[id];
c._pcLoop = (m && !m.loopable && (m.loopSeamDeg || 0) > LOOP_SEAM_DEG) ? 'pingpong' : 'repeat';
}
return c;
}
has(id) { return !!(id && id[0] !== '@' && this.clips.has(id)); }
meta(id) { return (this.manifest && this.manifest.clips && this.manifest.clips[id]) || null; }
// resolve a whole posture set at once; missing entries come back null (⇒ base-clip fallback)
resolve(p) {
return p ? { idle: this.get(p.idle), walk: this.get(p.walk), sit: this.get(p.sit), lean: this.get(p.lean) } : null;
}
stats() {
return {
groups: this.loaded.size, groupList: [...this.loaded].sort(),
clips: this.clips.size, bytes: this.bytes,
manifest: !!this.manifest,
catalogue: this.manifest ? this.manifest.clipCount : 0,
};
}
// AnimationClips hold NO GPU resources (these groups are 0 meshes / 0 materials / 0 images — E's
// glb_stat + clips_verify both assert it), so there is nothing to free but the references. The
// parsed gltf stays in core/loaders' URL cache exactly like every other GLB in the game.
dispose() { this.clips.clear(); this.groups.clear(); this.loaded.clear(); this.bytes = 0; }
}
// NOTE on imports, on purpose: nothing outside rigs.js imports this module STATICALLY. rigs.js
// reaches it with a dynamic `import()` behind the clip gate, and every other consumer (sim, keepers,
// band) touches it only through the live `fleet.bank` object. So `?classic=1` / `?noassets=1` fetch
// neither the clip GLBs nor this file — the zero-fetch-delta covenant holds on the JS surface too.