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:
parent
422f17263e
commit
78f49f7113
183
D-progress.md
183
D-progress.md
@ -1,5 +1,188 @@
|
||||
# PROCITY-D — progress (Lane D · Citizens)
|
||||
|
||||
## Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07)
|
||||
|
||||
**The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples ×
|
||||
146 active citizens, same seed, same pose, two arms — where `?clips=0` is a new flag that turns the
|
||||
motion library off and **nothing else** (`?classic=1` also changes the ped pool, the fog, the game
|
||||
and half the shell, so it cannot be the control for this):
|
||||
|
||||
| state of the street crowd | `?clips=0` = R40 | default = R41 |
|
||||
|---|---:|---:|
|
||||
| walking | **99.3%** | 78.1% |
|
||||
| sitting on an actual Lane B bench | — | **9.5%** |
|
||||
| leaning on a shopfront | — | **7.1%** |
|
||||
| stopped at a window in their own seeded idle | 0.4% | **5.1%** |
|
||||
| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% |
|
||||
| **distinct animation clips in play across the crowd** | **4** (`walk`/`idle`/`sit`/`look` — and 99.3% of the crowd is on `walk.glb` alone) | **20** |
|
||||
|
||||
Lane E's input was verified before anything was wired: `python3 pipeline/clips_verify.py` → GREEN,
|
||||
0 errors / 0 warnings, 46 clips / 6 groups / 3 498 124 B, every group zero-draw.
|
||||
|
||||
### What each clip category drives
|
||||
|
||||
| group | clips used | drives |
|
||||
|---|---|---|
|
||||
| `idles.glb` (boot) | **10 of 10** | the per-citizen deterministic idle — the resting action of every near-tier actor, plus the seeded shopkeeper idle |
|
||||
| `locomotion.glb` (boot) | 1 of 6 | `walk_shopping_bag` — the walk pool is `['@walk','@walk','walk_shopping_bag']`, so **1 in 3 citizens carries a bag** (measured 33.7% over 1 000 ids) |
|
||||
| `sitlean.glb` (lazy: first sit/lean intent) | **8 of 8** | 4 sits bound to Lane B's benches, 4 leans bound to shopfront walls |
|
||||
| `browse.glb` (lazy: first interior with browsers) | 5 of 8 | a real BROWSE state at Lane C's browse points, seeded per (shopId, slot) |
|
||||
| `venue.glb` (lazy: gig night, from the street) | 5 of 6 | gig-crowd widening + the publican pouring + the record-shop keeper in headphones |
|
||||
| `social.glb` | **0 of 8 — never fetched** | needs a two-person state machine + partner pairing. 896 KB for a state D does not have. → R42 |
|
||||
|
||||
Unused and why: the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node — a turn state
|
||||
is R42 and is the only thing that makes those five mean anything), `browse_hold_turn_l/r` (172°/147°
|
||||
loop seams, one-shot turns), `venue_clap_seated` (no seated venue slot).
|
||||
|
||||
### Boot fetch count and memory — measured in fresh headless contexts
|
||||
|
||||
| | clip fetches | bytes resident | groups / clips |
|
||||
|---|---:|---:|---|
|
||||
| **at boot** | **4** — manifest (16 KB) · `clipbank.js` · `idles.glb` · `locomotion.glb` | **1 242 272 B** | 2 / 16 |
|
||||
| + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 |
|
||||
| + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 |
|
||||
| + first gig night | +1 `venue.glb` | 2 602 476 B | 5 / 38 |
|
||||
| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 |
|
||||
|
||||
Six fetches at boot was what the round warned about. The answer is **two GLBs at boot, three on first
|
||||
demand and one never**, each promise-cached (the bank does not grow across 6 interior enter/exit cycles).
|
||||
**Heap delta (library ON − OFF), forced GC, fresh contexts: +3.34 MB and +6.87 MB across two runs** —
|
||||
the spread is GC timing; the floor is resident clip bytes plus parsed `AnimationClip` overhead.
|
||||
`mixerMs` unchanged: median 0.1 ms both arms.
|
||||
|
||||
### Determinism — same seed, same postures, byte-equal
|
||||
|
||||
- 150 active citizens, **two fresh browser contexts → byte-equal** posture signature.
|
||||
- **CONTROL:** seed 20261991 gives a different signature — "byte-equal" is not "constant".
|
||||
- **CONTROL that matters for lazy loading:** with **every clip GLB delayed 2 s** by a Playwright
|
||||
route, the signature is **identical**. Posture is a pure function of `(citySeed, id)`
|
||||
(`postures.js posturesFor`), decided once at citizen creation and never re-rolled, so loading can
|
||||
change *when* a posture appears, never *which one*.
|
||||
- One level down: `fleet.clipsRequested` is published **synchronously** by `loadPedFleet` before the
|
||||
dynamic import starts, and every roll that can move a citizen is gated on that rather than on
|
||||
"has it landed", so the number of randoms drawn per boot cannot depend on the network. Six
|
||||
freshly-keyed streams (`posture`, `benchstop`, `leanstop`, `browse-pose`, `keeper-pose`,
|
||||
`gig-pose`) collide with none of the 12 pre-R41 keys — gated, with the control that all 18 produce
|
||||
different draws on the same id.
|
||||
- 1 000 citizens × two module instances in node: identical (`sha256 08ecfe041b384460`).
|
||||
|
||||
### Draw table — before / after, same seed, same bookmarks
|
||||
|
||||
| view | `?clips=0` | default | Δ | budget |
|
||||
|---|---:|---:|---:|---|
|
||||
| street_noon | 193 | 193 | **+0** | ≤300 |
|
||||
| crossroads_busy | 108 | 108 | **+0** | ≤300 |
|
||||
| night_crowd | 128 | 128 | **+0** | ≤300 |
|
||||
| market_square | 94 | 94 | **+0** | ≤300 |
|
||||
| night_neon | 111 | 111 | **+0** | ≤300 |
|
||||
| interior (record) | 110 | 110 | **+0** | ≤350 · margin **240** |
|
||||
|
||||
Ruling 4 gave the street no headroom and the round spent none. The clips are skeleton-only (0 tris,
|
||||
0 meshes, 0 materials), so the only mechanism by which they could move a draw count is putting a ped
|
||||
somewhere else — bounded by the unchanged near-cap of 24 rigs, each already one draw. **Stated, not
|
||||
hidden:** 193 is the worst of these five bookmarks on this seed, not the town's global worst (B's
|
||||
R40 street number is 291/300 at a night pose these bookmarks do not reach). The **delta** is the
|
||||
claim, and it is 0.
|
||||
|
||||
### `?noassets=1` and `?classic=1` — the fallback survives, proven
|
||||
|
||||
| | clip fetches | bank | town | console |
|
||||
|---|---:|---|---|---|
|
||||
| `?noassets=1` | **0** (no GLB, no manifest, not even `clipbank.js`) | none | 20 chunks, 165 citizens walking | 0 errors |
|
||||
| `?classic=1` | **0** | none | 18 chunks, 139 citizens walking | 0 errors |
|
||||
| `?clips=0` | **0** | none | full town | 0 errors |
|
||||
|
||||
The library rides `clips = dance`, the R36 `djs = dance` trick again: the shell already passes
|
||||
`dance: !CLASSIC`, so **no `index.html` edit was needed** and the zero-fetch-delta covenant holds —
|
||||
including on the JS surface, because `clipbank.js` is reached by a dynamic `import()` and is not
|
||||
fetched when the gate is off.
|
||||
**One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no
|
||||
manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**:
|
||||
`postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/
|
||||
`band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero
|
||||
asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found.
|
||||
|
||||
|
||||
### Leak
|
||||
|
||||
Warm the caches with one enter/exit, then **6 more cycles into the same shop**: geometries 118 → 118
|
||||
(**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups / 32 clips. The R2 shared-resource
|
||||
disposal contract still holds with more clips resident — `AnimationClip`s hold no GPU resources, and
|
||||
`_disposeInner` still disposes only the clone's own `Skeleton`.
|
||||
|
||||
### The two measurements that changed the design
|
||||
|
||||
1. **The idle pool was invisible.** Wired only to the R17/R29 window-shop loiter — which fires at a
|
||||
graph NODE, and edges are long — a census found **0.8% of citizens stopped at any instant**. Nine
|
||||
of ten new idles were assigned, deterministic, and never seen: the brief's headline failing while
|
||||
the gate went green. Fix: a **window pause** on the R8 patronage stride check (the moment the sim
|
||||
already asks "is there a shop beside me"). Stopped share **0.8% → 5.1%**, and all ten idles now
|
||||
appear in a 12-sample census.
|
||||
2. **The lean was never firing.** Also wired to the node loiter at first — i.e. at intersections,
|
||||
where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved to the
|
||||
same stride check. Now 7.1%.
|
||||
|
||||
### The bench is a real bench
|
||||
|
||||
R17's bench-sit sat a ped down at whatever node it stopped at, upright, on air. R41 binds it to Lane
|
||||
B's actual furniture: `benchStationsFor(edge)` mirrors `furniture.js:203-209` exactly, a ped can only
|
||||
take a bench on its own footpath side, and the 1.6 m plank **seats two** (a measured need — the first
|
||||
soak put two citizens on one station to the centimetre). Because that is a mirror of another lane's
|
||||
file, it is **gated, not trusted**: every derived station inside the streamed window must coincide
|
||||
with real instanced geometry within **2 cm**, yaw included — today **14/14 and 14/14**, against a
|
||||
scene of 2 435 instances. **CONTROL:** the same stations offset 2 m match **0/14**. Filed to Lane B:
|
||||
one `benchStops(plan)` export next to the existing `busShelterStops(plan)` retires the mirror.
|
||||
|
||||
### Gig — widened, not redesigned
|
||||
|
||||
The v3 dance pick (`gigdance` over `fleet.danceClips`) is untouched. A second, independently keyed
|
||||
`gig-pose` roll decides whether a crowd slot takes a venue clip instead — northern soul for a dancer,
|
||||
clap/cheer/headphones for a stander. Measured on a live gig: **8 crowd slots, 6 swapped, 2 keeping
|
||||
their R13 pick verbatim, 0 pending**. A slot that does not swap, and every boot where `venue.glb`
|
||||
is not resident, is byte-identical to R13.
|
||||
|
||||
### Gates added
|
||||
|
||||
| gate | what it proves |
|
||||
|---|---|
|
||||
| `node tools/qa/r41_postures.mjs` | manifest resolution · determinism · stream isolation · pool spread · loop safety. Zero deps, ~40 ms, five controls. |
|
||||
| `tools/.venv/bin/python tools/qa/r41_citizens.py` | 7 arms in fresh contexts: boot ledger · lazy loading · determinism incl. the 2 s forced stall · draw table both arms · `?noassets`/`?classic` · bench binding + control · leak · gig widening. |
|
||||
| `tools/.venv/bin/python tools/qa/r41_shots.py` | the two acceptance shots, camera chosen by measurement and occlusion-raycast, every figure measured for stature. |
|
||||
|
||||
**→ F: three lines for `qa.sh`.** All exit 0/1, all deterministic.
|
||||
|
||||
### Shots
|
||||
|
||||
`docs/shots/laneD/r41_street_postures.jpg` — seed 20261990, midday, camera (11.6, 58.5) yaw −1.890.
|
||||
**8 near-tier rigs in frame, 3 distinct clips** (`@walk`, `walk_shopping_bag`, `sit_hands_thighs`): a
|
||||
hi-vis worker **sitting on a real Lane B bench** with his feet on the footpath, a comical ped walking
|
||||
centre-frame, and six more walkers up and down the strip. 126 draws of a 300 budget.
|
||||
|
||||
`docs/shots/laneD/r41_browse_interior.jpg` — The Op Shop (opshop), 3 browse points. A **browser at
|
||||
the clothes rack on `browse_hold_idle`** — the ped who ducked in off the street, holding something
|
||||
instead of standing to attention — and the keeper behind the counter on `idle_happy_1`, his own
|
||||
seeded idle. 69 draws of a 350 budget.
|
||||
|
||||
Both carry a `.txt` sidecar with the human-sized line: stature measured off the posed skeleton for
|
||||
every figure, as a ratio of that citizen's own nominal height. Today: sits 76–77%, browsers/keepers
|
||||
99–101%, walkers 95–100% of nominal. **No giant (>2.0 m), no fold (<55% of nominal).**
|
||||
|
||||
### Goldens
|
||||
|
||||
`node web/js/citygen/selfcheck.js` → **157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No
|
||||
citygen file was touched.
|
||||
|
||||
### Files touched (only these)
|
||||
|
||||
`web/js/citizens/postures.js` (new) · `web/js/citizens/clipbank.js` (new) · `web/js/citizens/rigs.js`
|
||||
· `web/js/citizens/sim.js` · `web/js/citizens/keepers.js` · `web/js/citizens/band.js` ·
|
||||
`tools/qa/r41_postures.mjs` (new) · `tools/qa/r41_citizens.py` (new) · `tools/qa/r41_shots.py` (new)
|
||||
· `docs/shots/laneD/r41_*.{jpg,txt}` (new) · `D-progress.md` · `docs/LANES/LANE_D_NOTES.md`.
|
||||
**No git command was run.** Nothing under `web/models/`, `web/assets/`, `web/js/world/`,
|
||||
`web/js/interiors/`, `web/index.html` or `tools/qa.sh` was touched.
|
||||
|
||||
---
|
||||
|
||||
## Round 40 (§40.5) — the door points and the footpath: measured, kerb-clamped, gated. (2026-08-04)
|
||||
|
||||
**Verification first, per the brief.** New gate `tools/qa/door_footpath_check.mjs` re-derives every
|
||||
|
||||
@ -6,6 +6,282 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street
|
||||
|
||||
Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb`
|
||||
+ `web/assets/motion_manifest.json` are the input, verified before wiring (`python3
|
||||
pipeline/clips_verify.py` → GREEN 0/0, 46 clips / 6 groups / 3 498 124 B on today's tree).
|
||||
|
||||
### The one number that says what the round did
|
||||
|
||||
A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same
|
||||
pose, two arms:
|
||||
|
||||
| state | `?clips=0` (R40) | default (R41) |
|
||||
|---|---:|---:|
|
||||
| walking | **99.3%** | 78.1% |
|
||||
| bench-sit (on a real bench) | — | **9.5%** |
|
||||
| shopfront lean | — | **7.1%** |
|
||||
| stopped at a window, own seeded idle | 0.4% | **5.1%** |
|
||||
| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% |
|
||||
| distinct clips in play across the crowd | **4** (`walk`/`idle`/`sit`/`look`, 99.3% on `walk.glb`) | **20** |
|
||||
|
||||
`?clips=0` is a new flag in `rigs.js` that turns the motion library off and NOTHING else. It exists
|
||||
because `?classic=1` is a bad control for this — it also changes the ped pool (17 vs 22), the
|
||||
sit/look/dance clips, the fog, the game and half the shell, so a heap or draw delta measured against
|
||||
it says nothing about the library.
|
||||
|
||||
### The files, and the one law that holds them together
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `web/js/citizens/postures.js` | **NEW.** Pure, THREE-free, load-state-free: the clip pools and the seeded pickers. Node imports it directly (the gate does). |
|
||||
| `web/js/citizens/clipbank.js` | **NEW.** The group loader. **Dynamically imported** by rigs.js behind the gate, so `?classic=1` does not fetch the module either. |
|
||||
| `rigs.js` | `loadPedFleet` gains `clips = dance` (the R36 `djs = dance` trick again: the shell already passes `dance: !CLASSIC`, so **no shell edit was needed**). `makeActor` gains per-instance clip swapping; `spawnRig` gains `setClip`. |
|
||||
| `sim.js` · `keepers.js` · `band.js` | the states. |
|
||||
|
||||
**THE LAW: posture is a pure function of (citySeed, id), decided once, never re-rolled.** Lazy
|
||||
loading can change *when* a posture appears but never *which one it is*. That is what makes six
|
||||
groups affordable — and it is gated: arm 3 of `tools/qa/r41_citizens.py` delays **every** clip GLB by
|
||||
2 s with a Playwright route and asserts the posture signature is byte-identical to the unstalled run.
|
||||
|
||||
The same discipline runs one level down. `fleet.clipsRequested` is published **synchronously** by
|
||||
`loadPedFleet` before the dynamic import even starts, and every roll that can move a citizen (bench,
|
||||
lean, pause) is gated on that — never on `fleet.bank`, which only answers "is it resident yet".
|
||||
Otherwise the first second of a boot would draw a different number of randoms than the rest of it and
|
||||
"same seed → same crowd" would depend on the network. Every such roll is also drawn
|
||||
**unconditionally** (the R17/R29 pattern: roll always, act only if allowed), and the POSE falls back
|
||||
to the R2/R16/R29 base clip while the variant is in flight — so a slow fetch changes nothing at all.
|
||||
|
||||
### Clip → state map (29 of the 46 wired, 17 deliberately not)
|
||||
|
||||
| category | group | clips used | drives |
|
||||
|---|---|---|---|
|
||||
| idle | `idles.glb` | all 10 | **per-citizen deterministic idle** (`posturesFor().idle`) — the resting action of every near-tier actor, so it plays on every window pause, and the seeded shopkeeper idle |
|
||||
| locomotion | `locomotion.glb` | `walk_shopping_bag` (1 of 6) | the walk pool is `['@walk','@walk','walk_shopping_bag']` ⇒ **1 in 3 citizens carries a shopping bag**; `@walk` is the R2 `walk.glb` |
|
||||
| sitlean | `sitlean.glb` | all 8 | 4 sits on **Lane B's actual benches**, 4 leans on **shopfront walls** |
|
||||
| browse | `browse.glb` | 5 of 8 | interior browsers at Lane C's browse points, seeded per (shopId, slot) |
|
||||
| venue | `venue.glb` | 5 of 6 | gig crowd widening + `venue_bartending` for pub/rsl/band_room keepers + `venue_headphones` for record-shop keepers |
|
||||
| social | `social.glb` | **0 of 8** | **not loaded, not fetched.** A two-person clip needs a two-person state machine and a partner-pairing pass. 896 KB for a state D does not have. → R42 |
|
||||
|
||||
Not used and why: `browse_hold_turn_l/r` (172°/147° loop seam — one-shot turns with no turn state),
|
||||
the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node; a turn-state machine is R42's
|
||||
job and it is the only thing that makes those five clips mean anything), `venue_clap_seated` (no
|
||||
seated venue slot yet).
|
||||
|
||||
**Loop safety is measured, not assumed.** E ships `loopSeamDeg` per clip; `clipbank.js` puts anything
|
||||
with `loopable:false` **and** a seam > 25° on `THREE.LoopPingPong` instead of repeat. Exactly one pool
|
||||
clip lands there — `browse_pick_up` (92.15°) — and ping-ponging it is why it reads as crate-digging
|
||||
(down, up, down) rather than a teleport back to the start. The other 28 either loop or have a
|
||||
measured seam ≤ 5° (E's `loopable` flag is conservative; the seam is the ground truth).
|
||||
|
||||
### THE BENCH — the state that needed geometry, and how the mirror is kept honest
|
||||
|
||||
R17's bench-sit sat a ped down at whatever graph node it stopped at, upright, on air ("no
|
||||
bench-position binding" — its own comment). R41 binds it. Lane B places benches at
|
||||
`s = 14, 40, 66, …` (step 26 m) on alternating sides of every edge (`furniture.js:203-209`).
|
||||
`sim.js benchStationsFor(edge)` mirrors that rule exactly, and a ped can only take a bench on ITS
|
||||
OWN footpath side: the sim's lane perpendicular is `forward × (uz, −ux)` = `−forward ×` the furniture
|
||||
perpendicular, so the match condition is `side === −forward`.
|
||||
|
||||
The bench's local +Z is its FRONT (B's template puts the backrest at z = −0.2, the seat at y = 0.45)
|
||||
and rig fronts are local −Z after the R13 facing-normalise, so a sitter is `benchYaw + π`, nudged
|
||||
6 cm onto the seat. The plank is 1.6 m — **it seats two**, ±0.38 m on a seeded draw. That was a
|
||||
measured need, not a flourish: the first soak put `-1,-6#3` and `-1,-6#5` on the same station to the
|
||||
centimetre.
|
||||
|
||||
**A mirror that drifts is a ped sitting on air again, so it is gated, not trusted.** Arm 6 of
|
||||
`tools/qa/r41_citizens.py` walks every station the sim derives inside the streamed window and
|
||||
requires real instanced geometry within **2 cm** of it, with the yaw matching mod 2π. Today:
|
||||
**14/14 position, 14/14 yaw**, against a scene holding 2 435 instances. **CONTROL:** the same
|
||||
stations offset 2 m match **0/14** — so "coincides" is a measurement, not a hit on any nearby prop.
|
||||
|
||||
> **→ LANE B, one line, and D deletes the mirror:** please export
|
||||
> `benchStops(plan)` from `web/js/world/furniture.js` next to the existing `busShelterStops(plan)`
|
||||
> (same shape, same reason it exists). D switches to the import and drops `benchStationsFor`
|
||||
> entirely. Until then the gate is the seatbelt.
|
||||
>
|
||||
> **→ LANE B, observed while mirroring, not fixed (your file):** `pushYaw(lists.bench, …, yaw + …)`
|
||||
> with `yaw = atan2(ux, uz)` maps the bench's local **+Z to ALONG the edge**, so the bench's 1.6 m
|
||||
> plank runs ACROSS the footpath and it faces up the street, not the road — the comment on line 203
|
||||
> says "facing the road". The streetlight arm above it (`armYaw`, line 197, "arm reaches over the
|
||||
> road") has the same convention and so has the same question. D's sitters bind to the bench
|
||||
> TRANSFORM, so they sit correctly either way; this is only cosmetic and only yours to rule on.
|
||||
|
||||
### THE SHOPFRONT LEAN — and the measurement that moved it
|
||||
|
||||
First wired to the R17/R29 window-shop loiter, which fires at a graph NODE — i.e. at an
|
||||
intersection, where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved
|
||||
to the R8 patronage stride check (every 10 m walked), which is already the moment the sim asks "is
|
||||
there a shop beside me" — which is exactly when a leaner is beside a wall. Strictly downstream of the
|
||||
duck-in decision, on its own `leanstop` stream.
|
||||
|
||||
The wall anchor uses `shop._raw` — the door point **before** R40's footpath clamp. The clamp exists
|
||||
to drag doors onto the walkable strip (its whole job), but a leaner wants the facade the door was
|
||||
derived from, not the kerb the ped walks on. `_raw` is the shell's `lot + front-normal·(d/2+0.6)`,
|
||||
i.e. 0.6 m in front of the facade; push `LEAN_WALL_BACK` 0.30 m further out along the edge normal and
|
||||
the ped's back is ~0.15 m off the wall, offset `LEAN_SIDE` 1.05 m along the frontage so they are not
|
||||
in the doorway, facing the road.
|
||||
|
||||
### THE WINDOW PAUSE — the measurement that changed the design
|
||||
|
||||
The headline of the round is the per-citizen idle. Wired only to R17/R29's node loiter, a census of
|
||||
the live crowd found **0.8% of citizens stopped at any instant** — edges are long, node arrivals are
|
||||
rare — so nine of the ten new idles were assigned, deterministic, and **never seen**. That is a real
|
||||
failure of the brief dressed up as a green tick.
|
||||
|
||||
Fix, on the same stride check as the lean: a ped who walks past a shop and neither goes in nor leans
|
||||
on it sometimes just **stops and looks at the window**, in their own seeded idle. No reposition, no
|
||||
new clip, no new fetch — the actor's resting action already IS this citizen's idle
|
||||
(`setIdleClip` at acquire). Stopped-citizen share went **0.8% → 5.1%**, and all ten idles now appear
|
||||
in a 12-sample census (`idle_cocky_lean` 1.5/sample down to `idle_breathing` 0.4/sample).
|
||||
|
||||
### Rates, and what they buy (all seeded, all tunable in one place at the top of `sim.js`)
|
||||
|
||||
| constant | value | effect at the retail heart |
|
||||
|---|---:|---|
|
||||
| `BENCH_STOP_FRAC` · `BENCH_DWELL` | 0.40 · 9–20 s | 9.5% of the crowd seated |
|
||||
| `LEAN_FRAC` · `LEAN_DWELL` | 0.12 · 6–14 s | 7.1% leaning |
|
||||
| `PAUSE_FRAC` · `PAUSE_DWELL` | 0.17 · 4–9 s | 5.1% stopped in their own idle |
|
||||
|
||||
### The pooled-actor problem, and why `makeActor` grew four methods
|
||||
|
||||
Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen
|
||||
idle cannot be baked in at construction — a recycled actor would carry the previous citizen's
|
||||
posture. `makeActor` now memoises one `AnimationAction` per SOURCE clip and re-points four live slots
|
||||
(`walkA`/`idleA`/`sitA`/`lookA`) on acquire, ≤3 per frame (`NEW_RIG_PER_FRAME`). three.js shares the
|
||||
`PropertyMixer` bindings per (root, track) across every action on the same root, so the marginal cost
|
||||
of an extra action is its interpolants, not another copy of the skeleton. `setSitting(s, clip)` and
|
||||
`setLooking(l, fade, clip)` gained an optional clip and keep their exact R16/R29 semantics with it
|
||||
omitted; `setIdleClip(null)` / `setWalkClip(null)` restore the base actions, which is precisely what a
|
||||
citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte.
|
||||
|
||||
`spawnRig` (single-clip figures: keepers, browsers, gig) gained `setClip`, the **self-heal seam**: a
|
||||
figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group
|
||||
lands. Its re-plant generalises R29's lesson — `_rotOnly` drops the Hips POSITION track, so a clip's
|
||||
authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's
|
||||
original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip
|
||||
whose lowest bone travels, so `setClip` samples the posed skeleton at 6 points across the clip and
|
||||
plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own
|
||||
authored range rather than an arbitrary phase's error.
|
||||
|
||||
### Memory and fetch — the ledger, measured in fresh headless contexts
|
||||
|
||||
| | fetches | bytes resident | groups / clips |
|
||||
|---|---:|---:|---|
|
||||
| at boot | **4** (manifest 16 KB · `clipbank.js` · `idles.glb` · `locomotion.glb`) | **1 242 272 B** | 2 / 16 |
|
||||
| + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 |
|
||||
| + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 |
|
||||
| + first gig night (from the street, `setGig`) | +1 `venue.glb` | 2 602 476 B | 5 / 38 |
|
||||
| ever | **never** `social.glb` | — | — |
|
||||
| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 |
|
||||
|
||||
**One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no
|
||||
manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**:
|
||||
`postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/
|
||||
`band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero
|
||||
asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found.
|
||||
|
||||
|
||||
Six fetches at boot was the thing the round warned about; the answer is **two GLBs at boot, three
|
||||
on first demand and one never**, and each lazy group is promise-cached so N racing callers cost one fetch (proven:
|
||||
the bank does not grow across 6 interior enter/exit cycles). **Heap delta (library ON − OFF),
|
||||
`performance.memory` after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs** —
|
||||
the spread is GC timing, the floor is the resident clip bytes plus parsed `AnimationClip` overhead.
|
||||
`mixerMs` is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms.
|
||||
|
||||
### Zero draw — before/after, same seed, same bookmarks
|
||||
|
||||
| view | `?clips=0` | default | Δ | budget |
|
||||
|---|---:|---:|---:|---|
|
||||
| street_noon | 193 | 193 | **+0** | ≤300 |
|
||||
| crossroads_busy | 108 | 108 | **+0** | ≤300 |
|
||||
| night_crowd | 128 | 128 | **+0** | ≤300 |
|
||||
| market_square | 94 | 94 | **+0** | ≤300 |
|
||||
| night_neon | 111 | 111 | **+0** | ≤300 |
|
||||
| interior (record) | 110 | 110 | **+0** | ≤350, margin **240** |
|
||||
|
||||
Worst street view over the five bookmarks: **193 both arms**. Ruling 4 gave this round no street
|
||||
headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0,
|
||||
materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw
|
||||
count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap
|
||||
of 24 rigs, each of which is already one draw. **Caveat stated rather than hidden:** 193 is the worst
|
||||
of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law
|
||||
is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0.
|
||||
|
||||
### Leak
|
||||
|
||||
`tools/qa/r41_citizens.py` arm 6b: warm the caches with one enter/exit, then **6 more cycles into the
|
||||
same shop** — geometries 118 → 118 (**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups /
|
||||
32 clips. The R2 shared-resource disposal contract still holds with more clips resident:
|
||||
`AnimationClip`s hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and
|
||||
`_disposeInner` still disposes only the clone's own `Skeleton`. Warm-first is deliberate — counting
|
||||
cycle 1 measures cold start, not retention, and doing so is what made this arm read `+10` once.
|
||||
|
||||
### Gates added (both new files, no collision)
|
||||
|
||||
- `node tools/qa/r41_postures.mjs` — zero deps, ~40 ms. Manifest resolution (every pool id resolves
|
||||
with the right category and group, **CONTROL:** a bogus id fails the same resolver) · determinism
|
||||
over 1 000 citizens in two module instances (**CONTROL:** a different seed differs) · stream
|
||||
isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; **CONTROL:** all 18 streams
|
||||
produce different draws on the same id) · pool spread (all 10 idles, 90–110 each per 1 000) · loop
|
||||
safety (**CONTROL:** the ping-pong class is non-empty).
|
||||
- `tools/.venv/bin/python tools/qa/r41_citizens.py` — 7 arms in fresh headless contexts against a
|
||||
no-store server: boot ledger · lazy loading · determinism incl. the **2 s forced-stall** control ·
|
||||
draw table both arms · `?noassets=1` + `?classic=1` · bench binding + control · leak · gig widening.
|
||||
- `tools/.venv/bin/python tools/qa/r41_shots.py` — the two acceptance shots, camera chosen by
|
||||
measurement (bench station with the most shop doors around it, occlusion-raycast, then held while
|
||||
the crowd fills in) and every figure measured for stature.
|
||||
|
||||
**→ F: three lines for `qa.sh`.** All three exit 0/1 and are deterministic.
|
||||
|
||||
### The stature law, and why the first version of it was wrong
|
||||
|
||||
The shot harness first checked an absolute seated band `[0.9, 1.5]` m (R16's, written for the
|
||||
drummer on `sit.glb`) and called a 1.32 m leaner a failure. It is not one: the library's four wall
|
||||
leans take 15–25% off a standing crown **by construction** (you are leaning back), and its four sits
|
||||
run from bolt-upright-in-a-chair to slumped. A fixed band is the wrong instrument. What R10 forbids
|
||||
is a GIANT and what R16 forbids is a FOLD (keeping `Hips.quaternion` lays the body flat, head at hip
|
||||
height). So the gate now reports stature with its ratio to that citizen's own nominal height and
|
||||
fails on **stature > 2.0 m**, **stature < 55% of nominal**, or a nominal height outside the seeded
|
||||
[1.4, 2.0]. Measured today across both shots: leans 93%, sits 75–86%, browsers/keepers 93–101%,
|
||||
walkers 96–99% of nominal. No giant, no fold.
|
||||
|
||||
### Shots
|
||||
|
||||
- `docs/shots/laneD/r41_street_postures.jpg` (+ `.txt` sidecar with the human-sized line) — seed
|
||||
20261990, camera (11.6, 58.5) yaw −1.890. **8 near-tier rigs, 3 distinct clips**: a hi-vis worker
|
||||
seated on a real Lane B bench, a comical ped walking centre-frame, six more walkers (a third of
|
||||
them on `walk_shopping_bag`). 126 draws / 300.
|
||||
- `docs/shots/laneD/r41_browse_interior.jpg` (+ `.txt`) — The Op Shop, 3 browse points: a browser at
|
||||
the clothes rack on `browse_hold_idle` and the keeper at the counter on `idle_happy_1`. 69/350.
|
||||
|
||||
Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported
|
||||
3–6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. `r41_shots.py` now
|
||||
ray-tests every candidate figure from the lens and counts only the ones that can actually be seen,
|
||||
and raycasts the camera pose itself (an unchecked "best score" pose put the lens inside a building
|
||||
twice — high score, grey wall).
|
||||
|
||||
### Goldens
|
||||
|
||||
`node web/js/citygen/selfcheck.js` → **157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No citygen
|
||||
file was touched.
|
||||
|
||||
### Filed to R42
|
||||
|
||||
1. **The turn state.** `turn_left_90` / `turn_right_90` / `turn_walk_180` / `turn_in_place` /
|
||||
`walk_to_stand` are shipped and unused because the sim turns instantly at a node. A short turn
|
||||
state at `_advance`'s node branch (hold the ped for the clip's duration, blend the heading) makes
|
||||
five clips real and would kill the remaining tell that these are waypoint walkers.
|
||||
2. **`social.glb`.** 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same
|
||||
loiter within N metres take `social_shake_1`/`_2` (E flagged the pair) or the conversation loop
|
||||
facing each other. The manifest's `pair` field is the whole contract.
|
||||
3. **`benchStops(plan)`** from Lane B (above) retires D's mirror.
|
||||
4. **`browse_hold_walk` / `browse_hold_turn_*`** want a browser who walks the shop floor rather than
|
||||
standing at a fixed point — Lane C's browse points would need a small path, not a pose.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate
|
||||
|
||||
Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want.
|
||||
|
||||
BIN
docs/shots/laneD/r41_browse_interior.jpg
Normal file
BIN
docs/shots/laneD/r41_browse_interior.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
5
docs/shots/laneD/r41_browse_interior.txt
Normal file
5
docs/shots/laneD/r41_browse_interior.txt
Normal 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).
|
||||
BIN
docs/shots/laneD/r41_street_postures.jpg
Normal file
BIN
docs/shots/laneD/r41_street_postures.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
6
docs/shots/laneD/r41_street_postures.txt
Normal file
6
docs/shots/laneD/r41_street_postures.txt
Normal 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
419
tools/qa/r41_citizens.py
Normal 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
151
tools/qa/r41_postures.mjs
Normal 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
410
tools/qa/r41_shots.py
Normal 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 6–20 s dwells, so the honest way to shoot the busy moment is to stand still until it
|
||||
# happens. Chasing subjects with a moving camera was tried first and lost them to
|
||||
# rig-reacquisition every time.
|
||||
place = pg.evaluate(PLACE)
|
||||
print(f"place: {place.get('stations', 0)} bench stations near shop doors; "
|
||||
f"{len(place.get('cands', []))} clear camera poses")
|
||||
best, m = None, None
|
||||
for cand in place.get('cands', [])[:4]:
|
||||
for _ in range(2):
|
||||
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
|
||||
pg.wait_for_timeout(2500)
|
||||
for _ in range(14): # ~35 s of holding this pose
|
||||
fr = pg.evaluate(FRAME)
|
||||
if fr['n'] and (not best or fr['score'] > best['score']):
|
||||
mm = pg.evaluate(MEASURE)
|
||||
pg.screenshot(path=str(OUTDIR / 'r41_street_postures.jpg'), type='jpeg', quality=90)
|
||||
best, m = dict(fr, **{k: cand[k] for k in ('px', 'pz', 'yaw')}), mm
|
||||
print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {fr['score']} n={fr['n']} "
|
||||
f"clips={fr['clips']} posed={fr['posed']} states={fr['states']}")
|
||||
if best and best['posed'] >= 2 and best['clips'] >= 4: break
|
||||
pg.wait_for_timeout(2500)
|
||||
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
|
||||
if best and best['posed'] >= 2 and best['clips'] >= 4: break
|
||||
if not best:
|
||||
print(' never framed anybody'); rc = 1
|
||||
else:
|
||||
line, ok = human_line(m['rows'])
|
||||
name = 'r41_street_postures.jpg'
|
||||
states = {}
|
||||
for r in m['rows']: states[r['state']] = states.get(r['state'], 0) + 1
|
||||
cap = (f"PROCITY R41 §41.3 — {name}\n"
|
||||
f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) yaw {best['yaw']:.3f}\n"
|
||||
f"{len(m['rows'])} near-tier rigs in frame · states {states} · "
|
||||
f"{len(set(r['clip'] for r in m['rows']))} distinct clips: "
|
||||
f"{', '.join(sorted(set(r['clip'] for r in m['rows'])))}\n"
|
||||
f"draws {m['info']['drawCalls']} / budget {m['info']['budget']['draws']} · tris {m['info']['tris']}\n"
|
||||
f"clips resident: {m['clips']['groups']} groups / {m['clips']['clips']} clips / {m['clips']['bytes']} B\n"
|
||||
f"{line}\n")
|
||||
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
|
||||
print(cap)
|
||||
if not ok: rc = 1
|
||||
|
||||
# ── shot 2: a browsing interior ───────────────────────────────────────────────────────
|
||||
info = pg.evaluate(INTERIOR)
|
||||
print('interior:', json.dumps(info))
|
||||
if not info.get('ok'):
|
||||
print(' skipped interior shot'); rc = 1
|
||||
else:
|
||||
fr = pg.evaluate(INT_FRAME)
|
||||
print('frame:', json.dumps(fr))
|
||||
if fr.get('ok'):
|
||||
im = pg.evaluate(INT_MEASURE)
|
||||
browsers = [r for r in im['rows'] if r['kind'] == 'browser']
|
||||
line, ok = human_line(im['rows'])
|
||||
name = 'r41_browse_interior.jpg'
|
||||
pg.screenshot(path=str(OUTDIR / name), type='jpeg', quality=90)
|
||||
cap = (f"PROCITY R41 §41.3 — {name}\n"
|
||||
f"seed {SEED} · {info['shop']['name']} ({info['shop']['type']}) · "
|
||||
f"{info['shop']['count']} patronage occupants · {info['browsePoints']} browse points\n"
|
||||
f"figures: {json.dumps(im['rows'])}\n"
|
||||
f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n"
|
||||
f"{line}\n")
|
||||
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
|
||||
print(cap)
|
||||
if not ok or not browsers: rc = 1
|
||||
else:
|
||||
rc = 1
|
||||
if errs:
|
||||
print('CONSOLE ERRORS:', errs[:6]); rc = 1
|
||||
b.close()
|
||||
finally:
|
||||
srv.terminate()
|
||||
print('\033[32m● shots written\033[0m' if rc == 0 else '\033[31m● problems — see above\033[0m')
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@ -19,6 +19,7 @@ import { rng, shuffle } from '../core/prng.js';
|
||||
import { pickRig, spawnRig, seatedLean } from './rigs.js';
|
||||
import { makePlaceholder } from './placeholder.js';
|
||||
import { loadGLB } from '../core/loaders.js';
|
||||
import { gigPostureFor, GROUP_OF } from './postures.js';
|
||||
|
||||
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
|
||||
// holds — F's smoke asserts it). RSL fields the most (8–12 watch points).
|
||||
@ -138,10 +139,19 @@ export class GigCrew {
|
||||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
|
||||
// [R41 §41.3] `venue` is the WIDENING seam, and it is deliberately narrow: the v3 dance pick below
|
||||
// (the `gigdance` stream over fleet.danceClips) is untouched, and gigPostureFor draws from its own
|
||||
// freshly-keyed `gig-pose` stream to decide whether THIS slot takes a venue.glb clip instead —
|
||||
// northern soul for a dancer, clap/cheer/headphones for a stander. Returns null for the rest, and
|
||||
// for every boot where venue.glb isn't resident, so the R13/R14 crowd survives byte-identical
|
||||
// wherever the swap doesn't fire. Not a redesign: no new member kinds, no new poses, no new draws.
|
||||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false, crowd = false }) {
|
||||
const r = rng(this.citySeed, 'gig', key);
|
||||
const h = height != null ? height : 1.6 + r() * 0.3;
|
||||
let actor, kind, usedIndex = null;
|
||||
const bank = this.fleet && this.fleet.bank;
|
||||
const venueId = crowd ? gigPostureFor(this.citySeed, key, !!dance) : null;
|
||||
if (bank && venueId) bank.ensureGroup(GROUP_OF[venueId]);
|
||||
let actor, kind, usedIndex = null, ph = 0;
|
||||
if (this.fleet && this.fleet.ready) {
|
||||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||||
const rig = pk && this.fleet.all[pk.index];
|
||||
@ -149,13 +159,20 @@ export class GigCrew {
|
||||
// dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
|
||||
const dcs = this.fleet.danceClips || [];
|
||||
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
|
||||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
|
||||
const venueClip = (!useSit && bank && venueId) ? bank.get(venueId) : null;
|
||||
// `phase: (ph = r())` keeps R12's draw ORDER and its short-circuit exactly: the phase is drawn
|
||||
// only when a rig actually resolved, so no crowd slot's identity moves under this round's edit.
|
||||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (venueClip || danceClip || this.fleet.idleClip), phase: (ph = r()), seated: useSit });
|
||||
if (sp && venueClip) sp.setClip(venueClip, { phase: ph }); // re-plant off the posed skeleton
|
||||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
||||
}
|
||||
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
||||
actor.fig.position.set(x, y, z);
|
||||
target.add(actor.fig);
|
||||
return { actor, kind, pedIndex: usedIndex };
|
||||
// `want` non-null only while the assigned venue clip is still in flight — update() installs it once.
|
||||
const wantsIt = kind === 'rig' && !seated && venueId && this.fleet && this.fleet.clipsRequested
|
||||
&& !(bank && bank.has(venueId));
|
||||
return { actor, kind, pedIndex: usedIndex, clipPhase: ph, venueId, want: wantsIt ? venueId : null };
|
||||
}
|
||||
|
||||
// spawn(roomGroup, { stage, watchPoints, gig, roster }) — the band on the deck + crowd at the watch points.
|
||||
@ -202,11 +219,12 @@ export class GigCrew {
|
||||
let fromRoster = 0;
|
||||
pts.forEach((w, i) => {
|
||||
const who = roster[i] || null;
|
||||
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance }
|
||||
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
|
||||
const { actor, kind, pedIndex, want, venueId, clipPhase } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance, crowd: true }
|
||||
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance, crowd: true });
|
||||
if (who) fromRoster++;
|
||||
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
||||
want, venueId, clipPhase, // R41: the venue-clip widening (null ⇒ this slot is R13 verbatim)
|
||||
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
||||
});
|
||||
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster };
|
||||
@ -271,7 +289,16 @@ export class GigCrew {
|
||||
update(dt) {
|
||||
this.t += dt;
|
||||
const t = this.t;
|
||||
const bank = this.fleet && this.fleet.bank;
|
||||
for (const m of this.members) {
|
||||
// [R41 §41.3] self-heal: venue.glb landed after this crowd spawned → install the assigned clip
|
||||
// once, in place. Which clip was assigned was decided at spawn from (citySeed, slot); this only
|
||||
// decides when it starts, so a slow fetch cannot change the crowd, only delay it.
|
||||
if (m.want && bank && m.actor.setClip) {
|
||||
const c = bank.get(m.want);
|
||||
if (c) { m.actor.setClip(c, { phase: m.clipPhase || 0 }); m.want = null; }
|
||||
else bank.ensureGroup(GROUP_OF[m.want]); // spawned before the bank existed → kick it now
|
||||
}
|
||||
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
|
||||
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
|
||||
if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix)
|
||||
|
||||
130
web/js/citizens/clipbank.js
Normal file
130
web/js/citizens/clipbank.js
Normal 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.
|
||||
@ -11,6 +11,7 @@ import * as THREE from 'three';
|
||||
import { rng } from '../core/prng.js';
|
||||
import { pickRig, spawnRig } from './rigs.js';
|
||||
import { makePlaceholder } from './placeholder.js';
|
||||
import { browsePostureFor, keeperPostureFor, GROUP_OF } from './postures.js';
|
||||
|
||||
const GREET_RANGE = 6.0; // m — start turning to the player inside this
|
||||
const GREET_CLAMP = 0.62; // rad — max turn off the counter-facing base (~35°)
|
||||
@ -30,14 +31,32 @@ export class KeeperManager {
|
||||
// browse = R9 interior-presence: a browser rig at a C browse point — faces the goods, no greet.
|
||||
// pedIndex = pick this exact fleet ped (so a browser IS the ped who ducked in off the street).
|
||||
// seedKey = per-instance seeding key (e.g. `${shopId}#${slot}`) so browsers at one shop differ.
|
||||
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null } = {}) {
|
||||
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null, slot = null } = {}) {
|
||||
const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId);
|
||||
const height = 1.58 + r() * 0.34;
|
||||
let actor, kind;
|
||||
// [R41 §41.3] A REAL BROWSE STATE. Until now a "browser" was a rig standing at Lane C's browse
|
||||
// point playing the shopkeeper's idle. It now plays a browse clip — hold / carry / open the
|
||||
// cabinet / pick the record up — seeded per (shopId, slot), NOT per visit, so the same shop reads
|
||||
// the same way every time you walk back in. F already passes `seedKey = "<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) {
|
||||
// NB the `r()` draw order below is R9's, unchanged and deliberately so: height, then pickRig
|
||||
// (only when the caller did NOT pin a pedIndex), then phase (only when a rig resolved). The
|
||||
// R41 posture is drawn from its OWN stream above, so no keeper's ped type or height moves.
|
||||
const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index;
|
||||
const rig = idx != null && this.fleet.all[idx];
|
||||
const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() });
|
||||
const want = bank ? bank.get(wantId) : null; // resident already? use it at spawn
|
||||
const spawned = rig && spawnRig(rig, { ry, height, clip: want || this.fleet.idleClip, phase: (phase = r()) });
|
||||
if (spawned && want) spawned.setClip(want, { phase }); // re-plant off the posed skeleton
|
||||
if (spawned) { actor = spawned; kind = 'rig'; }
|
||||
}
|
||||
if (!actor) { // asset-free fallback
|
||||
@ -47,7 +66,16 @@ export class KeeperManager {
|
||||
}
|
||||
actor.fig.position.set(x, 0, z);
|
||||
target.add(actor.fig);
|
||||
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse };
|
||||
// `want` is kept ONLY while the assigned clip is still unresolved — update() upgrades in place the
|
||||
// frame its group lands and then clears it. That is what makes lazy loading safe here: the clip a
|
||||
// browse point plays is decided by (citySeed, shopId, slot) at spawn, and the fetch only decides
|
||||
// when it starts, never which. A boot with no bank leaves `want` null and nothing ever runs.
|
||||
// Gated on `fleet.clipsRequested` (boot-stable), NOT on `bank` — a spawn that happens before the
|
||||
// dynamic import resolves would otherwise silently lose its posture forever. update() kicks the
|
||||
// group itself in that case, so the only thing timing decides is when the upgrade shows.
|
||||
const wantsIt = kind === 'rig' && wantId && this.fleet && this.fleet.clipsRequested && !(bank && bank.has(wantId));
|
||||
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse,
|
||||
want: wantsIt ? wantId : null, phase };
|
||||
this.keepers.push(k);
|
||||
return k;
|
||||
}
|
||||
@ -66,8 +94,15 @@ export class KeeperManager {
|
||||
// the camera position (the player IS the camera in interior mode).
|
||||
update(dt, playerPos = null) {
|
||||
const pp = playerPos || (this.camera && this.camera.position) || null;
|
||||
const bank = this.fleet && this.fleet.bank;
|
||||
for (const k of this.keepers) {
|
||||
const a = k.actor;
|
||||
// [R41 §41.3] self-heal: the assigned clip's group finished fetching → install it now, once.
|
||||
if (k.want && bank && a.setClip) {
|
||||
const c = bank.get(k.want);
|
||||
if (c) { a.setClip(c, { phase: k.phase }); k.want = null; }
|
||||
else bank.ensureGroup(GROUP_OF[k.want]); // spawned before the bank existed → kick it now
|
||||
}
|
||||
if (a.mixer) a.mixer.update(dt); // rig idle
|
||||
else a.tick?.(dt, false); // placeholder idle
|
||||
|
||||
|
||||
111
web/js/citizens/postures.js
Normal file
111
web/js/citizens/postures.js
Normal 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());
|
||||
}
|
||||
@ -31,6 +31,11 @@ export const PED_NAMES = {
|
||||
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
|
||||
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
|
||||
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig');
|
||||
// [R41 §41.3] exported so clipbank.js runs Lane E's 46 library clips through the IDENTICAL two-step
|
||||
// (`_canon` then `_rotOnly`) that walk/idle/sit/look/dance have ridden since R2 — one code path, so a
|
||||
// library clip cannot diverge from a base clip in how it binds. E measured 65 raw tracks → 64 after
|
||||
// the filter on all 46; clipbank.js re-asserts non-empty per clip at load.
|
||||
export const canonName = _canon;
|
||||
function canonRig(r) {
|
||||
if (!r) return r;
|
||||
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); });
|
||||
@ -42,6 +47,7 @@ function canonRig(r) {
|
||||
// flat). The character keeps its own upright bind root; the game translates walkers itself.
|
||||
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
|
||||
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
|
||||
export const rotOnlyClip = _rotOnly; // [R41 §41.3] same filter for the library clips — see canonName
|
||||
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
|
||||
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
|
||||
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
|
||||
@ -66,10 +72,29 @@ function loadRig(ref) {
|
||||
// the bodies ride the same `!CLASSIC` gate as the sit/look/dance clips: `?classic=1` fetches none of the
|
||||
// five and keeps the covenanted 17-pool; every other boot gets all 22. Separable for callers that want
|
||||
// dance clips without the bodies (or vice versa).
|
||||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance } = {}) {
|
||||
// `opts.clips` (R41 §41.3): Lane E's 46-clip MOTION LIBRARY (web/models/clips/*.glb +
|
||||
// web/assets/motion_manifest.json). DEFAULTS TO `dance` for exactly the reason `djs` does — the shell
|
||||
// already passes `dance: !CLASSIC` (index.html:232), so the classic-ness signal arrives with ZERO
|
||||
// shell edits and the library rides the same gate as the sit/look/dance clips. `?classic=1` ⇒ no
|
||||
// manifest fetch, no clip-GLB fetch, and (because the module is reached by dynamic `import()`) not
|
||||
// even a clipbank.js fetch: the zero-fetch-delta covenant is untouched. `?noassets=1` never calls
|
||||
// loadPedFleet at all. On, the bank fetches THREE things at boot — the 16 KB manifest and the two
|
||||
// eager groups (postures.js BOOT_GROUPS: idles.glb 1 029 496 B + locomotion.glb 212 776 B) — and
|
||||
// everything else lazily on first demand. `fleet.ready` deliberately does NOT depend on the bank: a
|
||||
// dead manifest or a 404 group leaves the R2 8-clip fleet running exactly as it does today.
|
||||
// `?clips=0` is the CONTROL ARM, and it exists because `?classic=1` is a bad control for this: it
|
||||
// changes the ped pool, the sit/look/dance clips, the fog, the game and half the shell at once, so a
|
||||
// draw or heap delta measured against it says nothing about the motion library specifically. This
|
||||
// flag turns off the library and NOTHING else. Read the same way furniture.js reads ?noassets.
|
||||
const CLIPS_OFF = (() => { try { return new URLSearchParams(location.search).get('clips') === '0'; } catch { return false; } })();
|
||||
|
||||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance,
|
||||
clips = dance, clipBase = null, manifestUrl = null, clipGroups = null } = {}) {
|
||||
if (CLIPS_OFF) clips = false;
|
||||
const fleet = {
|
||||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||||
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
|
||||
bank: null, // R41 ClipBank | null (null ⇒ every consumer takes its base clip)
|
||||
ready: false, whenReady: null,
|
||||
};
|
||||
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
||||
@ -111,6 +136,24 @@ export function loadPedFleet(base = 'models/peds/', { sit = false, look = false,
|
||||
if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
|
||||
const c = r && r.anims && r.anims[0];
|
||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } })));
|
||||
// [R41 §41.3] the motion library. Dynamic import ⇒ clipbank.js is not even fetched when the gate is
|
||||
// off. Fail-soft twice over: the import and the boot both swallow, so `fleet.bank` stays null and
|
||||
// every downstream `bank && bank.get(...)` falls straight through to the base clip.
|
||||
// Published SYNCHRONOUSLY, before the import even starts: consumers that must make a STREAM-
|
||||
// AFFECTING decision (the sim's bench/lean/pause rolls) need a boot-stable answer to "is the
|
||||
// library on for this boot", not "has it landed yet" — otherwise the first second of a boot would
|
||||
// draw a different number of randoms than the rest of it, and 'same seed → same crowd' would
|
||||
// depend on network timing. `fleet.bank` stays the RESIDENCY question; this is the GATE question.
|
||||
fleet.clipsRequested = !!clips;
|
||||
if (clips) jobs.push(
|
||||
Promise.all([import('./clipbank.js'), import('./postures.js')]).then(([cb, po]) => {
|
||||
const bank = new cb.ClipBank({
|
||||
clipBase: clipBase || base.replace(/peds\/?$/, 'clips/'),
|
||||
manifestUrl: manifestUrl || base.replace(/models\/peds\/?$/, 'assets/motion_manifest.json'),
|
||||
});
|
||||
fleet.bank = bank; // published immediately: lazy groups can be
|
||||
return bank.boot(clipGroups || po.BOOT_GROUPS); // kicked before the boot groups land
|
||||
}).catch((e) => { console.warn('[rigs] motion library unavailable, base clips stay:', e && e.message); }));
|
||||
fleet.whenReady = Promise.all(jobs).then(() => {
|
||||
fleet.normal = nSlots.filter(Boolean);
|
||||
fleet.comical = cSlots.filter(Boolean);
|
||||
@ -219,6 +262,8 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s
|
||||
const mixer = new THREE.AnimationMixer(inner);
|
||||
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
|
||||
const act = _action(mixer, inner, chosen);
|
||||
const bindPlantY = inner.position.y; // R41: the STANDING feet-plant, to restore before any re-plant
|
||||
let curAct = act;
|
||||
let seatBone = null;
|
||||
if (act) {
|
||||
act.time = phase * (act.getClip().duration || 0); act.play();
|
||||
@ -238,8 +283,56 @@ export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, s
|
||||
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
}
|
||||
}
|
||||
// [R41 §41.3] setClip — swap this single-clip figure onto a motion-library clip AFTER it was built.
|
||||
// This is the self-healing seam: a keeper / browser / gig member is posed with whatever clip exists
|
||||
// at spawn (the R2 idle in the worst case) and upgrades in place the frame its lazily-fetched group
|
||||
// lands, so WHICH clip it was assigned never depends on load timing — only how soon it shows.
|
||||
//
|
||||
// The re-plant is the R29 lesson generalised. `_rotOnly` drops the Hips POSITION track (it must —
|
||||
// a foreign-scale source would inflate the rig), so a clip's authored vertical motion comes out as
|
||||
// the FEET moving instead of the hips: measured up to +0.205 m on look.glb. spawnRig's original
|
||||
// seated re-plant samples ONCE at t=0, which is right for a fixed pose and wrong for a clip whose
|
||||
// lowest bone travels. So this samples the posed skeleton at PLANT_SAMPLES points across the clip
|
||||
// and plants the MINIMUM — the soles can then never sink through the floor, and the residual float
|
||||
// is the clip's own authored range, not an arbitrary phase's error. Absolute (reset → measure →
|
||||
// correct), never incremental, and scale-aware via fig.scale.y.
|
||||
const PLANT_SAMPLES = 6;
|
||||
function _plant() {
|
||||
inner.position.y = bindPlantY;
|
||||
const clipDur = (curAct && curAct.getClip().duration) || 0;
|
||||
const t0 = curAct ? curAct.time : 0;
|
||||
let lo = Infinity;
|
||||
for (let i = 0; i < PLANT_SAMPLES; i++) {
|
||||
if (curAct) curAct.time = clipDur * (i / PLANT_SAMPLES);
|
||||
mixer.update(0);
|
||||
inner.updateWorldMatrix(true, true);
|
||||
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); if (_wp.y < lo) lo = _wp.y; } });
|
||||
if (clipDur <= 0) break;
|
||||
}
|
||||
if (curAct) { curAct.time = t0; mixer.update(0); }
|
||||
if (!isFinite(lo)) return;
|
||||
const S = fig.scale.y || 1;
|
||||
inner.position.y -= (lo - fig.position.y) / S;
|
||||
}
|
||||
function setClip(next, { seated: st = seated, phase: ph = phase } = {}) {
|
||||
if (!next) return false;
|
||||
const na = _action(mixer, inner, next);
|
||||
if (!na) return false;
|
||||
if (curAct && curAct !== na) { curAct.stop(); curAct.setEffectiveWeight(0); }
|
||||
if (next._pcLoop === 'pingpong') na.setLoop(THREE.LoopPingPong, Infinity);
|
||||
curAct = na;
|
||||
na.reset().play().setEffectiveWeight(1);
|
||||
na.time = ph * (next.duration || 0);
|
||||
_plant();
|
||||
if (st) {
|
||||
fig.userData.procitySeated = true;
|
||||
seatBone = null;
|
||||
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||||
return { fig, inner, mixer, height, head, seatBone, dispose };
|
||||
return { fig, inner, mixer, height, head, get seatBone() { return seatBone; }, setClip, dispose };
|
||||
}
|
||||
|
||||
// R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets
|
||||
@ -258,20 +351,48 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
|
||||
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
|
||||
const mixer = new THREE.AnimationMixer(inner);
|
||||
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
|
||||
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name)));
|
||||
// [R41 §41.3] one AnimationAction per SOURCE clip, memoised. Pooled actors are recycled across
|
||||
// citizens of the same ped type, so the per-citizen idle/walk cannot be baked in at construction —
|
||||
// it is swapped on acquire (≤3/frame, NEW_RIG_PER_FRAME). Memoising by source clip means a pool
|
||||
// slot that has seen five different idles pays five bindings, not five per acquire; three.js shares
|
||||
// the PropertyMixer bindings per (root, track) across all of them, so the marginal cost of an extra
|
||||
// action is its interpolants, not another copy of the skeleton.
|
||||
const _acts = new Map();
|
||||
function _act(clip) {
|
||||
if (!clip) return null;
|
||||
let a = _acts.get(clip);
|
||||
if (a === undefined) {
|
||||
a = _action(mixer, inner, clip);
|
||||
// library clips whose measured loop seam does not close play ping-pong instead of popping
|
||||
// (clipbank.js LOOP_SEAM_DEG) — `browse_pick_up` becomes crate-digging rather than a teleport.
|
||||
if (a && clip._pcLoop === 'pingpong') a.setLoop(THREE.LoopPingPong, Infinity);
|
||||
_acts.set(clip, a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const baseWalkA = _act(walkClip || rig.anims.find(c => /walk/i.test(c.name)));
|
||||
const baseIdleA = _act(idleClip || rig.anims.find(c => /idle/i.test(c.name)));
|
||||
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
|
||||
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
|
||||
const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
|
||||
const baseSitA = sitClip ? _act(sitClip) : null;
|
||||
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
|
||||
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
|
||||
const lookA = lookClip ? _action(mixer, inner, lookClip) : null;
|
||||
const baseLookA = lookClip ? _act(lookClip) : null;
|
||||
// R41: the four live slots. They START at the base actions, so an actor nobody swaps is the R40
|
||||
// actor exactly; setIdleClip/setWalkClip/setSitting(_,clip)/setLooking(_,_,clip) re-point them.
|
||||
let walkA = baseWalkA, idleA = baseIdleA, sitA = baseSitA, lookA = baseLookA;
|
||||
let seatBone = null;
|
||||
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
if (baseSitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
// [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
|
||||
// exists, so walkers/?classic pay nothing.
|
||||
// exists, so walkers/?classic pay nothing. R41: built lazily too, since a lean clip can arrive later.
|
||||
let footBones = null;
|
||||
if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; }
|
||||
function _footBones() {
|
||||
if (footBones !== null) return footBones;
|
||||
const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); });
|
||||
footBones = fb.length ? fb : false;
|
||||
return footBones;
|
||||
}
|
||||
if (baseLookA) _footBones();
|
||||
let sitting = false, looking = false;
|
||||
let moving = null; // tri-state so the first setMoving always applies
|
||||
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||
@ -290,12 +411,39 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
|
||||
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
|
||||
}
|
||||
// [R41 §41.3] the per-citizen resting/moving clips. Called on ACQUIRE only (the pool hands a recycled
|
||||
// actor to a new citizen), before setPhase + setMoving(_, 0) — so `moving = null` here is enough to
|
||||
// make the next setMoving apply instantly and no crossfade can be left mid-flight. Passing null
|
||||
// restores the base clip, which is exactly what a citizen whose assigned clip has not landed yet
|
||||
// (or whose boot has no bank at all) gets ⇒ the R40 actor, byte for byte.
|
||||
function setIdleClip(clip) {
|
||||
const a = clip ? (_act(clip) || baseIdleA) : baseIdleA;
|
||||
if (!a || a === idleA) return;
|
||||
if (idleA) { idleA.setEffectiveWeight(0); idleA.stop(); }
|
||||
idleA = a; idleA.enabled = true; idleA.reset().play(); idleA.setEffectiveWeight(0);
|
||||
moving = null;
|
||||
}
|
||||
function setWalkClip(clip) {
|
||||
const a = clip ? (_act(clip) || baseWalkA) : baseWalkA;
|
||||
if (!a || a === walkA) return;
|
||||
if (walkA) { walkA.setEffectiveWeight(0); walkA.stop(); }
|
||||
walkA = a; walkA.enabled = true; walkA.reset().play(); walkA.setEffectiveWeight(0);
|
||||
moving = null;
|
||||
}
|
||||
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
|
||||
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
|
||||
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
|
||||
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
|
||||
function setSitting(s) {
|
||||
if (!sitA || s === sitting) return;
|
||||
// R41: `clip` (optional) is a motion-library sit variant (sitlean.glb) for THIS citizen; omitted /
|
||||
// null ⇒ the R16 sit.glb pose, unchanged. With neither, setSitting stays the no-op it has always
|
||||
// been under ?classic.
|
||||
function setSitting(s, clip = null) {
|
||||
const target = s ? ((clip && _act(clip)) || baseSitA) : null;
|
||||
if (s && !target) return; // nothing to sit with → inert (classic / no clip)
|
||||
if (!s && !sitting) return; // already standing
|
||||
if (s && sitting && target === sitA) return; // already in this exact pose
|
||||
if (s && sitA && sitA !== target) sitA.stop(); // swapping pose mid-sit
|
||||
if (s) sitA = target;
|
||||
sitting = s;
|
||||
moving = null; // force the next setMoving to re-apply after standing up
|
||||
if (s) {
|
||||
@ -310,7 +458,8 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
|
||||
fig.userData.procitySeated = true;
|
||||
} else {
|
||||
sitA.stop();
|
||||
if (sitA) sitA.stop();
|
||||
sitA = baseSitA; // back to the base pose for the next citizen
|
||||
inner.position.y = bindPlantY; // restore the standing feet-plant
|
||||
if (idleA) idleA.setEffectiveWeight(1);
|
||||
fig.userData.procitySeated = false;
|
||||
@ -321,8 +470,17 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
|
||||
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
|
||||
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
|
||||
function setLooking(l, fade = 0.3) {
|
||||
if (!lookA || l === looking) return;
|
||||
// R41: `clip` (optional) is any STANDING posed library clip for this citizen — a lean against a
|
||||
// shopfront (sitlean.glb's four lean_wall_*) rides the identical seam as R29's glance, because it is
|
||||
// the identical problem: a standing clip, no hip descent to recover, but a per-frame foot float that
|
||||
// plantFeet() absorbs. Omitted / null ⇒ look.glb, unchanged.
|
||||
function setLooking(l, fade = 0.3, clip = null) {
|
||||
const target = l ? ((clip && _act(clip)) || baseLookA) : null;
|
||||
if (l && !target) return; // no standing pose available → inert (classic)
|
||||
if (!l && !looking) return;
|
||||
if (l && looking && target === lookA) return; // already in this exact pose
|
||||
if (l && lookA && lookA !== target) lookA.stop(); // swapping pose mid-look
|
||||
if (l) { lookA = target; _footBones(); }
|
||||
looking = l;
|
||||
moving = null; // force the next setMoving to re-apply on the way out
|
||||
if (l) {
|
||||
@ -331,8 +489,9 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
else lookA.setEffectiveWeight(1);
|
||||
if (walkA) walkA.setEffectiveWeight(0);
|
||||
} else {
|
||||
if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); }
|
||||
else lookA.setEffectiveWeight(0);
|
||||
if (idleA) { idleA.reset().play(); if (lookA) lookA.crossFadeTo(idleA, fade, false); }
|
||||
else if (lookA) lookA.setEffectiveWeight(0);
|
||||
lookA = baseLookA; // back to the base pose for the next citizen
|
||||
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
|
||||
}
|
||||
}
|
||||
@ -360,9 +519,11 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip =
|
||||
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
||||
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
|
||||
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
|
||||
if (sitA) sitA.time = p * (sitA.getClip().duration || 1); // R41: so does a row of bench-sitters
|
||||
}
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _acts.clear(); _disposeInner(inner); }
|
||||
|
||||
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose,
|
||||
return { fig, inner, mixer, head, get seatBone() { return seatBone; }, nominalHeight: nom,
|
||||
setMoving, setSitting, setLooking, setIdleClip, setWalkClip, plantFeet, setPhase, dispose,
|
||||
hasClips: !!(walkA && idleA) };
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import { pickRig, makeActor } from './rigs.js';
|
||||
import { makePlaceholder } from './placeholder.js';
|
||||
import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js';
|
||||
import { snapDoorToFootpath } from './door_snap.js';
|
||||
import { posturesFor, postureSig, GROUP_OF } from './postures.js';
|
||||
|
||||
// ---- tuning (CITY_SPEC budgets) ----
|
||||
const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor
|
||||
@ -38,6 +39,35 @@ const GIG_SURGE = 0.55; // patron chance at the venue while
|
||||
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
|
||||
const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around
|
||||
|
||||
// ---- R41 §41.3: the street furniture the clips actually sit and lean on ----
|
||||
// A REAL bench sit. R17's bench-sit sits a ped down at whatever node it stopped at — upright, on air,
|
||||
// "no bench-position binding" (its own comment). R41 binds it: benches exist, at deterministic
|
||||
// stations, and a ped that walks past one on its own footpath side can stop AT IT.
|
||||
//
|
||||
// The station rule below MIRRORS Lane B's `buildChunkFurniture` (web/js/world/furniture.js:203-209)
|
||||
// exactly — s = 14, 40, 66, …, step 26; side alternating on floor(s/26)%2; offset
|
||||
// (width/2 + FOOT − 0.8) on the furniture perpendicular (−uz, ux); yaw = atan2(ux,uz) + (side>0 ? π : 0).
|
||||
// It is a MIRROR because furniture.js is Lane B's file and exports no bench enumerator (only
|
||||
// `busShelterStops`, its shelter twin). A mirror that drifts is a ped sitting on air again, so it is
|
||||
// GATED, not trusted: `tools/qa/r41_benches.mjs` builds real chunks through Lane B's own
|
||||
// buildChunkFurniture, reads the bench InstancedMesh matrices out of the scene, and requires an exact
|
||||
// 1:1 position/yaw match against this table on four towns. If B ever moves a bench, that gate goes red.
|
||||
// → Filed for Lane B/F in LANE_D_NOTES §41: `export function benchStops(plan)` next to busShelterStops
|
||||
// retires the mirror entirely. One line in B's file; D switches to the import and deletes this.
|
||||
const FURN_FOOT = 3.5; // furniture.js FOOT (verge width used for its offsets)
|
||||
const BENCH_S0 = 14, BENCH_STEP = 26; // furniture.js bench cadence
|
||||
const BENCH_STOP_FRAC = 0.40; // seeded chance a ped passing its own-side bench sits down
|
||||
const BENCH_DWELL = [9, 20]; // s — a sit is a proper stop, not a window-shop pause
|
||||
const BENCH_SEAT_FWD = 0.06; // m toward the bench front, so the seat takes the weight
|
||||
const BENCH_SEAT_SIDE = 0.38; // m along the 1.6 m plank — the bench seats two, side by side
|
||||
const LEAN_RANGE = 5.0; // m — a loiter this close to a shop door can become a lean
|
||||
const LEAN_FRAC = 0.12; // seeded chance a stride check beside a shopfront becomes a lean
|
||||
const LEAN_DWELL = [6, 14]; // s — a lean is a longer stop than a window-shop glance
|
||||
const PAUSE_FRAC = 0.17; // …else a seeded chance they just stop and look in the window
|
||||
const PAUSE_DWELL = [4, 9]; // s — long enough to read the idle, short enough to keep moving
|
||||
const LEAN_WALL_BACK = 0.30; // m further from the road than the raw door point (≈ the facade)
|
||||
const LEAN_SIDE = 1.05; // m along the frontage, so the leaner isn't in the doorway
|
||||
|
||||
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
|
||||
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||
const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h
|
||||
@ -71,6 +101,25 @@ export function identityOf(citySeed, edgeCount, id) {
|
||||
const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0;
|
||||
return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar };
|
||||
}
|
||||
// [R41 §41.3] Lane B's benches on one prepared sim edge ({A,B,ux,uz,len,width}), as a pure function —
|
||||
// exported so `tools/qa/r41_citizens.py` can check the SAME code the sim uses against the bench
|
||||
// geometry actually standing in the built world, rather than a second copy of the rule. See the
|
||||
// FURN_*/BENCH_* block above for the mirror's provenance and the seam filed to Lane B.
|
||||
export function benchStationsFor(e) {
|
||||
const out = [];
|
||||
const halfRoad = (e.width || 4) / 2;
|
||||
const px = -e.uz, pz = e.ux; // furniture.js perpendicular
|
||||
const yaw = Math.atan2(e.ux, e.uz);
|
||||
for (let s = BENCH_S0; s < e.len - 6; s += BENCH_STEP) {
|
||||
const side = (Math.floor(s / BENCH_STEP) % 2) ? 1 : -1;
|
||||
const off = (halfRoad + FURN_FOOT - 0.8) * side;
|
||||
out.push({ s, side,
|
||||
x: e.A.x + e.ux * s + px * off, z: e.A.z + e.uz * s + pz * off,
|
||||
yaw: yaw + (side > 0 ? Math.PI : 0) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// stable signature string for one citizen (pedIndex assigned later, once the fleet is known)
|
||||
export function signatureOf(id, idn, pedIndex) {
|
||||
return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`;
|
||||
@ -262,6 +311,13 @@ export class CitizenSim {
|
||||
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||||
// [R41 §41.3] this citizen's four standing postures — a PURE function of (citySeed, id), decided
|
||||
// here once and never re-rolled, so lazy clip loading can change when a posture shows but never
|
||||
// which one it is. Two more freshly-keyed streams for the new street stops; like R17/R29 they
|
||||
// are drawn independently and cannot shift turn/loiter/patron/benchsit/glance.
|
||||
posture: posturesFor(this.citySeed, id),
|
||||
bench: null, benchRng: rng(this.citySeed, 'benchstop', id),
|
||||
lean: null, leanRng: rng(this.citySeed, 'leanstop', id),
|
||||
};
|
||||
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
|
||||
this._placeOnLane(c);
|
||||
@ -271,6 +327,81 @@ export class CitizenSim {
|
||||
_sig(c) {
|
||||
return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex);
|
||||
}
|
||||
|
||||
// ================= R41 §41.3: the motion library =================
|
||||
get bank() { return (this.fleet && this.fleet.bank) || null; }
|
||||
// The GATE question — "is the motion library on for this boot" — answered synchronously from frame
|
||||
// zero (rigs.js sets it before the dynamic import starts). Every roll that can change a citizen's
|
||||
// POSITION is gated on this, never on `bank`, so the number of randoms drawn per boot cannot depend
|
||||
// on when a fetch landed. `bank` answers the different question of what is resident right now, and
|
||||
// is only ever used to pick WHICH clip plays — with the R2/R16/R29 base clip as the fallback.
|
||||
get clipsOn() { return !!(this.fleet && this.fleet.clipsRequested); }
|
||||
// ask for a group; harmless (and free) when there is no bank. Called at the moment of INTENT, so a
|
||||
// group is fetched the first time the town actually wants it, never at boot "just in case".
|
||||
_wantGroup(file) { const b = this.bank; if (b && file) b.ensureGroup(file); }
|
||||
_wantClip(id) { const b = this.bank; if (b && id && GROUP_OF[id]) b.ensureGroup(GROUP_OF[id]); }
|
||||
// the assigned clip if it is resident, else null ⇒ the caller's pre-R41 base clip
|
||||
_clip(id) { const b = this.bank; return b ? b.get(id) : null; }
|
||||
|
||||
// The determinism artefact for postures: one line per ACTIVE citizen, sorted, pure identity —
|
||||
// never live state, so it holds while the crowd walks. Two runs must produce byte-equal output
|
||||
// (tools/qa/r41_postures.mjs asserts it, and re-derives the same lines from postures.js alone).
|
||||
postureSignature() {
|
||||
return this._activeList.map((c) => postureSig(c.id, c.posture || posturesFor(this.citySeed, c.id))).sort();
|
||||
}
|
||||
// what is actually resident right now (the memory ledger the round asks to be stated)
|
||||
clipStats() { const b = this.bank; return b ? b.stats() : { groups: 0, clips: 0, bytes: 0, manifest: false, catalogue: 0 }; }
|
||||
|
||||
// Lane B's benches, enumerated from B's own placement rule — see the FURN_* block above for why
|
||||
// this is a mirror and what gates it. Cached per edge (pure function of the edge, no rng).
|
||||
_benchStations(ei) {
|
||||
const e = this.edges[ei];
|
||||
return e._benches || (e._benches = benchStationsFor(e));
|
||||
}
|
||||
// The bench's local +Z is its FRONT (furniture.js's template puts the backrest at z=−0.2 and the
|
||||
// seat at y=0.45); rig fronts are local −Z after the R13 facing-normalise, so a sitter facing the
|
||||
// same way as the bench is `yaw + π`. Nudged BENCH_SEAT_FWD off the backrest onto the seat.
|
||||
// `seat` is ±1: the bench's seat plank is 1.6 m of local X, so it takes TWO. Measured need — with
|
||||
// benches 26 m apart per side, two peds picking the same station within one dwell is uncommon but
|
||||
// real (seen on the first soak: -1,-6#3 and -1,-6#5 co-located to the centimetre). A seeded ± puts
|
||||
// them side by side like people instead of inside each other. Residual: both can still draw the
|
||||
// same side; that reads as one ped, not a glitch, and it is bounded by the same 26 m cadence.
|
||||
_seatPose(st, seat) {
|
||||
const fx = Math.sin(st.yaw), fz = Math.cos(st.yaw); // bench local +Z (its front)
|
||||
const rx = Math.cos(st.yaw), rz = -Math.sin(st.yaw); // bench local +X (along the plank)
|
||||
return { x: st.x + fx * BENCH_SEAT_FWD + rx * BENCH_SEAT_SIDE * seat,
|
||||
z: st.z + fz * BENCH_SEAT_FWD + rz * BENCH_SEAT_SIDE * seat,
|
||||
facing: st.yaw + Math.PI };
|
||||
}
|
||||
|
||||
// A shopfront to put a back against. `shop._raw` is the door point BEFORE R40's footpath clamp,
|
||||
// i.e. the shell's `lot centre + front normal · (d/2 + 0.6)` — 0.6 m off the facade, which is the
|
||||
// wall we want. `n` is the unit outward normal of the nearest street (centreline → door), so the
|
||||
// building is at +n and the road at −n: stand LEAN_WALL_BACK further along +n, offset LEAN_SIDE
|
||||
// along the frontage so the leaner isn't blocking the doorway, and face −n (the road).
|
||||
// Pure geometry over the sim's own edges — no lots, no plan, no rng beyond the caller's roll.
|
||||
// O(1): the reference street is the one the LEANER IS WALKING ON, not a scan of every edge in
|
||||
// town. The ped is inside LEAN_RANGE (5 m) of that door, on that street's footpath, so the shop
|
||||
// fronts that street in every case but a corner — and a corner's residue is a facing, not a
|
||||
// position. The scan version cost 30 986 iterations per lean event on adelaide_real.
|
||||
_leanPose(shop, side, e) {
|
||||
const base = shop._raw || shop;
|
||||
if (!e) return null;
|
||||
const ax = e.A.x, az = e.A.z, dx = e.B.x - ax, dz = e.B.z - az;
|
||||
const L2 = dx * dx + dz * dz;
|
||||
let t = L2 < 1e-9 ? 0 : ((base.x - ax) * dx + (base.z - az) * dz) / L2;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
const qx = ax + t * dx, qz = az + t * dz;
|
||||
const bd = Math.hypot(base.x - qx, base.z - qz);
|
||||
if (!isFinite(bd) || bd < 1e-6) return null;
|
||||
const nx = (base.x - qx) / bd, nz = (base.z - qz) / bd; // outward: road → building
|
||||
const tx = -nz, tz = nx; // along the frontage
|
||||
return {
|
||||
x: base.x + nx * LEAN_WALL_BACK + tx * LEAN_SIDE * side,
|
||||
z: base.z + nz * LEAN_WALL_BACK + tz * LEAN_SIDE * side,
|
||||
facing: Math.atan2(nx, nz), // rig front −Z looks along (−nx,−nz)
|
||||
};
|
||||
}
|
||||
activeCitizens() { return this._activeList; }
|
||||
streamEncountered() { return [...this._encountered].sort(); }
|
||||
// hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes
|
||||
@ -312,6 +443,13 @@ export class CitizenSim {
|
||||
loit: rng(this.citySeed, 'loiter', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||||
// [R41 §41.3] this citizen's four standing postures — a PURE function of (citySeed, id), decided
|
||||
// here once and never re-rolled, so lazy clip loading can change when a posture shows but never
|
||||
// which one it is. Two more freshly-keyed streams for the new street stops; like R17/R29 they
|
||||
// are drawn independently and cannot shift turn/loiter/patron/benchsit/glance.
|
||||
posture: posturesFor(this.citySeed, id),
|
||||
bench: null, benchRng: rng(this.citySeed, 'benchstop', id),
|
||||
lean: null, leanRng: rng(this.citySeed, 'leanstop', id),
|
||||
};
|
||||
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||||
if (this.mode === 'rig' && this.fleet.ready) {
|
||||
@ -357,7 +495,11 @@ export class CitizenSim {
|
||||
for (const [key, list] of shopsByChunk) {
|
||||
snapped.set(key, list.map((s) => {
|
||||
const p = snapDoorToFootpath(s.x, s.z, this.edges);
|
||||
return p.moved ? { ...s, x: p.x, z: p.z } : s;
|
||||
// [R41] keep the PRE-clamp point as `_raw`. The clamp deliberately drags the door onto the
|
||||
// walkable strip (that is its whole job), but a leaner wants the facade the door was
|
||||
// derived from, not the kerb the ped walks on — see _leanPose. Non-enumerable-ish extra
|
||||
// field only; every existing reader takes x/z/hours/shopId and is untouched.
|
||||
return p.moved ? { ...s, x: p.x, z: p.z, _raw: { x: s.x, z: s.z } } : s;
|
||||
}));
|
||||
}
|
||||
shopsByChunk = snapped;
|
||||
@ -372,6 +514,11 @@ export class CitizenSim {
|
||||
// per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works.
|
||||
setGig(venueShopId, on = true) {
|
||||
if (venueShopId == null) return;
|
||||
// [R41 §41.3] gig night is the venue clips' cue. F flips this from the STREET when the doors open,
|
||||
// long before the player walks in, so venue.glb (526 KB) is resident by the time GigCrew spawns —
|
||||
// lazy, one fetch, and never paid by a town that has no gig on. GigCrew re-asks anyway and heals
|
||||
// itself if this never ran (band.js), so the ordering is an optimisation, not a dependency.
|
||||
if (on) this._wantGroup('venue.glb');
|
||||
if (on) this._gigVenues.add(venueShopId);
|
||||
// gig ends (on→off transition) → disperse tonight's roster so the next night starts fresh + it stays
|
||||
// bounded. Persists across interior exit/re-enter (the gig stays "on" through those) — only the real
|
||||
@ -448,6 +595,25 @@ export class CitizenSim {
|
||||
}
|
||||
return gigBest || best;
|
||||
}
|
||||
// [R41 §41.3] the nearest shop FRONT within `range` — open or shut; a leaner doesn't need the door
|
||||
// to work, just a wall. Separate from `_nearestOpenShop` on purpose: that function's own-chunk
|
||||
// blindness is a frozen v2 semantic the R23 note explains at length, and this is new behaviour with
|
||||
// no covenant to keep, so it scans the honest 3×3 (range ≪ CHUNK, so the sweep is exact). Only ever
|
||||
// called at the instant a loiter begins, and only when the bank exists ⇒ ?classic never runs it.
|
||||
_nearestShopPoint(c, range) {
|
||||
if (!this.shopsByChunk) return null;
|
||||
const cx = chunkCoord(c.x), cz = chunkCoord(c.z);
|
||||
let best = null, bd = range;
|
||||
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const list = this.shopsByChunk.get(chunkKey(cx + dx, cz + dz));
|
||||
if (!list) continue;
|
||||
for (const s of list) {
|
||||
const d = Math.hypot(s.x - c.x, s.z - c.z);
|
||||
if (d < bd) { bd = d; best = s; }
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
_beginVisit(c, shop) {
|
||||
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
|
||||
c.patronTarget = shop; c.patron = 'going';
|
||||
@ -591,10 +757,43 @@ export class CitizenSim {
|
||||
c.facing = Math.atan2(-dx / dist, -dz / dist);
|
||||
return;
|
||||
}
|
||||
if (c.loiter > 0) { c.loiter -= dt; return; }
|
||||
if (c.loiter > 0) {
|
||||
c.loiter -= dt;
|
||||
// [R41] a bench sit / shopfront lean is a POSITIONED stop: the ped was moved off its lane to the
|
||||
// furniture. When the stop expires, drop the binding and put them back on the footpath in one
|
||||
// step, so the next frame walks from the lane and not from the seat.
|
||||
if (c.loiter <= 0 && (c.bench || c.lean)) { c.bench = null; c.lean = null; this._placeOnLane(c); }
|
||||
return;
|
||||
}
|
||||
const e = this.edges[c.edge];
|
||||
const adv = c.speed * this._speedMult() * dt;
|
||||
const s0 = c.s;
|
||||
c.s += adv;
|
||||
// [R41 §41.3] THE BENCH. Lane B puts benches at fixed stations along every edge, on alternating
|
||||
// sides. A ped walks the footpath on the side given by its travel direction, so it can only use a
|
||||
// bench on ITS side: the sim's lane perpendicular is (forward·uz, −forward·ux) = −forward × the
|
||||
// furniture perpendicular, hence `side === −forward`. Crossing that station's arc-length this
|
||||
// frame is the trigger; a dedicated stream decides. Gated on `clipsOn` (the boot-stable "is the
|
||||
// library on" answer), NOT on residency — so ?classic / ?noassets never run a line of it, and a
|
||||
// boot that has it on draws the identical randoms from frame zero whatever the network does. All
|
||||
// three draws are unconditional; the POSE falls back to R16's sit.glb until the variant lands.
|
||||
if (c.benchRng && this.clipsOn && !c.patron) {
|
||||
for (const st of this._benchStations(c.edge)) {
|
||||
if (st.side !== -c.forward) continue;
|
||||
if (!(s0 < st.s && c.s >= st.s)) continue;
|
||||
const wantsBench = c.benchRng() < BENCH_STOP_FRAC;
|
||||
const seat = c.benchRng() < 0.5 ? -1 : 1; // drawn always — see clipsOn
|
||||
const dwell = c.benchRng();
|
||||
if (!wantsBench) continue;
|
||||
this._wantClip(c.posture && c.posture.sit); // first sit intent fetches sitlean.glb
|
||||
const p = this._seatPose(st, seat);
|
||||
c.bench = st; c.lean = null; c.sit = false; c.glance = false;
|
||||
c.s = st.s; // resume from the bench, not past it
|
||||
c.loiter = BENCH_DWELL[0] + dwell * (BENCH_DWELL[1] - BENCH_DWELL[0]);
|
||||
c.x = p.x; c.z = p.z; c.facing = p.facing;
|
||||
return; // the POSE falls back to R16's sit.glb
|
||||
} // until this citizen's variant lands
|
||||
}
|
||||
if (c.s >= e.len) {
|
||||
// arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter
|
||||
const node = c.forward > 0 ? e.b : e.a;
|
||||
@ -633,6 +832,41 @@ export class CitizenSim {
|
||||
const chance = (shop && this._gigVenues.has(shop.shopId))
|
||||
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
|
||||
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
|
||||
// [R41 §41.3] THE SHOPFRONT LEAN — the ped that walked past a shop and DIDN'T go in.
|
||||
// This rides the patronage stride check on purpose. R17/R29's window-shop stop fires at a
|
||||
// graph NODE, i.e. at an intersection, where there is rarely a shopfront to lean on (measured:
|
||||
// 0 leans in a 9 s run when it was wired there). The stride check is the moment the sim
|
||||
// already asks "is there a shop beside me", which is exactly when a leaner is beside a wall.
|
||||
// Strictly downstream of the patron decision, on its own stream, and gated on `clipsOn` — so
|
||||
// with the library off (?classic / ?noassets / ?clips=0) not one line of it runs.
|
||||
// …and THE WINDOW PAUSE, which is what finally makes the 10-idle pool visible. Measured, and
|
||||
// it changed the design: with the pool wired only to R17/R29's node loiter, a census of the
|
||||
// live crowd found 0.8% of citizens stopped at any instant (the loiter fires at a graph NODE,
|
||||
// and edges are long) — so nine of ten new idles were assigned, deterministic, and never
|
||||
// seen. A ped who walks past a shop and neither goes in nor leans on it now sometimes just
|
||||
// STOPS and looks at the window, in their own seeded idle. No reposition, no new clip: the
|
||||
// actor's resting action is already this citizen's idle (setIdleClip at acquire).
|
||||
// All four draws are unconditional so the stream position cannot depend on load timing.
|
||||
else if (c.leanRng && this.clipsOn && !c.bench) {
|
||||
const wantsLean = c.leanRng() < LEAN_FRAC;
|
||||
const side = c.leanRng() < 0.5 ? 1 : -1;
|
||||
const wantsPause = c.leanRng() < PAUSE_FRAC;
|
||||
const dwell = c.leanRng();
|
||||
const wall = (wantsLean || wantsPause) ? this._nearestShopPoint(c, LEAN_RANGE) : null;
|
||||
if (wall && wantsLean) {
|
||||
this._wantClip(c.posture && c.posture.lean); // first lean intent fetches sitlean.glb
|
||||
const p = this._leanPose(wall, side, this.edges[c.edge]); // the ped's CURRENT edge
|
||||
// (a node arrival above may have moved it)
|
||||
if (p) {
|
||||
c.lean = wall; c.sit = false; c.glance = false;
|
||||
c.loiter = LEAN_DWELL[0] + dwell * (LEAN_DWELL[1] - LEAN_DWELL[0]);
|
||||
c.x = p.x; c.z = p.z; c.facing = p.facing; // pose falls back to R29's look.glb
|
||||
} // until this citizen's variant lands
|
||||
} else if (wall && wantsPause) {
|
||||
c.sit = false; c.glance = false; // plain stop ⇒ the seeded idle plays
|
||||
c.loiter = PAUSE_DWELL[0] + dwell * (PAUSE_DWELL[1] - PAUSE_DWELL[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -642,6 +876,15 @@ export class CitizenSim {
|
||||
if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) {
|
||||
const a = this.rigPool.acquire(c.pedIndex);
|
||||
if (a) {
|
||||
// [R41 §41.3] THE HEADLINE. Pooled actors are shared between citizens of the same ped type, so
|
||||
// the per-citizen posture is installed here, on acquire, not baked at construction: the same
|
||||
// citizen always stands the same way, and the street stops being one person copy-pasted.
|
||||
// Both calls are inert when the bank is absent or the clip has not landed (they fall back to
|
||||
// the base walk/idle actions) ⇒ ?classic / ?noassets / pre-load are the R40 actor exactly.
|
||||
if (this.bank && c.posture) {
|
||||
a.setIdleClip?.(this._clip(c.posture.idle));
|
||||
a.setWalkClip?.(this._clip(c.posture.walk));
|
||||
}
|
||||
a.setPhase(c.phase);
|
||||
a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed
|
||||
a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose)
|
||||
@ -760,13 +1003,20 @@ export class CitizenSim {
|
||||
// R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath
|
||||
// edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null),
|
||||
// so every non-sitting ped is byte-identical.
|
||||
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
||||
if (a.setSitting) a.setSitting(wantSit);
|
||||
// [R41 §41.3] `c.bench` is a real bench-bound sit (the ped was moved onto Lane B's furniture);
|
||||
// it and the R17 free sit both come out here, the bench one carrying this citizen's own seeded
|
||||
// sitlean variant. `sitClip` null ⇒ setSitting falls back to R16's sit.glb; both null ⇒ inert.
|
||||
const benchSit = !!(c.bench && c.loiter > 0);
|
||||
const wantSit = !!((benchSit || c.sit) && c.loiter > 0 && a.setSitting);
|
||||
if (a.setSitting) a.setSitting(wantSit, benchSit ? this._clip(c.posture && c.posture.sit) : null);
|
||||
// [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles.
|
||||
// No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical.
|
||||
const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking);
|
||||
if (a.setLooking) a.setLooking(wantLook);
|
||||
if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0);
|
||||
// [R41] …and now also leans: same standing-posed-clip seam, this citizen's own lean variant.
|
||||
const wantLean = !wantSit && !!(c.lean && c.loiter > 0 && a.setLooking);
|
||||
const wantLook = !wantSit && !wantLean && !!(c.glance && c.loiter > 0 && a.setLooking);
|
||||
if (a.setLooking) a.setLooking(wantSit ? false : (wantLean || wantLook),
|
||||
0.3, wantLean ? this._clip(c.posture && c.posture.lean) : null);
|
||||
if (!wantSit && !wantLean && !wantLook) a.setMoving?.(c.loiter <= 0);
|
||||
} else if (want === 'mid') {
|
||||
mid.push(c);
|
||||
}
|
||||
@ -783,7 +1033,7 @@ export class CitizenSim {
|
||||
if (a.mixer) {
|
||||
// [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's
|
||||
// post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`.
|
||||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance) a.plantFeet?.(); } else extra.push(c);
|
||||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance || c.lean) a.plantFeet?.(); } else extra.push(c);
|
||||
} else {
|
||||
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
|
||||
}
|
||||
@ -794,7 +1044,7 @@ export class CitizenSim {
|
||||
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
|
||||
const c = extra[(this._mixerCursor + k) % extra.length];
|
||||
c.actor.mixer.update(c._acc); c._acc = 0;
|
||||
if (c.glance) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran
|
||||
if (c.glance || c.lean) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran (R41: leaners too)
|
||||
|
||||
}
|
||||
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user