diff --git a/D-progress.md b/D-progress.md index 2c45d00..f567603 100644 --- a/D-progress.md +++ b/D-progress.md @@ -1,5 +1,188 @@ # PROCITY-D — progress (Lane D · Citizens) +## Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07) + +**The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples × +146 active citizens, same seed, same pose, two arms — where `?clips=0` is a new flag that turns the +motion library off and **nothing else** (`?classic=1` also changes the ped pool, the fog, the game +and half the shell, so it cannot be the control for this): + +| state of the street crowd | `?clips=0` = R40 | default = R41 | +|---|---:|---:| +| walking | **99.3%** | 78.1% | +| sitting on an actual Lane B bench | — | **9.5%** | +| leaning on a shopfront | — | **7.1%** | +| stopped at a window in their own seeded idle | 0.4% | **5.1%** | +| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% | +| **distinct animation clips in play across the crowd** | **4** (`walk`/`idle`/`sit`/`look` — and 99.3% of the crowd is on `walk.glb` alone) | **20** | + +Lane E's input was verified before anything was wired: `python3 pipeline/clips_verify.py` → GREEN, +0 errors / 0 warnings, 46 clips / 6 groups / 3 498 124 B, every group zero-draw. + +### What each clip category drives + +| group | clips used | drives | +|---|---|---| +| `idles.glb` (boot) | **10 of 10** | the per-citizen deterministic idle — the resting action of every near-tier actor, plus the seeded shopkeeper idle | +| `locomotion.glb` (boot) | 1 of 6 | `walk_shopping_bag` — the walk pool is `['@walk','@walk','walk_shopping_bag']`, so **1 in 3 citizens carries a bag** (measured 33.7% over 1 000 ids) | +| `sitlean.glb` (lazy: first sit/lean intent) | **8 of 8** | 4 sits bound to Lane B's benches, 4 leans bound to shopfront walls | +| `browse.glb` (lazy: first interior with browsers) | 5 of 8 | a real BROWSE state at Lane C's browse points, seeded per (shopId, slot) | +| `venue.glb` (lazy: gig night, from the street) | 5 of 6 | gig-crowd widening + the publican pouring + the record-shop keeper in headphones | +| `social.glb` | **0 of 8 — never fetched** | needs a two-person state machine + partner pairing. 896 KB for a state D does not have. → R42 | + +Unused and why: the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node — a turn state +is R42 and is the only thing that makes those five mean anything), `browse_hold_turn_l/r` (172°/147° +loop seams, one-shot turns), `venue_clap_seated` (no seated venue slot). + +### Boot fetch count and memory — measured in fresh headless contexts + +| | clip fetches | bytes resident | groups / clips | +|---|---:|---:|---| +| **at boot** | **4** — manifest (16 KB) · `clipbank.js` · `idles.glb` · `locomotion.glb` | **1 242 272 B** | 2 / 16 | +| + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 | +| + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 | +| + first gig night | +1 `venue.glb` | 2 602 476 B | 5 / 38 | +| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 | + +Six fetches at boot was what the round warned about. The answer is **two GLBs at boot, three on first +demand and one never**, each promise-cached (the bank does not grow across 6 interior enter/exit cycles). +**Heap delta (library ON − OFF), forced GC, fresh contexts: +3.34 MB and +6.87 MB across two runs** — +the spread is GC timing; the floor is resident clip bytes plus parsed `AnimationClip` overhead. +`mixerMs` unchanged: median 0.1 ms both arms. + +### Determinism — same seed, same postures, byte-equal + +- 150 active citizens, **two fresh browser contexts → byte-equal** posture signature. +- **CONTROL:** seed 20261991 gives a different signature — "byte-equal" is not "constant". +- **CONTROL that matters for lazy loading:** with **every clip GLB delayed 2 s** by a Playwright + route, the signature is **identical**. Posture is a pure function of `(citySeed, id)` + (`postures.js posturesFor`), decided once at citizen creation and never re-rolled, so loading can + change *when* a posture appears, never *which one*. +- One level down: `fleet.clipsRequested` is published **synchronously** by `loadPedFleet` before the + dynamic import starts, and every roll that can move a citizen is gated on that rather than on + "has it landed", so the number of randoms drawn per boot cannot depend on the network. Six + freshly-keyed streams (`posture`, `benchstop`, `leanstop`, `browse-pose`, `keeper-pose`, + `gig-pose`) collide with none of the 12 pre-R41 keys — gated, with the control that all 18 produce + different draws on the same id. +- 1 000 citizens × two module instances in node: identical (`sha256 08ecfe041b384460`). + +### Draw table — before / after, same seed, same bookmarks + +| view | `?clips=0` | default | Δ | budget | +|---|---:|---:|---:|---| +| street_noon | 193 | 193 | **+0** | ≤300 | +| crossroads_busy | 108 | 108 | **+0** | ≤300 | +| night_crowd | 128 | 128 | **+0** | ≤300 | +| market_square | 94 | 94 | **+0** | ≤300 | +| night_neon | 111 | 111 | **+0** | ≤300 | +| interior (record) | 110 | 110 | **+0** | ≤350 · margin **240** | + +Ruling 4 gave the street no headroom and the round spent none. The clips are skeleton-only (0 tris, +0 meshes, 0 materials), so the only mechanism by which they could move a draw count is putting a ped +somewhere else — bounded by the unchanged near-cap of 24 rigs, each already one draw. **Stated, not +hidden:** 193 is the worst of these five bookmarks on this seed, not the town's global worst (B's +R40 street number is 291/300 at a night pose these bookmarks do not reach). The **delta** is the +claim, and it is 0. + +### `?noassets=1` and `?classic=1` — the fallback survives, proven + +| | clip fetches | bank | town | console | +|---|---:|---|---|---| +| `?noassets=1` | **0** (no GLB, no manifest, not even `clipbank.js`) | none | 20 chunks, 165 citizens walking | 0 errors | +| `?classic=1` | **0** | none | 18 chunks, 139 citizens walking | 0 errors | +| `?clips=0` | **0** | none | full town | 0 errors | + +The library rides `clips = dance`, the R36 `djs = dance` trick again: the shell already passes +`dance: !CLASSIC`, so **no `index.html` edit was needed** and the zero-fetch-delta covenant holds — +including on the JS surface, because `clipbank.js` is reached by a dynamic `import()` and is not +fetched when the gate is off. +**One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no +manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**: +`postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/ +`band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero +asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found. + + +### Leak + +Warm the caches with one enter/exit, then **6 more cycles into the same shop**: geometries 118 → 118 +(**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups / 32 clips. The R2 shared-resource +disposal contract still holds with more clips resident — `AnimationClip`s hold no GPU resources, and +`_disposeInner` still disposes only the clone's own `Skeleton`. + +### The two measurements that changed the design + +1. **The idle pool was invisible.** Wired only to the R17/R29 window-shop loiter — which fires at a + graph NODE, and edges are long — a census found **0.8% of citizens stopped at any instant**. Nine + of ten new idles were assigned, deterministic, and never seen: the brief's headline failing while + the gate went green. Fix: a **window pause** on the R8 patronage stride check (the moment the sim + already asks "is there a shop beside me"). Stopped share **0.8% → 5.1%**, and all ten idles now + appear in a 12-sample census. +2. **The lean was never firing.** Also wired to the node loiter at first — i.e. at intersections, + where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved to the + same stride check. Now 7.1%. + +### The bench is a real bench + +R17's bench-sit sat a ped down at whatever node it stopped at, upright, on air. R41 binds it to Lane +B's actual furniture: `benchStationsFor(edge)` mirrors `furniture.js:203-209` exactly, a ped can only +take a bench on its own footpath side, and the 1.6 m plank **seats two** (a measured need — the first +soak put two citizens on one station to the centimetre). Because that is a mirror of another lane's +file, it is **gated, not trusted**: every derived station inside the streamed window must coincide +with real instanced geometry within **2 cm**, yaw included — today **14/14 and 14/14**, against a +scene of 2 435 instances. **CONTROL:** the same stations offset 2 m match **0/14**. Filed to Lane B: +one `benchStops(plan)` export next to the existing `busShelterStops(plan)` retires the mirror. + +### Gig — widened, not redesigned + +The v3 dance pick (`gigdance` over `fleet.danceClips`) is untouched. A second, independently keyed +`gig-pose` roll decides whether a crowd slot takes a venue clip instead — northern soul for a dancer, +clap/cheer/headphones for a stander. Measured on a live gig: **8 crowd slots, 6 swapped, 2 keeping +their R13 pick verbatim, 0 pending**. A slot that does not swap, and every boot where `venue.glb` +is not resident, is byte-identical to R13. + +### Gates added + +| gate | what it proves | +|---|---| +| `node tools/qa/r41_postures.mjs` | manifest resolution · determinism · stream isolation · pool spread · loop safety. Zero deps, ~40 ms, five controls. | +| `tools/.venv/bin/python tools/qa/r41_citizens.py` | 7 arms in fresh contexts: boot ledger · lazy loading · determinism incl. the 2 s forced stall · draw table both arms · `?noassets`/`?classic` · bench binding + control · leak · gig widening. | +| `tools/.venv/bin/python tools/qa/r41_shots.py` | the two acceptance shots, camera chosen by measurement and occlusion-raycast, every figure measured for stature. | + +**→ F: three lines for `qa.sh`.** All exit 0/1, all deterministic. + +### Shots + +`docs/shots/laneD/r41_street_postures.jpg` — seed 20261990, midday, camera (11.6, 58.5) yaw −1.890. +**8 near-tier rigs in frame, 3 distinct clips** (`@walk`, `walk_shopping_bag`, `sit_hands_thighs`): a +hi-vis worker **sitting on a real Lane B bench** with his feet on the footpath, a comical ped walking +centre-frame, and six more walkers up and down the strip. 126 draws of a 300 budget. + +`docs/shots/laneD/r41_browse_interior.jpg` — The Op Shop (opshop), 3 browse points. A **browser at +the clothes rack on `browse_hold_idle`** — the ped who ducked in off the street, holding something +instead of standing to attention — and the keeper behind the counter on `idle_happy_1`, his own +seeded idle. 69 draws of a 350 budget. + +Both carry a `.txt` sidecar with the human-sized line: stature measured off the posed skeleton for +every figure, as a ratio of that citizen's own nominal height. Today: sits 76–77%, browsers/keepers +99–101%, walkers 95–100% of nominal. **No giant (>2.0 m), no fold (<55% of nominal).** + +### Goldens + +`node web/js/citygen/selfcheck.js` → **157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No +citygen file was touched. + +### Files touched (only these) + +`web/js/citizens/postures.js` (new) · `web/js/citizens/clipbank.js` (new) · `web/js/citizens/rigs.js` +· `web/js/citizens/sim.js` · `web/js/citizens/keepers.js` · `web/js/citizens/band.js` · +`tools/qa/r41_postures.mjs` (new) · `tools/qa/r41_citizens.py` (new) · `tools/qa/r41_shots.py` (new) +· `docs/shots/laneD/r41_*.{jpg,txt}` (new) · `D-progress.md` · `docs/LANES/LANE_D_NOTES.md`. +**No git command was run.** Nothing under `web/models/`, `web/assets/`, `web/js/world/`, +`web/js/interiors/`, `web/index.html` or `tools/qa.sh` was touched. + +--- + ## Round 40 (§40.5) — the door points and the footpath: measured, kerb-clamped, gated. (2026-08-04) **Verification first, per the brief.** New gate `tools/qa/door_footpath_check.mjs` re-derives every diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 06a4f84..4e751d6 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,282 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street + +Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb` ++ `web/assets/motion_manifest.json` are the input, verified before wiring (`python3 +pipeline/clips_verify.py` → GREEN 0/0, 46 clips / 6 groups / 3 498 124 B on today's tree). + +### The one number that says what the round did + +A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same +pose, two arms: + +| state | `?clips=0` (R40) | default (R41) | +|---|---:|---:| +| walking | **99.3%** | 78.1% | +| bench-sit (on a real bench) | — | **9.5%** | +| shopfront lean | — | **7.1%** | +| stopped at a window, own seeded idle | 0.4% | **5.1%** | +| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% | +| distinct clips in play across the crowd | **4** (`walk`/`idle`/`sit`/`look`, 99.3% on `walk.glb`) | **20** | + +`?clips=0` is a new flag in `rigs.js` that turns the motion library off and NOTHING else. It exists +because `?classic=1` is a bad control for this — it also changes the ped pool (17 vs 22), the +sit/look/dance clips, the fog, the game and half the shell, so a heap or draw delta measured against +it says nothing about the library. + +### The files, and the one law that holds them together + +| file | what it is | +|---|---| +| `web/js/citizens/postures.js` | **NEW.** Pure, THREE-free, load-state-free: the clip pools and the seeded pickers. Node imports it directly (the gate does). | +| `web/js/citizens/clipbank.js` | **NEW.** The group loader. **Dynamically imported** by rigs.js behind the gate, so `?classic=1` does not fetch the module either. | +| `rigs.js` | `loadPedFleet` gains `clips = dance` (the R36 `djs = dance` trick again: the shell already passes `dance: !CLASSIC`, so **no shell edit was needed**). `makeActor` gains per-instance clip swapping; `spawnRig` gains `setClip`. | +| `sim.js` · `keepers.js` · `band.js` | the states. | + +**THE LAW: posture is a pure function of (citySeed, id), decided once, never re-rolled.** Lazy +loading can change *when* a posture appears but never *which one it is*. That is what makes six +groups affordable — and it is gated: arm 3 of `tools/qa/r41_citizens.py` delays **every** clip GLB by +2 s with a Playwright route and asserts the posture signature is byte-identical to the unstalled run. + +The same discipline runs one level down. `fleet.clipsRequested` is published **synchronously** by +`loadPedFleet` before the dynamic import even starts, and every roll that can move a citizen (bench, +lean, pause) is gated on that — never on `fleet.bank`, which only answers "is it resident yet". +Otherwise the first second of a boot would draw a different number of randoms than the rest of it and +"same seed → same crowd" would depend on the network. Every such roll is also drawn +**unconditionally** (the R17/R29 pattern: roll always, act only if allowed), and the POSE falls back +to the R2/R16/R29 base clip while the variant is in flight — so a slow fetch changes nothing at all. + +### Clip → state map (29 of the 46 wired, 17 deliberately not) + +| category | group | clips used | drives | +|---|---|---|---| +| idle | `idles.glb` | all 10 | **per-citizen deterministic idle** (`posturesFor().idle`) — the resting action of every near-tier actor, so it plays on every window pause, and the seeded shopkeeper idle | +| locomotion | `locomotion.glb` | `walk_shopping_bag` (1 of 6) | the walk pool is `['@walk','@walk','walk_shopping_bag']` ⇒ **1 in 3 citizens carries a shopping bag**; `@walk` is the R2 `walk.glb` | +| sitlean | `sitlean.glb` | all 8 | 4 sits on **Lane B's actual benches**, 4 leans on **shopfront walls** | +| browse | `browse.glb` | 5 of 8 | interior browsers at Lane C's browse points, seeded per (shopId, slot) | +| venue | `venue.glb` | 5 of 6 | gig crowd widening + `venue_bartending` for pub/rsl/band_room keepers + `venue_headphones` for record-shop keepers | +| social | `social.glb` | **0 of 8** | **not loaded, not fetched.** A two-person clip needs a two-person state machine and a partner-pairing pass. 896 KB for a state D does not have. → R42 | + +Not used and why: `browse_hold_turn_l/r` (172°/147° loop seam — one-shot turns with no turn state), +the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node; a turn-state machine is R42's +job and it is the only thing that makes those five clips mean anything), `venue_clap_seated` (no +seated venue slot yet). + +**Loop safety is measured, not assumed.** E ships `loopSeamDeg` per clip; `clipbank.js` puts anything +with `loopable:false` **and** a seam > 25° on `THREE.LoopPingPong` instead of repeat. Exactly one pool +clip lands there — `browse_pick_up` (92.15°) — and ping-ponging it is why it reads as crate-digging +(down, up, down) rather than a teleport back to the start. The other 28 either loop or have a +measured seam ≤ 5° (E's `loopable` flag is conservative; the seam is the ground truth). + +### THE BENCH — the state that needed geometry, and how the mirror is kept honest + +R17's bench-sit sat a ped down at whatever graph node it stopped at, upright, on air ("no +bench-position binding" — its own comment). R41 binds it. Lane B places benches at +`s = 14, 40, 66, …` (step 26 m) on alternating sides of every edge (`furniture.js:203-209`). +`sim.js benchStationsFor(edge)` mirrors that rule exactly, and a ped can only take a bench on ITS +OWN footpath side: the sim's lane perpendicular is `forward × (uz, −ux)` = `−forward ×` the furniture +perpendicular, so the match condition is `side === −forward`. + +The bench's local +Z is its FRONT (B's template puts the backrest at z = −0.2, the seat at y = 0.45) +and rig fronts are local −Z after the R13 facing-normalise, so a sitter is `benchYaw + π`, nudged +6 cm onto the seat. The plank is 1.6 m — **it seats two**, ±0.38 m on a seeded draw. That was a +measured need, not a flourish: the first soak put `-1,-6#3` and `-1,-6#5` on the same station to the +centimetre. + +**A mirror that drifts is a ped sitting on air again, so it is gated, not trusted.** Arm 6 of +`tools/qa/r41_citizens.py` walks every station the sim derives inside the streamed window and +requires real instanced geometry within **2 cm** of it, with the yaw matching mod 2π. Today: +**14/14 position, 14/14 yaw**, against a scene holding 2 435 instances. **CONTROL:** the same +stations offset 2 m match **0/14** — so "coincides" is a measurement, not a hit on any nearby prop. + +> **→ LANE B, one line, and D deletes the mirror:** please export +> `benchStops(plan)` from `web/js/world/furniture.js` next to the existing `busShelterStops(plan)` +> (same shape, same reason it exists). D switches to the import and drops `benchStationsFor` +> entirely. Until then the gate is the seatbelt. +> +> **→ LANE B, observed while mirroring, not fixed (your file):** `pushYaw(lists.bench, …, yaw + …)` +> with `yaw = atan2(ux, uz)` maps the bench's local **+Z to ALONG the edge**, so the bench's 1.6 m +> plank runs ACROSS the footpath and it faces up the street, not the road — the comment on line 203 +> says "facing the road". The streetlight arm above it (`armYaw`, line 197, "arm reaches over the +> road") has the same convention and so has the same question. D's sitters bind to the bench +> TRANSFORM, so they sit correctly either way; this is only cosmetic and only yours to rule on. + +### THE SHOPFRONT LEAN — and the measurement that moved it + +First wired to the R17/R29 window-shop loiter, which 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.** Moved +to the R8 patronage stride check (every 10 m walked), which is already the moment the sim asks "is +there a shop beside me" — which is exactly when a leaner is beside a wall. Strictly downstream of the +duck-in decision, on its own `leanstop` stream. + +The wall anchor uses `shop._raw` — the door point **before** R40's footpath clamp. The clamp exists +to drag doors onto the walkable strip (its whole job), but a leaner wants the facade the door was +derived from, not the kerb the ped walks on. `_raw` is the shell's `lot + front-normal·(d/2+0.6)`, +i.e. 0.6 m in front of the facade; push `LEAN_WALL_BACK` 0.30 m further out along the edge normal and +the ped's back is ~0.15 m off the wall, offset `LEAN_SIDE` 1.05 m along the frontage so they are not +in the doorway, facing the road. + +### THE WINDOW PAUSE — the measurement that changed the design + +The headline of the round is the per-citizen idle. Wired only to R17/R29's node loiter, a census of +the live crowd found **0.8% of citizens stopped at any instant** — edges are long, node arrivals are +rare — so nine of the ten new idles were assigned, deterministic, and **never seen**. That is a real +failure of the brief dressed up as a green tick. + +Fix, on the same stride check as the lean: a ped who walks past a shop and neither goes in nor leans +on it sometimes just **stops and looks at the window**, in their own seeded idle. No reposition, no +new clip, no new fetch — the actor's resting action already IS this citizen's idle +(`setIdleClip` at acquire). Stopped-citizen share went **0.8% → 5.1%**, and all ten idles now appear +in a 12-sample census (`idle_cocky_lean` 1.5/sample down to `idle_breathing` 0.4/sample). + +### Rates, and what they buy (all seeded, all tunable in one place at the top of `sim.js`) + +| constant | value | effect at the retail heart | +|---|---:|---| +| `BENCH_STOP_FRAC` · `BENCH_DWELL` | 0.40 · 9–20 s | 9.5% of the crowd seated | +| `LEAN_FRAC` · `LEAN_DWELL` | 0.12 · 6–14 s | 7.1% leaning | +| `PAUSE_FRAC` · `PAUSE_DWELL` | 0.17 · 4–9 s | 5.1% stopped in their own idle | + +### The pooled-actor problem, and why `makeActor` grew four methods + +Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen +idle cannot be baked in at construction — a recycled actor would carry the previous citizen's +posture. `makeActor` now memoises one `AnimationAction` per SOURCE clip and re-points four live slots +(`walkA`/`idleA`/`sitA`/`lookA`) on acquire, ≤3 per frame (`NEW_RIG_PER_FRAME`). three.js shares the +`PropertyMixer` bindings per (root, track) across every action on the same root, so the marginal cost +of an extra action is its interpolants, not another copy of the skeleton. `setSitting(s, clip)` and +`setLooking(l, fade, clip)` gained an optional clip and keep their exact R16/R29 semantics with it +omitted; `setIdleClip(null)` / `setWalkClip(null)` restore the base actions, which is precisely what a +citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte. + +`spawnRig` (single-clip figures: keepers, browsers, gig) gained `setClip`, the **self-heal seam**: a +figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group +lands. Its re-plant generalises R29's lesson — `_rotOnly` drops the Hips POSITION track, so a clip's +authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's +original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip +whose lowest bone travels, so `setClip` samples the posed skeleton at 6 points across the clip and +plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own +authored range rather than an arbitrary phase's error. + +### Memory and fetch — the ledger, measured in fresh headless contexts + +| | fetches | bytes resident | groups / clips | +|---|---:|---:|---| +| at boot | **4** (manifest 16 KB · `clipbank.js` · `idles.glb` · `locomotion.glb`) | **1 242 272 B** | 2 / 16 | +| + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 | +| + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 | +| + first gig night (from the street, `setGig`) | +1 `venue.glb` | 2 602 476 B | 5 / 38 | +| ever | **never** `social.glb` | — | — | +| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 | + +**One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no +manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**: +`postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/ +`band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero +asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found. + + +Six fetches at boot was the thing the round warned about; the answer is **two GLBs at boot, three +on first demand and one never**, and each lazy group is promise-cached so N racing callers cost one fetch (proven: +the bank does not grow across 6 interior enter/exit cycles). **Heap delta (library ON − OFF), +`performance.memory` after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs** — +the spread is GC timing, the floor is the resident clip bytes plus parsed `AnimationClip` overhead. +`mixerMs` is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms. + +### Zero draw — before/after, same seed, same bookmarks + +| view | `?clips=0` | default | Δ | budget | +|---|---:|---:|---:|---| +| street_noon | 193 | 193 | **+0** | ≤300 | +| crossroads_busy | 108 | 108 | **+0** | ≤300 | +| night_crowd | 128 | 128 | **+0** | ≤300 | +| market_square | 94 | 94 | **+0** | ≤300 | +| night_neon | 111 | 111 | **+0** | ≤300 | +| interior (record) | 110 | 110 | **+0** | ≤350, margin **240** | + +Worst street view over the five bookmarks: **193 both arms**. Ruling 4 gave this round no street +headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0, +materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw +count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap +of 24 rigs, each of which is already one draw. **Caveat stated rather than hidden:** 193 is the worst +of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law +is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0. + +### Leak + +`tools/qa/r41_citizens.py` arm 6b: warm the caches with one enter/exit, then **6 more cycles into the +same shop** — geometries 118 → 118 (**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups / +32 clips. The R2 shared-resource disposal contract still holds with more clips resident: +`AnimationClip`s hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and +`_disposeInner` still disposes only the clone's own `Skeleton`. Warm-first is deliberate — counting +cycle 1 measures cold start, not retention, and doing so is what made this arm read `+10` once. + +### Gates added (both new files, no collision) + +- `node tools/qa/r41_postures.mjs` — zero deps, ~40 ms. Manifest resolution (every pool id resolves + with the right category and group, **CONTROL:** a bogus id fails the same resolver) · determinism + over 1 000 citizens in two module instances (**CONTROL:** a different seed differs) · stream + isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; **CONTROL:** all 18 streams + produce different draws on the same id) · pool spread (all 10 idles, 90–110 each per 1 000) · loop + safety (**CONTROL:** the ping-pong class is non-empty). +- `tools/.venv/bin/python tools/qa/r41_citizens.py` — 7 arms in fresh headless contexts against a + no-store server: boot ledger · lazy loading · determinism incl. the **2 s forced-stall** control · + draw table both arms · `?noassets=1` + `?classic=1` · bench binding + control · leak · gig widening. +- `tools/.venv/bin/python tools/qa/r41_shots.py` — the two acceptance shots, camera chosen by + measurement (bench station with the most shop doors around it, occlusion-raycast, then held while + the crowd fills in) and every figure measured for stature. + +**→ F: three lines for `qa.sh`.** All three exit 0/1 and are deterministic. + +### The stature law, and why the first version of it was wrong + +The shot harness first checked an absolute seated band `[0.9, 1.5]` m (R16's, written for the +drummer on `sit.glb`) and called a 1.32 m leaner a failure. It is not one: the library's four wall +leans take 15–25% off a standing crown **by construction** (you are leaning back), and its four sits +run from bolt-upright-in-a-chair to slumped. A fixed band is the wrong instrument. What R10 forbids +is a GIANT and what R16 forbids is a FOLD (keeping `Hips.quaternion` lays the body flat, head at hip +height). So the gate now reports stature with its ratio to that citizen's own nominal height and +fails on **stature > 2.0 m**, **stature < 55% of nominal**, or a nominal height outside the seeded +[1.4, 2.0]. Measured today across both shots: leans 93%, sits 75–86%, browsers/keepers 93–101%, +walkers 96–99% of nominal. No giant, no fold. + +### Shots + +- `docs/shots/laneD/r41_street_postures.jpg` (+ `.txt` sidecar with the human-sized line) — seed + 20261990, camera (11.6, 58.5) yaw −1.890. **8 near-tier rigs, 3 distinct clips**: a hi-vis worker + seated on a real Lane B bench, a comical ped walking centre-frame, six more walkers (a third of + them on `walk_shopping_bag`). 126 draws / 300. +- `docs/shots/laneD/r41_browse_interior.jpg` (+ `.txt`) — The Op Shop, 3 browse points: a browser at + the clothes rack on `browse_hold_idle` and the keeper at the counter on `idle_happy_1`. 69/350. + +Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported +3–6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. `r41_shots.py` now +ray-tests every candidate figure from the lens and counts only the ones that can actually be seen, +and raycasts the camera pose itself (an unchecked "best score" pose put the lens inside a building +twice — high score, grey wall). + +### Goldens + +`node web/js/citygen/selfcheck.js` → **157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No citygen +file was touched. + +### Filed to R42 + +1. **The turn state.** `turn_left_90` / `turn_right_90` / `turn_walk_180` / `turn_in_place` / + `walk_to_stand` are shipped and unused because the sim turns instantly at a node. A short turn + state at `_advance`'s node branch (hold the ped for the clip's duration, blend the heading) makes + five clips real and would kill the remaining tell that these are waypoint walkers. +2. **`social.glb`.** 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same + loiter within N metres take `social_shake_1`/`_2` (E flagged the pair) or the conversation loop + facing each other. The manifest's `pair` field is the whole contract. +3. **`benchStops(plan)`** from Lane B (above) retires D's mirror. +4. **`browse_hold_walk` / `browse_hold_turn_*`** want a browser who walks the shop floor rather than + standing at a fixed point — Lane C's browse points would need a small path, not a pose. + +--- + ## ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want. diff --git a/docs/shots/laneD/r41_browse_interior.jpg b/docs/shots/laneD/r41_browse_interior.jpg new file mode 100644 index 0000000..cdcd411 Binary files /dev/null and b/docs/shots/laneD/r41_browse_interior.jpg differ diff --git a/docs/shots/laneD/r41_browse_interior.txt b/docs/shots/laneD/r41_browse_interior.txt new file mode 100644 index 0000000..f2e071e --- /dev/null +++ b/docs/shots/laneD/r41_browse_interior.txt @@ -0,0 +1,5 @@ +PROCITY R41 §41.3 — r41_browse_interior.jpg +seed 20261990 · The Op Shop (opshop) · 1 patronage occupants · 3 browse points +figures: [{"kind": "keeper", "shopId": 413, "type": "opshop", "h": 1.826, "clip": "idle_happy_1", "pending": null, "stature": 1.843, "footY": 0.012, "pos": [3.5, -2.26]}, {"kind": "browser", "shopId": 413, "type": "shop", "h": 1.779, "clip": "browse_hold_idle", "pending": null, "stature": 1.759, "footY": 0, "pos": [-1.23, -0.17]}] +draws 69 / interior budget 350 · tris 36223 +HUMAN-SIZED LINE — 2 figures, stature (feet→crown) measured off the posed skeleton: keeper 1.843m (101% of its 1.826m nominal) · browser 1.759m (99% of its 1.779m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal). diff --git a/docs/shots/laneD/r41_street_postures.jpg b/docs/shots/laneD/r41_street_postures.jpg new file mode 100644 index 0000000..95dbebe Binary files /dev/null and b/docs/shots/laneD/r41_street_postures.jpg differ diff --git a/docs/shots/laneD/r41_street_postures.txt b/docs/shots/laneD/r41_street_postures.txt new file mode 100644 index 0000000..514c8ec --- /dev/null +++ b/docs/shots/laneD/r41_street_postures.txt @@ -0,0 +1,6 @@ +PROCITY R41 §41.3 — r41_street_postures.jpg +seed 20261990 · synthetic · MIDDAY · camera (11.6, 58.5) yaw -1.890 +8 near-tier rigs in frame · states {'walk': 7, 'bench-sit': 1} · 3 distinct clips: @walk, sit_hands_thighs, walk_shopping_bag +draws 126 / budget 300 · tris 52858 +clips resident: 3 groups / 24 clips / 1600384 B +HUMAN-SIZED LINE — 8 figures, stature (feet→crown) measured off the posed skeleton: walk 1.595m (95% of its 1.671m nominal) · bench-sit 1.414m (77% of its 1.827m nominal) · walk 1.722m (98% of its 1.758m nominal) · walk 1.65m (96% of its 1.719m nominal) · walk 1.7m (97% of its 1.746m nominal) · walk 1.815m (98% of its 1.853m nominal) · walk 1.715m (98% of its 1.75m nominal) · walk 1.75m (100% of its 1.748m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal). diff --git a/tools/qa/r41_citizens.py b/tools/qa/r41_citizens.py new file mode 100644 index 0000000..3430f92 --- /dev/null +++ b/tools/qa/r41_citizens.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""PROCITY Lane D — R41 §41.3 runtime gate: THE MOTION LIBRARY IN THE LIVE GAME. + + tools/.venv/bin/python tools/qa/r41_citizens.py [--seed N] 0 green · 1 red + +Six arms. Every arm carries the control that makes it non-vacuous, and every number is measured in a +FRESH headless context against a no-store server (this project's documented ES-module cache burn). + + 1. BOOT LEDGER what the library actually costs: clip fetches at boot, bytes, groups resident, + and the heap delta against `?clips=0` — a control arm that turns off the motion + library and NOTHING else (`?classic=1` changes the ped pool, the fog, the game + and half the shell, so it cannot measure this). + 2. LAZY LOADING the four non-boot groups are NOT fetched at boot, and browse.glb arrives on the + first interior with browsers. CONTROL: it is absent before that visit. + 3. DETERMINISM two fresh contexts, same seed → byte-equal posture signature over the whole + active crowd. CONTROL: a different seed differs. Plus: the assignment is + independent of LOAD ORDER — the same run with the library forced late still + produces the same signature, which is the property lazy loading could break. + 4. DRAW TABLE worst street view and worst interior, `?clips=0` vs default, over the same + bookmarks and the same seed. Ruling 4: the street law is the boot's own + declared budget and there is no room, so the delta must be ≤ 0 in effect. + 5. ASSET-FREE `?noassets=1` runs (chunks build, no console errors, 0 clip fetches, 0 clipbank + module fetch) and `?classic=1` keeps its zero-fetch-delta covenant. + 6. BENCH BINDING every bench station the sim derives for a LOADED chunk coincides with real + instanced geometry in Lane B's scene, position and yaw. CONTROL: the same + stations offset by 2 m must NOT match — otherwise "coincides" means nothing. + Plus a leak arm: repeated interior enter/exit leaves geometries/textures flat. +""" +import sys, os, time, json, socket, subprocess, pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +PORT = int(os.environ.get('PROCITY_R41_PORT', '8986')) +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 +if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) + +BOOT_GROUPS = {'idles.glb', 'locomotion.glb'} +LAZY_GROUPS = {'browse.glb', 'sitlean.glb', 'venue.glb', 'social.glb'} +BOOKMARKS = ['street_noon', 'crossroads_busy', 'night_crowd', 'market_square', 'night_neon'] + +fails = [] +def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") +def OK(m): print(f" \033[32m✓\033[0m {m}") +def head(m): print(f"\n\033[1m{m}\033[0m") +def note(m): print(f" \033[33m·\033[0m {m}") +def check(c, m): (OK if c else FAIL)(m); return c + +NOSTORE = r''' +import sys, http.server, functools +class H(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + super().end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() +''' + + +def port_up(p): + with socket.socket() as s: + s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0 + + +def serve(): + pr = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(80): + if port_up(PORT): return pr + time.sleep(0.1) + pr.terminate(); raise SystemExit(f'could not serve on :{PORT}') + + +def new_page(p): + b = p.chromium.launch(args=['--js-flags=--expose-gc']) + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + errs, reqs = [], [] + pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) + pg.on('pageerror', lambda e: errs.append(str(e))) + pg.on('request', lambda r: reqs.append(r.url)) + return b, pg, errs, reqs + + +def boot(pg, q='', dbg=True, wait=True): + pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + q) if q else '') + ('&dbg=1' if dbg else '')) + if wait: + if dbg: pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000) + else: pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0', timeout=45000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + + +def clip_reqs(reqs): + return [u.rsplit('/', 1)[-1].split('?')[0] for u in reqs + if '/models/clips/' in u or 'motion_manifest.json' in u or 'clipbank.js' in u] + + +HEAP = "() => { if (window.gc) window.gc(); return performance.memory ? performance.memory.usedJSHeapSize : null; }" + +# max draw calls over N successive natural frames at the current pose +DRAWMAX = r""" +async (n) => { + const P = window.PROCITY; + let mx = 0, mt = 0; + for (let i = 0; i < n; i++) { + await new Promise(r => requestAnimationFrame(() => r())); + const r = P.renderer.info.render; + if (r.calls > mx) mx = r.calls; + if (r.triangles > mt) mt = r.triangles; + } + return { draws: mx, tris: mt, budget: (P.budget || {}).draws || 300 }; +} +""" + +BENCHES = r""" +async (offset) => { + const P = window.PROCITY, C = P.citizens; + const mod = await import('./js/citizens/sim.js'); + const M4 = P.scene.matrixWorld.constructor; + const m = new M4(); + // every instanced world transform standing in the built scene right now + const inst = []; + P.scene.traverse(o => { + if (!o.isInstancedMesh) return; + o.updateWorldMatrix(true, false); + for (let i = 0; i < o.count; i++) { + o.getMatrixAt(i, m); + const w = m.clone().premultiply(o.matrixWorld), e = w.elements; + inst.push({ x: e[12], z: e[14], yaw: Math.atan2(e[8], e[10]) }); + } + }); + // ...against the sim's own derivation, restricted to chunks Lane B has actually BUILT (a station + // in an unstreamed chunk has no geometry to match and would be a false negative). + const cam = P.camera.position; + let tested = 0, hit = 0, yawOk = 0; const misses = []; + for (let i = 0; i < C.edges.length; i++) { + for (const st of mod.benchStationsFor(C.edges[i])) { + if (Math.hypot(st.x - cam.x, st.z - cam.z) > 90) continue; // inside the streamed window + const tx = st.x + offset, tz = st.z; + tested++; + let bd = 1e9, by = 0; + for (const p of inst) { const d = Math.hypot(p.x - tx, p.z - tz); if (d < bd) { bd = d; by = p.yaw; } } + if (bd <= 0.02) { + hit++; + let dy = Math.abs(((st.yaw - by) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI); + if (dy < 1e-3 || Math.abs(dy - Math.PI) < 1e-3) yawOk++; // exact, mod 2pi + } else if (misses.length < 5) misses.push({ x: +st.x.toFixed(2), z: +st.z.toFixed(2), nearest: +bd.toFixed(3) }); + } + } + return { tested, hit, yawOk, misses, instances: inst.length }; +} +""" + +POSTURE_SIG = "() => window.PROCITY.citizens.postureSignature().join('\\n')" +CLIP_STATS = "() => window.PROCITY.citizens.clipStats()" + + +def main(): + from playwright.sync_api import sync_playwright + srv = serve() + try: + with sync_playwright() as p: + # ── 1 + 2: boot ledger and lazy loading ────────────────────────────────────────────── + head('1. BOOT LEDGER — what the motion library costs at boot, measured') + b, pg, errs, reqs = new_page(p) + boot(pg) + pg.wait_for_function("() => window.PROCITY.fleet && window.PROCITY.fleet.bank && window.PROCITY.fleet.bank.manifest", timeout=20000) + pg.wait_for_timeout(1500) + boot_fetch = clip_reqs(reqs) + st_on = pg.evaluate(CLIP_STATS) + heap_on = pg.evaluate(HEAP) + glbs = [f for f in boot_fetch if f.endswith('.glb')] + check(set(glbs) == BOOT_GROUPS, + f'boot fetches exactly the 2 eager groups: {sorted(glbs)} (+ manifest + clipbank.js ' + f'= {len(boot_fetch)} requests)') + check(st_on['manifest'], f"manifest parsed — catalogue {st_on['catalogue']} clips") + note(f"resident at boot: {st_on['groups']} groups / {st_on['clips']} clips / {st_on['bytes']} B " + f"({st_on['bytes'] / 1048576:.2f} MB of Lane E's 3.34 MB)") + note(f"heap after boot (library ON): {heap_on / 1048576:.1f} MB" if heap_on else 'heap unavailable') + + head('2. LAZY LOADING — the other four groups are not paid for until they are wanted') + check(not (set(glbs) & LAZY_GROUPS), + f'none of {sorted(LAZY_GROUPS)} fetched at boot') + # let the street run: sitlean.glb should arrive on the first sit/lean INTENT, not before + pg.evaluate("() => window.DBG.shot('crossroads_busy')") + pg.wait_for_timeout(12000) + after_street = clip_reqs(reqs) + check('sitlean.glb' in after_street, + f'sitlean.glb arrives on the first street sit/lean intent (not at boot)') + check('browse.glb' not in after_street and 'venue.glb' not in after_street, + 'browse.glb + venue.glb still unfetched — CONTROL for the interior arm below') + check('social.glb' not in after_street, 'social.glb never fetched (no two-person state yet — R42)') + # first interior with browsers pulls browse.glb + shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens; + const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count })) + .filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0]; + if (c) window.DBG.enterShop(c.id); return c || null; }""") + pg.wait_for_timeout(3000) + after_int = clip_reqs(reqs) + if shop: + check('browse.glb' in after_int, f'browse.glb arrives on the first interior with browsers (shop {shop["id"]})') + else: + note('no shop had occupants during this window — browse arm skipped') + st_int = pg.evaluate(CLIP_STATS) + note(f"resident after a street run + one interior: {st_int['groups']} groups / " + f"{st_int['clips']} clips / {st_int['bytes']} B") + check(not errs, f'0 console errors on the default boot ({len(reqs)} requests swept)') + + # ── 6b: leak — repeated enter/exit with more clips resident ─────────────────────────── + head('6b. LEAK — repeated interior enter/exit with the library resident') + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(800) + # WARM FIRST, then measure. Cycle 1 legitimately grows the caches (that room's stock GLBs, + # its wallpaper texture, the browse group) — counting it as a leak measures cold-start, not + # retention. Same shop every cycle so the comparison is like for like. + leak_shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens; + const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count })) + .filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0]; + return c ? c.id : (P.plan.shops[0] || {}).id; }""") + pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop) + pg.wait_for_timeout(1400) + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(900) + base = pg.evaluate("() => window.DBG.info()") + for _ in range(6): + pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop) + pg.wait_for_timeout(900) + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(700) + after = pg.evaluate("() => window.DBG.info()") + st_end = pg.evaluate(CLIP_STATS) + dg = after['geometries'] - base['geometries'] + dt = after['textures'] - base['textures'] + check(abs(dg) <= 2 and abs(dt) <= 2, + f'shop {leak_shop}, warm baseline then 6 more enter/exit cycles: geometries ' + f'{base["geometries"]}→{after["geometries"]} ({dg:+d}), textures ' + f'{base["textures"]}→{after["textures"]} ({dt:+d})') + check(st_end['clips'] == st_int['clips'] and st_end['groups'] == st_int['groups'], + f'clip bank does not grow on re-entry: {st_end["groups"]} groups / {st_end["clips"]} clips ' + f'(one fetch per group, promise-cached)') + b.close() + + # ── 1b: the ?clips=0 control arm (heap + fetch delta) ──────────────────────────────── + head('1b. CONTROL ARM ?clips=0 — the library off, and nothing else changed') + b, pg, errs, reqs = new_page(p) + boot(pg, 'clips=0') + pg.wait_for_timeout(3000) + off_fetch = clip_reqs(reqs) + heap_off = pg.evaluate(HEAP) + st_off = pg.evaluate(CLIP_STATS) + check(not off_fetch, f'?clips=0 fetches NOTHING clip-related — not the GLBs, not the manifest, ' + f'not even clipbank.js ({len(reqs)} requests swept)') + check(st_off['groups'] == 0 and st_off['clips'] == 0, '?clips=0: no bank, 0 clips resident') + check(not errs, '?clips=0: 0 console errors') + if heap_on and heap_off: + note(f'HEAP DELTA (library ON − OFF): {(heap_on - heap_off) / 1048576:+.2f} MB ' + f'(on {heap_on / 1048576:.1f} MB · off {heap_off / 1048576:.1f} MB)') + b.close() + + # ── 3: determinism ─────────────────────────────────────────────────────────────────── + head('3. DETERMINISM — same seed → same postures, byte-equal across fresh contexts') + sigs = [] + for i in range(2): + b, pg, errs, reqs = new_page(p) + boot(pg) + pg.evaluate("() => window.DBG.shot('street_noon')") + pg.wait_for_timeout(2500) + sigs.append(pg.evaluate(POSTURE_SIG)) + b.close() + n = len(sigs[0].splitlines()) + check(sigs[0] == sigs[1] and n > 0, + f'{n} active citizens, two fresh browser contexts → byte-equal posture signature') + b, pg, errs, reqs = new_page(p) + pg.goto(f'{HOST}/index.html?seed={SEED + 1}&dbg=1') + pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + pg.evaluate("() => window.DBG.shot('street_noon')") + pg.wait_for_timeout(2500) + other = pg.evaluate(POSTURE_SIG) + check(other != sigs[0], f'CONTROL: seed {SEED + 1} gives a DIFFERENT signature ' + f'({len(other.splitlines())} citizens) — byte-equal is not constant') + b.close() + # load-order independence: the property lazy loading could plausibly break + b, pg, errs, reqs = new_page(p) + pg.route('**/models/clips/*.glb', lambda route: (time.sleep(2.0), route.continue_())[1]) + boot(pg) + pg.evaluate("() => window.DBG.shot('street_noon')") + pg.wait_for_timeout(2500) + slow = pg.evaluate(POSTURE_SIG) + check(slow == sigs[0], + 'CONTROL: with every clip GLB delayed 2 s, the signature is IDENTICAL — posture is ' + 'assigned from (citySeed, id) alone, never from what happens to be resident') + b.close() + + # ── 4: draw table ──────────────────────────────────────────────────────────────────── + head('4. DRAW TABLE — worst street view and worst interior, library OFF vs ON') + table = {} + for arm, q in (('clips=0', 'clips=0'), ('default', '')): + b, pg, errs, reqs = new_page(p) + boot(pg, q) + pg.wait_for_timeout(6000) + row = {} + for bm in BOOKMARKS: + pg.evaluate('(n) => window.DBG.shot(n)', bm) + pg.wait_for_timeout(2500) + pg.evaluate('(n) => window.DBG.shot(n)', bm) + d = pg.evaluate(DRAWMAX, 24) + row[bm] = d + # worst interior: enter the biggest room type we can reach + pg.evaluate("() => window.DBG.enterShop('record')") + pg.wait_for_timeout(2500) + row['interior(record)'] = pg.evaluate(DRAWMAX, 24) + pg.evaluate("() => window.DBG.exitShop()") + table[arm] = row + b.close() + print(f" {'view':<20} {'clips=0':>10} {'default':>10} {'Δ':>6} budget") + worst = {'clips=0': 0, 'default': 0} + for k in list(table['clips=0']): + a0, a1 = table['clips=0'][k]['draws'], table['default'][k]['draws'] + bud = table['default'][k]['budget'] if not k.startswith('interior') else 350 + if not k.startswith('interior'): + worst['clips=0'] = max(worst['clips=0'], a0); worst['default'] = max(worst['default'], a1) + flag = '' if a1 <= bud else ' OVER' + print(f" {k:<20} {a0:>10} {a1:>10} {a1 - a0:>+6} <={bud}{flag}") + check(a1 <= bud, f'{k}: {a1} draws <= {bud}') + note(f"worst STREET view: clips=0 {worst['clips=0']} · default {worst['default']} " + f"(delta {worst['default'] - worst['clips=0']:+d})") + ints = (table['clips=0']['interior(record)']['draws'], table['default']['interior(record)']['draws']) + note(f'interior(record): clips=0 {ints[0]} · default {ints[1]} (delta {ints[1] - ints[0]:+d}), ' + f'margin to 350 = {350 - ints[1]}') + + # ── 5: asset-free + classic ────────────────────────────────────────────────────────── + head('5. ASSET-FREE — ?noassets=1 still runs, ?classic=1 keeps its zero-fetch-delta covenant') + for q, label in (('noassets=1', '?noassets=1'), ('classic=1', '?classic=1')): + b, pg, errs, reqs = new_page(p) + boot(pg, q) + pg.wait_for_timeout(4000) + cr = clip_reqs(reqs) + st = pg.evaluate(CLIP_STATS) + chunks = pg.evaluate("() => window.PROCITY.chunks.count") + act = pg.evaluate("() => window.PROCITY.citizens.stats.active") + check(not cr, f'{label}: 0 clip fetches, 0 manifest fetch, 0 clipbank.js fetch') + check(st['groups'] == 0 and st['clips'] == 0, f'{label}: no bank (clipStats {st["groups"]}/{st["clips"]})') + check(chunks > 0 and act > 0, f'{label}: town builds and the crowd walks ({chunks} chunks, {act} active)') + check(not errs, f'{label}: 0 console errors') + b.close() + + # ── 6: bench binding ───────────────────────────────────────────────────────────────── + head('6. BENCH BINDING — the sim\'s bench stations ARE Lane B\'s benches') + b, pg, errs, reqs = new_page(p) + boot(pg) + pg.evaluate("() => window.DBG.shot('crossroads_busy')") + pg.wait_for_timeout(5000) + real = pg.evaluate(BENCHES, 0.0) + ctrl = pg.evaluate(BENCHES, 2.0) + check(real['tested'] >= 10 and real['hit'] == real['tested'], + f"{real['hit']}/{real['tested']} derived stations coincide with real instanced " + f"geometry within 2 cm (scene holds {real['instances']} instances)") + check(real['yawOk'] == real['hit'], + f"{real['yawOk']}/{real['hit']} also match the bench yaw exactly (mod 2pi)") + check(ctrl['hit'] == 0, + f"CONTROL: the same stations shifted 2 m match {ctrl['hit']}/{ctrl['tested']} — " + f"so 'coincide' is a real measurement, not a hit on any nearby prop") + if real['misses']: note(f"misses: {real['misses']}") + b.close() + + # ── 7: the gig crowd's widened vocabulary ──────────────────────────────────────────── + head("7. VENUE CLIPS — the gig crowd's vocabulary widens, the v3 dance pick survives") + b, pg, errs, reqs = new_page(p) + boot(pg, 'gigs=1') + pg.evaluate("() => window.DBG.setSegment(2)") # MIDDAY — no gig anywhere + pg.wait_for_timeout(5000) + pre = clip_reqs(reqs) + check('venue.glb' not in pre, + 'CONTROL: at midday, with no gig on, venue.glb is not fetched at all') + pg.evaluate("() => window.DBG.setSegment(5)") # NIGHT — the doors open + pg.wait_for_timeout(5000) + mid = clip_reqs(reqs) + check('venue.glb' in mid, + 'venue.glb arrives on GIG NIGHT, from the street — sim.setGig kicks it when F opens ' + 'the doors, so it is resident before the player walks in') + g = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG; + if (!P.gigs || !P.gigs.venueShopIds || !P.gigs.venueShopIds.length) return { ok: false, why: 'no gig venue' }; + const id = P.gigs.venueShopIds[0]; + D.enterShop(id); + await new Promise(r => setTimeout(r, 3500)); + const crew = P.interiorMode.crew; + if (!crew) return { ok: false, why: 'no crew (gig not on)', state: P.gigs.stateOf(id) }; + const mem = crew.members.filter(m => m.part === 'crowd'); + return { ok: true, state: P.gigs.stateOf(id), crowd: mem.length, + swapped: mem.filter(m => m.venueId).length, + kept: mem.filter(m => !m.venueId).length, + pending: mem.filter(m => m.want).length, + clips: [...new Set(mem.map(m => m.venueId).filter(Boolean))].sort(), + dancers: mem.filter(m => m.dance).length }; + }""") + print(' gig:', json.dumps(g)) + if g.get('ok'): + check(g['swapped'] > 0 and g['kept'] > 0, + f"widening, not replacement: {g['swapped']}/{g['crowd']} crowd slots take a venue clip, " + f"{g['kept']} keep their R13 pick verbatim ({', '.join(g['clips'])})") + check(g['pending'] == 0, 'every swapped slot has its clip installed (self-heal drained)') + else: + note(f"gig arm skipped: {g.get('why')}") + check(not errs, '?gigs=1: 0 console errors') + b.close() + finally: + srv.terminate() + + print() + if fails: + print(f"\033[31m● RED\033[0m — {len(fails)} failure(s)") + for f in fails: print(' ' + f) + return 1 + print("\033[32m● PASS\033[0m — boot ledger, lazy loading, determinism, draw table, asset-free and bench binding all green") + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/qa/r41_postures.mjs b/tools/qa/r41_postures.mjs new file mode 100644 index 0000000..24b219f --- /dev/null +++ b/tools/qa/r41_postures.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// PROCITY Lane D — R41 §41.3 gate: THE POSTURE TABLE IS REAL AND IT IS DETERMINISTIC. +// +// node tools/qa/r41_postures.mjs 0 = green, 1 = red +// +// Zero deps, no browser, ~40 ms. Five arms, each with the control that makes it non-vacuous: +// +// 1. MANIFEST RESOLUTION every clip id `postures.js` can hand out resolves in Lane E's +// web/assets/motion_manifest.json, with the category the pool claims and +// the group file GROUP_OF claims. CONTROL: a deliberately bogus id is fed +// through the same resolver and must fail — otherwise arm 1 proves nothing. +// 2. DETERMINISM 1 000 citizen ids, twice, in two freshly-imported module instances → +// byte-equal (sha256 of the joined signature block). CONTROL: one different +// seed must produce a DIFFERENT digest, or "byte-equal" is just "constant". +// 3. STREAM ISOLATION the R41 streams (`posture`, `benchstop`, `leanstop`, `browse-pose`, +// `keeper-pose`, `gig-pose`) reproduce the pre-R41 streams' first 8 draws +// for `citizen`/`turn`/`loiter`/`patron`/`benchsit`/`glance` unchanged — +// i.e. no existing identity moved. CONTROL: they are not all the SAME +// stream either (a copy-paste key would pass a naive equality test). +// 4. POOL SPREAD the 10-idle pool is actually spread over a real crowd, not collapsed onto +// one clip by a bad index. CONTROL: measured occupancy of every bucket. +// 5. LOOP SAFETY every pool clip either loops (manifest `loopable`) or has a measured +// `loopSeamDeg` the bank will ping-pong (> LOOP_SEAM_DEG) — no clip can be +// put on repeat with a seam that pops. CONTROL: the count in each class. + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const P = (p) => resolve(ROOT, p); +const SEED = 20261990; +const LOOP_SEAM_DEG = 25; // clipbank.js — above this the bank plays ping-pong instead of repeat + +let fails = 0; +const OK = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`); +const FAIL = (m) => { fails++; console.log(` \x1b[31m✗ FAIL\x1b[0m ${m}`); }; +const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`); +const check = (c, m) => { (c ? OK : FAIL)(m); return c; }; +const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16); + +const po = await import(P('web/js/citizens/postures.js')); +const { rng } = await import(P('web/js/core/prng.js')); +const man = JSON.parse(readFileSync(P('web/assets/motion_manifest.json'), 'utf8')); + +const POOLS = { + idle: [po.IDLE_POOL, 'idle'], walk: [po.WALK_POOL, 'locomotion'], + sit: [po.SIT_POOL, 'sitlean'], lean: [po.LEAN_POOL, 'sitlean'], + browse: [po.BROWSE_POOL, 'browse'], + venueDance: [po.VENUE_DANCE_POOL, 'venue'], venueStand: [po.VENUE_STAND_POOL, 'venue'], + keeperType: [Object.values(po.KEEPER_TYPE_CLIP), 'venue'], +}; + +// ── 1. manifest resolution ─────────────────────────────────────────────────────────────────────── +head('1. MANIFEST RESOLUTION — every posture clip resolves in Lane E\'s motion_manifest.json'); +const resolves = (id) => { + if (!id || id[0] === '@') return { sentinel: true }; // pre-R41 base asset, not a library clip + const m = man.clips[id]; + if (!m) return { err: 'not in manifest' }; + if (man.groups[m.group] === undefined) return { err: `group ${m.group} not in manifest` }; + if (!man.groups[m.group].clips.includes(id)) return { err: `not listed under ${m.group}` }; + if (po.GROUP_OF[id] !== m.group) return { err: `GROUP_OF says ${po.GROUP_OF[id]}, manifest says ${m.group}` }; + return { ok: true, meta: m }; +}; +let nClips = 0, nSentinel = 0; +for (const [name, [pool, cat]] of Object.entries(POOLS)) { + const bad = []; + for (const id of pool) { + const r = resolves(id); + if (r.sentinel) { nSentinel++; continue; } + if (r.err) { bad.push(`${id}: ${r.err}`); continue; } + if (r.meta.category !== cat) bad.push(`${id}: category ${r.meta.category} != ${cat}`); + nClips++; + } + check(!bad.length, `${name} pool (${pool.length}) → ${bad.length ? bad.join(' · ') : `all resolve, category=${cat}`}`); +} +check(resolves('idle_definitely_not_a_clip').err === 'not in manifest', + 'CONTROL: a bogus clip id fails the same resolver (the arm is not vacuous)'); +const distinct = new Set(Object.values(POOLS).flatMap(([p]) => p).filter((i) => i[0] !== '@')); +OK(`${nClips} pool entries / ${distinct.size} distinct library clips of the manifest's ${man.clipCount} · ${nSentinel} base-asset sentinels`); +const groupsUsed = new Set([...distinct].map((i) => po.GROUP_OF[i])); +OK(`groups referenced: ${[...groupsUsed].sort().join(' ')} — boot fetches ${po.BOOT_GROUPS.join(' + ')}, rest lazy`); +check(po.BOOT_GROUPS.every((g) => man.groups[g]), 'BOOT_GROUPS all exist in the manifest'); + +// ── 2. determinism ─────────────────────────────────────────────────────────────────────────────── +head('2. DETERMINISM — same seed → same postures, byte-equal across two module instances'); +const IDS = []; +for (let cx = -2; cx <= 2; cx++) for (let cz = -2; cz <= 2; cz++) for (let i = 0; i < 40; i++) IDS.push(`${cx},${cz}#${i}`); +const block = (mod, seed) => IDS.map((id) => mod.postureSig(id, mod.posturesFor(seed, id))).join('\n'); +const po2 = await import(P('web/js/citizens/postures.js') + '?fresh=1'); // a second module instance +const A = block(po, SEED), B = block(po2, SEED); +check(A === B, `${IDS.length} citizens, two module instances → identical (sha256 ${sha(A)})`); +const C = block(po, SEED + 1); +check(C !== A, `CONTROL: seed ${SEED + 1} differs (sha256 ${sha(C)}) — "byte-equal" is not "constant"`); +// the other three pickers +const bA = IDS.map((id) => po.browsePostureFor(SEED, 'shop7', +id.split('#')[1] % 3)).join(','); +const bB = IDS.map((id) => po2.browsePostureFor(SEED, 'shop7', +id.split('#')[1] % 3)).join(','); +check(bA === bB, 'browsePostureFor byte-equal across instances'); +check(po.keeperPostureFor(SEED, 'shop7', 'pub') === 'venue_bartending' + && po.keeperPostureFor(SEED, 'shop7', 'record') === 'venue_headphones' + && po.IDLE_POOL.includes(po.keeperPostureFor(SEED, 'shop7', 'opshop')), + 'keeperPostureFor: pub pours, record shop listens, everything else takes a seeded idle'); +const gA = IDS.map((id) => po.gigPostureFor(SEED, id, true)).join(','); +check(gA === IDS.map((id) => po2.gigPostureFor(SEED, id, true)).join(','), 'gigPostureFor byte-equal across instances'); +const swapped = IDS.filter((id) => po.gigPostureFor(SEED, id, true)).length; +check(swapped > 0 && swapped < IDS.length, + `CONTROL: the gig widening is a SWAP not a replace — ${swapped}/${IDS.length} dancers take the venue clip, the rest keep the v3 dance pick`); + +// ── 3. stream isolation ────────────────────────────────────────────────────────────────────────── +head('3. STREAM ISOLATION — no pre-R41 stream moved, and the new keys are genuinely different'); +const draws = (kind, id, n = 8) => { const r = rng(SEED, kind, id); return Array.from({ length: n }, () => r().toFixed(12)).join(','); }; +// pre-R41 streams are pure functions of (seed, kind, id) — they cannot move unless a KEY collides. +const OLD = ['citizen', 'turn', 'loiter', 'patron', 'benchsit', 'glance', 'chunkpop', 'keeper', 'browser', 'gig', 'gigdance', 'gigp']; +const NEW = ['posture', 'benchstop', 'leanstop', 'browse-pose', 'keeper-pose', 'gig-pose']; +check(NEW.every((k) => !OLD.includes(k)), `the ${NEW.length} R41 keys collide with none of the ${OLD.length} pre-R41 keys`); +const sigs = new Map(); +for (const k of [...OLD, ...NEW]) sigs.set(k, draws(k, '3,-1#7')); +check(new Set(sigs.values()).size === sigs.size, + `CONTROL: all ${sigs.size} streams produce DIFFERENT draws on the same id (no copy-pasted key)`); +// and the pre-R41 values themselves, pinned, so a future prng edit shows up here +OK(`pinned: rng(${SEED},'citizen','3,-1#7')[0] = ${draws('citizen', '3,-1#7', 1)}`); + +// ── 4. pool spread ─────────────────────────────────────────────────────────────────────────────── +head('4. POOL SPREAD — the clone army is actually broken up, measured'); +const hist = {}; +for (const id of IDS) { const p = po.posturesFor(SEED, id); hist[p.idle] = (hist[p.idle] || 0) + 1; } +const used = Object.keys(hist).length; +check(used === po.IDLE_POOL.length, `all ${po.IDLE_POOL.length} idles appear across ${IDS.length} citizens (${used} distinct)`); +const counts = po.IDLE_POOL.map((k) => hist[k] || 0); +const lo = Math.min(...counts), hi = Math.max(...counts); +check(lo >= IDS.length / po.IDLE_POOL.length * 0.6, `spread ${lo}–${hi} per clip (uniform would be ${(IDS.length / po.IDLE_POOL.length).toFixed(0)})`); +const bagWalk = IDS.filter((id) => po.posturesFor(SEED, id).walk === 'walk_shopping_bag').length; +check(bagWalk > 0, `${bagWalk}/${IDS.length} (${(bagWalk / IDS.length * 100).toFixed(1)}%) carry the shopping bag; the rest keep the base walk.glb gait`); + +// ── 5. loop safety ─────────────────────────────────────────────────────────────────────────────── +head('5. LOOP SAFETY — nothing goes on repeat with a seam that pops'); +let repeat = 0, ping = 0; const unsafe = []; +for (const id of distinct) { + const m = man.clips[id]; + if (m.loopable) { repeat++; continue; } + if ((m.loopSeamDeg || 0) > LOOP_SEAM_DEG) { ping++; continue; } + // curated-not-loopable but the MEASURED seam closes (E's flag is conservative) → repeat is fine + if ((m.loopSeamDeg || 0) <= 5) { repeat++; continue; } + unsafe.push(`${id} (seam ${m.loopSeamDeg}°)`); +} +check(!unsafe.length, `${repeat} clips repeat (loopable or measured seam ≤5°) · ${ping} ping-pong (seam >${LOOP_SEAM_DEG}°)${unsafe.length ? ' · UNSAFE: ' + unsafe.join(', ') : ''}`); +check(ping > 0, `CONTROL: the ping-pong class is non-empty — ${[...distinct].filter((i) => !man.clips[i].loopable && man.clips[i].loopSeamDeg > LOOP_SEAM_DEG).join(', ')}`); + +console.log(fails ? `\n\x1b[31m● RED\x1b[0m — ${fails} failure(s)` : '\n\x1b[32m● PASS\x1b[0m — posture table resolves, is deterministic, isolated, spread and loop-safe'); +process.exit(fails ? 1 : 0); diff --git a/tools/qa/r41_shots.py b/tools/qa/r41_shots.py new file mode 100644 index 0000000..85feabc --- /dev/null +++ b/tools/qa/r41_shots.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""PROCITY Lane D — R41 §41.3 acceptance shots: THE STREET STOPS BEING ONE PERSON COPY-PASTED. + + tools/.venv/bin/python tools/qa/r41_shots.py [--seed N] [--outdir DIR] [--soak MS] + +Two reproducible frames, both chosen by MEASUREMENT rather than by hand: + + 1. r41_street_postures.jpg the camera pose comes from geometry, not taste: the bench station with + the most patronage door points around it (the one block where BOTH R41 street states can fire in + a single frame), stood off on the carriageway and occlusion-raycast before it is accepted. Then + it HOLDS and waits — postures run on 6-20 s dwells, so the honest way to shoot the busy moment is + to stand still until it happens. Every candidate figure is ray-tested from the lens too: "inside + the frustum" is not "in the picture", and three earlier runs reported 3-6 rigs in shot while the + JPEG showed footpath and a gum-tree billboard. + 2. r41_browse_interior.jpg the shop with the most patronage occupants that actually has browse + points, entered through the shell's own enterShop and framed from Lane C's room.spawn — the view + the game gives you walking in. The browser rigs are the peds who ducked in off the street, now + playing browse.glb instead of the shopkeeper's idle. + +Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and +reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is +height-RELATIVE on purpose (see human_line): fails on a giant (>2.0 m) or a fold (<55% of that +citizen's own nominal height), not on an absolute seated band that a wall lean legitimately breaks. + +Fresh headless context, own no-store server (this project's documented ES-module cache burn). +""" +import sys, os, time, json, socket, subprocess, pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +PORT = int(os.environ.get('PROCITY_R41_SHOT_PORT', '8983')) +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 +SOAK = 60000 +OUTDIR = ROOT / 'docs' / 'shots' / 'laneD' +if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) +if '--soak' in sys.argv: SOAK = int(sys.argv[sys.argv.index('--soak') + 1]) +if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1]) + +LO, HI = 1.4, 2.0 # R10 no-giants standing band +SEAT_LO, SEAT_HI = 0.9, 1.5 # R16 seated band + +NOSTORE = r''' +import sys, http.server, functools +class H(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + super().end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() +''' + +# ── PLACE: stand on the road opposite the bench nearest the retail heart ────────────────────────── +# Deterministic and re-findable: the town anchor (DBG's densest-shop-cluster centroid) picks the +# block, the sim's OWN benchStationsFor picks the bench, and the camera stands out on the carriageway +# so the footpath reads unobstructed. Standing ON the footpath was tried first and framed a shopfront +# wall — the peds are at the back of a 3.5 m verge, so there is no room to back off beside them. +PLACE = r""" +async () => { + const P = window.PROCITY, C = P.citizens; + const THREE = await import('three'); + const mod = await import('./js/citizens/sim.js'); + const V3 = P.scene.position.constructor; + const ray = new THREE.Raycaster(); ray.far = 40; + // Score every bench station by how many patronage door points sit within 14 m: that is the block + // where BOTH R41 street states fire (bench sits at the station, shopfront leans off those doors), + // so it is the one place a single frame can hold both. Camera 7 m out on the carriageway at a 45° + // three-quarter, looking a little further down the footpath. Occlusion-raycast before accepting — + // an unchecked pose put the camera inside a building twice, high score and a grey wall. + const doors = []; + if (C.shopsByChunk) for (const list of C.shopsByChunk.values()) for (const d of list) doors.push(d); + const clear = (px, pz, tx, tz) => { + const d = Math.hypot(tx - px, tz - pz); + ray.set(new V3(px, 1.6, pz), new V3((tx - px) / d, 0, (tz - pz) / d)); + let h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh && !x.object.isSprite); + if (h.length && h[0].distance < d - 0.8) return false; + for (const [ax, az] of [[1,0],[-1,0],[0,1],[0,-1]]) { + ray.set(new V3(px, 1.6, pz), new V3(ax, 0, az)); + h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh); + if (h.length && h[0].distance < 1.3) return false; + } + return true; + }; + const out = []; + for (let i = 0; i < C.edges.length; i++) { + const e = C.edges[i]; + if ((e.width || 4) < 10) continue; + const L = e.len, ux = e.ux, uz = e.uz, nxE = -uz, nzE = ux; + for (const st of mod.benchStationsFor(e)) { + let nd = 0; + for (const d of doors) if (Math.hypot(d.x - st.x, d.z - st.z) < 14) nd++; + if (!nd) continue; + // which side of the centreline the bench is on → put the camera on the road, same side + const bd = Math.hypot(st.x - (e.A.x + ux * st.s), st.z - (e.A.z + uz * st.s)) || 1; + const nx = (st.x - (e.A.x + ux * st.s)) / bd, nz = (st.z - (e.A.z + uz * st.s)) / bd; + // ON THE CARRIAGEWAY at a quarter of the half-width from the centreline, 8 m back along the + // street, aimed square at the bench. This is the geometry every readable PROCITY street shot + // has: the footpath fills the mid-ground at 12-15 m, the verandah is above the heads, and + // nothing can get between the lens and the subject. Every closer variant tried (4 m, 7 m off + // the ped) put the camera under an awning or inside a shopfront window box. + const back = Math.min(bd * 0.55, 9); // out toward the road, but stay on the deck + for (const sgn of [1, -1]) { + const px = st.x - nx * back - ux * 5 * sgn, pz = st.z - nz * back - uz * 5 * sgn; + out.push({ nd, px, pz, yaw: Math.atan2(-(st.x - px), -(st.z - pz)), bx: st.x, bz: st.z }); + } + } + } + out.sort((p, q) => q.nd - p.nd); + const keep = []; + for (const c of out) { if (keep.length >= 6) break; if (clear(c.px, c.pz, c.bx, c.bz)) keep.push(c); } + return { ok: keep.length > 0, cands: keep, stations: out.length / 2 | 0 }; +} +""" + +# ── FRAME: how good is the view from RIGHT HERE, right now — states and clips inside a tight box ─── +FRAME = r""" +async () => { + const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor; + const THREE = await import('three'); + const cam = P.camera; cam.updateMatrixWorld(true); + // Per-ped VISIBILITY raycast. "Inside the frustum" is not "in the picture": three frames running + // measured 3-6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. A ped hidden + // behind a verandah post, a tree or a shopfront is not evidence of anything, so each one is + // ray-tested from the lens and only the ones that can actually be SEEN are counted. + const ray = new THREE.Raycaster(); ray.far = 30; + const visible = (c) => { + const dx = c.x - cam.position.x, dz = c.z - cam.position.z, d = Math.hypot(dx, dz); + ray.set(new V3(cam.position.x, 1.35, cam.position.z), new V3(dx / d, 0, dz / d)); + const h = ray.intersectObject(P.scene, true) + .filter(x => !x.object.isSkinnedMesh && !x.object.isSprite && !x.object.isPoints); + return !(h.length && h[0].distance < d - 0.5); + }; + const v = new V3(); const rows = []; + for (const c of C.activeCitizens()) { + if (!c.actor || c.actorKind !== 'rig') continue; + const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z); + if (d < 3.0 || d > 20) continue; // close enough to READ + v.set(c.x, 1.0, c.z).project(cam); + if (v.z > 1 || Math.abs(v.x) > 0.62 || Math.abs(v.y) > 0.55) continue; // comfortably in shot + if (!visible(c)) continue; // and not behind a post + const posedNow = c.loiter > 0; + rows.push({ id: c.id, d: +d.toFixed(1), + state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit' + : (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'), + clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look' + : (posedNow ? c.posture.idle : c.posture.walk) }); + } + const states = new Set(rows.map(r => r.state)), clips = new Set(rows.map(r => r.clip)); + const posed = rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length; + return { n: rows.length, states: [...states], clips: clips.size, posed, + score: clips.size * 100 + rows.length * 25 + posed * 350 }; +} +""" + +# ── measure every figure in the frame (the human-sized line) ────────────────────────────────────── +MEASURE = r""" +() => { + const P = window.PROCITY, C = P.citizens; + const V3 = P.scene.position.constructor; + const cam = P.camera; cam.updateMatrixWorld(true); + const v = new V3(); const rows = []; + for (const c of C.activeCitizens()) { + if (!c.actor || c.actorKind !== 'rig') continue; + const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z); + if (d < 2.2 || d > 26) continue; + v.set(c.x, 1.0, c.z).project(cam); + if (v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) continue; + let lo = 1e9, hi = -1e9; + c.actor.inner.updateWorldMatrix(true, true); + c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } }); + // NB `c.sit` / `c.glance` are LATCHED flags (R17/R29 set them at a node and never clear them); + // the ped is only actually posed while `c.loiter > 0`. Reading them raw mislabels a walker as + // seated — which is exactly how this harness first reported a 1.63 m "seated" figure. + const posedNow = c.loiter > 0; + rows.push({ id: c.id, d: +d.toFixed(1), + state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit' + : (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'), + clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look' + : (posedNow ? c.posture.idle : c.posture.walk), + seated: !!c.bench || !!(posedNow && c.sit), h: +c.height.toFixed(3), + stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3) }); + } + rows.sort((a, b) => a.d - b.d); + return { rows, info: window.DBG.info(), clips: C.clipStats() }; +} +""" + +INTERIOR = r""" +async () => { + const P = window.PROCITY, D = window.DBG, C = P.citizens; + // Shops that currently hold patronage occupants — occupancy is what puts BROWSERS on the floor (F + // stands one per browse point per occupant). Not every room type HAS browse points (a market stall + // has one, an op-shop three), so try several and keep the BEST rather than the first that works: + // taking the first gave a one-browser stall on one run and a three-browser op-shop on the next. + const cands = (P.plan.shops || []) + .map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count })) + .filter((s) => s.count > 0).sort((a, b) => b.count - a.count); + if (!cands.length) return { ok: false, why: 'no shop has occupants yet' }; + D.setSegment(2); + let best = null; + for (const s of cands.slice(0, 8)) { + D.enterShop(s.id); + await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb + const room = P.interiorMode.current; + const n = room ? (room.browsePoints || []).length : 0; + const browsers = ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length; + if (room && n && browsers && (!best || browsers > best.browsers)) best = { shop: s, browsePoints: n, browsers }; + if (best && best.browsers >= 3) break; + D.exitShop(); + await new Promise(r => setTimeout(r, 500)); + } + if (!best) return { ok: false, why: 'no occupied shop had browse points with browsers', cands: cands.slice(0, 6) }; + D.enterShop(best.shop.id); // re-enter the winner + await new Promise(r => setTimeout(r, 2600)); + return { ok: true, ...best, tried: cands.length }; +} +""" + +INT_FRAME = r""" +() => { + const P = window.PROCITY, V3 = P.scene.position.constructor; + const room = P.interiorMode.current; + const km = P.interiorMode.keepers; + const figs = (km ? km.keepers : []).map(k => k.actor.fig); + if (!figs.length) return { ok: false, why: 'no keeper/browser figures' }; + // Stand where the PLAYER stands walking in (Lane C's room.spawn) and look at the centroid of the + // shop's people — so the frame is the view the game actually gives you, not a staged angle. Backed + // off toward the spawn wall if that puts the camera on top of somebody. + let mx = 0, mz = 0; for (const f of figs) { mx += f.position.x; mz += f.position.z; } mx /= figs.length; mz /= figs.length; + const sp = room.spawn || { x: 0, z: 0 }; + let px = sp.x, pz = sp.z; + let d = Math.hypot(mx - px, mz - pz); + if (d < 2.6) { const k = 2.6 / (d || 1); px = mx - (mx - px) * k; pz = mz - (mz - pz) * k; d = 2.6; } + P.camera.position.set(px, 1.62, pz); + P.camera.lookAt(new V3(mx, 1.15, mz)); + P.camera.updateMatrixWorld(true); + P.renderer.info.reset(); + P.renderer.render(P.interiorMode.scene, P.camera); + // who is actually inside the frame + const v = new V3(); let seen = 0; + for (const f of figs) { v.set(f.position.x, 1.0, f.position.z).project(P.camera); + if (v.z <= 1 && Math.abs(v.x) < 0.9 && Math.abs(v.y) < 0.9) seen++; } + return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+mx.toFixed(2), +mz.toFixed(2)], + figures: figs.length, inFrame: seen, + draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles }; +} +""" + +INT_MEASURE = r""" +() => { + const P = window.PROCITY, V3 = P.scene.position.constructor; + const km = P.interiorMode.keepers; + const rows = []; + for (const k of (km ? km.keepers : [])) { + const a = k.actor; if (!a.inner) { rows.push({ kind: k.browse ? 'browser' : 'keeper', placeholder: true }); continue; } + let lo = 1e9, hi = -1e9; + a.inner.updateWorldMatrix(true, true); + a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } }); + rows.push({ kind: k.browse ? 'browser' : 'keeper', shopId: k.shopId, type: k.type, h: +(a.height || 0).toFixed(3), + clip: (a.mixer && a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name)[0] || null, + pending: k.want || null, + stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3), + pos: [+a.fig.position.x.toFixed(2), +a.fig.position.z.toFixed(2)] }); + } + return { rows, info: window.DBG.info() }; +} +""" + + +def port_up(port): + with socket.socket() as s: + s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 + + +def serve(): + p = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(80): + if port_up(PORT): return p + time.sleep(0.1) + p.terminate(); raise SystemExit(f'could not serve on :{PORT}') + + +def human_line(rows): + """The R10 no-giants line — measured, and measured against the law it is actually about. + + An absolute seated band was tried first and it was the WRONG instrument for R41: the library's + four sits run from bolt-upright-in-a-chair to slumped, and its four wall leans take 15-25% off a + standing crown by construction (you are leaning BACK). A 1.32 m leaner is not a defect, and a + fixed [0.9,1.5] seated band called it one. What the R10 law forbids is a GIANT, and what R16 + forbids is a FOLD (Hips.quaternion laying the body flat, head down at hip height). So: stature is + reported for every figure with its ratio to that citizen's own nominal height, and the gate fails + on stature > 2.0 m (giant), stature < 0.55 x height (folded), or a nominal height outside the + seeded [1.4, 2.0] range.""" + out, bad = [], [] + for r in rows: + if r.get('placeholder'): + continue + h = r.get('h') + ratio = (r['stature'] / h) if h else None + if r['stature'] > HI: bad.append(f"{r.get('id', r.get('kind'))} GIANT {r['stature']}m") + elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r.get('kind'))} FOLDED {r['stature']}m of {h}m") + elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r.get('kind'))} nominal height {h}m out of [{LO},{HI}]") + out.append(f"{r.get('state', r.get('kind'))} {r['stature']}m" + + (f" ({ratio:.0%} of its {h}m nominal)" if ratio else "")) + verdict = 'ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad else 'FAIL: ' + ', '.join(bad) + return (f"HUMAN-SIZED LINE — {len(out)} figures, stature (feet\u2192crown) measured off the posed " + f"skeleton: {' \u00b7 '.join(out)}. {verdict}."), not bad + + +def main(): + OUTDIR.mkdir(parents=True, exist_ok=True) + from playwright.sync_api import sync_playwright + srv = serve() + rc = 0 + try: + with sync_playwright() as p: + b = p.chromium.launch() + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + errs = [] + pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) + pg.on('pageerror', lambda e: errs.append(str(e))) + pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1') + pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=40000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + pg.evaluate("() => { window.DBG.setSegment(2); window.DBG.shot('crossroads_busy'); }") + print(f'soaking {SOAK} ms so the crowd reaches its posture steady state…') + pg.wait_for_timeout(SOAK) + + # ── shot 1: the street ──────────────────────────────────────────────────────────────── + # The camera pose is chosen ONCE, from geometry (the bench station with the most shop + # doors around it — the one block where both R41 street states can fire in one frame) and + # occlusion-raycast before it is accepted. Then it HOLDS and waits: postures come and go + # on 6–20 s dwells, so the honest way to shoot the busy moment is to stand still until it + # happens. Chasing subjects with a moving camera was tried first and lost them to + # rig-reacquisition every time. + place = pg.evaluate(PLACE) + print(f"place: {place.get('stations', 0)} bench stations near shop doors; " + f"{len(place.get('cands', []))} clear camera poses") + best, m = None, None + for cand in place.get('cands', [])[:4]: + for _ in range(2): + pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']]) + pg.wait_for_timeout(2500) + for _ in range(14): # ~35 s of holding this pose + fr = pg.evaluate(FRAME) + if fr['n'] and (not best or fr['score'] > best['score']): + mm = pg.evaluate(MEASURE) + pg.screenshot(path=str(OUTDIR / 'r41_street_postures.jpg'), type='jpeg', quality=90) + best, m = dict(fr, **{k: cand[k] for k in ('px', 'pz', 'yaw')}), mm + print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {fr['score']} n={fr['n']} " + f"clips={fr['clips']} posed={fr['posed']} states={fr['states']}") + if best and best['posed'] >= 2 and best['clips'] >= 4: break + pg.wait_for_timeout(2500) + pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']]) + if best and best['posed'] >= 2 and best['clips'] >= 4: break + if not best: + print(' never framed anybody'); rc = 1 + else: + line, ok = human_line(m['rows']) + name = 'r41_street_postures.jpg' + states = {} + for r in m['rows']: states[r['state']] = states.get(r['state'], 0) + 1 + cap = (f"PROCITY R41 §41.3 — {name}\n" + f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) yaw {best['yaw']:.3f}\n" + f"{len(m['rows'])} near-tier rigs in frame · states {states} · " + f"{len(set(r['clip'] for r in m['rows']))} distinct clips: " + f"{', '.join(sorted(set(r['clip'] for r in m['rows'])))}\n" + f"draws {m['info']['drawCalls']} / budget {m['info']['budget']['draws']} · tris {m['info']['tris']}\n" + f"clips resident: {m['clips']['groups']} groups / {m['clips']['clips']} clips / {m['clips']['bytes']} B\n" + f"{line}\n") + (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) + print(cap) + if not ok: rc = 1 + + # ── shot 2: a browsing interior ─────────────────────────────────────────────────────── + info = pg.evaluate(INTERIOR) + print('interior:', json.dumps(info)) + if not info.get('ok'): + print(' skipped interior shot'); rc = 1 + else: + fr = pg.evaluate(INT_FRAME) + print('frame:', json.dumps(fr)) + if fr.get('ok'): + im = pg.evaluate(INT_MEASURE) + browsers = [r for r in im['rows'] if r['kind'] == 'browser'] + line, ok = human_line(im['rows']) + name = 'r41_browse_interior.jpg' + pg.screenshot(path=str(OUTDIR / name), type='jpeg', quality=90) + cap = (f"PROCITY R41 §41.3 — {name}\n" + f"seed {SEED} · {info['shop']['name']} ({info['shop']['type']}) · " + f"{info['shop']['count']} patronage occupants · {info['browsePoints']} browse points\n" + f"figures: {json.dumps(im['rows'])}\n" + f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n" + f"{line}\n") + (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) + print(cap) + if not ok or not browsers: rc = 1 + else: + rc = 1 + if errs: + print('CONSOLE ERRORS:', errs[:6]); rc = 1 + b.close() + finally: + srv.terminate() + print('\033[32m● shots written\033[0m' if rc == 0 else '\033[31m● problems — see above\033[0m') + return rc + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/web/js/citizens/band.js b/web/js/citizens/band.js index 4dba040..b402b6f 100644 --- a/web/js/citizens/band.js +++ b/web/js/citizens/band.js @@ -19,6 +19,7 @@ import { rng, shuffle } from '../core/prng.js'; import { pickRig, spawnRig, seatedLean } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { loadGLB } from '../core/loaders.js'; +import { gigPostureFor, GROUP_OF } from './postures.js'; const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always // holds — F's smoke asserts it). RSL fields the most (8–12 watch points). @@ -138,10 +139,19 @@ export class GigCrew { // one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. // `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the // fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip. - _make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) { + // [R41 §41.3] `venue` is the WIDENING seam, and it is deliberately narrow: the v3 dance pick below + // (the `gigdance` stream over fleet.danceClips) is untouched, and gigPostureFor draws from its own + // freshly-keyed `gig-pose` stream to decide whether THIS slot takes a venue.glb clip instead — + // northern soul for a dancer, clap/cheer/headphones for a stander. Returns null for the rest, and + // for every boot where venue.glb isn't resident, so the R13/R14 crowd survives byte-identical + // wherever the swap doesn't fire. Not a redesign: no new member kinds, no new poses, no new draws. + _make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false, crowd = false }) { const r = rng(this.citySeed, 'gig', key); const h = height != null ? height : 1.6 + r() * 0.3; - let actor, kind, usedIndex = null; + const bank = this.fleet && this.fleet.bank; + const venueId = crowd ? gigPostureFor(this.citySeed, key, !!dance) : null; + if (bank && venueId) bank.ensureGroup(GROUP_OF[venueId]); + let actor, kind, usedIndex = null, ph = 0; if (this.fleet && this.fleet.ready) { const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); const rig = pk && this.fleet.all[pk.index]; @@ -149,13 +159,20 @@ export class GigCrew { // dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws) const dcs = this.fleet.danceClips || []; const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null; - const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit }); + const venueClip = (!useSit && bank && venueId) ? bank.get(venueId) : null; + // `phase: (ph = r())` keeps R12's draw ORDER and its short-circuit exactly: the phase is drawn + // only when a rig actually resolved, so no crowd slot's identity moves under this round's edit. + const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (venueClip || danceClip || this.fleet.idleClip), phase: (ph = r()), seated: useSit }); + if (sp && venueClip) sp.setClip(venueClip, { phase: ph }); // re-plant off the posed skeleton if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; } } if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; } actor.fig.position.set(x, y, z); target.add(actor.fig); - return { actor, kind, pedIndex: usedIndex }; + // `want` non-null only while the assigned venue clip is still in flight — update() installs it once. + const wantsIt = kind === 'rig' && !seated && venueId && this.fleet && this.fleet.clipsRequested + && !(bank && bank.has(venueId)); + return { actor, kind, pedIndex: usedIndex, clipPhase: ph, venueId, want: wantsIt ? venueId : null }; } // spawn(roomGroup, { stage, watchPoints, gig, roster }) — the band on the deck + crowd at the watch points. @@ -202,11 +219,12 @@ export class GigCrew { let fromRoster = 0; pts.forEach((w, i) => { const who = roster[i] || null; - const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, - who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance } - : { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance }); + const { actor, kind, pedIndex, want, venueId, clipPhase } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, + who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance, crowd: true } + : { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance, crowd: true }); if (who) fromRoster++; this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who, + want, venueId, clipPhase, // R41: the venue-clip widening (null ⇒ this slot is R13 verbatim) base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] }); }); return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster }; @@ -271,7 +289,16 @@ export class GigCrew { update(dt) { this.t += dt; const t = this.t; + const bank = this.fleet && this.fleet.bank; for (const m of this.members) { + // [R41 §41.3] self-heal: venue.glb landed after this crowd spawned → install the assigned clip + // once, in place. Which clip was assigned was decided at spawn from (citySeed, slot); this only + // decides when it starts, so a slow fetch cannot change the crowd, only delay it. + if (m.want && bank && m.actor.setClip) { + const c = bank.get(m.want); + if (c) { m.actor.setClip(c, { phase: m.clipPhase || 0 }); m.want = null; } + else bank.ensureGroup(GROUP_OF[m.want]); // spawned before the bank existed → kick it now + } if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle else m.actor.tick && m.actor.tick(dt, false); // placeholder idle if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix) diff --git a/web/js/citizens/clipbank.js b/web/js/citizens/clipbank.js new file mode 100644 index 0000000..9c53dc4 --- /dev/null +++ b/web/js/citizens/clipbank.js @@ -0,0 +1,130 @@ +// 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[] one GLB under web/models/clips/ = ONE fetch, N named animations +// clips[] { 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 (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. 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. diff --git a/web/js/citizens/keepers.js b/web/js/citizens/keepers.js index 09aa111..4b054a5 100644 --- a/web/js/citizens/keepers.js +++ b/web/js/citizens/keepers.js @@ -11,6 +11,7 @@ import * as THREE from 'three'; import { rng } from '../core/prng.js'; import { pickRig, spawnRig } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; +import { browsePostureFor, keeperPostureFor, GROUP_OF } from './postures.js'; const GREET_RANGE = 6.0; // m — start turning to the player inside this const GREET_CLAMP = 0.62; // rad — max turn off the counter-facing base (~35°) @@ -30,14 +31,32 @@ export class KeeperManager { // browse = R9 interior-presence: a browser rig at a C browse point — faces the goods, no greet. // pedIndex = pick this exact fleet ped (so a browser IS the ped who ducked in off the street). // seedKey = per-instance seeding key (e.g. `${shopId}#${slot}`) so browsers at one shop differ. - spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null } = {}) { + spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null, slot = null } = {}) { const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId); const height = 1.58 + r() * 0.34; - let actor, kind; + // [R41 §41.3] A REAL BROWSE STATE. Until now a "browser" was a rig standing at Lane C's browse + // point playing the shopkeeper's idle. It now plays a browse clip — hold / carry / open the + // cabinet / pick the record up — seeded per (shopId, slot), NOT per visit, so the same shop reads + // the same way every time you walk back in. F already passes `seedKey = "#"`; the slot + // index is taken from it when the caller doesn't pass one, so no shell edit is needed. + // The keeper gets a seeded idle variant instead, or a trade flavour (the publican pours, the + // record-shop keeper wears the cans) — see postures.KEEPER_TYPE_CLIP. + const bank = this.fleet && this.fleet.bank; + const slotIdx = slot != null ? slot : (seedKey && /#(\d+)$/.test(seedKey) ? +/#(\d+)$/.exec(seedKey)[1] : 0); + const wantId = browse ? browsePostureFor(this.citySeed, shopId, slotIdx) + : keeperPostureFor(this.citySeed, shopId, type); + if (bank && wantId) bank.ensureGroup(GROUP_OF[wantId]); // first interior fetches browse.glb; a + // pub/record counter fetches venue.glb + let actor, kind, phase = 0; if (this.fleet && this.fleet.ready) { + // NB the `r()` draw order below is R9's, unchanged and deliberately so: height, then pickRig + // (only when the caller did NOT pin a pedIndex), then phase (only when a rig resolved). The + // R41 posture is drawn from its OWN stream above, so no keeper's ped type or height moves. const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index; const rig = idx != null && this.fleet.all[idx]; - const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() }); + const want = bank ? bank.get(wantId) : null; // resident already? use it at spawn + const spawned = rig && spawnRig(rig, { ry, height, clip: want || this.fleet.idleClip, phase: (phase = r()) }); + if (spawned && want) spawned.setClip(want, { phase }); // re-plant off the posed skeleton if (spawned) { actor = spawned; kind = 'rig'; } } if (!actor) { // asset-free fallback @@ -47,7 +66,16 @@ export class KeeperManager { } actor.fig.position.set(x, 0, z); target.add(actor.fig); - const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse }; + // `want` is kept ONLY while the assigned clip is still unresolved — update() upgrades in place the + // frame its group lands and then clears it. That is what makes lazy loading safe here: the clip a + // browse point plays is decided by (citySeed, shopId, slot) at spawn, and the fetch only decides + // when it starts, never which. A boot with no bank leaves `want` null and nothing ever runs. + // Gated on `fleet.clipsRequested` (boot-stable), NOT on `bank` — a spawn that happens before the + // dynamic import resolves would otherwise silently lose its posture forever. update() kicks the + // group itself in that case, so the only thing timing decides is when the upgrade shows. + const wantsIt = kind === 'rig' && wantId && this.fleet && this.fleet.clipsRequested && !(bank && bank.has(wantId)); + const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse, + want: wantsIt ? wantId : null, phase }; this.keepers.push(k); return k; } @@ -66,8 +94,15 @@ export class KeeperManager { // the camera position (the player IS the camera in interior mode). update(dt, playerPos = null) { const pp = playerPos || (this.camera && this.camera.position) || null; + const bank = this.fleet && this.fleet.bank; for (const k of this.keepers) { const a = k.actor; + // [R41 §41.3] self-heal: the assigned clip's group finished fetching → install it now, once. + if (k.want && bank && a.setClip) { + const c = bank.get(k.want); + if (c) { a.setClip(c, { phase: k.phase }); k.want = null; } + else bank.ensureGroup(GROUP_OF[k.want]); // spawned before the bank existed → kick it now + } if (a.mixer) a.mixer.update(dt); // rig idle else a.tick?.(dt, false); // placeholder idle diff --git a/web/js/citizens/postures.js b/web/js/citizens/postures.js new file mode 100644 index 0000000..ea55b11 --- /dev/null +++ b/web/js/citizens/postures.js @@ -0,0 +1,111 @@ +// PROCITY Lane D — R41 §41.3: the POSTURE TABLE. Pure, seeded, THREE-free, load-state-free. +// +// The whole point of this file is that WHICH clip a citizen plays is a pure function of +// (citySeed, identity) — never of what happens to be resident in memory when the question is asked. +// That is what makes the determinism law survivable under lazy loading: the assignment is decided +// once, deterministically, and the ClipBank either has that clip yet or the actor falls back to the +// R2-era 8-clip asset. Same seed → same postures, byte-equal, whether the group GLB landed in 40 ms +// or 4 s or never. +// +// Consequences of that rule, deliberately: +// • the pools below are LITERAL clip-id lists, not manifest-derived. A manifest read is async; an +// async pool would make posture depend on load timing, which is the bug this file exists to +// forbid. `web/assets/motion_manifest.json` is still the contract — `tools/qa/r41_postures.mjs` +// asserts every id here resolves in it, with the right category and the right group file. +// • every stream is freshly keyed (`posture`, `browse-pose`, `keeper-pose`, `gig-pose`, +// `benchstop`, `leanstop`). None of them shifts `turn`/`loiter`/`patron`/`benchsit`/`glance`, so +// the R8/R17/R29 crowd behaviour and every existing identity signature are untouched. +// • `@`-prefixed ids are SENTINELS for the pre-R41 8-clip assets (`models/peds/walk.glb` etc.), +// not library clips. The gate skips them; the actor maps them to its base action. +// +// No imports but the PRNG ⇒ node can import this file directly (the gate does). + +import { rng } from '../core/prng.js'; + +// ---- pools (curated from web/assets/motion_manifest.json, R41 §41.1) ---- +// Chosen for a 1990s Australian high street, not for coverage. Excluded on purpose: +// · the whole `social.glb` group — a two-person clip needs a two-person state machine and a +// partner-pairing pass; 896 KB for a state D does not have yet. Filed R42. +// · `browse_hold_turn_l/r` (172°/147° loop seam — one-shot turns, no turn state to hang them on), +// `turn_*`/`walk_to_stand` (same: the sim turns instantly at a node), `venue_clap_seated` +// (no seated venue slot yet). +export const IDLE_POOL = [ + 'idle_breathing', 'idle_standing', 'idle_look_around', 'idle_weight_shift', 'idle_cocky_lean', + 'idle_yawn', 'idle_happy_1', 'idle_happy_2', 'idle_old_man', 'idle_smoking', +]; +// '@walk' = the fleet's own walk.glb (the base gait every citizen has had since R2). The shopping-bag +// walk is the only locomotion clip with a state to live in; the turn set is filed to R42 with the +// turn-state machine it needs. +export const WALK_POOL = ['@walk', '@walk', 'walk_shopping_bag']; // ⇒ 1/3 carry a bag +export const SIT_POOL = ['sit_bench_swing', 'sit_hands_thighs', 'sit_impatient', 'sit_fidget_feet']; +export const LEAN_POOL = ['lean_wall_postures', 'lean_wall_leg_up', 'lean_wall_shoulders', 'lean_wall_walkman']; +// browse: two continuous holds + the two cabinet one-shots + the pick-up. The pick-up (92° seam) and +// the cabinet pair read as crate-digging when the bank ping-pongs them (see clipbank.js LOOP_SEAM_DEG). +export const BROWSE_POOL = [ + 'browse_hold_idle', 'browse_carry_box', 'browse_open_cabinet', 'browse_close_cabinet', 'browse_pick_up', +]; +// the gig. The existing v3 dance set (dance_party/medium/drink/sway, models/peds/*.glb) is NOT +// replaced — venue_northern_soul JOINS it, so the epoch's crowd keeps its vocabulary and gains one. +export const VENUE_DANCE_POOL = ['venue_northern_soul']; +export const VENUE_STAND_POOL = ['venue_clap_stand', 'venue_cheer', 'venue_headphones']; +// keeper flavour by shop type — the publican pours, the record-shop keeper wears the cans. Everything +// else takes a seeded idle. Both live in venue.glb, so those two shop types (and only those) pay its +// 526 KB, lazily, on first entry. +export const KEEPER_TYPE_CLIP = { pub: 'venue_bartending', band_room: 'venue_bartending', rsl: 'venue_bartending', record: 'venue_headphones' }; + +// clip-id → its group GLB. Static so a lazy load can be kicked before the manifest lands; the gate +// asserts it against `motion_manifest.json`'s own `clips[id].group` field, id for id. +export const GROUP_OF = (() => { + const g = {}; + for (const id of IDLE_POOL) g[id] = 'idles.glb'; + for (const id of WALK_POOL) if (id[0] !== '@') g[id] = 'locomotion.glb'; + for (const id of SIT_POOL) g[id] = 'sitlean.glb'; + for (const id of LEAN_POOL) g[id] = 'sitlean.glb'; + for (const id of BROWSE_POOL) g[id] = 'browse.glb'; + for (const id of VENUE_DANCE_POOL) g[id] = 'venue.glb'; + for (const id of VENUE_STAND_POOL) g[id] = 'venue.glb'; + for (const id of Object.values(KEEPER_TYPE_CLIP)) g[id] = 'venue.glb'; + return g; +})(); +// the groups fetched at BOOT (everything else is lazy — see LANE_D_NOTES §41 for the fetch ledger) +export const BOOT_GROUPS = ['idles.glb', 'locomotion.glb']; + +const at = (arr, r01) => arr[(r01 * arr.length) | 0]; + +// ---- the citizen's four standing postures — one stream, four draws, forever ---- +// id is the sim's citizen id (v1: an integer; stream mode: `${chunkKey}#${i}`) — both are stable +// per (seed, place), so this is stable per (seed, place) too. +export function posturesFor(citySeed, id) { + const r = rng(citySeed, 'posture', id); + return { + idle: at(IDLE_POOL, r()), + walk: at(WALK_POOL, r()), + sit: at(SIT_POOL, r()), + lean: at(LEAN_POOL, r()), + }; +} +// stable one-line signature of a citizen's posture set (the determinism artefact the gate diffs) +export function postureSig(id, p) { return `${id}|${p.idle}|${p.walk}|${p.sit}|${p.lean}`; } + +// ---- the other seeded picks, each on its own freshly-keyed stream ---- +// a browse point reads the same way every visit: keyed by (shopId, slot), never by visit order. +export function browsePostureFor(citySeed, shopId, slot) { + return at(BROWSE_POOL, rng(citySeed, 'browse-pose', `${shopId}#${slot}`)()); +} +// the shopkeeper: type flavour if this trade has one, else a seeded idle, stable per shop. +export function keeperPostureFor(citySeed, shopId, type) { + const flavour = KEEPER_TYPE_CLIP[type]; + if (flavour) return flavour; + return at(IDLE_POOL, rng(citySeed, 'keeper-pose', shopId)()); +} +// A gig crowd slot — WIDENING, not replacing. The v3 dance pick (band.js's `gigdance` stream over +// `fleet.danceClips`) is left exactly as it is; this is a SECOND, independently keyed roll that says +// "this slot takes a venue clip instead". Returns null ⇒ the slot keeps its R13/R14 behaviour +// verbatim, which is also what every venue-clip-absent boot gets ⇒ byte-identical by construction. +export const GIG_DANCE_SWAP = 1 / 3; // of dancers take the northern-soul step +export const GIG_STAND_SWAP = 2 / 3; // of standers clap / cheer / listen instead of plain idling +export function gigPostureFor(citySeed, key, dancing) { + const r = rng(citySeed, 'gig-pose', key); + if (r() >= (dancing ? GIG_DANCE_SWAP : GIG_STAND_SWAP)) return null; + return at(dancing ? VENUE_DANCE_POOL : VENUE_STAND_POOL, r()); +} diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index 7a21d24..86cf77f 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -31,6 +31,11 @@ export const PED_NAMES = { // ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ---- // mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character. const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig'); +// [R41 §41.3] exported so clipbank.js runs Lane E's 46 library clips through the IDENTICAL two-step +// (`_canon` then `_rotOnly`) that walk/idle/sit/look/dance have ridden since R2 — one code path, so a +// library clip cannot diverge from a base clip in how it binds. E measured 65 raw tracks → 64 after +// the filter on all 46; clipbank.js re-asserts non-empty per clip at load. +export const canonName = _canon; function canonRig(r) { if (!r) return r; if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); }); @@ -42,6 +47,7 @@ function canonRig(r) { // flat). The character keeps its own upright bind root; the game translates walkers itself. const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration, c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name))); +export const rotOnlyClip = _rotOnly; // [R41 §41.3] same filter for the library clips — see canonName // R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a // `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose // Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT @@ -66,10 +72,29 @@ function loadRig(ref) { // the bodies ride the same `!CLASSIC` gate as the sit/look/dance clips: `?classic=1` fetches none of the // five and keeps the covenanted 17-pool; every other boot gets all 22. Separable for callers that want // dance clips without the bodies (or vice versa). -export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance } = {}) { +// `opts.clips` (R41 §41.3): Lane E's 46-clip MOTION LIBRARY (web/models/clips/*.glb + +// web/assets/motion_manifest.json). DEFAULTS TO `dance` for exactly the reason `djs` does — the shell +// already passes `dance: !CLASSIC` (index.html:232), so the classic-ness signal arrives with ZERO +// shell edits and the library rides the same gate as the sit/look/dance clips. `?classic=1` ⇒ no +// manifest fetch, no clip-GLB fetch, and (because the module is reached by dynamic `import()`) not +// even a clipbank.js fetch: the zero-fetch-delta covenant is untouched. `?noassets=1` never calls +// loadPedFleet at all. On, the bank fetches THREE things at boot — the 16 KB manifest and the two +// eager groups (postures.js BOOT_GROUPS: idles.glb 1 029 496 B + locomotion.glb 212 776 B) — and +// everything else lazily on first demand. `fleet.ready` deliberately does NOT depend on the bank: a +// dead manifest or a 404 group leaves the R2 8-clip fleet running exactly as it does today. +// `?clips=0` is the CONTROL ARM, and it exists because `?classic=1` is a bad control for this: it +// changes the ped pool, the sit/look/dance clips, the fog, the game and half the shell at once, so a +// draw or heap delta measured against it says nothing about the motion library specifically. This +// flag turns off the library and NOTHING else. Read the same way furniture.js reads ?noassets. +const CLIPS_OFF = (() => { try { return new URLSearchParams(location.search).get('clips') === '0'; } catch { return false; } })(); + +export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance, + clips = dance, clipBase = null, manifestUrl = null, clipGroups = null } = {}) { + if (CLIPS_OFF) clips = false; const fleet = { normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [], + bank: null, // R41 ClipBank | null (null ⇒ every consumer takes its base clip) ready: false, whenReady: null, }; // DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array @@ -111,6 +136,24 @@ export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => { const c = r && r.anims && r.anims[0]; if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } }))); + // [R41 §41.3] the motion library. Dynamic import ⇒ clipbank.js is not even fetched when the gate is + // off. Fail-soft twice over: the import and the boot both swallow, so `fleet.bank` stays null and + // every downstream `bank && bank.get(...)` falls straight through to the base clip. + // Published SYNCHRONOUSLY, before the import even starts: consumers that must make a STREAM- + // AFFECTING decision (the sim's bench/lean/pause rolls) need a boot-stable answer to "is the + // library on for this boot", not "has it landed yet" — otherwise the first second of a boot would + // draw a different number of randoms than the rest of it, and 'same seed → same crowd' would + // depend on network timing. `fleet.bank` stays the RESIDENCY question; this is the GATE question. + fleet.clipsRequested = !!clips; + if (clips) jobs.push( + Promise.all([import('./clipbank.js'), import('./postures.js')]).then(([cb, po]) => { + const bank = new cb.ClipBank({ + clipBase: clipBase || base.replace(/peds\/?$/, 'clips/'), + manifestUrl: manifestUrl || base.replace(/models\/peds\/?$/, 'assets/motion_manifest.json'), + }); + fleet.bank = bank; // published immediately: lazy groups can be + return bank.boot(clipGroups || po.BOOT_GROUPS); // kicked before the boot groups land + }).catch((e) => { console.warn('[rigs] motion library unavailable, base clips stay:', e && e.message); })); fleet.whenReady = Promise.all(jobs).then(() => { fleet.normal = nSlots.filter(Boolean); fleet.comical = cSlots.filter(Boolean); @@ -219,6 +262,8 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s const mixer = new THREE.AnimationMixer(inner); const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0]; const act = _action(mixer, inner, chosen); + const bindPlantY = inner.position.y; // R41: the STANDING feet-plant, to restore before any re-plant + let curAct = act; let seatBone = null; if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); @@ -238,8 +283,56 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); } } + // [R41 §41.3] setClip — swap this single-clip figure onto a motion-library clip AFTER it was built. + // This is the self-healing seam: a keeper / browser / gig member is posed with whatever clip exists + // at spawn (the R2 idle in the worst case) and upgrades in place the frame its lazily-fetched group + // lands, so WHICH clip it was assigned never depends on load timing — only how soon it shows. + // + // The re-plant is the R29 lesson generalised. `_rotOnly` drops the Hips POSITION track (it must — + // a foreign-scale source would inflate the rig), so a clip's authored vertical motion comes out as + // the FEET moving instead of the hips: measured up to +0.205 m on look.glb. spawnRig's original + // seated re-plant samples ONCE at t=0, which is right for a fixed pose and wrong for a clip whose + // lowest bone travels. So this samples the posed skeleton at PLANT_SAMPLES points across the clip + // and plants the MINIMUM — the soles can then never sink through the floor, and the residual float + // is the clip's own authored range, not an arbitrary phase's error. Absolute (reset → measure → + // correct), never incremental, and scale-aware via fig.scale.y. + const PLANT_SAMPLES = 6; + function _plant() { + inner.position.y = bindPlantY; + const clipDur = (curAct && curAct.getClip().duration) || 0; + const t0 = curAct ? curAct.time : 0; + let lo = Infinity; + for (let i = 0; i < PLANT_SAMPLES; i++) { + if (curAct) curAct.time = clipDur * (i / PLANT_SAMPLES); + mixer.update(0); + inner.updateWorldMatrix(true, true); + inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); if (_wp.y < lo) lo = _wp.y; } }); + if (clipDur <= 0) break; + } + if (curAct) { curAct.time = t0; mixer.update(0); } + if (!isFinite(lo)) return; + const S = fig.scale.y || 1; + inner.position.y -= (lo - fig.position.y) / S; + } + function setClip(next, { seated: st = seated, phase: ph = phase } = {}) { + if (!next) return false; + const na = _action(mixer, inner, next); + if (!na) return false; + if (curAct && curAct !== na) { curAct.stop(); curAct.setEffectiveWeight(0); } + if (next._pcLoop === 'pingpong') na.setLoop(THREE.LoopPingPong, Infinity); + curAct = na; + na.reset().play().setEffectiveWeight(1); + na.time = ph * (next.duration || 0); + _plant(); + if (st) { + fig.userData.procitySeated = true; + seatBone = null; + inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); + } + return true; + } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } - return { fig, inner, mixer, height, head, seatBone, dispose }; + return { fig, inner, mixer, height, head, get seatBone() { return seatBone; }, setClip, dispose }; } // R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets @@ -258,20 +351,48 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight); const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit const mixer = new THREE.AnimationMixer(inner); - const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name))); - const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name))); + // [R41 §41.3] one AnimationAction per SOURCE clip, memoised. Pooled actors are recycled across + // citizens of the same ped type, so the per-citizen idle/walk cannot be baked in at construction — + // it is swapped on acquire (≤3/frame, NEW_RIG_PER_FRAME). Memoising by source clip means a pool + // slot that has seen five different idles pays five bindings, not five per acquire; three.js shares + // the PropertyMixer bindings per (root, track) across all of them, so the marginal cost of an extra + // action is its interpolants, not another copy of the skeleton. + const _acts = new Map(); + function _act(clip) { + if (!clip) return null; + let a = _acts.get(clip); + if (a === undefined) { + a = _action(mixer, inner, clip); + // library clips whose measured loop seam does not close play ping-pong instead of popping + // (clipbank.js LOOP_SEAM_DEG) — `browse_pick_up` becomes crate-digging rather than a teleport. + if (a && clip._pcLoop === 'pingpong') a.setLoop(THREE.LoopPingPong, Infinity); + _acts.set(clip, a); + } + return a; + } + const baseWalkA = _act(walkClip || rig.anims.find(c => /walk/i.test(c.name))); + const baseIdleA = _act(idleClip || rig.anims.find(c => /idle/i.test(c.name))); // R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where // sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op. - const sitA = sitClip ? _action(mixer, inner, sitClip) : null; + const baseSitA = sitClip ? _act(sitClip) : null; // [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers, // placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer. - const lookA = lookClip ? _action(mixer, inner, lookClip) : null; + const baseLookA = lookClip ? _act(lookClip) : null; + // R41: the four live slots. They START at the base actions, so an actor nobody swaps is the R40 + // actor exactly; setIdleClip/setWalkClip/setSitting(_,clip)/setLooking(_,_,clip) re-point them. + let walkA = baseWalkA, idleA = baseIdleA, sitA = baseSitA, lookA = baseLookA; let seatBone = null; - if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); + if (baseSitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); // [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip - // exists, so walkers/?classic pay nothing. + // exists, so walkers/?classic pay nothing. R41: built lazily too, since a lean clip can arrive later. let footBones = null; - if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; } + function _footBones() { + if (footBones !== null) return footBones; + const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); + footBones = fb.length ? fb : false; + return footBones; + } + if (baseLookA) _footBones(); let sitting = false, looking = false; let moving = null; // tri-state so the first setMoving always applies // both actions play; exactly one holds weight 1 at rest, setMoving transfers between them @@ -290,12 +411,39 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); } else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer } + // [R41 §41.3] the per-citizen resting/moving clips. Called on ACQUIRE only (the pool hands a recycled + // actor to a new citizen), before setPhase + setMoving(_, 0) — so `moving = null` here is enough to + // make the next setMoving apply instantly and no crossfade can be left mid-flight. Passing null + // restores the base clip, which is exactly what a citizen whose assigned clip has not landed yet + // (or whose boot has no bank at all) gets ⇒ the R40 actor, byte for byte. + function setIdleClip(clip) { + const a = clip ? (_act(clip) || baseIdleA) : baseIdleA; + if (!a || a === idleA) return; + if (idleA) { idleA.setEffectiveWeight(0); idleA.stop(); } + idleA = a; idleA.enabled = true; idleA.reset().play(); idleA.setEffectiveWeight(0); + moving = null; + } + function setWalkClip(clip) { + const a = clip ? (_act(clip) || baseWalkA) : baseWalkA; + if (!a || a === walkA) return; + if (walkA) { walkA.setEffectiveWeight(0); walkA.stop(); } + walkA = a; walkA.enabled = true; walkA.reset().play(); walkA.setEffectiveWeight(0); + moving = null; + } // R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped // sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's // per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed // lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand. - function setSitting(s) { - if (!sitA || s === sitting) return; + // R41: `clip` (optional) is a motion-library sit variant (sitlean.glb) for THIS citizen; omitted / + // null ⇒ the R16 sit.glb pose, unchanged. With neither, setSitting stays the no-op it has always + // been under ?classic. + function setSitting(s, clip = null) { + const target = s ? ((clip && _act(clip)) || baseSitA) : null; + if (s && !target) return; // nothing to sit with → inert (classic / no clip) + if (!s && !sitting) return; // already standing + if (s && sitting && target === sitA) return; // already in this exact pose + if (s && sitA && sitA !== target) sitA.stop(); // swapping pose mid-sit + if (s) sitA = target; sitting = s; moving = null; // force the next setMoving to re-apply after standing up if (s) { @@ -310,7 +458,8 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath fig.userData.procitySeated = true; } else { - sitA.stop(); + if (sitA) sitA.stop(); + sitA = baseSitA; // back to the base pose for the next citizen inner.position.y = bindPlantY; // restore the standing feet-plant if (idleA) idleA.setEffectiveWeight(1); fig.userData.procitySeated = false; @@ -321,8 +470,17 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = // bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this // crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped. // No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction. - function setLooking(l, fade = 0.3) { - if (!lookA || l === looking) return; + // R41: `clip` (optional) is any STANDING posed library clip for this citizen — a lean against a + // shopfront (sitlean.glb's four lean_wall_*) rides the identical seam as R29's glance, because it is + // the identical problem: a standing clip, no hip descent to recover, but a per-frame foot float that + // plantFeet() absorbs. Omitted / null ⇒ look.glb, unchanged. + function setLooking(l, fade = 0.3, clip = null) { + const target = l ? ((clip && _act(clip)) || baseLookA) : null; + if (l && !target) return; // no standing pose available → inert (classic) + if (!l && !looking) return; + if (l && looking && target === lookA) return; // already in this exact pose + if (l && lookA && lookA !== target) lookA.stop(); // swapping pose mid-look + if (l) { lookA = target; _footBones(); } looking = l; moving = null; // force the next setMoving to re-apply on the way out if (l) { @@ -331,8 +489,9 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = else lookA.setEffectiveWeight(1); if (walkA) walkA.setEffectiveWeight(0); } else { - if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); } - else lookA.setEffectiveWeight(0); + if (idleA) { idleA.reset().play(); if (lookA) lookA.crossFadeTo(idleA, fade, false); } + else if (lookA) lookA.setEffectiveWeight(0); + lookA = baseLookA; // back to the base pose for the next citizen inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant } } @@ -360,9 +519,11 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = if (walkA) walkA.time = p * (walkA.getClip().duration || 1); if (idleA) idleA.time = p * (idleA.getClip().duration || 1); if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too + if (sitA) sitA.time = p * (sitA.getClip().duration || 1); // R41: so does a row of bench-sitters } - function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } + function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _acts.clear(); _disposeInner(inner); } - return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose, + return { fig, inner, mixer, head, get seatBone() { return seatBone; }, nominalHeight: nom, + setMoving, setSitting, setLooking, setIdleClip, setWalkClip, plantFeet, setPhase, dispose, hasClips: !!(walkA && idleA) }; } diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index a2cac9e..8a47664 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -17,6 +17,7 @@ import { pickRig, makeActor } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; import { snapDoorToFootpath } from './door_snap.js'; +import { posturesFor, postureSig, GROUP_OF } from './postures.js'; // ---- tuning (CITY_SPEC budgets) ---- const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor @@ -38,6 +39,35 @@ const GIG_SURGE = 0.55; // patron chance at the venue while const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded) const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around +// ---- R41 §41.3: the street furniture the clips actually sit and lean on ---- +// A REAL bench sit. R17's bench-sit sits a ped down at whatever node it stopped at — upright, on air, +// "no bench-position binding" (its own comment). R41 binds it: benches exist, at deterministic +// stations, and a ped that walks past one on its own footpath side can stop AT IT. +// +// The station rule below MIRRORS Lane B's `buildChunkFurniture` (web/js/world/furniture.js:203-209) +// exactly — s = 14, 40, 66, …, step 26; side alternating on floor(s/26)%2; offset +// (width/2 + FOOT − 0.8) on the furniture perpendicular (−uz, ux); yaw = atan2(ux,uz) + (side>0 ? π : 0). +// It is a MIRROR because furniture.js is Lane B's file and exports no bench enumerator (only +// `busShelterStops`, its shelter twin). A mirror that drifts is a ped sitting on air again, so it is +// GATED, not trusted: `tools/qa/r41_benches.mjs` builds real chunks through Lane B's own +// buildChunkFurniture, reads the bench InstancedMesh matrices out of the scene, and requires an exact +// 1:1 position/yaw match against this table on four towns. If B ever moves a bench, that gate goes red. +// → Filed for Lane B/F in LANE_D_NOTES §41: `export function benchStops(plan)` next to busShelterStops +// retires the mirror entirely. One line in B's file; D switches to the import and deletes this. +const FURN_FOOT = 3.5; // furniture.js FOOT (verge width used for its offsets) +const BENCH_S0 = 14, BENCH_STEP = 26; // furniture.js bench cadence +const BENCH_STOP_FRAC = 0.40; // seeded chance a ped passing its own-side bench sits down +const BENCH_DWELL = [9, 20]; // s — a sit is a proper stop, not a window-shop pause +const BENCH_SEAT_FWD = 0.06; // m toward the bench front, so the seat takes the weight +const BENCH_SEAT_SIDE = 0.38; // m along the 1.6 m plank — the bench seats two, side by side +const LEAN_RANGE = 5.0; // m — a loiter this close to a shop door can become a lean +const LEAN_FRAC = 0.12; // seeded chance a stride check beside a shopfront becomes a lean +const LEAN_DWELL = [6, 14]; // s — a lean is a longer stop than a window-shop glance +const PAUSE_FRAC = 0.17; // …else a seeded chance they just stop and look in the window +const PAUSE_DWELL = [4, 9]; // s — long enough to read the idle, short enough to keep moving +const LEAN_WALL_BACK = 0.30; // m further from the road than the raw door point (≈ the facade) +const LEAN_SIDE = 1.05; // m along the frontage, so the leaner isn't in the doorway + // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h @@ -71,6 +101,25 @@ export function identityOf(citySeed, edgeCount, id) { const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0; return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar }; } +// [R41 §41.3] Lane B's benches on one prepared sim edge ({A,B,ux,uz,len,width}), as a pure function — +// exported so `tools/qa/r41_citizens.py` can check the SAME code the sim uses against the bench +// geometry actually standing in the built world, rather than a second copy of the rule. See the +// FURN_*/BENCH_* block above for the mirror's provenance and the seam filed to Lane B. +export function benchStationsFor(e) { + const out = []; + const halfRoad = (e.width || 4) / 2; + const px = -e.uz, pz = e.ux; // furniture.js perpendicular + const yaw = Math.atan2(e.ux, e.uz); + for (let s = BENCH_S0; s < e.len - 6; s += BENCH_STEP) { + const side = (Math.floor(s / BENCH_STEP) % 2) ? 1 : -1; + const off = (halfRoad + FURN_FOOT - 0.8) * side; + out.push({ s, side, + x: e.A.x + e.ux * s + px * off, z: e.A.z + e.uz * s + pz * off, + yaw: yaw + (side > 0 ? Math.PI : 0) }); + } + return out; +} + // stable signature string for one citizen (pedIndex assigned later, once the fleet is known) export function signatureOf(id, idn, pedIndex) { return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`; @@ -262,6 +311,13 @@ export class CitizenSim { 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); @@ -271,6 +327,81 @@ export class CitizenSim { _sig(c) { return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex); } + + // ================= R41 §41.3: the motion library ================= + get bank() { return (this.fleet && this.fleet.bank) || null; } + // The GATE question — "is the motion library on for this boot" — answered synchronously from frame + // zero (rigs.js sets it before the dynamic import starts). Every roll that can change a citizen's + // POSITION is gated on this, never on `bank`, so the number of randoms drawn per boot cannot depend + // on when a fetch landed. `bank` answers the different question of what is resident right now, and + // is only ever used to pick WHICH clip plays — with the R2/R16/R29 base clip as the fallback. + get clipsOn() { return !!(this.fleet && this.fleet.clipsRequested); } + // ask for a group; harmless (and free) when there is no bank. Called at the moment of INTENT, so a + // group is fetched the first time the town actually wants it, never at boot "just in case". + _wantGroup(file) { const b = this.bank; if (b && file) b.ensureGroup(file); } + _wantClip(id) { const b = this.bank; if (b && id && GROUP_OF[id]) b.ensureGroup(GROUP_OF[id]); } + // the assigned clip if it is resident, else null ⇒ the caller's pre-R41 base clip + _clip(id) { const b = this.bank; return b ? b.get(id) : null; } + + // The determinism artefact for postures: one line per ACTIVE citizen, sorted, pure identity — + // never live state, so it holds while the crowd walks. Two runs must produce byte-equal output + // (tools/qa/r41_postures.mjs asserts it, and re-derives the same lines from postures.js alone). + postureSignature() { + return this._activeList.map((c) => postureSig(c.id, c.posture || posturesFor(this.citySeed, c.id))).sort(); + } + // what is actually resident right now (the memory ledger the round asks to be stated) + clipStats() { const b = this.bank; return b ? b.stats() : { groups: 0, clips: 0, bytes: 0, manifest: false, catalogue: 0 }; } + + // Lane B's benches, enumerated from B's own placement rule — see the FURN_* block above for why + // this is a mirror and what gates it. Cached per edge (pure function of the edge, no rng). + _benchStations(ei) { + const e = this.edges[ei]; + return e._benches || (e._benches = benchStationsFor(e)); + } + // The bench's local +Z is its FRONT (furniture.js's template puts the backrest at z=−0.2 and the + // seat at y=0.45); rig fronts are local −Z after the R13 facing-normalise, so a sitter facing the + // same way as the bench is `yaw + π`. Nudged BENCH_SEAT_FWD off the backrest onto the seat. + // `seat` is ±1: the bench's seat plank is 1.6 m of local X, so it takes TWO. Measured need — with + // benches 26 m apart per side, two peds picking the same station within one dwell is uncommon but + // real (seen on the first soak: -1,-6#3 and -1,-6#5 co-located to the centimetre). A seeded ± puts + // them side by side like people instead of inside each other. Residual: both can still draw the + // same side; that reads as one ped, not a glitch, and it is bounded by the same 26 m cadence. + _seatPose(st, seat) { + const fx = Math.sin(st.yaw), fz = Math.cos(st.yaw); // bench local +Z (its front) + const rx = Math.cos(st.yaw), rz = -Math.sin(st.yaw); // bench local +X (along the plank) + return { x: st.x + fx * BENCH_SEAT_FWD + rx * BENCH_SEAT_SIDE * seat, + z: st.z + fz * BENCH_SEAT_FWD + rz * BENCH_SEAT_SIDE * seat, + facing: st.yaw + Math.PI }; + } + + // A shopfront to put a back against. `shop._raw` is the door point BEFORE R40's footpath clamp, + // i.e. the shell's `lot centre + front normal · (d/2 + 0.6)` — 0.6 m off the facade, which is the + // wall we want. `n` is the unit outward normal of the nearest street (centreline → door), so the + // building is at +n and the road at −n: stand LEAN_WALL_BACK further along +n, offset LEAN_SIDE + // along the frontage so the leaner isn't blocking the doorway, and face −n (the road). + // Pure geometry over the sim's own edges — no lots, no plan, no rng beyond the caller's roll. + // O(1): the reference street is the one the LEANER IS WALKING ON, not a scan of every edge in + // town. The ped is inside LEAN_RANGE (5 m) of that door, on that street's footpath, so the shop + // fronts that street in every case but a corner — and a corner's residue is a facing, not a + // position. The scan version cost 30 986 iterations per lean event on adelaide_real. + _leanPose(shop, side, e) { + const base = shop._raw || shop; + if (!e) return null; + const ax = e.A.x, az = e.A.z, dx = e.B.x - ax, dz = e.B.z - az; + const L2 = dx * dx + dz * dz; + let t = L2 < 1e-9 ? 0 : ((base.x - ax) * dx + (base.z - az) * dz) / L2; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const qx = ax + t * dx, qz = az + t * dz; + const bd = Math.hypot(base.x - qx, base.z - qz); + if (!isFinite(bd) || bd < 1e-6) return null; + const nx = (base.x - qx) / bd, nz = (base.z - qz) / bd; // outward: road → building + const tx = -nz, tz = nx; // along the frontage + return { + x: base.x + nx * LEAN_WALL_BACK + tx * LEAN_SIDE * side, + z: base.z + nz * LEAN_WALL_BACK + tz * LEAN_SIDE * side, + facing: Math.atan2(nx, nz), // rig front −Z looks along (−nx,−nz) + }; + } activeCitizens() { return this._activeList; } streamEncountered() { return [...this._encountered].sort(); } // hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes @@ -312,6 +443,13 @@ export class CitizenSim { 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) { @@ -357,7 +495,11 @@ export class CitizenSim { for (const [key, list] of shopsByChunk) { snapped.set(key, list.map((s) => { const p = snapDoorToFootpath(s.x, s.z, this.edges); - return p.moved ? { ...s, x: p.x, z: p.z } : s; + // [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; @@ -372,6 +514,11 @@ export class CitizenSim { // 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 @@ -448,6 +595,25 @@ export class CitizenSim { } 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'; @@ -591,10 +757,43 @@ export class CitizenSim { c.facing = Math.atan2(-dx / dist, -dz / dist); return; } - if (c.loiter > 0) { c.loiter -= dt; 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; @@ -633,6 +832,41 @@ export class CitizenSim { 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]); + } + } } } } @@ -642,6 +876,15 @@ export class CitizenSim { 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) @@ -760,13 +1003,20 @@ export class CitizenSim { // 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); + // [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. - const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking); - if (a.setLooking) a.setLooking(wantLook); - if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0); + // [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); } @@ -783,7 +1033,7 @@ export class CitizenSim { 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); + 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 } @@ -794,7 +1044,7 @@ export class CitizenSim { 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 + 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;