Lane D R41 §41.3: the town stops walking — 99.3% to 78.1%, at zero draws

THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that
turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean
0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20.
The town was 99.3% people walking because standing still had nowhere to happen.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 18:11:30 +10:00
parent 422f17263e
commit 78f49f7113
15 changed files with 2202 additions and 38 deletions

View File

@ -1,5 +1,188 @@
# PROCITY-D — progress (Lane D · Citizens) # 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 7677%, browsers/keepers
99101%, walkers 95100% 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) ## 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 **Verification first, per the brief.** New gate `tools/qa/door_footpath_check.mjs` re-derives every

View File

@ -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 · 920 s | 9.5% of the crowd seated |
| `LEAN_FRAC` · `LEAN_DWELL` | 0.12 · 614 s | 7.1% leaning |
| `PAUSE_FRAC` · `PAUSE_DWELL` | 0.17 · 49 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, 90110 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 1525% 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 7586%, browsers/keepers 93101%,
walkers 9699% 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
36 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 ## 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. Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want.

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

View File

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

View File

@ -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).

419
tools/qa/r41_citizens.py Normal file
View File

@ -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())

151
tools/qa/r41_postures.mjs Normal file
View File

@ -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);

410
tools/qa/r41_shots.py Normal file
View File

@ -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 620 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())

View File

@ -19,6 +19,7 @@ import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig, seatedLean } from './rigs.js'; import { pickRig, spawnRig, seatedLean } from './rigs.js';
import { makePlaceholder } from './placeholder.js'; import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.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 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 (812 watch points). // holds — F's smoke asserts it). RSL fields the most (812 watch points).
@ -138,10 +139,19 @@ export class GigCrew {
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. // 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 // `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. // 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 r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3; 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) { if (this.fleet && this.fleet.ready) {
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index]; 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) // dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
const dcs = this.fleet.danceClips || []; const dcs = this.fleet.danceClips || [];
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null; 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 (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'; } 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); actor.fig.position.set(x, y, z);
target.add(actor.fig); 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. // 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; let fromRoster = 0;
pts.forEach((w, i) => { pts.forEach((w, i) => {
const who = roster[i] || null; const who = roster[i] || null;
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, 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 } 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 }); : { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance, crowd: true });
if (who) fromRoster++; if (who) fromRoster++;
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who, 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: [] }); 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 }; return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster };
@ -271,7 +289,16 @@ export class GigCrew {
update(dt) { update(dt) {
this.t += dt; this.t += dt;
const t = this.t; const t = this.t;
const bank = this.fleet && this.fleet.bank;
for (const m of this.members) { 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 if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
else m.actor.tick && m.actor.tick(dt, false); // placeholder 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) if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix)

130
web/js/citizens/clipbank.js Normal file
View File

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

View File

@ -11,6 +11,7 @@ import * as THREE from 'three';
import { rng } from '../core/prng.js'; import { rng } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js'; import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.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_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°) 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. // 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). // 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. // 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 r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId);
const height = 1.58 + r() * 0.34; 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 = "<shopId>#<i>"`; 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) { 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 idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index;
const rig = idx != null && this.fleet.all[idx]; 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 (spawned) { actor = spawned; kind = 'rig'; }
} }
if (!actor) { // asset-free fallback if (!actor) { // asset-free fallback
@ -47,7 +66,16 @@ export class KeeperManager {
} }
actor.fig.position.set(x, 0, z); actor.fig.position.set(x, 0, z);
target.add(actor.fig); 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); this.keepers.push(k);
return k; return k;
} }
@ -66,8 +94,15 @@ export class KeeperManager {
// the camera position (the player IS the camera in interior mode). // the camera position (the player IS the camera in interior mode).
update(dt, playerPos = null) { update(dt, playerPos = null) {
const pp = playerPos || (this.camera && this.camera.position) || null; const pp = playerPos || (this.camera && this.camera.position) || null;
const bank = this.fleet && this.fleet.bank;
for (const k of this.keepers) { for (const k of this.keepers) {
const a = k.actor; 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 if (a.mixer) a.mixer.update(dt); // rig idle
else a.tick?.(dt, false); // placeholder idle else a.tick?.(dt, false); // placeholder idle

111
web/js/citizens/postures.js Normal file
View File

@ -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());
}

View File

@ -31,6 +31,11 @@ export const PED_NAMES = {
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ---- // ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character. // mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig'); 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) { function canonRig(r) {
if (!r) return r; if (!r) return r;
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); }); 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. // flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration, const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name))); 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 // 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 // `_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 // 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 // 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 // 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). // 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 = { const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [], 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, ready: false, whenReady: null,
}; };
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array // 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 => { if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
const c = r && r.anims && r.anims[0]; const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } }))); 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.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean); fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.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 mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0]; 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 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; let seatBone = null;
if (act) { if (act) {
act.time = phase * (act.getClip().duration || 0); act.play(); 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; }); 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); } 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 // 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 { 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 bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
const mixer = new THREE.AnimationMixer(inner); const mixer = new THREE.AnimationMixer(inner);
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name))); // [R41 §41.3] one AnimationAction per SOURCE clip, memoised. Pooled actors are recycled across
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name))); // 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 // 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. // 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, // [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. // 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; 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 // [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; 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 sitting = false, looking = false;
let moving = null; // tri-state so the first setMoving always applies 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 // 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); } if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer 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 // 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 // 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 // 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. // lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
function setSitting(s) { // R41: `clip` (optional) is a motion-library sit variant (sitlean.glb) for THIS citizen; omitted /
if (!sitA || s === sitting) return; // 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; sitting = s;
moving = null; // force the next setMoving to re-apply after standing up moving = null; // force the next setMoving to re-apply after standing up
if (s) { 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 if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
fig.userData.procitySeated = true; fig.userData.procitySeated = true;
} else { } 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 inner.position.y = bindPlantY; // restore the standing feet-plant
if (idleA) idleA.setEffectiveWeight(1); if (idleA) idleA.setEffectiveWeight(1);
fig.userData.procitySeated = false; 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 // 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. // 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. // No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
function setLooking(l, fade = 0.3) { // R41: `clip` (optional) is any STANDING posed library clip for this citizen — a lean against a
if (!lookA || l === looking) return; // 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; looking = l;
moving = null; // force the next setMoving to re-apply on the way out moving = null; // force the next setMoving to re-apply on the way out
if (l) { if (l) {
@ -331,8 +489,9 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
else lookA.setEffectiveWeight(1); else lookA.setEffectiveWeight(1);
if (walkA) walkA.setEffectiveWeight(0); if (walkA) walkA.setEffectiveWeight(0);
} else { } else {
if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); } if (idleA) { idleA.reset().play(); if (lookA) lookA.crossFadeTo(idleA, fade, false); }
else lookA.setEffectiveWeight(0); 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 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 (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.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 (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) }; hasClips: !!(walkA && idleA) };
} }

View File

@ -17,6 +17,7 @@ import { pickRig, makeActor } from './rigs.js';
import { makePlaceholder } from './placeholder.js'; import { makePlaceholder } from './placeholder.js';
import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js';
import { snapDoorToFootpath } from './door_snap.js'; import { snapDoorToFootpath } from './door_snap.js';
import { posturesFor, postureSig, GROUP_OF } from './postures.js';
// ---- tuning (CITY_SPEC budgets) ---- // ---- tuning (CITY_SPEC budgets) ----
const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor 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 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 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, // 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. // 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 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; const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0;
return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar }; 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) // stable signature string for one citizen (pedIndex assigned later, once the fleet is known)
export function signatureOf(id, idn, pedIndex) { export function signatureOf(id, idn, pedIndex) {
return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`; 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), 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 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 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; } } 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); this._placeOnLane(c);
@ -271,6 +327,81 @@ export class CitizenSim {
_sig(c) { _sig(c) {
return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex); 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; } activeCitizens() { return this._activeList; }
streamEncountered() { return [...this._encountered].sort(); } streamEncountered() { return [...this._encountered].sort(); }
// hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes // 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), loit: rng(this.citySeed, 'loiter', id),
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron 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 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) // assign a real ped type if the fleet is already up (roster can grow after upgrade)
if (this.mode === 'rig' && this.fleet.ready) { if (this.mode === 'rig' && this.fleet.ready) {
@ -357,7 +495,11 @@ export class CitizenSim {
for (const [key, list] of shopsByChunk) { for (const [key, list] of shopsByChunk) {
snapped.set(key, list.map((s) => { snapped.set(key, list.map((s) => {
const p = snapDoorToFootpath(s.x, s.z, this.edges); 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; 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. // per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works.
setGig(venueShopId, on = true) { setGig(venueShopId, on = true) {
if (venueShopId == null) return; 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); if (on) this._gigVenues.add(venueShopId);
// gig ends (on→off transition) → disperse tonight's roster so the next night starts fresh + it stays // 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 // 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; 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) { _beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out 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'; c.patronTarget = shop; c.patron = 'going';
@ -591,10 +757,43 @@ export class CitizenSim {
c.facing = Math.atan2(-dx / dist, -dz / dist); c.facing = Math.atan2(-dx / dist, -dz / dist);
return; 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 e = this.edges[c.edge];
const adv = c.speed * this._speedMult() * dt; const adv = c.speed * this._speedMult() * dt;
const s0 = c.s;
c.s += adv; 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) { if (c.s >= e.len) {
// arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter // arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter
const node = c.forward > 0 ? e.b : e.a; const node = c.forward > 0 ? e.b : e.a;
@ -633,6 +832,41 @@ export class CitizenSim {
const chance = (shop && this._gigVenues.has(shop.shopId)) const chance = (shop && this._gigVenues.has(shop.shopId))
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance(); ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
if (shop && c.patronRng() < chance) this._beginVisit(c, shop); 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) { if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) {
const a = this.rigPool.acquire(c.pedIndex); const a = this.rigPool.acquire(c.pedIndex);
if (a) { 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.setPhase(c.phase);
a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed
a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose) a.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 // 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), // edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null),
// so every non-sitting ped is byte-identical. // so every non-sitting ped is byte-identical.
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting); // [R41 §41.3] `c.bench` is a real bench-bound sit (the ped was moved onto Lane B's furniture);
if (a.setSitting) a.setSitting(wantSit); // 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. // [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. // 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); // [R41] …and now also leans: same standing-posed-clip seam, this citizen's own lean variant.
if (a.setLooking) a.setLooking(wantLook); const wantLean = !wantSit && !!(c.lean && c.loiter > 0 && a.setLooking);
if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0); 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') { } else if (want === 'mid') {
mid.push(c); mid.push(c);
} }
@ -783,7 +1033,7 @@ export class CitizenSim {
if (a.mixer) { if (a.mixer) {
// [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's // [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`. // 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 { } else {
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder 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++) { for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
const c = extra[(this._mixerCursor + k) % extra.length]; const c = extra[(this._mixerCursor + k) % extra.length];
c.actor.mixer.update(c._acc); c._acc = 0; 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; this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;