Lane F R41 §41.6: 18 gates green — the deny-list, the wardrobe switch, and no byte-hash goldens
qa.sh --strict: 18 passed · 0 failed · 0 warn · 0 skipped. selfcheck 157,647/157,647, fingerprint 0x5f76e76 unmoved. No tag — John's playtest session rules the epoch. THE FIVE ASKS. [C] wardrobe wired via exported WARDROBE_BASE + stockBaseFor(shop), preloaded inside the existing STOCK_REAL gate: ON under ?stock=real (52 pack items, 69 garment ids on rendered meshes, 1 atlas, exactly 2 requests) and CHEAPER (59->55 draws, 111->100 textures); OFF is what ships by default, A/B'd against a reverted control tree — default arm identical on every counter (160 URLs same hash, 131/18,018 street, 59/28,505 interior). [D] three gates wired (r41_shots.py writes to TMPDIR so gate runs never rewrite committed shots). [E] clips_verify wired — 46 clips / 6 groups / 3,498,124 B. [B's bookmark finding] CONFIRMED AND WIDER: street_noon, shopfront_detail, crossroads_busy AND market_square all give 3 distinct hashes across 3 boots; only night_neon is stable. RULING: no gate pins a screenshot byte-hash on any bookmark — everything R41 asserts is a counter. Filed to B: one await document.fonts.ready in buildings.js. [C's drawSweep] CONFIRMED INDEPENDENTLY with F's own sweep (never calls C's): GLB-on worst 123 @ dept/auto. The phantom control (one stale room left in scene) reads 197 — +74 on every reading — decomposing R39/R40's '188' as 116 real + ~72 phantom. NEW GATES, controls demonstrated. r41_denylist.mjs (ruling 3): 738 files / 197.3 MiB scanned by path AND bytes, 7 banned names, 0 hits; the control plants a banned manifest row every run -> RED on 2 names -> removed -> GREEN. r41_integration.py: wardrobe both arms · interior <=350 on both instruments · ?noassets=1 zero across six fetch classes over a 3-shop walk (control fetches 5/6) · ?clips=0 142 URLs and ?classic=1 125 URLs with zero R41 cargo. BUDGETS. Street: noon 282 · NIGHT 291/120,093 — margin 9, INTACT · classic 269 byte-exact · ?clips=0 NIGHT 291, delta 0. No lane spent a street draw. Interior: true pre-R41 116, R41 123 @ dept/auto, margin 227; over 33 real shops worst 110; pub 49 quiet / 77 gig night. FILED BACK: flags_check's _enter_record_shop went RED on 8 smokes because §41.4's bins put every one of the first six record-shop counters inside 2.6 m — widened to the whole open set, verified not a game defect. smoke_djdance read _actions[0] and D's per-instance clip swapping made the mixer multi-action, so a healthy dancer was called a stall — now follows the clip by name. D's r41_shots.py browse arm is deterministically red here (re-enters the winner after a ~3 s/candidate scan, by which time the occupant has left) — wired WARN-level with the reason at the call site, fix handed to D. F's own r39_transmission.py had an unmeasured 30 s goto default that flaked once under a full strict run; pinned to 90 s. 11 proof frames in docs/shots/laneF_r41/ with sidecars: street_postures (5 rigs, 3 clips playing) against its ?clips=0 control (1 clip) · street_lean · browse_interior · pub_furnished + the honest kit before/after pair · record_dj_booth · opshop_wardrobe on/off · credits_panel with ODbL on screen. Every humanoid frame carries its R10 human-sized line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
239
F-progress.md
@ -4,6 +4,245 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 41 (§41.6) — THE LIBRARY LANDS: five seams wired, six gates added, and the interior margin was never 162
|
||||
|
||||
*Full runbook detail (gate arms, the control transcripts, the A/B method): [LANE_F_NOTES §41](docs/LANES/LANE_F_NOTES.md).
|
||||
Shots + their index: `docs/shots/laneF_r41/` (README in the folder).*
|
||||
|
||||
Every number below is from a command run today on the integrated tree — five lanes' uncommitted work
|
||||
in one working copy — in fresh headless contexts on port-isolated no-store servers, seed 20261990,
|
||||
1280×720. **No git command was run.** No tag (John's playtest session rules the epoch).
|
||||
|
||||
### The five asks, each with its outcome
|
||||
|
||||
**① [C] THE WARDROBE IS ON — and the arm that ships by default is still the canvas.**
|
||||
Two marked edits: `interior_mode.js` gains `WARDROBE_BASE = 'assets/wardrobe/'` and
|
||||
`stockBaseFor(shop)` (godverse per-shop base first — a real shop's real stock outranks the town art;
|
||||
else the wardrobe base for `opshop`; else `undefined` → the town-wide packs), consumed at `enter()`
|
||||
and in `stockInfo`. `index.html` preloads `preloadStockPack('opshop', { base: WARDROBE_BASE })` beside
|
||||
the three town-wide preloads, inside the same `STOCK_REAL` gate. One constant, one resolver, both
|
||||
sides — the R24 cache-key lesson, because `(type, base)` is the cache identity and a base spelled two
|
||||
ways fails silently to parody.
|
||||
|
||||
| | `?stock=real` (ON) | default boot (OFF) |
|
||||
|---|---|---|
|
||||
| room's resolved base | `assets/wardrobe/` | `null` |
|
||||
| pack items resolved at build time | **52** | 0 |
|
||||
| real garment ids on rendered meshes | **69** (Puppy Jumper · Splatter Smiley Tee · Squiggle Knit Dress · Trackie Dacks…) | 0 |
|
||||
| atlas the GPU is sampling | `wardrobe_atlas_00.webp` | — |
|
||||
| wardrobe requests | **exactly 2** (index + one atlas) | **0** |
|
||||
| console errors | 0 | 0 |
|
||||
|
||||
**Which ships default: the canvas.** `STOCK_REAL` is `params.get('stock') === 'real' && !NOASSETS` —
|
||||
opt-in, unchanged. **Both arms A/B'd against a control tree with F's two edits reverted** (`web/`
|
||||
copied, `assets`/`models`/`vendor` symlinked to the live tree so another lane's wave is identical on
|
||||
both arms; town frozen `pop=0&weather=0&tram=0&magpie=0&washing=0`):
|
||||
|
||||
| frozen town, control vs treatment | default arm | `?stock=real` arm |
|
||||
|---|---|---|
|
||||
| fetch surface | **160 URLs, identical hash, added [] removed []** | **+2 URLs exactly** (`stock_opshop_index.json`, `wardrobe_atlas_00.webp`), removed [] |
|
||||
| street (`shopfront_detail`) | **131 draws / 18,018 tris — identical** | 131 / 18,018 — identical |
|
||||
| op-shop interior | **59 draws / 28,505 tris — identical** | 59 → **55 draws (−4)**, 28,505 → **28,065 tris** |
|
||||
| geometries · textures | **199 · 111 — identical** | 199 → 194 · **111 → 100 (−11)** |
|
||||
|
||||
So: nothing breaks without it, byte-for-byte, and turning it on is a **draw and texture WIN** — 52
|
||||
garments come off ONE shared atlas material where the procedural path built a canvas per garment.
|
||||
|
||||
**② [D] THREE GATES WIRED** into `qa.sh` Gate 10: `node tools/qa/r41_postures.mjs`,
|
||||
`tools/.venv/bin/python tools/qa/r41_citizens.py`, `…/r41_shots.py`. The shot gate is wired with
|
||||
`--outdir "${PROCITY_SHOT_OUT:-$TMPDIR/procity-r41-shots}"` so a routine gate run never rewrites the
|
||||
round's committed shots; `PROCITY_SHOT_OUT=docs/shots/laneD` regenerates them deliberately. Two run
|
||||
strict; **`r41_shots.py` runs warn-level** with its reason recorded at the call site — see filing 5
|
||||
below, its browse arm loses its browser to the sim between the scan and the frame.
|
||||
|
||||
**③ [E] `pipeline/clips_verify.py` WIRED** as Gate 10's first gate (no venv, no browser, ~1 s):
|
||||
**GREEN, 0 errors / 0 warnings, 46 clips / 6 groups / 3,498,124 B.**
|
||||
|
||||
**④ [B] THE NON-REPRODUCIBLE BOOKMARKS — confirmed, and the set is BIGGER than B measured.**
|
||||
Independently, on today's tree: `?classic=1`, town frozen, 2.5 s settle, sha256 of the PNG, **3 boots
|
||||
per bookmark**:
|
||||
|
||||
| bookmark | distinct hashes over 3 boots |
|
||||
|---|---|
|
||||
| `street_noon` · `shopfront_detail` · `crossroads_busy` | **3 of 3 — not reproducible** (B's finding, reproduced) |
|
||||
| `market_square` | **3 of 3 — not reproducible** (B measured this one as rock-solid; it is not, here) |
|
||||
| `night_neon` | **1 of 3 — STABLE** |
|
||||
|
||||
**THE RULING: no R41 gate pins a screenshot byte-hash on any bookmark, and none should while the sign
|
||||
atlas paints `bold Npx Arial` before the font resolves.** Only `night_neon` could carry one and one
|
||||
stable bookmark is not a golden suite. Everything R41 asserts is a **counter** — draw calls,
|
||||
triangles, fetch surface, item ids, texture URLs, stature — all of which reproduce exactly. The tour
|
||||
shots are evidence, captioned with the counters that are gated, and their README says so. **Filed to
|
||||
B for R42:** one `await document.fonts.ready` before the first sign-atlas paint in `buildings.js`,
|
||||
then re-measure — cheaper than a perceptual-compare harness, and it fixes the cause.
|
||||
|
||||
**⑤ [C] THE CORRECTED `drawSweep` — confirmed on an independent instrument, and the true numbers.**
|
||||
F wrote its own sweep (`r41_integration.py` arm 2): F's own scene bookkeeping, F's own strip-and-
|
||||
measure loop, `PROCITY_C.drawSweep` never called — because the thing C found broken was exactly the
|
||||
sweep's scene bookkeeping.
|
||||
|
||||
| 12 types × 6 archetypes, seed 1990 | F's own sweep | C's instrument, today | margin to ≤350 |
|
||||
|---|---:|---:|---:|
|
||||
| GLB off | **93** @ `opshop/hall` | 93 @ `opshop/hall` | 257 |
|
||||
| **GLB on** | **123** @ `dept/auto` | **123** @ `dept/auto` (twice) | **227** |
|
||||
| GLB on + `?stock=real` | **123** @ `dept/auto` | 123 @ `dept/auto` | 227 |
|
||||
| GLB on + `noKit` (pre-R41 control) | — | **116** @ `dept/auto` | 234 |
|
||||
|
||||
**THE PHANTOM CONTROL:** the same loop with one stale room deliberately left in the scene — the
|
||||
pre-R41 behaviour, reproduced on purpose — reads **197 @ `dept/auto`, an inflation of +74 draws on
|
||||
every reading**. That is C's §41.4-0 finding confirmed independently, and it decomposes R39/R40's
|
||||
"worst 188" exactly: 116 of dept plus ~72 of a record shop nobody was looking at.
|
||||
|
||||
**THE TRUE NUMBERS, used everywhere in this close: worst interior 123 draws, law ≤350, MARGIN 227.**
|
||||
Not 188/162. §41.4 spent **+7 draws at the worst room** (116 → 123). Two honest notes: C published
|
||||
**122**, and today's tree reads **123** on both instruments — C's table also reads 1–4 draws light on
|
||||
record/book/video/pawn/milkbar/band_room **in the `noKit` arm too**, so the difference is C's
|
||||
measurement environment, not the R41 kit. And even the inflated historical reading (197) was never a
|
||||
breach of the law.
|
||||
|
||||
### The new gates F owns, each with its control demonstrated
|
||||
|
||||
**`tools/qa/r41_denylist.mjs` — RULING 3 BECOMES REPO LAW** (node, zero deps, 0.8 s). Seven banned
|
||||
names (`character_kit_modular`, `exports/bodies/`, `elsa_coronation_hair_wig_kh3`, `daphne_sexy`,
|
||||
`mocaponline`, `SK_Mannequin`, `anatomy/`) checked two ways: banned **path components**, and banned
|
||||
**bytes in every file under `web/`, binaries included** — a GLB carries its source node names in the
|
||||
JSON chunk and a manifest carries the path it was published from, so "nothing references it" is a
|
||||
claim about text and "nothing contains it" is the claim a licence audit asks.
|
||||
**Result: 738 files · 197.3 MiB · 0 symlinks · 0 hits.**
|
||||
**CONTROL, run every time:** the gate writes a plausible plant (a manifest row naming
|
||||
`~/Documents/character_kit_modular/exports/bodies/body_07.glb`) into `web/assets/`, asserts the scan
|
||||
goes **RED on 2 names**, removes it in a `finally`, and asserts the scan returns to **0 hits**. A
|
||||
deny-list that has never fired is indistinguishable from one with a typo in its regex.
|
||||
|
||||
**`tools/qa/r41_integration.py` — the seams F wired and the budgets F answers for** (Playwright, five
|
||||
arms, own port):
|
||||
1. **the wardrobe, both arms** — the table above; the OFF arm is the control.
|
||||
2. **the interior budget on F's own sweep** + the phantom control — the table above.
|
||||
3. **`?noassets=1` still clean** over a walk that enters op-shop, record and pub: **0 GLB · 0 clip
|
||||
GLB · 0 motion manifest · 0 wardrobe · 0 stock pack · 0 asset manifest**, 0 console errors,
|
||||
106 requests swept. **CONTROL:** the same walk on `?stock=real` fetches **5 of 6** cargo classes,
|
||||
so the classifier can see what it claims is absent.
|
||||
4. **`?clips=0` / `?classic=1` carry no R41 cargo** — `?clips=0`: 142 distinct URLs, 0 clip cargo, 0
|
||||
wardrobe, `clipStats {0 groups, 0 clips, 0 B}`. `?classic=1`: 125 URLs, same three zeros.
|
||||
**CONTROL:** the default boot fetches the library (2 requests, bank 2 groups / 16 clips /
|
||||
1,242,272 B), so the zero-arms are a gate and not a tautology.
|
||||
5. **the deny-list from the network side** — **190 distinct URLs** across street + gig night + 3
|
||||
interiors, 0 banned names; the matcher demonstrated firing on a synthetic banned URL.
|
||||
|
||||
### The budgets, restated on the final integrated tree
|
||||
|
||||
**STREET (ruling 4) — the pin method of record**, `budget_walk.py --method stepwalk --stepm 2
|
||||
--fixed-yaw 0 --laps 2` = 802 stations down the main spine at yaw 0, the worst bearing:
|
||||
|
||||
| boot | R40 pin | **R41 measured** | law |
|
||||
|---|---:|---:|---|
|
||||
| default, noon (seg 2) | 282 | **282 / 100,346 tris** | ≤300 ✅ |
|
||||
| default, **NIGHT (seg 5)** | 291 | **291 / 120,093 tris** | ≤300 ✅ — **margin 9, intact** |
|
||||
| `?classic=1`, NIGHT | 269 / 90,580 | **269 / 90,580** | ≤300 ✅ **byte-exact** |
|
||||
| default, NIGHT, `?clips=0` | — | **291** / 82,013 | Δ draws **0** |
|
||||
|
||||
**NO LANE SPENT A STREET DRAW.** The pin reproduces R40 to the digit on every class, and the
|
||||
motion library's own A/B (`?clips=0` vs default at the same station set) is **0 draws**: clips are
|
||||
skeleton-only, 0 tris, and a seated ped is a ped that was already drawn.
|
||||
|
||||
**INTERIOR — before/after, and the instrument correction that dwarfs both:**
|
||||
|
||||
| | draws | law | margin |
|
||||
|---|---:|---|---:|
|
||||
| R39/R40 published worst (**phantom-inflated**) | 188 | ≤350 | 162 |
|
||||
| pre-R41 worst, corrected instrument (`noKit`) | **116** | ≤350 | 234 |
|
||||
| **R41 worst, corrected instrument** (12 types × 6 archetypes) | **123** @ `dept/auto` | ≤350 | **227** |
|
||||
| the same reading with the phantom re-introduced | 197 | ≤350 | 153 |
|
||||
|
||||
And the **game-side** number, which is the one a player can actually stand in — 33 real open shops
|
||||
entered through the shell with GLB **and** `?stock=real`, draws read off the interior scene's own
|
||||
render: **worst 110 @ `dept` (Golden Department Store), margin 240.** Per type: dept 110 · toy 110 ·
|
||||
book 78 · opshop 67 · video 54 · record 51 · pawn 47 · milkbar 46 · stall 17. Venues are shut at
|
||||
midday, so the pub was measured on its own clock: **49 quiet (18:30) and 77 on gig night with the band
|
||||
and the crowd in the room** — the round's most-furnished interior, at 22% of the law.
|
||||
|
||||
### QA
|
||||
|
||||
`bash tools/qa.sh --strict` →
|
||||
|
||||
```
|
||||
SUMMARY 18 passed · 0 failed · 0 warn · 0 skipped
|
||||
● QA GREEN — every landed gate passed. (exit 0)
|
||||
```
|
||||
|
||||
All six R41 gates included and green, D's warn-level shot gate passing on this run too. R39's
|
||||
transmission gate independently corroborates F's sweep from the other side: **GLB-on worst room 123
|
||||
(dept/auto) with E's fix vs 193 (opshop/hall) with transmission restored — 70 draws saved**, and its
|
||||
GLB-off control pair is equal at 93 on both roots, as it must be.
|
||||
|
||||
`node web/js/citygen/selfcheck.js` → **ALL GREEN 157,647/157,647, fingerprint `0x5f76e76`** —
|
||||
unmoved. Nothing this round writes a plan field.
|
||||
|
||||
### Filed back (two of them were red gates F fixed in its own file)
|
||||
|
||||
1. **`tools/flags_check.py` `_enter_record_shop` — R41 closed its search window, 8 smokes went RED.**
|
||||
It took the first OPEN record shop **of six** whose counter sits ≥2.6 m from every bin (so
|
||||
counter-E can never fall into `binUnderAim`'s 2.5 m fallback). §41.4's 2–4 kit bins per record shop
|
||||
brought that distance down across the board: the first six now read **2.36 / 2.02 / 2.26 / 2.21 /
|
||||
2.21 / 2.10 m**. Measured over twelve, **3 still clear the bar** (Colonial 2.80 · Bluey's 2.79 ·
|
||||
Gazza's 2.84) — so the property survives and the window was the problem. **Widened to the whole
|
||||
open set, and it now reports the shop and its margin** so the next narrowing shows up in the log
|
||||
instead of arriving as eight failures. Verified first that this is not a game defect: the shell
|
||||
orders `aimed bin → trySell → nearest bin`, so the sell card wins at a counter regardless of the
|
||||
fallback radius. **→ LANE C, for information:** record-shop counters now sit 2.0–2.8 m from the
|
||||
nearest bin. Nothing is broken; if you ever want the counter to keep a clear 2.6 m, that is the
|
||||
number.
|
||||
2. **`tools/flags_check.py smoke_djdance` — R41 made the mixer multi-action and the stall test lied.**
|
||||
It read `mixer._actions[0]` before and after a 700 ms window and failed when `tAfter <= t`. D's
|
||||
§41.3 gives every actor per-instance clip swapping and the gig widening installs a venue clip into
|
||||
some crowd slots, so index 0 is no longer the live action nor the same one 700 ms later: a healthy
|
||||
dancer read **t 24.19 → "tAfter" 14.21** and the gate called it a stalled page. Fixed to identify
|
||||
the running action by clip **name**, follow that clip across the window, measure advance **modulo
|
||||
the clip duration** (a 30.58 s loop sampled across its wrap is advancing, not stalled), and treat a
|
||||
legitimate mid-window swap as advancing. Now green, and it reports both durations in play
|
||||
(30.58 s and 52.42 s — the R13 dance pick and D's venue widening, side by side).
|
||||
3. **→ LANE B: the bookmark instability is wider than filed** (`market_square` too — 3 distinct
|
||||
hashes over 3 boots). The R42 fix is one `await document.fonts.ready` in `buildings.js`.
|
||||
4. **→ LANE C: worst interior reads 123 on this tree, not 122** — on C's own instrument as well as
|
||||
F's. The `noKit` arm carries the same 1–4 draw spread, so it is not the kit.
|
||||
5. **→ LANE D: `r41_shots.py`'s BROWSE arm goes red on this tree, and it is the harness, not the game.**
|
||||
Its street arm is excellent and reproducible (8 near-tier rigs, states `{walk 7, bench-sit 1}`, 3
|
||||
distinct clips `@walk`/`sit_hands_thighs`/`walk_shopping_bag`, 126 draws, every stature clean). The
|
||||
browse arm scans up to 8 shops for browsers, keeps the best, then **RE-ENTERS the winner** — and a
|
||||
patronage occupant is free to walk out during the ~3 s per candidate that scan costs, so the
|
||||
re-entry spawns 0 browsers and the arm asserts on an empty room. **Measured: 3 consecutive
|
||||
standalone runs all land on `T & R Books` with `figures: 1` (keeper only), byte-identical at 34
|
||||
draws / 36,751 tris** — while the same gate passed with `figures: 2` earlier the same day under
|
||||
different machine load, with nothing in R41's shipped code moving in between. **Fix:** re-check
|
||||
`k.browse` after the re-entry and re-pick, or don't re-enter — keep the winner entered. Until then
|
||||
it is wired **warn-level** in `qa.sh` with that reason recorded at the call site, so it runs and
|
||||
reports without failing a round for a browser who went home. (F's own tour hits the same seam and
|
||||
gets a browser every run by dwelling 25 s on the street first so several shops hold occupants —
|
||||
`docs/shots/laneF_r41/browse_interior.jpg`, a browser on `browse_close_cabinet` at a bookshelf.)
|
||||
6. **F's own `r39_transmission.py` had an unmeasured 30 s navigation timeout, and it cost a round a
|
||||
RED.** Its `pg.goto(interior_test.html?localdepot=1)` waits for `load` — every subresource — on
|
||||
Playwright's 30 s default. Inside a full `--strict` run (four browser gates back to back on a
|
||||
loaded box) it timed out once and printed a stack trace where an assertion belongs; the same gate
|
||||
went green **twice standalone, immediately after**, and the page reaches `load` in **0.4 s over 27
|
||||
requests, 3 runs of 3**. Pinned to an explicit 90 s — ~200× the measured cost, so it cannot mask a
|
||||
regression, and one class of false red is out of `--strict` for good. An unstated default is a
|
||||
number nobody measured.
|
||||
7. **→ LANE C: `assets/wardrobe/` is now spelled in two files** — F's exported `WARDROBE_BASE` in
|
||||
`interior_mode.js` (the shipping path) and a local `const` at `interior_test.html:93` (yours). They
|
||||
match today. Two spellings of one cache key is the exact shape of the R23 bug, so if the wardrobe
|
||||
ever moves, import the constant instead of editing both.
|
||||
|
||||
### Files touched (only these)
|
||||
|
||||
`web/index.html` (`[Lane F R41]` marks — Lane B's `[Lane B R41]` marks untouched) ·
|
||||
`web/js/world/interior_mode.js` · `tools/qa.sh` · `tools/flags_check.py` ·
|
||||
`tools/qa/r41_denylist.mjs` (new) · `tools/qa/r41_integration.py` (new) · `tools/qa/r41_tour.py`
|
||||
(new) · `tools/qa/r39_transmission.py` (F's own gate, one timeout — filing 6) ·
|
||||
`docs/shots/laneF_r41/**` (new) · `F-progress.md` · `docs/LANES/LANE_F_NOTES.md` ·
|
||||
`README.md`. **No git command was run. No tag.**
|
||||
|
||||
---
|
||||
|
||||
## Round 40 (§40.6 — THE PLAYTEST HARNESS) — F8 turns John's session into tickets; the wave-1 asks settled
|
||||
|
||||
*Full spec (note schema · tour stops · gate arms · ask detail): [LANE_F_NOTES §40](docs/LANES/LANE_F_NOTES.md).*
|
||||
|
||||
@ -30,6 +30,7 @@ Click **START** to lock the mouse. Then:
|
||||
| wake up (sleep or arrive) | **the morning paper** rides the wake/arrival toast: "⚖ SOLD 'Milk Bar Dreams' — hammer $8, $7 after fees" (or "PASSED IN — it comes home"). The news is a toast, never saved — the ledger is your cash and crate |
|
||||
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
|
||||
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
|
||||
| **F2** | **credits & licences** — what PROCITY is built from and what each source asks for in return (R41). On a real-map boot the HUD link itself carries the obligation: `© OpenStreetMap contributors · credits (F2)` |
|
||||
| **M** | 2D town map (street only) |
|
||||
| **[** / **]** | step time of day back / forward · **T** pauses the clock |
|
||||
| **P** | capture screenshots (street) |
|
||||
@ -100,7 +101,8 @@ master off:
|
||||
| `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass |
|
||||
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
|
||||
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
|
||||
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
|
||||
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock — and, since R41, the **op-shop wardrobe**: 52 named 1990s Australian garment layers on the clothes rails and shelves, one 246 KB atlas, one material, and it costs *fewer* draws and 11 fewer textures than the procedural canvas it replaces. Opt-in: a default boot still builds the canvas, byte-identically |
|
||||
| `?clips=0` | **library on** | turns the R41 **motion library** off and nothing else — 46 Mixamo clips in 6 grouped GLBs (2 fetched at boot, 3 on first demand, 1 never). With it off the crowd is 99.3% walking on one clip; with it on, 78.1% walking, 9.5% sitting on a real bench, 7.1% leaning on a shopfront, 20 distinct clips in play. `?classic=1` is a bad control for this (it also changes the ped pool, the fog and half the shell) which is exactly why this flag exists |
|
||||
| `?verge=0` | **off** | reverts the ground to the **pre-kerb-ruling** geometry (bitumen painted to the full corridor). John's v8 ruling made the band between kerb and shopfront **footpath** — 9 m on the synthetic town's 28 m main, **7 m** on a real 24 m main, and **3 m** on the `side`@12 edge that is 72% of the corpus. The flag stays forever as the gate's falsifiability control: the poster-clearance leg is asserted to FAIL under it, at exactly 14 posters standing on road paint. `?classic=1` forces the ruling off — the covenanted town keeps its paint |
|
||||
| `?r=N` · `?shadows=0\|1` | auto | streaming radius and the sun-shadow pass. **Auto is load-bearing**: a big town (>120 shops or >200 street edges) gets radius 2 with shadows off. A `?r=` **above** auto is a DIAGNOSTIC boot and declares its own law (R40): `?r=3` measures **382 noon / 391 night** draws at 45–48 live chunks — structural, not fat — so it boots under a pinned **≤420** ceiling (`PROCITY.budget`, console-warned, gated by `tools/qa/r40_lane_b.py`), tri law unchanged. Not a supported boot |
|
||||
| `?live=1` · `?live=<url>` | **off** | **tier 2** — live stock from the GODVERSE server (sold-in-the-real-shop means gone in the game). Opt-in by design: absent ⇒ byte-identical to tier 1, and the game never asks a question it has no reason to expect an answer to |
|
||||
|
||||
@ -3000,6 +3000,143 @@ favour by 141 draws.
|
||||
|
||||
**No tag.**
|
||||
|
||||
## §41 — R41: THE LIBRARY LANDS (§41.6). The wiring, the six gates, and the instrument that was wrong for two rounds.
|
||||
|
||||
*(PROCITY-F, 2026-08-07. Five lanes landed first; F consumed. Every number here is from a command run
|
||||
that day on the integrated tree — five lanes' uncommitted work in one working copy — in fresh headless
|
||||
contexts off port-isolated no-store servers, seed 20261990, 1280×720. Summary + the round's tables live
|
||||
in [`F-progress.md`](../../F-progress.md) § Round 41. **No git command was run. No tag.**)*
|
||||
|
||||
### 41-A — THE WARDROBE SEAM (Lane C's ask #1) — the two edits, and why they are shaped that way
|
||||
|
||||
Lane C's §41.4 wardrobe is a stock pack under its **own base**. Two things must agree or it fails
|
||||
silently to the procedural canvas: the boot **preload** (because `getStockPack` is synchronous and the
|
||||
room is built the instant the door opens) and the base string used at **build time** (because C's cache
|
||||
identity is the literal `(type, base)` pair since R24). The R23/R24 bug was exactly this — two spellings
|
||||
of a base, a cache miss, and a real shop quietly showing parody. So the base is a constant and the
|
||||
resolution is a function, exported from one file and imported by the other:
|
||||
|
||||
```js
|
||||
// web/js/world/interior_mode.js [Lane F R41 §41.6]
|
||||
export const WARDROBE_BASE = 'assets/wardrobe/';
|
||||
export function stockBaseFor(shop) {
|
||||
return godverseBaseFor(shop) || (shop && shop.type === 'opshop' ? WARDROBE_BASE : undefined);
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence is deliberate**: a shop carrying A's `godverseShopId` keeps its OWN pack — a real shop's
|
||||
real stock outranks town-wide art. Measured: G's manifest lists 15 shops over `record`/`book`/`toy`
|
||||
only, no `opshop`, so that arm is defensive rather than live today.
|
||||
|
||||
Consumed in exactly two places (`enter()`'s `gBase`, and `stockInfo`'s `base`), and in `index.html`
|
||||
one preload inside the existing `STOCK_REAL` gate.
|
||||
|
||||
**One drift risk, recorded rather than fixed:** `web/interior_test.html:93` declares its own
|
||||
`const WARDROBE_BASE = 'assets/wardrobe/'`. Both literals read the same today (checked), and that page
|
||||
is Lane C's, not F's — but they are now two spellings of one cache key, which is the exact shape of the
|
||||
R23 bug. **→ LANE C:** if the wardrobe ever moves, import the constant from `interior_mode.js` (or
|
||||
publish it from `stockpack.js` and let both sides import it) rather than editing two files. **Every arm of the wardrobe is therefore behind
|
||||
`?stock=real`, which is opt-in** (`params.get('stock') === 'real' && !NOASSETS`) — so the default boot
|
||||
and `?noassets=1` fetch nothing, and the canvas is what ships.
|
||||
|
||||
**The A/B that proves "nothing breaks without it".** A control tree with F's two edits reverted:
|
||||
`web/` copied file-by-file, `assets`/`models`/`vendor` **symlinked to the live tree** so another lane's
|
||||
mid-round wave is byte-identical on both arms, served on its own no-store port. Town frozen
|
||||
(`pop=0&weather=0&tram=0&magpie=0&washing=0`) — every one of those moves on wall-clock time (R38's
|
||||
recorded trap) and an unfrozen town measured twice differs from itself; the first run of this A/B
|
||||
without the freeze showed a 3-draw "difference" that was two browsers walking. Frozen, the default arm
|
||||
is identical on **every** counter (160 URLs same hash, 131/18,018 street, 59/28,505 interior, 199
|
||||
geometries, 111 textures) and the `?stock=real` arm differs by **exactly two URLs**.
|
||||
|
||||
### 41-B — THE DENY-LIST GATE (ruling 3) — `tools/qa/r41_denylist.mjs`
|
||||
|
||||
Two scans, because they answer different questions:
|
||||
|
||||
| scan | what it catches |
|
||||
|---|---|
|
||||
| **paths** — no banned component anywhere under `web/` | a wildcard copy that landed the directory |
|
||||
| **bytes** — every file, binaries included, folded to lower-case and searched for seven literals | a GLB whose JSON chunk still names its source mesh; a manifest row naming the path it was published from; a symlink pointing into a banned tree (the link TARGET is scanned, not followed) |
|
||||
|
||||
The byte scan is the one that matters and it is why the gate reads 197 MiB in 0.8 s rather than
|
||||
grepping the text files: *"nothing references it"* is a claim about source, *"nothing contains it"* is
|
||||
the claim a licence audit actually asks.
|
||||
|
||||
**The control runs on every invocation, not on demand.** The gate writes a plausible plant — a manifest
|
||||
row naming `~/Documents/character_kit_modular/exports/bodies/body_07.glb` — into `web/assets/`, asserts
|
||||
RED, removes it in a `finally`, and asserts the tree returns to its pre-control hit count. It also
|
||||
removes a stale plant from a crashed earlier run before it starts, so a killed gate cannot poison the
|
||||
tree silently. R25's law: a deny-list that has never fired is indistinguishable from one with a typo in
|
||||
its regex.
|
||||
|
||||
### 41-C — WHY F WROTE ITS OWN INTERIOR SWEEP
|
||||
|
||||
C's §41.4-0 found that `drawSweep` — the ≤350 law's own instrument, which F mirrors in
|
||||
`r39_transmission.py` — cleared every interior from the scene **except `current`**, so every reading
|
||||
carried a constant offset from an unrelated second room. The correct response to "the instrument was
|
||||
wrong" is not to re-read the instrument. `r41_integration.py` arm 2 is F's own loop with F's own scene
|
||||
bookkeeping, and it ships the **phantom control**: the same loop with one stale room deliberately left
|
||||
in the scene, which is the pre-R41 behaviour reproduced on purpose.
|
||||
|
||||
- F's sweep, GLB on: **123 @ `dept/auto`** — and C's instrument reads 123 today too, twice. Agreement
|
||||
between two independent instruments is the evidence; C's published **122** is 1 draw light against
|
||||
this tree, and the same 1–4 draw spread appears in the `noKit` arm, so it is not the kit.
|
||||
- phantom arm: **197 @ `dept/auto`, +74 on every reading** — which decomposes R39/R40's "worst 188"
|
||||
as 116 of dept plus ~72 of a record shop nobody was looking at, exactly as C said.
|
||||
- **Margin is 227, not 162.** Two rounds of proposals were sized against a number that included a room
|
||||
that was not on screen.
|
||||
|
||||
### 41-D — THE SCREENSHOT-GOLDEN RULING (Lane B's finding, extended)
|
||||
|
||||
Measured independently — `?classic=1`, town frozen, 2.5 s settle, sha256 of the PNG, three boots per
|
||||
bookmark: `street_noon`, `shopfront_detail`, `crossroads_busy` **and `market_square`** all give **3
|
||||
distinct hashes over 3 boots**. Only `night_neon` is stable (1 of 3). B measured `market_square` as
|
||||
rock-solid; on this tree it is not.
|
||||
|
||||
**Ruling for the gate: no screenshot byte-hash, on any bookmark, for as long as the sign atlas paints
|
||||
`bold ${fs}px Arial` before the font has resolved.** One stable bookmark is not a golden suite, and a
|
||||
"golden" that is green four times in five teaches the next reader to ignore it. Every R41 assertion is
|
||||
a **counter** — draw calls, triangles, fetch surface, item ids, texture URLs, stature — and every
|
||||
counter reproduces exactly. The tour shots are evidence, captioned with the counters that are gated,
|
||||
and `docs/shots/laneF_r41/README.md` says so in the folder so nobody mistakes them for goldens.
|
||||
**→ B, for R42:** one `await document.fonts.ready` before the first sign-atlas paint in `buildings.js`
|
||||
is cheaper than a perceptual-compare harness and fixes the cause rather than the symptom.
|
||||
|
||||
### 41-E — THE TOUR HARNESS (`tools/qa/r41_tour.py`) — three things it had to learn
|
||||
|
||||
1. **Search and measure in the SAME evaluate.** Postures run on 6–20 s dwells. The first cut searched
|
||||
for a camera in one `evaluate` and measured the frame in the next; by then the sitter had stood up,
|
||||
and it shot four walkers — twice, with the harness reporting success both times.
|
||||
2. **Only 2–10 of the ~150 active citizens are near-tier RIGS at any instant** (measured over five
|
||||
bookmarks × four samples); impostors carry no posture. So one frame cannot hold all three new street
|
||||
states, `crossroads_busy` is where the rigs are, and the tour ships a supporting per-anchor frame
|
||||
(`street_lean.jpg`) rather than pretending.
|
||||
3. **Read the clip off the MIXER, never off `c.posture`.** `posture` is the *assignment* — a pure
|
||||
function of `(citySeed, id)` that exists whether or not the library ever loads. Reading it made the
|
||||
`?clips=0` control caption claim `walk_shopping_bag` on a boot that had fetched nothing. Off the
|
||||
mixer, the control honestly reads **1 clip** against the default's **3**.
|
||||
|
||||
Same class of error, three times, in one harness: *the note about the thing is not the thing.*
|
||||
|
||||
### 41-F — GATE 10 IN `qa.sh`
|
||||
|
||||
Node/stdlib half always runs (`clips_verify.py` · `r41_postures.mjs` · `r41_denylist.mjs`); browser
|
||||
half rides the Playwright venv and skips with `--no-r41` (`r41_citizens.py` · `r41_shots.py` ·
|
||||
`r41_integration.py`). D's shot gate is wired to `${PROCITY_SHOT_OUT:-$TMPDIR/procity-r41-shots}` so a
|
||||
routine run never rewrites a lane's committed shots — the gate exercises the full camera-measurement
|
||||
and stature path either way; only the destination changes.
|
||||
|
||||
**One of the six is warn-level and the reason is written at the call site**, per the R6 policy for a
|
||||
gate that can go red for a reason unrelated to the property it asserts: `r41_shots.py`'s browse arm
|
||||
loses its browser to the sim between the candidate scan and the frame (measured, three consecutive
|
||||
identical failures; the street arm is solid). A `run_gate` there would fail a round because a shopper
|
||||
went home. The warn prints on every run, the finding is filed to D with the fix, and `--strict` stays
|
||||
answerable for properties the code actually controls.
|
||||
|
||||
`tools/qa/r41_tour.py` is deliberately **not** in `qa.sh`: it holds up to 70 polls waiting for the
|
||||
crowd to offer the frame, and everything it asserts (the wardrobe both arms, stature, the budgets) is
|
||||
already gated by `r41_integration.py` and `r41_shots.py` in seconds. It is the round's camera, not its
|
||||
gate — run it by hand when the pictures need re-shooting.
|
||||
|
||||
## §40 — R40: THE PLAYTEST HARNESS (§40.6, the keystone) + the wave-1 asks. F8 turns John's session into tickets.
|
||||
|
||||
*(PROCITY-F, 2026-08-04. The no-rulings round: A/B/D/E landed first, F consumed. All numbers below
|
||||
|
||||
25
docs/shots/laneF_r41/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# R41 §41.6 — the proof tour (Lane F)
|
||||
|
||||
Regenerate: `tools/.venv/bin/python tools/qa/r41_tour.py` (seed 20261990, fresh headless context per
|
||||
frame, own no-store server, 1280×720). Every camera is derived from where the thing actually is and
|
||||
then the frame is checked for what is actually in it; every frame with a humanoid carries the R10
|
||||
human-sized line in its `.txt` sidecar (stature feet→crown off the **posed** skeleton, as a ratio of
|
||||
that citizen's own nominal height — fails on a giant >2.0 m or a fold <55%, never on an absolute
|
||||
seated band).
|
||||
|
||||
**These are EVIDENCE, not goldens.** No R41 gate pins a screenshot hash: measured on this tree,
|
||||
4 of the 5 classic bookmarks are not byte-reproducible across boots (see `F-progress.md` § Round 41,
|
||||
ask 4). Everything asserted is a counter — draws, tris, fetch surface, item ids, texture URLs,
|
||||
stature — and every counter in these captions is reproducible.
|
||||
|
||||
| frame | what it proves |
|
||||
|---|---|
|
||||
| `street_postures.jpg` | D's §41.3 headline: 5 rigs, **3 distinct clips actually playing** (`Walk`, `walk_shopping_bag`, `sit_impatient`), a citizen on a real Lane B bench. 173 draws of ≤300. |
|
||||
| `street_postures_clips0.jpg` | **the control** — the same seed, the same camera, `?clips=0`: 4 rigs, **1 clip** (`Walk`), clip bank 0/0/0 B. This is R40's street. |
|
||||
| `street_lean.jpg` | the third new street state: a citizen on `lean_wall_leg_up` against a shopfront + 4 walkers. |
|
||||
| `browse_interior.jpg` | a browser at a Lane C browse point on `browse_hold_idle` — holding something, not standing to attention — with the keeper on his own seeded idle. |
|
||||
| `pub_furnished.jpg` | the hero: the pub on gig night. The band and the crowd are the R13 gig layer, **not** R41 cargo — which is why the fittings claim is measured in the pair below and not here. |
|
||||
| `pub_kit_after.jpg` / `pub_kit_before.jpg` | the honest A/B: same pub, same seed, same camera, 18:30 so no gig crowd on either arm. `noKit:true` drops exactly the R41 specs: **13 → 8 fittings, 49 → 38 draws, 49,708 → 6,948 tris**. |
|
||||
| `record_dj_booth.jpg` | the rigged DJ booth: `deck_1200_rigged` (platter + tonearm) and `mixer_ttm54i_rigged` on the booth bench. Placed and contract-recorded; nothing drives them (C §41.4-7). |
|
||||
| `opshop_wardrobe.jpg` / `opshop_wardrobe_off.jpg` | the wardrobe pair: same shop, same camera. ON = 52 named 1990s Australian garments off ONE 246 KB atlas, 69 on rendered meshes. OFF = the procedural canvas, **which is what ships by default** (`?stock=real` is opt-in). |
|
||||
| `credits_panel.jpg` | Lane B's §41.5 surface on an OSM boot: the HUD link reads `© OpenStreetMap contributors · credits (F2)` and the panel prints the ODbL credit verbatim. |
|
||||
BIN
docs/shots/laneF_r41/browse_interior.jpg
Normal file
|
After Width: | Height: | Size: 327 KiB |
5
docs/shots/laneF_r41/browse_interior.txt
Normal file
@ -0,0 +1,5 @@
|
||||
PROCITY R41 §41.6 — browse_interior
|
||||
seed 20261990, The Book Cellar (book), segment 2. 8 fittings placed · 2 figures in the room (1 at Lane C browse points) · camera [1.03, 1.89] looking at [0.43, -1.05] (Lane C's own room.spawn line).
|
||||
40 draws of the ≤350 interior law (33841 tris).
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 2 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[idle_happy_1] 1.763m (100% of its 1.758m nominal) · browser[browse_close_cabinet] 1.736m (95% of its 1.819m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/credits_panel.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
4
docs/shots/laneF_r41/credits_panel.txt
Normal file
@ -0,0 +1,4 @@
|
||||
PROCITY R41 §41.6 — credits_panel
|
||||
seed 20261990, ?plansrc=osm&town=katoomba — an OSM boot, so the obligation is live and the HUD link itself carries it: '© OpenStreetMap contributors · credits (F2)'.
|
||||
F2 opens Lane B's §41.5 surface. On screen: the © OpenStreetMap contributors credit verbatim (True), the ODbL 1.0 licence name (True), and the Mixamo row that records why R41's 46 clips owe no attribution (True).
|
||||
Zero draws by construction (DOM), zero boot fetches (credits.json is fetched on first open).
|
||||
BIN
docs/shots/laneF_r41/opshop_wardrobe.jpg
Normal file
|
After Width: | Height: | Size: 315 KiB |
6
docs/shots/laneF_r41/opshop_wardrobe.txt
Normal file
@ -0,0 +1,6 @@
|
||||
PROCITY R41 §41.6 — opshop_wardrobe
|
||||
seed 20261990, Second Time Redfern (opshop), midday. Camera [-0.02, 0.94] → [-1.75, -1], IDENTICAL on both arms.
|
||||
?stock=real — Lane C's wardrobe pack, turned on by F's one line: base assets/wardrobe/, 52 named 1990s Australian garment layers off ONE 246 KB atlas, 69 of them on rendered meshes (Puppy Jumper, Splatter Smiley Tee, Squiggle Knit Dress, Trackie Dacks, Loud Art Shirt, Silver Phat Pants…).
|
||||
41 draws of the ≤350 interior law (19648 tris).
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 1 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[idle_happy_2] 1.845m (101% of its 1.823m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/opshop_wardrobe_off.jpg
Normal file
|
After Width: | Height: | Size: 304 KiB |
6
docs/shots/laneF_r41/opshop_wardrobe_off.txt
Normal file
@ -0,0 +1,6 @@
|
||||
PROCITY R41 §41.6 — opshop_wardrobe_off
|
||||
seed 20261990, Second Time Redfern (opshop), midday. Camera [-0.02, 0.94] → [-1.75, -1], IDENTICAL on both arms.
|
||||
DEFAULT BOOT (the control, and what ships) — no pack, so Lane C's procedural garment canvas, byte-identically. Nothing breaks without the wardrobe.
|
||||
52 draws of the ≤350 interior law (19996 tris).
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 1 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[idle_happy_2] 1.841m (101% of its 1.823m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/pub_furnished.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
5
docs/shots/laneF_r41/pub_furnished.txt
Normal file
@ -0,0 +1,5 @@
|
||||
PROCITY R41 §41.6 — pub_furnished (the hero: gig night)
|
||||
seed 20261990, The Exchange Hotel (pub), segment 5, ?gigs=1. 13 fittings placed by §41.4 — the plywood bar, the DJ booth, the speaker stack, the jukebox, the couch, the bistro table. The band and the crowd are the R13 gig layer, NOT R41 cargo: the fittings claim is measured in the pub_kit pair, not here.
|
||||
71 draws of the ≤350 interior law (222526 tris). Camera [2.38, 3.66] → [0, -0.97].
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 1 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[venue_bartending] 1.384m (83% of its 1.676m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/pub_kit_after.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
5
docs/shots/laneF_r41/pub_kit_after.txt
Normal file
@ -0,0 +1,5 @@
|
||||
PROCITY R41 §41.6 — pub_kit_after (AFTER — the R41 kit)
|
||||
seed 20261990, The Exchange Hotel (pub), 18:30 (segment 4) — open, gig not on, so no band and no crowd on either arm and nothing but the furniture differs. 13 fittings.
|
||||
49 draws / 49708 tris. Camera [2.38, 3.66] → [0, -0.97], identical on both arms.
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 1 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[venue_bartending] 1.191m (71% of its 1.676m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/pub_kit_before.jpg
Normal file
|
After Width: | Height: | Size: 267 KiB |
4
docs/shots/laneF_r41/pub_kit_before.txt
Normal file
@ -0,0 +1,4 @@
|
||||
PROCITY R41 §41.6 — pub_kit_before (BEFORE — the CONTROL)
|
||||
The SAME shop, the SAME seed, the SAME camera, rebuilt with Lane C's `noKit: true`, which drops exactly the R41 specs and reproduces the pre-R41 room.
|
||||
fittings 13 → 8 · draws 49 → 38 (-11) · tris 49708 → 6948. That difference is what §41.4 bought in this room, measured — and nothing else in the frame moved.
|
||||
NOTE: the keeper rides the room group and is disposed with it, so the control frame has no shopkeeper. Every OTHER difference in the picture is the kit.
|
||||
BIN
docs/shots/laneF_r41/record_dj_booth.jpg
Normal file
|
After Width: | Height: | Size: 281 KiB |
5
docs/shots/laneF_r41/record_dj_booth.txt
Normal file
@ -0,0 +1,5 @@
|
||||
PROCITY R41 §41.6 — record_dj_booth
|
||||
seed 20261990, The Kelpie Sound Co. (record), segment 2. 18 fittings placed · 1 figures in the room (0 at Lane C browse points) · camera [1.69, -2.31] looking at [1.75, -4.81] (Lane C's own room.spawn line).
|
||||
40 draws of the ≤350 interior law (36252 tris).
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 1 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: keeper[venue_headphones] 1.683m (98% of its 1.718m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/street_lean.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
5
docs/shots/laneF_r41/street_lean.txt
Normal file
@ -0,0 +1,5 @@
|
||||
PROCITY R41 §41.6 — street_lean (supporting frame: the lean state)
|
||||
seed 20261990, midday, camera (22.52, -241.90) → (22.52, -248.90).
|
||||
5 rigs in frame · states ['lean', 'walk'] · clips ['Walk', 'lean_wall_leg_up'] · 129 draws of the ≤300 street law.
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 5 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: lean[lean_wall_leg_up] 1.224m (79% of its 1.55m nominal) · walk[Walk] 1.506m (96% of its 1.569m nominal) · walk[Walk] 1.614m (97% of its 1.669m nominal) · walk[Walk] 1.796m (98% of its 1.837m nominal) · walk[Walk] 1.747m (97% of its 1.806m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/street_postures.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
6
docs/shots/laneF_r41/street_postures.txt
Normal file
@ -0,0 +1,6 @@
|
||||
PROCITY R41 §41.6 — street_postures
|
||||
seed 20261990, midday (seg 2), camera (8.58, 16.69) looking at (12.08, 10.63) — the SAME pose on both arms.
|
||||
DEFAULT BOOT (R41): the motion library is on.
|
||||
5 rigs in frame · posture states ['bench-sit', 'walk'] · 3 distinct clips (Walk, sit_impatient, walk_shopping_bag) · 173 draws of the ≤300 street law (43562 tris) · clip bank 3 groups / 24 clips / 1600384 B.
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 5 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: bench-sit[sit_impatient] 1.263m (69% of its 1.836m nominal) · walk[walk_shopping_bag] 1.74m (97% of its 1.799m nominal) · walk[walk_shopping_bag] 1.742m (97% of its 1.8m nominal) · walk[Walk] 1.783m (97% of its 1.846m nominal) · walk[Walk] 1.729m (96% of its 1.793m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
BIN
docs/shots/laneF_r41/street_postures_clips0.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
6
docs/shots/laneF_r41/street_postures_clips0.txt
Normal file
@ -0,0 +1,6 @@
|
||||
PROCITY R41 §41.6 — street_postures_clips0
|
||||
seed 20261990, midday (seg 2), camera (8.58, 16.69) looking at (12.08, 10.63) — the SAME pose on both arms.
|
||||
CONTROL ?clips=0 (= R40): the motion library is OFF and nothing else changes.
|
||||
4 rigs in frame · posture states ['walk'] · 1 distinct clips (Walk) · 174 draws of the ≤300 street law (42005 tris) · clip bank 0 groups / 0 clips / 0 B.
|
||||
|
||||
HUMAN-SIZED LINE (R10) — 4 figure(s) in frame, stature (feet→crown) measured off the posed skeleton: walk[Walk] 1.766m (96% of its 1.836m nominal) · walk[Walk] 1.778m (99% of its 1.799m nominal) · walk[Walk] 1.75m (97% of its 1.8m nominal) · walk[Walk] 1.775m (96% of its 1.846m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
|
||||
@ -2212,10 +2212,20 @@ def smoke_djdance(p):
|
||||
const crew = P.interiorMode.crew;
|
||||
const dancers = crew ? crew.members.filter(m => m.part === 'crowd' && m.dance) : [];
|
||||
const durs = new Set(clips.map(c => +c.duration.toFixed(2)));
|
||||
// [Lane F R41 §41.6] READ THE ACTION THAT IS RUNNING, NOT `_actions[0]`. Until R41 a crowd
|
||||
// rig's mixer held exactly one action, so index 0 was the dancer's clip. D's §41.3 gives
|
||||
// every actor per-instance clip swapping and the gig widening installs a venue clip into
|
||||
// some slots, so the mixer now holds SEVERAL actions and index 0 need not be the live one
|
||||
// — nor the same one 700 ms later. That is how this arm went red on a perfectly healthy
|
||||
// dancer: t 24.19 -> "tAfter" 14.21, which is not a stall, it is two different actions.
|
||||
// Identify by clip NAME and follow that clip across the window.
|
||||
const liveAct = (mx) => { const a = (mx ? mx._actions : []) || [];
|
||||
return a.find(x => x.getEffectiveWeight() > 0.5) || a[0] || null; };
|
||||
const before = dancers.map(m => {
|
||||
const acts = m.actor.mixer ? m.actor.mixer._actions : [];
|
||||
return { kind: m.kind, clipDur: acts.length ? +acts[0]._clip.duration.toFixed(2) : null,
|
||||
t: acts.length ? acts[0].time : null, y: m.actor.fig.position.y };
|
||||
const act = liveAct(m.actor.mixer);
|
||||
return { kind: m.kind, clipDur: act ? +act._clip.duration.toFixed(2) : null,
|
||||
clipName: act ? act._clip.name : null,
|
||||
t: act ? act.time : null, y: m.actor.fig.position.y };
|
||||
});
|
||||
let minY = dancers.map(m => m.actor.fig.position.y), maxY = minY.slice();
|
||||
for (let k = 0; k < 14; k++) {
|
||||
@ -2223,13 +2233,17 @@ def smoke_djdance(p):
|
||||
dancers.forEach((m, i) => { const y = m.actor.fig.position.y;
|
||||
minY[i] = Math.min(minY[i], y); maxY[i] = Math.max(maxY[i], y); });
|
||||
}
|
||||
const after = dancers.map(m => {
|
||||
const acts = m.actor.mixer ? m.actor.mixer._actions : [];
|
||||
return acts.length ? acts[0].time : null;
|
||||
const after = dancers.map((m, i) => {
|
||||
const acts = (m.actor.mixer ? m.actor.mixer._actions : []) || [];
|
||||
const same = acts.find(x => x._clip.name === before[i].clipName);
|
||||
const act = same || liveAct(m.actor.mixer);
|
||||
return act ? { t: act.time, name: act._clip.name, dur: +act._clip.duration.toFixed(2) } : null;
|
||||
});
|
||||
D.exitShop();
|
||||
return { nClips: clips.length, census,
|
||||
dancers: before.map((d, i) => ({ ...d, tAfter: after[i],
|
||||
dancers: before.map((d, i) => ({ ...d,
|
||||
tAfter: after[i] ? after[i].t : null,
|
||||
clipAfter: after[i] ? after[i].name : null,
|
||||
isDance: d.clipDur != null && durs.has(d.clipDur),
|
||||
amp: +(maxY[i] - minY[i]).toFixed(4) })) };
|
||||
}""")
|
||||
@ -2246,7 +2260,17 @@ def smoke_djdance(p):
|
||||
FAIL('gig night: no dancers in the crowd — the subject is absent')
|
||||
else:
|
||||
not_dance = [d for d in dn if not d['isDance'] or d['kind'] != 'rig']
|
||||
stalled = [d for d in dn if d['tAfter'] is not None and d['t'] is not None and d['tAfter'] <= d['t']]
|
||||
# [Lane F R41 §41.6] "advancing" is measured MODULO THE CLIP, and only for a dancer still
|
||||
# on the same clip. A 30.58 s loop sampled across its wrap point reads tAfter < t and is
|
||||
# advancing perfectly; a slot that legitimately swapped clip inside the 700 ms window is
|
||||
# not comparable at all. Both used to read as "page stalled".
|
||||
def _advanced(d):
|
||||
if d['t'] is None or d['tAfter'] is None: return False
|
||||
if d.get('clipAfter') and d.get('clipName') and d['clipAfter'] != d['clipName']: return True
|
||||
dur = d['clipDur'] or 0
|
||||
if dur <= 0: return d['tAfter'] > d['t']
|
||||
return ((d['tAfter'] - d['t']) % dur) > 1e-4
|
||||
stalled = [d for d in dn if not _advanced(d)]
|
||||
bouncing = [d for d in dn if d['amp'] > 0.02]
|
||||
if not_dance: FAIL(f"gig night: dancer NOT driven by a dance clip: {not_dance[:2]}")
|
||||
elif stalled: FAIL(f"gig night: dancer mixer not advancing (page stalled?) — amplitude read is vacuous: {stalled[:2]}")
|
||||
@ -2535,26 +2559,46 @@ def _counter_sell(pg, max_sales=10):
|
||||
|
||||
|
||||
def _enter_record_shop(pg):
|
||||
"""Midday, first OPEN record shop whose counter sits > 2.6 m from every bin (so counter-E can
|
||||
never fall into binUnderAim's 2.5 m nearest-bin fallback — the routing's dig branch would win).
|
||||
Returns the shop id it settled in, or an err."""
|
||||
"""Midday, an OPEN record shop whose counter sits > 2.6 m from every bin (so counter-E can never
|
||||
fall into binUnderAim's 2.5 m nearest-bin fallback — the routing's dig branch would win).
|
||||
Returns the shop id it settled in, or an err.
|
||||
|
||||
[Lane F R41 §41.6] THE SEARCH WINDOW WAS `slice(0, 6)` AND R41 CLOSED IT. Lane C's §41.4 gives
|
||||
every record shop 2-4 kit bins on top of the recipe's crates (measured, this tree: 2-10 bins per
|
||||
room, mean 8.3), so the counter-to-nearest-bin distance came down across the board and the first
|
||||
six shops now read 2.36 / 2.02 / 2.26 / 2.21 / 2.21 / 2.10 m — all inside 2.6, and eight smokes
|
||||
went RED on the same line. Measured over the first twelve instead: **3 of 12 still clear the bar**
|
||||
(`Colonial Records` 2.80 · `Bluey's Records` 2.79 · `Gazza's Records` 2.84), so the requirement is
|
||||
still satisfiable and this is the harness's window being too small, not the game losing the
|
||||
property. Widened to the whole open set, and it now REPORTS the shop and the margin it found so
|
||||
the next narrowing is visible in the log instead of arriving as eight failures.
|
||||
|
||||
The routing itself was verified independently before this was called a harness fix (F, R41): the
|
||||
shell orders `aimed bin → trySell → nearest bin`, so the sell card wins at the counter regardless
|
||||
of the fallback radius; the 2.6 m rule is belt-and-braces for the AIM, not the ordering."""
|
||||
return pg.evaluate("""() => {
|
||||
const P = window.PROCITY, D = window.DBG; D.setSegment(2);
|
||||
const recs = (P.plan.shops || []).filter(s => s.type === 'record' && P.isOpen(s));
|
||||
if (!recs.length) return { err: 'no open record shop' };
|
||||
for (const s of recs.slice(0, 6)) {
|
||||
let bestMargin = 0, bestName = null, tried = 0;
|
||||
for (const s of recs) {
|
||||
tried++;
|
||||
D.enterShop(s.id);
|
||||
const room = P.interiorMode.current;
|
||||
const c = room.counter && room.counter.pose;
|
||||
const bins = []; room.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||
if (c && bins.length) {
|
||||
const wp = new P.THREE.Vector3();
|
||||
const near = bins.some(b => { b.getWorldPosition(wp); return Math.hypot(wp.x - c.x, wp.z - c.z) < 2.6; });
|
||||
if (!near) return { shop: s.id, name: s.name, bins: bins.length };
|
||||
let dmin = Infinity;
|
||||
for (const b of bins) { b.getWorldPosition(wp); dmin = Math.min(dmin, Math.hypot(wp.x - c.x, wp.z - c.z)); }
|
||||
if (dmin > bestMargin) { bestMargin = dmin; bestName = s.name; }
|
||||
if (dmin >= 2.6) return { shop: s.id, name: s.name, bins: bins.length,
|
||||
counterToNearestBin: +dmin.toFixed(2), tried };
|
||||
}
|
||||
D.exitShop();
|
||||
}
|
||||
return { err: 'no record shop with counter clear of bins in the first 6' };
|
||||
return { err: `no open record shop has its counter >=2.6 m from every bin (${tried} tried; ` +
|
||||
`roomiest was ${bestName} at ${bestMargin.toFixed(2)} m)` };
|
||||
}""")
|
||||
|
||||
|
||||
|
||||
69
tools/qa.sh
@ -13,6 +13,9 @@
|
||||
# 2. citygen selfcheck — node web/js/citygen/selfcheck.js (Lane A ships it)
|
||||
# 3. manifest validator — python3 pipeline/validate_manifest.py (Lane E ships it)
|
||||
# 8. the R39 (v9 wave 1) gates — tools/qa/r39_address.mjs · r39_transmission.py · r39_runtime.py
|
||||
# 9. the R40 gates — tools/qa/door_footpath_check.mjs · r40_lane_b.py · r40_playtest.py
|
||||
# 10. the R41 (THE LIBRARY LANDS) gates — pipeline/clips_verify.py · tools/qa/r41_postures.mjs ·
|
||||
# r41_denylist.mjs · r41_citizens.py · r41_shots.py · r41_integration.py
|
||||
#
|
||||
# Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running
|
||||
# game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands.
|
||||
@ -185,6 +188,72 @@ else
|
||||
soft_skip "R40 browser gates (budget law · selector ruling · playtest harness)" "Playwright venv absent"
|
||||
fi
|
||||
|
||||
# ── Gate 10 (Lane F, round 41): THE LIBRARY LANDS ────────────────────────────
|
||||
# [Lane F R41 §41.6] Six gates, wired per each lane's §41 ask. The node/stdlib half always runs
|
||||
# (fast, zero deps); the browser half rides the Playwright venv like every other class here.
|
||||
#
|
||||
# • E §41.1 — pipeline/clips_verify.py: CLIP-MANIFEST INTEGRITY. Every clip id in
|
||||
# web/assets/motion_manifest.json resolves to a named animation in its group GLB, sizes match,
|
||||
# all six groups are zero-draw and Draco-free, all six carry the identical 66-node skeleton,
|
||||
# every channel targets a real node, every declared duration equals the keyframes in the bytes.
|
||||
# Ships with the library, so a renamed clip or a re-packed group fails HERE, not in the game.
|
||||
# • D §41.3 — r41_postures.mjs: the posture table resolves, is deterministic (two module
|
||||
# instances byte-equal), stream-isolated from the 12 pre-R41 keys, spread across all 10 idles,
|
||||
# and loop-safe. Five controls in the same run.
|
||||
# • F §41.6 — r41_denylist.mjs: RULING 3 AS REPO LAW. Nothing under web/ may reference OR CONTAIN
|
||||
# a banned name — paths AND bytes, binaries included (a GLB carries its source node names). The
|
||||
# control plants a real banned reference, asserts RED, removes it, asserts GREEN: this gate can
|
||||
# never go quietly vacuous.
|
||||
# • D §41.3 — r41_citizens.py: 7 browser arms (boot ledger · lazy loading · determinism incl. a
|
||||
# 2 s forced clip stall · the draw table both arms · ?noassets/?classic · bench binding + its
|
||||
# 2 m-offset control · leak · gig widening).
|
||||
# • D §41.3 — r41_shots.py: the acceptance shots, camera chosen by measurement + occlusion
|
||||
# raycast, every figure measured for stature (the R10 no-giants line). Written to a scratch
|
||||
# outdir by default so a gate run never rewrites the round's committed shots; set
|
||||
# PROCITY_SHOT_OUT=docs/shots/laneD to regenerate them deliberately. WARN-LEVEL — see the
|
||||
# measured reason at the call site (its browse arm can lose its browser to the sim mid-scan).
|
||||
# • F §41.6 — r41_integration.py: the seams F wired and the budgets F answers for — the wardrobe
|
||||
# both arms (ON resolves the pack, OFF is the control and is what ships default), the interior
|
||||
# ≤350 law on F'S OWN sweep plus the PHANTOM control that reproduces the pre-R41 instrument bug,
|
||||
# ?noassets=1 clean over a three-shop walk, ?clips=0 / ?classic=1 carrying no R41 cargo, and the
|
||||
# deny-list checked again from the network side.
|
||||
if [ -f pipeline/clips_verify.py ]; then
|
||||
run_gate "R41 clip-manifest integrity (E §41.1: 46 clips resolve · zero-draw · one skeleton · durations in the bytes)" \
|
||||
python3 pipeline/clips_verify.py
|
||||
else
|
||||
skip_gate "R41 clip-manifest integrity" "pipeline/clips_verify.py not landed yet (Lane E)"
|
||||
fi
|
||||
|
||||
run_gate "R41 postures (D §41.3: manifest resolution · determinism · stream isolation · pool spread · loop safety)" \
|
||||
node tools/qa/r41_postures.mjs
|
||||
|
||||
run_gate "R41 deny-list (F §41.6 / ruling 3: web/ clean by path AND bytes · plant-and-remove control)" \
|
||||
node tools/qa/r41_denylist.mjs
|
||||
|
||||
R41_SKIP=0; for a in "$@"; do [ "$a" = "--no-r41" ] && R41_SKIP=1; done
|
||||
if [ "$R41_SKIP" = 1 ]; then
|
||||
soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "--no-r41"
|
||||
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
run_gate "R41 citizens (D §41.3: boot ledger · lazy · determinism under a 2s stall · bench binding + control · leak)" \
|
||||
tools/.venv/bin/python tools/qa/r41_citizens.py
|
||||
# WARN-LEVEL, and the reason is measured, not a shrug (R6 policy for a gate that can go red for a
|
||||
# reason unrelated to the property it asserts). Its street arm is solid — 8 near-tier rigs, 3 distinct
|
||||
# clips, every stature clean, reproducible run to run. Its BROWSE arm is not: it scans up to 8 shops
|
||||
# for browsers, keeps the best, then RE-ENTERS the winner — and a patronage occupant is free to walk
|
||||
# out during the ~3 s per candidate that scan costs, so the re-entry spawns 0 browsers and the arm
|
||||
# fails on an empty room. Measured on this tree: 3 consecutive standalone runs all land on `T & R
|
||||
# Books` with figures 1 (keeper only), byte-identical (34 draws / 36,751 tris), while the same gate
|
||||
# passed with figures 2 earlier the same day under different machine load. Nothing in R41's shipped
|
||||
# code moved between those runs. FILED TO D (F-progress §Round 41): re-check `k.browse` after the
|
||||
# re-entry and re-pick, or keep the winner entered instead of re-entering it.
|
||||
warn_gate "R41 acceptance shots (D §41.3: measured camera · occlusion raycast · R10 stature line per figure)" \
|
||||
tools/.venv/bin/python tools/qa/r41_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r41-shots}"
|
||||
run_gate "R41 integration (F §41.6: wardrobe both arms · interior ≤350 on F's sweep + phantom control · noassets · clip gates · deny-list live)" \
|
||||
tools/.venv/bin/python tools/qa/r41_integration.py
|
||||
else
|
||||
soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "Playwright venv absent"
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
hr
|
||||
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \
|
||||
|
||||
@ -156,7 +156,14 @@ def glass_root(glb_name=BOOKSHELF, material=MATERIAL):
|
||||
|
||||
# ── the sweep ────────────────────────────────────────────────────────────────────────────────────
|
||||
def sweep(pg, host, glb):
|
||||
pg.goto(f'{host}/interior_test.html?localdepot=1')
|
||||
# [Lane F R41 §41.6] EXPLICIT NAVIGATION TIMEOUT. `goto` waits for `load` — every subresource —
|
||||
# and inherits Playwright's 30 s default, which is not a measured number for this page. It tripped
|
||||
# once inside a full `qa.sh --strict` run (four browser gates back to back on a loaded box) and
|
||||
# turned an infrastructure hiccup into a RED gate with a stack trace where an assertion should be.
|
||||
# Measured on this tree: the page reaches `load` in **0.4 s over 27 requests**, three runs out of
|
||||
# three, and the gate passes standalone every time. 90 s is therefore ~200x the real cost and
|
||||
# cannot mask a genuine regression — anything approaching it is a different bug entirely.
|
||||
pg.goto(f'{host}/interior_test.html?localdepot=1', timeout=90000)
|
||||
pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000)
|
||||
pg.wait_for_timeout(600)
|
||||
return pg.evaluate('(glb) => window.PROCITY_C.drawSweep({ glb })', glb)
|
||||
|
||||
146
tools/qa/r41_denylist.mjs
Normal file
@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
// PROCITY Lane F — R41 §41.6 THE DENY-LIST GATE (ruling 3 becomes repo law, enforced).
|
||||
//
|
||||
// node tools/qa/r41_denylist.mjs [--verbose]
|
||||
//
|
||||
// ROUND41 ruling 3, verbatim: these never enter `web/` under any circumstance —
|
||||
// • character_kit_modular/exports/bodies/** (44 bodies, 30 named -nsfw, 465k tris, broken bboxes)
|
||||
// • any anatomy/ dir
|
||||
// • elsa_coronation_hair_wig_kh3.glb (Disney/Square Enix IP rip)
|
||||
// • daphne_sexy.glb (unverified rip)
|
||||
// • ~/Documents/mocaponline/** (vendor demo packs, licence unread — includes Epic's
|
||||
// EULA-bound SK_Mannequin.fbx, named in the ruling)
|
||||
//
|
||||
// This is a LICENCE/IP gate, so it is deliberately blunt in two directions at once:
|
||||
//
|
||||
// 1. PATHS — no file under web/ may sit at, or under, a banned path component.
|
||||
// 2. BYTES — no file under web/ may CONTAIN a banned name, in any encoding this repo uses. The scan
|
||||
// reads every byte of every file, binaries included, and that is the point: a GLB carries its
|
||||
// source mesh/node names inside the JSON chunk, and a manifest carries the path it was published
|
||||
// from. "Nothing references it" is a claim about text; "nothing contains it" is a claim about the
|
||||
// bytes that ship, and the second is the one a licence audit asks.
|
||||
//
|
||||
// THE CONTROL RUNS EVERY TIME. A deny-list that has never fired is indistinguishable from a deny-list
|
||||
// with a typo in its regex — the R25 vacuous-gate law. So the gate plants a real banned reference in a
|
||||
// real file under web/, asserts the scan goes RED on it, removes it, and asserts the scan goes GREEN
|
||||
// again. Both arms print. The plant is removed in a `finally`, and its name is asserted absent before
|
||||
// the run starts (so a crashed earlier run cannot poison the tree silently).
|
||||
//
|
||||
// Exit 0 = clean · 1 = a banned reference is in web/, or the control failed to fire.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const WEB = path.join(ROOT, 'web');
|
||||
const VERBOSE = process.argv.includes('--verbose');
|
||||
|
||||
const C = { red: '\x1b[31m', grn: '\x1b[32m', yel: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', off: '\x1b[0m' };
|
||||
let fails = 0;
|
||||
const OK = (m) => console.log(` ${C.grn}✓${C.off} ${m}`);
|
||||
const FAIL = (m) => { console.log(` ${C.red}✗${C.off} ${m}`); fails++; };
|
||||
const NOTE = (m) => console.log(` ${C.yel}·${C.off} ${m}`);
|
||||
const head = (m) => console.log(`\n${C.bold}${m}${C.off}`);
|
||||
|
||||
// ── the list ─────────────────────────────────────────────────────────────────────────────────────
|
||||
// `token`: a byte sequence that must not appear in any file under web/ (matched case-insensitively).
|
||||
// `component`: a path component that must not appear in any path under web/.
|
||||
const BANNED = [
|
||||
{ token: 'character_kit_modular', why: 'ruling 3 — the 44 bodies (30 -nsfw, 465k tris, 0x0x0 bboxes)' },
|
||||
{ token: 'exports/bodies/', why: 'ruling 3 — the bodies export dir, by its own path' },
|
||||
{ token: 'elsa_coronation_hair_wig_kh3', why: 'ruling 3 — Disney/Square Enix IP rip' },
|
||||
{ token: 'daphne_sexy', why: 'ruling 3 — unverified rip' },
|
||||
{ token: 'mocaponline', why: 'ruling 3 — vendor demo packs, licence unread' },
|
||||
{ token: 'SK_Mannequin', why: "ruling 3 — Epic's EULA-bound mannequin, named in the mocaponline clause" },
|
||||
{ token: 'anatomy/', why: 'ruling 3 — any anatomy/ dir' },
|
||||
];
|
||||
const BANNED_COMPONENTS = ['anatomy', 'character_kit_modular', 'mocaponline'];
|
||||
|
||||
// ── the scan ─────────────────────────────────────────────────────────────────────────────────────
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isSymbolicLink()) { out.push({ p, link: true }); continue; }
|
||||
if (e.isDirectory()) walk(p, out);
|
||||
else if (e.isFile()) out.push({ p, link: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Case-insensitive byte search. Tokens are pure ASCII, so lower-casing the haystack bytes in place is
|
||||
// exact — no encoding assumption, works on UTF-8 text and on GLB/webp/jpg binaries alike.
|
||||
const needles = BANNED.map((b) => ({ ...b, bytes: Buffer.from(b.token.toLowerCase(), 'latin1') }));
|
||||
function scanBytes(buf) {
|
||||
const lower = Buffer.from(buf); // copy, then fold A-Z → a-z in place
|
||||
for (let i = 0; i < lower.length; i++) { const c = lower[i]; if (c >= 65 && c <= 90) lower[i] = c + 32; }
|
||||
const hits = [];
|
||||
for (const n of needles) if (lower.includes(n.bytes)) hits.push(n);
|
||||
return hits;
|
||||
}
|
||||
|
||||
function scan() {
|
||||
const files = walk(WEB);
|
||||
const hits = [];
|
||||
let bytes = 0, links = 0;
|
||||
for (const f of files) {
|
||||
const rel = path.relative(ROOT, f.p);
|
||||
const comps = rel.split(path.sep);
|
||||
for (const c of BANNED_COMPONENTS) {
|
||||
if (comps.some((x) => x.toLowerCase() === c)) hits.push({ rel, kind: 'path', token: c, why: `banned path component "${c}"` });
|
||||
}
|
||||
if (f.link) { // a symlink is a path, not bytes — its TARGET is the risk
|
||||
links++;
|
||||
let t = ''; try { t = fs.readlinkSync(f.p); } catch { /* dangling */ }
|
||||
for (const h of scanBytes(Buffer.from(t, 'utf8'))) hits.push({ rel, kind: 'symlink', token: h.token, why: `${h.why} (symlink → ${t})` });
|
||||
continue;
|
||||
}
|
||||
let buf; try { buf = fs.readFileSync(f.p); } catch { continue; }
|
||||
bytes += buf.length;
|
||||
for (const h of scanBytes(buf)) hits.push({ rel, kind: 'bytes', token: h.token, why: h.why });
|
||||
}
|
||||
return { files: files.length, bytes, links, hits };
|
||||
}
|
||||
|
||||
// ── run ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
console.log(`${C.bold}PROCITY R41 §41.6 — DENY-LIST COMPLIANCE (ruling 3)${C.off} ${C.dim}web/ · paths + bytes${C.off}`);
|
||||
|
||||
head('1. THE TREE — every file under web/, every byte, against the seven banned names');
|
||||
const CONTROL_FILE = path.join(WEB, 'assets', '.r41_denylist_control.tmp.json');
|
||||
if (fs.existsSync(CONTROL_FILE)) { fs.rmSync(CONTROL_FILE); NOTE(`removed a stale control plant from an earlier crashed run: ${path.relative(ROOT, CONTROL_FILE)}`); }
|
||||
|
||||
const t0 = Date.now();
|
||||
const clean = scan();
|
||||
const ms = Date.now() - t0;
|
||||
NOTE(`${clean.files} files · ${(clean.bytes / 1048576).toFixed(1)} MiB · ${clean.links} symlink(s) · ${ms} ms`);
|
||||
if (clean.hits.length === 0) {
|
||||
OK(`0 banned references under web/ — ${BANNED.length} names × ${clean.files} files, paths AND bytes`);
|
||||
} else {
|
||||
for (const h of clean.hits) FAIL(`${h.rel} — ${h.kind}: "${h.token}" (${h.why})`);
|
||||
}
|
||||
if (VERBOSE) for (const b of BANNED) console.log(` ${C.dim}· ${b.token} — ${b.why}${C.off}`);
|
||||
|
||||
head('2. THE CONTROL — plant a real banned reference, the gate must go RED, then GREEN again');
|
||||
let planted = false;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(CONTROL_FILE), { recursive: true });
|
||||
// A plausible plant, not a strawman: this is exactly the shape a wildcard copy would leave behind —
|
||||
// a manifest row naming the source it was published from.
|
||||
fs.writeFileSync(CONTROL_FILE, JSON.stringify({
|
||||
_control: 'R41 deny-list gate control plant — deleted by the gate that wrote it',
|
||||
assets: [{ file: 'body_07.glb', source: '~/Documents/character_kit_modular/exports/bodies/body_07.glb' }],
|
||||
}, null, 1));
|
||||
planted = true;
|
||||
const red = scan();
|
||||
const caught = red.hits.filter((h) => h.rel.includes('.r41_denylist_control.tmp'));
|
||||
if (caught.length >= 2) OK(`RED arm fired: the plant is caught on ${caught.length} names (${caught.map((h) => `"${h.token}"`).join(', ')})`);
|
||||
else FAIL(`RED arm did NOT fire as expected — ${caught.length} hit(s) on the plant; the scan is vacuous`);
|
||||
} finally {
|
||||
if (planted) fs.rmSync(CONTROL_FILE, { force: true });
|
||||
}
|
||||
const after = scan();
|
||||
if (after.hits.length === clean.hits.length) OK(`GREEN arm restored: ${after.hits.length} hit(s), identical to the pre-control scan — the plant left nothing behind`);
|
||||
else FAIL(`the tree did not return to its pre-control state (${clean.hits.length} → ${after.hits.length} hits)`);
|
||||
|
||||
console.log('');
|
||||
if (fails === 0) { console.log(`${C.grn}● PASS${C.off} — web/ is deny-list clean, and the deny-list is not vacuous`); process.exit(0); }
|
||||
console.log(`${C.red}● FAIL${C.off} — ${fails} problem(s) above`); process.exit(1);
|
||||
354
tools/qa/r41_integration.py
Normal file
@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — R41 §41.6 INTEGRATION GATE: the seams F wired, and the budgets F is answerable for.
|
||||
|
||||
tools/.venv/bin/python tools/qa/r41_integration.py [--no-sweep]
|
||||
|
||||
Five arms, each carrying its own control (R25 vacuous-gate law — a gate that has never been able to
|
||||
fail is a gate nobody can trust):
|
||||
|
||||
1. THE WARDROBE, BOTH ARMS Lane C's §41.4 op-shop pack, turned on by F's one line. ON
|
||||
(F's ask #1 from C) (?stock=real): the room resolves base `assets/wardrobe/`, the
|
||||
52-item pack, real garment ids/titles on rendered meshes, and the
|
||||
GPU sampling the atlas file itself. OFF (default boot) is the
|
||||
CONTROL: zero wardrobe requests, base null, zero garment ids —
|
||||
the procedural canvas, which is what ships by default.
|
||||
|
||||
2. THE INTERIOR BUDGET, ON F'S 12 types x 6 archetypes, GLB on and GLB+stock=real, ≤350 law.
|
||||
OWN INSTRUMENT This does NOT call PROCITY_C.drawSweep: it is F's own loop with
|
||||
(F's ask #5 from C) F's own scene bookkeeping, because the thing C found broken in
|
||||
R39/R40 was exactly the sweep's scene bookkeeping. Ships the
|
||||
PHANTOM CONTROL: the same loop with one stale room deliberately
|
||||
left in the scene reproduces the pre-R41 inflation, so "the
|
||||
instrument was wrong, not the rooms" is a measurement here too.
|
||||
|
||||
3. ?noassets=1 STILL CLEAN Boot + enter a shop: zero GLB, zero clip GLB, zero motion
|
||||
manifest, zero wardrobe, zero stock-pack requests. CONTROL: the
|
||||
same walk on a ?stock=real boot fetches all of those classes.
|
||||
|
||||
4. ?clips=0 / ?classic=1 Neither boot may carry one byte of R41 cargo (clips, wardrobe,
|
||||
CARRY NO R41 CARGO kit fittings via the manifest are C's, measured separately).
|
||||
CONTROL: the default boot does fetch the clip groups.
|
||||
|
||||
5. DENY-LIST AT RUNTIME Ruling 3 again, from the other side: every URL the running game
|
||||
requests across street + interior + gig night is checked against
|
||||
the banned names. The static byte scan (r41_denylist.mjs) proves
|
||||
nothing banned is IN web/; this proves nothing banned is ASKED
|
||||
FOR. CONTROL: the matcher is shown firing on a synthetic URL.
|
||||
|
||||
Fresh headless context per arm, own port-isolated no-store server (this repo's ES-module cache burn).
|
||||
Exit 0 green, 1 red.
|
||||
"""
|
||||
import sys, os, json, time, socket, subprocess, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = int(os.environ.get('PROCITY_R41_INT_PORT', '8988'))
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
DRAW_LAW = 350
|
||||
# The town is FROZEN for every measured arm: everything below moves on wall-clock time (R38's recorded
|
||||
# trap), and a live town measured twice differs from itself.
|
||||
FREEZE = 'pop=0&weather=0&tram=0&magpie=0&washing=0'
|
||||
|
||||
BANNED = ['character_kit_modular', 'exports/bodies/', 'elsa_coronation_hair_wig_kh3',
|
||||
'daphne_sexy', 'mocaponline', 'sk_mannequin', 'anatomy/']
|
||||
|
||||
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(cond, m):
|
||||
(OK if cond else FAIL)(m)
|
||||
return cond
|
||||
|
||||
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(port):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
|
||||
|
||||
|
||||
def serve(root, port):
|
||||
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(80):
|
||||
if port_up(port): return proc
|
||||
time.sleep(0.1)
|
||||
proc.terminate(); raise SystemExit(f'could not serve on :{port}')
|
||||
|
||||
|
||||
def new_page(p):
|
||||
b = p.chromium.launch()
|
||||
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, query):
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&{FREEZE}' + (('&' + query) if query else ''))
|
||||
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'; }")
|
||||
|
||||
|
||||
def paths(reqs):
|
||||
"""server-relative paths only — blob:/data: URLs are canvas textures, not a fetch surface."""
|
||||
return [u.split('?')[0].split(f':{PORT}/')[-1] for u in reqs if not u.startswith(('blob:', 'data:'))]
|
||||
|
||||
|
||||
# ── arm 1: THE WARDROBE ──────────────────────────────────────────────────────────────────────────
|
||||
def arm_wardrobe(p):
|
||||
head('1. THE WARDROBE — Lane C §41.4 turned on by F, and the default arm that is its control')
|
||||
out = {}
|
||||
for q, label in (('stock=real', 'ON (?stock=real)'), ('', 'OFF (default boot)')):
|
||||
b, pg, errs, reqs = new_page(p)
|
||||
try:
|
||||
boot(pg, q)
|
||||
entered = pg.evaluate("() => window.DBG.enterShop('opshop')")
|
||||
pg.wait_for_timeout(2500) # atlas decode + the room's first frames
|
||||
si = pg.evaluate("() => window.DBG.stockInfo()")
|
||||
inf = pg.evaluate("() => window.DBG.info()")
|
||||
wr = [u for u in paths(reqs) if 'wardrobe' in u]
|
||||
ids = [i for i in (si.get('renderedIds') or []) if str(i).startswith('wr_')]
|
||||
tex = [t for t in (si.get('texUrls') or []) if 'wardrobe_atlas' in t]
|
||||
out[label] = dict(si=si, inf=inf, wr=wr, ids=ids, tex=tex, errs=errs)
|
||||
note(f"{label}: shop {entered.get('entered')} \"{entered.get('name')}\" · base={si.get('base')!r} · "
|
||||
f"packItems={si.get('packItems')} · {inf.get('drawCalls')} draws / {inf.get('tris')} tris")
|
||||
if q:
|
||||
check(si.get('base') == 'assets/wardrobe/', f'{label}: room resolved base assets/wardrobe/ (got {si.get("base")!r})')
|
||||
check(si.get('packItems') == 52, f'{label}: the 52-item pack is resolved AT BUILD TIME (getStockPack is sync)')
|
||||
check(len(ids) > 0, f'{label}: {len(ids)} real garment ids on rendered meshes '
|
||||
f'(e.g. {", ".join(str(t) for t in (si.get("renderedTitles") or [])[:3])})')
|
||||
check(len(tex) == 1, f'{label}: the GPU is sampling the wardrobe atlas itself, {len(tex)} atlas texture')
|
||||
check(sorted(wr) == ['assets/wardrobe/stock_opshop_index.json', 'assets/wardrobe/wardrobe_atlas_00.webp'],
|
||||
f'{label}: exactly 2 wardrobe requests — the index and ONE atlas ({len(wr)} seen)')
|
||||
else:
|
||||
check(not wr, f'{label}: 0 wardrobe requests — THE CONTROL, and what ships by default')
|
||||
check(si.get('base') is None and si.get('packItems') == 0,
|
||||
f'{label}: base null, no pack — the procedural garment canvas, unchanged')
|
||||
check(not ids, f'{label}: 0 wardrobe item ids rendered')
|
||||
check(not errs, f'{label}: 0 console errors')
|
||||
finally:
|
||||
b.close()
|
||||
on, off = out['ON (?stock=real)'], out['OFF (default boot)']
|
||||
d_draw = on['inf']['drawCalls'] - off['inf']['drawCalls']
|
||||
d_tex = on['inf']['textures'] - off['inf']['textures']
|
||||
note(f"the swap is a WIN, not a cost: {off['inf']['drawCalls']} → {on['inf']['drawCalls']} draws ({d_draw:+d}), "
|
||||
f"{off['inf']['textures']} → {on['inf']['textures']} textures ({d_tex:+d}) — 52 garments off ONE shared atlas")
|
||||
check(d_draw <= 0 and d_tex <= 0, 'the wardrobe costs no draws and no textures (one atlas replaces N canvases)')
|
||||
|
||||
|
||||
# ── arm 2: THE INTERIOR BUDGET, F'S OWN SWEEP ────────────────────────────────────────────────────
|
||||
SWEEP = r"""
|
||||
async ({ glb, stock, phantom }) => {
|
||||
const S = window.PROCITY_C, THREE = S.THREE;
|
||||
// F's OWN bookkeeping: strip EVERY interior out of the scene first, including the page's `current`.
|
||||
// This is the exact step whose absence made R39/R40's numbers carry a phantom second room.
|
||||
const strip = () => { for (const o of S.scene.children.filter(o => o.userData && o.userData.kind === 'interior')) S.scene.remove(o); };
|
||||
strip();
|
||||
let manifest = null;
|
||||
if (glb) manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
// THE PHANTOM CONTROL: put one unrelated room in the scene and LEAVE IT THERE for the whole sweep —
|
||||
// the pre-R41 behaviour, reproduced deliberately.
|
||||
let ghost = null;
|
||||
if (phantom) {
|
||||
ghost = S.buildInterior({ id: 'record', type: 'record', seed: 1990, storeys: 1 }, THREE,
|
||||
{ useGLB: glb, manifest, stock: stock ? 'real' : undefined });
|
||||
S.scene.add(ghost.group); if (glb) await ghost.glbReady;
|
||||
}
|
||||
const measure = async (type, arch) => {
|
||||
const r = S.buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE,
|
||||
{ archetype: arch, useGLB: glb, manifest,
|
||||
stock: stock ? 'real' : undefined,
|
||||
stockBase: (stock && type === 'opshop') ? S.WARDROBE_BASE : undefined });
|
||||
S.scene.add(r.group); if (glb) await r.glbReady;
|
||||
S.camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8);
|
||||
S.camera.lookAt(0, 0.9, -r.dims.D * 0.15);
|
||||
S.renderer.info.reset(); S.renderer.render(S.scene, S.camera);
|
||||
const d = S.renderer.info.render.calls;
|
||||
S.scene.remove(r.group); r.dispose();
|
||||
return d;
|
||||
};
|
||||
let worst = 0, worstAt = '', rooms = 0; const perType = {};
|
||||
for (const t of S.SHOP_TYPES) {
|
||||
let tw = 0;
|
||||
for (const a of [undefined, ...S.ARCHETYPE_KEYS]) {
|
||||
const d = await measure(t, a); rooms++;
|
||||
if (d > tw) tw = d;
|
||||
if (d > worst) { worst = d; worstAt = `${t}/${a || 'auto'}`; }
|
||||
}
|
||||
perType[t] = tw;
|
||||
}
|
||||
if (ghost) { S.scene.remove(ghost.group); ghost.dispose(); }
|
||||
S.rebuild();
|
||||
return { worst, worstAt, rooms, perType };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def arm_budget(p):
|
||||
head(f'2. THE INTERIOR BUDGET — F\'s own sweep (NOT PROCITY_C.drawSweep), 12 types x 6 archetypes, law ≤{DRAW_LAW}')
|
||||
b, pg, errs, reqs = new_page(p)
|
||||
try:
|
||||
pg.goto(f'{HOST}/interior_test.html?seed={SEED}&stock=real')
|
||||
pg.wait_for_function('window.PROCITY_C && window.PROCITY_C.SHOP_TYPES', timeout=45000)
|
||||
pg.wait_for_timeout(1500) # let the page's own stock preloads resolve
|
||||
res = {}
|
||||
for label, opts in (('GLB off', dict(glb=False, stock=False, phantom=False)),
|
||||
('GLB on', dict(glb=True, stock=False, phantom=False)),
|
||||
('GLB on + stock=real', dict(glb=True, stock=True, phantom=False))):
|
||||
r = pg.evaluate(SWEEP, opts)
|
||||
res[label] = r
|
||||
note(f"{label}: worst {r['worst']} @ {r['worstAt']} over {r['rooms']} rooms "
|
||||
f"(margin {DRAW_LAW - r['worst']})")
|
||||
check(r['worst'] <= DRAW_LAW, f'{label}: worst room {r["worst"]} ≤ {DRAW_LAW} — margin {DRAW_LAW - r["worst"]}')
|
||||
# the headline number C published, independently re-derived
|
||||
worst_on = res['GLB on']['worst']
|
||||
check(res['GLB on']['worstAt'].startswith('dept'),
|
||||
f"the worst room is a dept room ({res['GLB on']['worstAt']}) — C's §41.4 headline, on F's instrument")
|
||||
# THE PHANTOM CONTROL — reproduce the pre-R41 sweep bug on purpose
|
||||
ph = pg.evaluate(SWEEP, dict(glb=True, stock=False, phantom=True))
|
||||
infl = ph['worst'] - worst_on
|
||||
note(f"PHANTOM CONTROL (one stale room left in the scene, the pre-R41 behaviour): "
|
||||
f"worst {ph['worst']} @ {ph['worstAt']} — inflation +{infl} draws")
|
||||
check(infl > 0, f"the phantom is real and reproducible: leaving one room in the scene adds +{infl} draws to EVERY "
|
||||
f"reading — C's §41.4-0 finding confirmed on an independent instrument")
|
||||
check(ph['worst'] <= DRAW_LAW, f'even the inflated reading is under the law ({ph["worst"]} ≤ {DRAW_LAW}) — '
|
||||
f'no historical number was ever a real breach')
|
||||
check(not errs, f'0 console errors over {sum(r["rooms"] for r in res.values()) + ph["rooms"]} room builds')
|
||||
return res, ph
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
# ── arm 3: ?noassets=1 ───────────────────────────────────────────────────────────────────────────
|
||||
CARGO = {
|
||||
'GLB (props/rigs)': lambda u: u.endswith('.glb'),
|
||||
'clip group': lambda u: '/models/clips/' in u,
|
||||
'motion manifest': lambda u: 'motion_manifest.json' in u,
|
||||
'wardrobe': lambda u: 'assets/wardrobe/' in u,
|
||||
'stock pack': lambda u: 'stock_' in u and u.endswith('_index.json'),
|
||||
'asset manifest': lambda u: u.endswith('assets/manifest.json'),
|
||||
}
|
||||
|
||||
|
||||
def walk_and_classify(p, q):
|
||||
b, pg, errs, reqs = new_page(p)
|
||||
try:
|
||||
boot(pg, q)
|
||||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||||
for sel in ('opshop', 'record', 'pub'):
|
||||
pg.evaluate(f"() => window.DBG.enterShop('{sel}')")
|
||||
pg.wait_for_timeout(700)
|
||||
pg.evaluate("() => window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(200)
|
||||
pg.wait_for_timeout(800)
|
||||
ps = paths(reqs)
|
||||
return {k: [u for u in ps if f(u)] for k, f in CARGO.items()}, ps, errs
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def arm_noassets(p):
|
||||
head('3. ?noassets=1 — the asset law, over a walk that enters three shops (op shop, record, pub)')
|
||||
clean, ps_clean, errs_clean = walk_and_classify(p, 'noassets=1')
|
||||
for k, hits in clean.items():
|
||||
check(not hits, f'?noassets=1: 0 {k} requests' + (f' — SAW {hits[:3]}' if hits else ''))
|
||||
check(not errs_clean, f'?noassets=1: 0 console errors ({len(ps_clean)} requests swept)')
|
||||
ctrl, ps_ctrl, _ = walk_and_classify(p, 'stock=real')
|
||||
got = [k for k, v in ctrl.items() if v]
|
||||
note(f'CONTROL (?stock=real, same walk): {len(ps_ctrl)} requests, cargo classes present = {got}')
|
||||
check(len(got) >= 5, f'the classifier is not vacuous — the control boot fetches {len(got)}/6 cargo classes')
|
||||
return clean, ctrl
|
||||
|
||||
|
||||
# ── arm 4: ?clips=0 / ?classic=1 ─────────────────────────────────────────────────────────────────
|
||||
def arm_clip_gates(p):
|
||||
head('4. ?clips=0 and ?classic=1 — neither boot may carry one byte of R41 cargo')
|
||||
res = {}
|
||||
for q, label in (('clips=0', '?clips=0'), ('classic=1', '?classic=1'), ('', 'default')):
|
||||
b, pg, errs, reqs = new_page(p)
|
||||
try:
|
||||
boot(pg, q)
|
||||
pg.wait_for_timeout(2000) # a wrongly-gated dynamic import has time to land
|
||||
ps = paths(reqs)
|
||||
clips = [u for u in ps if '/models/clips/' in u or 'motion_manifest.json' in u or 'clipbank.js' in u]
|
||||
wr = [u for u in ps if 'assets/wardrobe/' in u]
|
||||
stats = pg.evaluate("() => (window.PROCITY.citizens && window.PROCITY.citizens.clipStats) "
|
||||
"? window.PROCITY.citizens.clipStats() : null")
|
||||
res[label] = dict(n=len(set(ps)), clips=clips, wr=wr, stats=stats, errs=errs)
|
||||
note(f'{label}: {len(set(ps))} distinct URLs · clip cargo {len(clips)} · wardrobe {len(wr)} · clipStats {stats}')
|
||||
if q:
|
||||
check(not clips, f'{label}: 0 clip GLBs, 0 motion manifest, 0 clipbank.js')
|
||||
check(not wr, f'{label}: 0 wardrobe requests')
|
||||
check(not errs, f'{label}: 0 console errors')
|
||||
finally:
|
||||
b.close()
|
||||
check(len(res['default']['clips']) > 0,
|
||||
f"CONTROL: the default boot DOES fetch the clip library ({len(res['default']['clips'])} requests) — "
|
||||
f"so the two zero-arms above are a gate, not a tautology")
|
||||
return res
|
||||
|
||||
|
||||
# ── arm 5: the deny-list, from the network side ──────────────────────────────────────────────────
|
||||
def arm_denylist_runtime(p):
|
||||
head('5. DENY-LIST AT RUNTIME (ruling 3) — nothing banned is even ASKED FOR, across a full walk')
|
||||
b, pg, errs, reqs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'stock=real&gigs=1')
|
||||
pg.evaluate("() => window.DBG.setSegment(5)")
|
||||
pg.evaluate("() => window.DBG.shot('venue_night')")
|
||||
for sel in ('record', 'pub', 'opshop'):
|
||||
pg.evaluate(f"() => window.DBG.enterShop('{sel}')")
|
||||
pg.wait_for_timeout(900)
|
||||
pg.evaluate("() => window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(200)
|
||||
pg.wait_for_timeout(1200)
|
||||
ps = paths(reqs)
|
||||
hits = [u for u in ps if any(t in u.lower() for t in BANNED)]
|
||||
check(not hits, f'{len(set(ps))} distinct URLs across street + gig night + 3 interiors — 0 banned names'
|
||||
+ (f' — SAW {hits[:3]}' if hits else ''))
|
||||
probe = 'assets/models/character_kit_modular/exports/bodies/body_07.glb'
|
||||
check(any(t in probe.lower() for t in BANNED), 'CONTROL: the matcher fires on a synthetic banned URL')
|
||||
check(not errs, '0 console errors over the walk')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def main():
|
||||
proc = serve(ROOT / 'web', PORT)
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
arm_wardrobe(p)
|
||||
if '--no-sweep' not in sys.argv:
|
||||
arm_budget(p)
|
||||
arm_noassets(p)
|
||||
arm_clip_gates(p)
|
||||
arm_denylist_runtime(p)
|
||||
finally:
|
||||
proc.terminate()
|
||||
print('')
|
||||
if fails:
|
||||
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
|
||||
for f in fails: print(f' · {f}')
|
||||
return 1
|
||||
print('\033[32m● PASS\033[0m — wardrobe both arms, interior budget on F\'s own instrument, '
|
||||
'?noassets clean, clip gates cargo-free, deny-list clean at runtime')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
737
tools/qa/r41_tour.py
Normal file
@ -0,0 +1,737 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — R41 §41.6 THE PROOF SHOTS: the round the game visibly changed.
|
||||
|
||||
tools/.venv/bin/python tools/qa/r41_tour.py [--only NAME[,NAME…]] [--outdir DIR]
|
||||
|
||||
R41 is the first round in a long time whose whole point is VISIBLE, so the tour is evidence, not
|
||||
decoration. Six frames, each chosen by MEASUREMENT (the camera is derived from where the thing
|
||||
actually is, then the frame is checked for what is actually in it), plus three before/after pairs
|
||||
where the control arm was cheap:
|
||||
|
||||
street_postures a busy street with citizens in VISIBLY DIFFERENT postures — D's headline.
|
||||
The camera is not a taste call: it is the live crowd's own best cluster, found
|
||||
by polling until a bench-sitter, a leaner and ≥2 walkers project into one
|
||||
frame, then held. PAIR: the same seed/camera at ?clips=0 (R40's street).
|
||||
browse_interior a browsing interior — browsers examining/holding at Lane C's browse points,
|
||||
framed from Lane C's own room.spawn (the view walking in gives you).
|
||||
pub_furnished the furnished pub. It had a bar and almost nothing else before.
|
||||
PAIR: the same room built with Lane C's `noKit:true` — the exact pre-R41 room.
|
||||
record_dj_booth the record shop's rigged DJ booth: deck_1200_rigged + mixer_ttm54i_rigged +
|
||||
a static 1200. Camera aimed at the booth group by its own userData.
|
||||
opshop_wardrobe an op-shop rack wearing the 52 real 1990s Australian garment layers.
|
||||
PAIR: the same shop, same camera, on a default boot — the procedural canvas.
|
||||
credits_panel Lane B's new attribution surface (F2), on an OSM boot so the ODbL credit that
|
||||
the round exists to make sayable is on screen.
|
||||
|
||||
R10 LAW: every frame containing a humanoid carries the human-sized line in a .txt sidecar — stature
|
||||
(feet→crown, off the POSED skeleton) for every figure in shot, as a ratio of that citizen's own
|
||||
nominal height. Fails on a giant (>2.0 m) or a fold (<55% of nominal), never on an absolute seated
|
||||
band (the library's leans legitimately take 20% off a standing crown — D's R41 finding).
|
||||
|
||||
Fresh headless context per frame, own no-store server. Exit 0 = every frame captured and every
|
||||
human-sized line clean.
|
||||
"""
|
||||
import sys, os, json, time, socket, subprocess, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = int(os.environ.get('PROCITY_R41_TOUR_PORT', '8982'))
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
OUTDIR = ROOT / 'docs' / 'shots' / 'laneF_r41'
|
||||
LO, HI = 1.4, 2.0
|
||||
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
|
||||
ONLY = None
|
||||
if '--only' in sys.argv: ONLY = set(sys.argv[sys.argv.index('--only') + 1].split(','))
|
||||
|
||||
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}")
|
||||
|
||||
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()
|
||||
'''
|
||||
|
||||
# ── the street: find the crowd's own best cluster, and hold until the postures are in it ──────────
|
||||
# Postures run on 6-20 s dwells, so the honest way to shoot the busy moment is to stand still until
|
||||
# it happens. The camera stands out on the carriageway: the peds are at the back of a 3.5 m verge, so
|
||||
# there is no room to back off beside them (D measured this the hard way in §41.3).
|
||||
STREET_LOOK = r"""
|
||||
() => {
|
||||
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
|
||||
const cam = P.camera;
|
||||
const people = [];
|
||||
for (const c of C.activeCitizens()) {
|
||||
if (!c.actor || c.actorKind !== 'rig') continue;
|
||||
const posedNow = c.loiter > 0;
|
||||
people.push({ id: c.id, x: c.x, z: c.z,
|
||||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
||||
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk') });
|
||||
}
|
||||
// An ANCHOR is any rigged citizen who is not walking — bench-sit and lean are the round's headline,
|
||||
// but a citizen stopped at a window in their own seeded idle is the third of D's three new states and
|
||||
// is just as much the point. Only ~2-10 of the ~150 active citizens are near-tier RIGS at any instant
|
||||
// (the rest are impostors and carry no posture), so the anchor set is small and worth widening.
|
||||
const anchors = people.filter(p => p.state !== 'walk');
|
||||
if (!anchors.length) return { ok: false, why: 'no non-walking rig on the street yet', n: people.length };
|
||||
// Score every candidate stand-off around every anchor: 12 bearings x 3 ranges, keeping the pose
|
||||
// that puts the MOST DISTINCT posture states in frame (a frame of six walkers proves nothing).
|
||||
// Candidate LOOK-AT targets: every anchor, plus the midpoint of every pair of anchors in DIFFERENT
|
||||
// states — the frame the round actually wants (a sitter AND a leaner AND the walkers between them)
|
||||
// is a frame aimed between two of them, and no amount of orbiting a single anchor will find it.
|
||||
const targets = anchors.map(a => ({ x: a.x, z: a.z, state: a.state }));
|
||||
for (let i = 0; i < anchors.length; i++)
|
||||
for (let j = i + 1; j < anchors.length; j++)
|
||||
if (anchors[i].state !== anchors[j].state && Math.hypot(anchors[i].x - anchors[j].x, anchors[i].z - anchors[j].z) < 34)
|
||||
targets.push({ x: (anchors[i].x + anchors[j].x) / 2, z: (anchors[i].z + anchors[j].z) / 2,
|
||||
state: anchors[i].state + '+' + anchors[j].state });
|
||||
let best = null;
|
||||
for (const a of targets) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const th = i * Math.PI / 6;
|
||||
for (const r of [7, 9.5, 12, 15, 18]) {
|
||||
const px = a.x + Math.cos(th) * r, pz = a.z + Math.sin(th) * r;
|
||||
cam.position.set(px, 1.62, pz);
|
||||
cam.lookAt(new V3(a.x, 1.25, a.z));
|
||||
cam.updateMatrixWorld(true);
|
||||
const v = new V3(); const seen = [];
|
||||
for (const p of people) {
|
||||
const d = Math.hypot(p.x - px, p.z - pz);
|
||||
if (d < 2.4 || d > 26) continue;
|
||||
v.set(p.x, 1.0, p.z).project(cam);
|
||||
if (v.z > 1 || Math.abs(v.x) > 0.9 || Math.abs(v.y) > 0.9) continue;
|
||||
seen.push(p);
|
||||
}
|
||||
const states = new Set(seen.map(s => s.state));
|
||||
const score = states.size * 100 + seen.length * 10 - r * 4; // ties go to the CLOSER frame — a
|
||||
// busy street 18 m off is mostly pavement (measured: the first accepted frame was two thirds footpath)
|
||||
if (!best || score > best.score) best = { score, px, pz, ax: a.x, az: a.z,
|
||||
states: [...states], inFrame: seen.length, anchor: a.state };
|
||||
}
|
||||
}
|
||||
}
|
||||
cam.position.set(best.px, 1.62, best.pz);
|
||||
cam.lookAt(new V3(best.ax, 1.25, best.az));
|
||||
cam.updateMatrixWorld(true);
|
||||
// MEASURE IN THE SAME BEAT. Searching in one evaluate and measuring in the next lets the dwell
|
||||
// clock run between them, and a bench somebody has just got up from is the frame you actually
|
||||
// shoot (measured: the first cut of this harness did exactly that, twice).
|
||||
const v2 = 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;
|
||||
v2.set(c.x, 1.0, c.z).project(cam);
|
||||
if (v2.z > 1 || Math.abs(v2.x) > 0.92 || Math.abs(v2.y) > 0.92) 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; } });
|
||||
const posedNow = c.loiter > 0;
|
||||
// THE CLIP IS READ OFF THE MIXER, not off `c.posture`. `posture` is the ASSIGNMENT — a pure
|
||||
// function of (citySeed, id) that exists whether or not the library ever loads (D's law: decided
|
||||
// once, never re-rolled; the base clip plays while a variant is in flight). Reading it reports
|
||||
// `walk_shopping_bag` on a ?clips=0 boot that has fetched nothing — exactly the note-about-the-
|
||||
// thing this round's control arm exists to avoid. Measured: the first cut of this caption did it.
|
||||
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
|
||||
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
|
||||
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: playing[0] || null,
|
||||
assigned: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
||||
: (posedNow ? c.posture.idle : c.posture.walk),
|
||||
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
||||
}
|
||||
rows.sort((a, b) => a.d - b.d);
|
||||
const states = [...new Set(rows.map(r => r.state))].sort();
|
||||
const clips = [...new Set(rows.map(r => r.clip).filter(Boolean))].sort();
|
||||
// Score on DISTINCT CLIPS as well as distinct states — which is the honest headline measure and D's
|
||||
// own (§41.3 census: 4 distinct clips across the whole crowd → 20). Two walkers on `@walk` and
|
||||
// `walk_shopping_bag` beside a sitter is three visibly different postures in one frame even though
|
||||
// the state machine calls two of them "walk".
|
||||
return { ok: true, ...best, rows, states, clips, inFrame: rows.length,
|
||||
score2: states.length * 100 + clips.length * 60 + rows.length * 10,
|
||||
info: window.DBG.info(), clipStats: C.clipStats() };
|
||||
}
|
||||
"""
|
||||
|
||||
# The R10 line: stature off the POSED skeleton for every rig actually in the picture.
|
||||
STREET_MEASURE = r"""
|
||||
() => {
|
||||
const P = window.PROCITY, C = P.citizens, 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.92 || Math.abs(v.y) > 0.92) 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; } });
|
||||
const posedNow = c.loiter > 0;
|
||||
// THE CLIP IS READ OFF THE MIXER, not off `c.posture`. `posture` is the ASSIGNMENT — a pure
|
||||
// function of (citySeed, id) that exists whether or not the library ever loads (D's law: decided
|
||||
// once, never re-rolled; the base clip plays while a variant is in flight). Reading it reports
|
||||
// `walk_shopping_bag` on a ?clips=0 boot that has fetched nothing — exactly the note-about-the-
|
||||
// thing this round's control arm exists to avoid. Measured: the first cut of this caption did it.
|
||||
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
|
||||
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
|
||||
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: playing[0] || null,
|
||||
assigned: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
||||
: (posedNow ? c.posture.idle : c.posture.walk),
|
||||
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
||||
}
|
||||
rows.sort((a, b) => a.d - b.d);
|
||||
return { rows, info: window.DBG.info(), clips: C.clipStats() };
|
||||
}
|
||||
"""
|
||||
|
||||
# ── interiors: frame a room from Lane C's own spawn, or at a named target ─────────────────────────
|
||||
INT_FRAME = r"""
|
||||
async ({ target, back, eye }) => {
|
||||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||||
const room = P.interiorMode.current;
|
||||
if (!room) return { ok: false, why: 'no room open' };
|
||||
const sp = room.spawn || { x: 0, z: 0 };
|
||||
let tx, tz, ty = 1.15;
|
||||
if (target === 'people') {
|
||||
const figs = ((P.interiorMode.keepers || {}).keepers || []).map(k => k.actor.fig);
|
||||
if (!figs.length) return { ok: false, why: 'no figures in the room' };
|
||||
tx = 0; tz = 0; for (const f of figs) { tx += f.position.x; tz += f.position.z; }
|
||||
tx /= figs.length; tz /= figs.length;
|
||||
} else if (target === 'centre') {
|
||||
tx = 0; tz = -room.dims.D * 0.12; ty = 1.05;
|
||||
} else { // a tagged group: djBooth, or a stock-bearing fitting
|
||||
let hit = null;
|
||||
room.group.traverse(o => {
|
||||
if (hit) return;
|
||||
const u = o.userData || {};
|
||||
if (target === 'djBooth' && u.djBooth) hit = o;
|
||||
if (target === 'garment' && Array.isArray(u.buyItems) && u.buyItems.some(b => b && b.item && String(b.item.id).startsWith('wr_'))) hit = o;
|
||||
if (target === 'stockrack' && (u.isStock || u.buyMesh) && !hit) hit = o;
|
||||
});
|
||||
if (!hit) return { ok: false, why: `no ${target} in this room` };
|
||||
// A fitting group's ORIGIN sits on the floor, so aiming at it frames carpet and cuts the prop in
|
||||
// half. Aim at the bounding-box centre of what is actually there.
|
||||
const THREE = await import('three');
|
||||
const c = new THREE.Box3().setFromObject(hit).getCenter(new V3());
|
||||
tx = c.x; tz = c.z; ty = Math.max(0.95, c.y);
|
||||
}
|
||||
// stand off `back` metres from the target along the line from Lane C's spawn
|
||||
let dx = sp.x - tx, dz = sp.z - tz; const L = Math.hypot(dx, dz) || 1;
|
||||
dx /= L; dz /= L;
|
||||
const d = Math.min(Math.max(back, 1.9), Math.max(L, back));
|
||||
const px = tx + dx * d, pz = tz + dz * d;
|
||||
P.camera.position.set(px, eye, pz);
|
||||
P.camera.lookAt(new V3(tx, ty, tz));
|
||||
P.camera.updateMatrixWorld(true);
|
||||
P.renderer.info.reset();
|
||||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||||
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+tx.toFixed(2), +tz.toFixed(2)],
|
||||
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles,
|
||||
shop: P.interiorMode.shop, dims: room.dims };
|
||||
}
|
||||
"""
|
||||
|
||||
INT_MEASURE = r"""
|
||||
() => {
|
||||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||||
const rows = [];
|
||||
for (const k of ((P.interiorMode.keepers || {}).keepers || [])) {
|
||||
const a = k.actor; if (!a || !a.inner) 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({ state: k.browse ? 'browser' : 'keeper', 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,
|
||||
stature: +(hi - lo).toFixed(3) });
|
||||
}
|
||||
return { rows, info: window.DBG.info() };
|
||||
}
|
||||
"""
|
||||
|
||||
# Lane C's noKit control, built INSIDE the running game: the same shop, the same seed, the same
|
||||
# archetype, with every R41 spec dropped — the exact pre-R41 room, in the room's own scene.
|
||||
NOKIT_SWAP = r"""
|
||||
async () => {
|
||||
const P = window.PROCITY;
|
||||
const M = await import('./js/interiors/interiors.js');
|
||||
const THREE = await import('three');
|
||||
const shopRef = P.interiorMode.shop;
|
||||
const shop = (P.plan.shops || []).find(s => s.id === shopRef.id);
|
||||
const old = P.interiorMode.current;
|
||||
const before = { draws: null };
|
||||
P.interiorMode.scene.remove(old.group);
|
||||
const manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
const room = M.buildInterior(shop, THREE, { useGLB: true, manifest, noKit: true });
|
||||
await room.glbReady;
|
||||
room.group.userData.kind = 'interior';
|
||||
P.interiorMode.scene.add(room.group);
|
||||
window.__F_NOKIT = { room, old };
|
||||
return { ok: true, shop: shopRef, pulls: room.pulls || null,
|
||||
fittings_before: (old.placement || []).length, fittings_after: (room.placement || []).length };
|
||||
}
|
||||
"""
|
||||
|
||||
NOKIT_FRAME = r"""
|
||||
({ cam, target, eye }) => {
|
||||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||||
P.camera.position.set(cam[0], eye, cam[1]);
|
||||
P.camera.lookAt(new V3(target[0], 1.05, target[1]));
|
||||
P.camera.updateMatrixWorld(true);
|
||||
P.renderer.info.reset();
|
||||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||||
return { draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
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 — height-RELATIVE, per D's R41 finding: a 1.32 m leaner is not a defect,
|
||||
a 2.4 m anything is, and a body folded to hip height is the R16 failure this exists to catch."""
|
||||
out, bad = [], []
|
||||
for r in rows:
|
||||
h = r.get('h') or 0
|
||||
ratio = (r['stature'] / h) if h else None
|
||||
if r['stature'] > HI: bad.append(f"{r.get('id', r['state'])} GIANT {r['stature']}m")
|
||||
elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r['state'])} FOLDED {r['stature']}m of {h}m")
|
||||
elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r['state'])} nominal {h}m out of [{LO},{HI}]")
|
||||
out.append(f"{r['state']}"
|
||||
+ (f"[{r['clip']}]" if r.get('clip') else '')
|
||||
+ f" {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 (R10) — {len(out)} figure(s) in frame, stature (feet→crown) measured "
|
||||
f"off the posed skeleton: {' · '.join(out)}. {verdict}."), not bad
|
||||
|
||||
|
||||
def write_shot(pg, name, caption, rows=None, capture=True):
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
if capture:
|
||||
pg.screenshot(path=str(OUTDIR / f'{name}.jpg'), type='jpeg', quality=92)
|
||||
txt = caption
|
||||
if rows is not None:
|
||||
line, ok = human_line(rows)
|
||||
txt += '\n\n' + line
|
||||
if not ok: FAIL(f'{name}: {line}')
|
||||
(OUTDIR / f'{name}.txt').write_text(txt + '\n')
|
||||
OK(f'{name}.jpg + .txt')
|
||||
|
||||
|
||||
def new_page(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)))
|
||||
return b, pg, errs
|
||||
|
||||
|
||||
def boot(pg, query):
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
|
||||
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'; }")
|
||||
|
||||
|
||||
# ── 1. THE STREET ────────────────────────────────────────────────────────────────────────────────
|
||||
def shot_street(p):
|
||||
head('1. street_postures — the crowd stops being one person copy-pasted (+ the ?clips=0 pair)')
|
||||
pose = None
|
||||
for q, name in (('', 'street_postures'), ('clips=0', 'street_postures_clips0')):
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, q + ('&' if q else '') + 'weather=0&magpie=0&washing=0')
|
||||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||||
if not q:
|
||||
# HOLD until the crowd offers the frame, and CAPTURE THE INSTANT IT DOES. Postures run
|
||||
# on 6-20 s dwells, so a camera chosen from a poll twenty seconds ago is aimed at a
|
||||
# bench somebody has already got up from — measured, twice, before this loop was built.
|
||||
# The search + the measurement are one evaluate; the capture is the next statement; the
|
||||
# file on disk is always the best frame seen so far.
|
||||
# …and RESAMPLE THE TOWN, not just the clock: the sitters and leaners of one 30 m stretch
|
||||
# are the same dozen citizens, so holding one spot for a minute samples one bench. The
|
||||
# loop hops between the shell's own street bookmarks every 8 polls.
|
||||
# Measured first (probe over 5 bookmarks x 4 samples): the near-tier holds only 2-10 RIGS
|
||||
# at a time and `crossroads_busy` is where the rigs are (9-10 vs 1-3 elsewhere), and the
|
||||
# non-walk share of them builds over ~10 s of dwell. So: dwell long, hop rarely, start
|
||||
# where the people are.
|
||||
STOPS = ['crossroads_busy', 'street_noon', 'crossroads_busy', 'patronage_door', 'night_crowd']
|
||||
best = None
|
||||
byAnchor = {}
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
for i in range(72):
|
||||
if i % 12 == 0:
|
||||
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
|
||||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||
pg.wait_for_timeout(900)
|
||||
r = pg.evaluate(STREET_LOOK)
|
||||
if not r.get('ok'):
|
||||
pg.wait_for_timeout(600); continue
|
||||
a = r['anchor'].split('+')[0]
|
||||
if not best or r['score2'] > best['score2']:
|
||||
best = r
|
||||
pg.wait_for_timeout(150) # one composer frame at the new pose
|
||||
pg.screenshot(path=str(OUTDIR / 'street_postures.jpg'), type='jpeg', quality=92)
|
||||
if a != 'walk' and (a not in byAnchor or r['score2'] > byAnchor[a]['score2']):
|
||||
byAnchor[a] = r
|
||||
pg.wait_for_timeout(150)
|
||||
pg.screenshot(path=str(OUTDIR / f'street_{a}.jpg'), type='jpeg', quality=92)
|
||||
if len(r['states']) >= 3 and len(r['clips']) >= 3 and r['inFrame'] >= 4: break
|
||||
pg.wait_for_timeout(450)
|
||||
if not best:
|
||||
FAIL('street_postures: never found a non-walking rig in 72 polls'); return
|
||||
pose = best
|
||||
m = dict(rows=best['rows'], info=best['info'], clips=best['clipStats'])
|
||||
note(f"camera ({best['px']:.1f},{best['pz']:.1f}) on the {best['anchor']} anchor · "
|
||||
f"{best['inFrame']} rigs in frame · states {best['states']} · clips {best['clips']}")
|
||||
# One frame can only hold what the near tier holds (2-10 rigs), so the round's three new
|
||||
# street states are also shot ONE PER ANCHOR — best frame per anchor kind, same boot.
|
||||
for a, r in sorted(byAnchor.items()):
|
||||
# …but never twice: if the per-anchor best IS the headline frame, it is the same
|
||||
# picture under a second name.
|
||||
if abs(r['px'] - best['px']) < 1e-6 and abs(r['pz'] - best['pz']) < 1e-6:
|
||||
for ext in ('.jpg', '.txt'):
|
||||
f = OUTDIR / f'street_{a}{ext}'
|
||||
if f.exists(): f.unlink()
|
||||
note(f'street_{a} is the headline frame itself — not written twice')
|
||||
continue
|
||||
line, ok2 = human_line(r['rows'])
|
||||
(OUTDIR / f'street_{a}.txt').write_text(
|
||||
f"PROCITY R41 §41.6 — street_{a} (supporting frame: the {a} state)\n"
|
||||
f"seed {SEED}, midday, camera ({r['px']:.2f}, {r['pz']:.2f}) → ({r['ax']:.2f}, {r['az']:.2f}).\n"
|
||||
f"{r['inFrame']} rigs in frame · states {r['states']} · clips {r['clips']} · "
|
||||
f"{r['info']['drawCalls']} draws of the ≤300 street law.\n\n{line}\n")
|
||||
if not ok2: FAIL(f'street_{a}: {line}')
|
||||
OK(f'street_{a}.jpg + .txt ({a}, {len(r["clips"])} clips in frame)')
|
||||
else:
|
||||
# the CONTROL arm: identical camera, R40's street (no motion library at all)
|
||||
pg.evaluate("""({px,pz,ax,az}) => { const P=window.PROCITY, V3=P.scene.position.constructor;
|
||||
P.player.teleport(px, pz, 0);
|
||||
P.camera.position.set(px, 1.62, pz); P.camera.lookAt(new V3(ax,1.25,az));
|
||||
P.chunks.warmup(P.camera.position); P.camera.updateMatrixWorld(true); }""", pose)
|
||||
pg.wait_for_timeout(1500)
|
||||
m = pg.evaluate(STREET_MEASURE)
|
||||
states = sorted(set(r['state'] for r in m['rows']))
|
||||
clips = sorted(set(r['clip'] for r in m['rows'] if r['clip']))
|
||||
note(f"{name}: {len(m['rows'])} figures · states {states} · {len(clips)} distinct clips · "
|
||||
f"{m['info']['drawCalls']} draws / {m['info']['tris']} tris · bank {m['clips']}")
|
||||
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
||||
f"seed {SEED}, midday (seg 2), camera ({pose['px']:.2f}, {pose['pz']:.2f}) looking at "
|
||||
f"({pose['ax']:.2f}, {pose['az']:.2f}) — the SAME pose on both arms.\n"
|
||||
f"{'DEFAULT BOOT (R41): the motion library is on.' if not q else 'CONTROL ?clips=0 (= R40): the motion library is OFF and nothing else changes.'}\n"
|
||||
f"{len(m['rows'])} rigs in frame · posture states {states} · {len(clips)} distinct clips "
|
||||
f"({', '.join(clips)}) · {m['info']['drawCalls']} draws of the ≤300 street law "
|
||||
f"({m['info']['tris']} tris) · clip bank {m['clips'].get('groups')} groups / "
|
||||
f"{m['clips'].get('clips')} clips / {m['clips'].get('bytes')} B.")
|
||||
write_shot(pg, name, cap, m['rows'], capture=bool(q))
|
||||
if not q and (len(clips) < 3 or len(states) < 2):
|
||||
FAIL(f'street_postures: {len(states)} state(s) / {len(clips)} clip(s) in frame — '
|
||||
f'the headline (visibly different postures) is not visible')
|
||||
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
# ── 2-5. THE INTERIORS ───────────────────────────────────────────────────────────────────────────
|
||||
def enter_best(pg, want_type=None, need=None, tries=10):
|
||||
"""Enter a shop of `want_type` (or the most-occupied shop) and settle. `need` is a JS predicate
|
||||
name checked on the built room: 'browsers' | 'djBooth' | 'garment'."""
|
||||
js = r"""
|
||||
async ({ want, need, tries }) => {
|
||||
const P = window.PROCITY, D = window.DBG, C = P.citizens;
|
||||
// Prefer the shop types whose floor is browsable (Lane C puts 3.00 browse points in every type,
|
||||
// but a record bin / clothes rail / bookshelf is what a browse clip READS as), then by how many
|
||||
// patronage occupants are actually inside — occupancy is what puts browsers on the floor.
|
||||
const PREF = ['record', 'opshop', 'book', 'video', 'stall', 'pawn'];
|
||||
const shops = (P.plan.shops || []).filter(s => !want || s.type === want)
|
||||
.map(s => ({ id: s.id, name: s.name, type: s.type, occ: (C.occupancyOf(s.id) || {}).count || 0 }))
|
||||
.sort((a, b) => (b.occ - a.occ) || ((PREF.indexOf(a.type) + 9) % 9) - ((PREF.indexOf(b.type) + 9) % 9));
|
||||
const has = (room) => {
|
||||
if (!room) return false;
|
||||
if (need === 'browsers') return (((P.interiorMode.keepers || {}).keepers) || []).filter(k => k.browse).length > 0;
|
||||
if (need === 'djBooth') { let f = false; room.group.traverse(o => { if ((o.userData || {}).djBooth) f = true; }); return f; }
|
||||
if (need === 'garment') { let f = false; room.group.traverse(o => { const u = o.userData || {};
|
||||
if (Array.isArray(u.buyItems) && u.buyItems.some(b => b && b.item && String(b.item.id).startsWith('wr_'))) f = true; }); return f; }
|
||||
return true;
|
||||
};
|
||||
// Take the BEST candidate, not the first that works: the first shop with one browser and the
|
||||
// shop with three are both "ok", and only one of them is a photograph of a browsing interior.
|
||||
let best = null;
|
||||
const cands = shops.filter(s => need !== 'browsers' || s.occ > 0).slice(0, tries);
|
||||
for (const s of cands) {
|
||||
D.enterShop(s.id);
|
||||
await new Promise(r => setTimeout(r, 2600));
|
||||
const room = P.interiorMode.current;
|
||||
const ks = ((P.interiorMode.keepers || {}).keepers) || [];
|
||||
if (room && has(room)) {
|
||||
const cand = { ok: true, shop: s, tried: shops.length,
|
||||
browsers: ks.filter(k => k.browse).length, keepers: ks.length,
|
||||
fittings: (room.placement || []).length };
|
||||
if (!best || cand.browsers > best.browsers) best = cand;
|
||||
if (need !== 'browsers' || cand.browsers >= 3) return best;
|
||||
}
|
||||
D.exitShop(); await new Promise(r => setTimeout(r, 350));
|
||||
}
|
||||
if (best) { D.enterShop(best.shop.id); await new Promise(r => setTimeout(r, 2600)); return best; }
|
||||
return { ok: false, why: `no ${want || 'shop'} satisfied "${need}"`, tried: shops.length };
|
||||
}
|
||||
"""
|
||||
return pg.evaluate(js, dict(want=want_type, need=need, tries=tries))
|
||||
|
||||
|
||||
def shot_interior(p, name, want_type, need, target, back, eye, seg, title, extra_q='', dwell=1200):
|
||||
head(f'{title}')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, f'gigs=1&weather=0&magpie=0&washing=0{extra_q}')
|
||||
pg.evaluate(f"() => window.DBG.setSegment({seg})")
|
||||
# Patronage is a wall-clock process (R8: peds duck in as they walk past), so a browser shot taken
|
||||
# 1.2 s after boot photographs an empty shop. Measured: 0 shops with occupants at 1.2 s.
|
||||
pg.wait_for_timeout(dwell)
|
||||
occ = pg.evaluate("() => (window.PROCITY.plan.shops||[]).filter(s => (window.PROCITY.citizens.occupancyOf(s.id)||{}).count > 0).length")
|
||||
note(f'after {dwell} ms of street time: {occ} shop(s) hold patronage occupants')
|
||||
r = enter_best(pg, want_type, need)
|
||||
if not r.get('ok'):
|
||||
FAIL(f"{name}: {r.get('why')}"); return None
|
||||
note(f"{r['shop']['name']} ({r['shop']['type']}, id {r['shop']['id']}) · {r['fittings']} fittings · "
|
||||
f"{r['keepers']} figures ({r['browsers']} browsing) · tried {r['tried']} candidates")
|
||||
fr = pg.evaluate(INT_FRAME, dict(target=target, back=back, eye=eye))
|
||||
if not fr.get('ok'):
|
||||
FAIL(f"{name}: {fr.get('why')}"); return None
|
||||
m = pg.evaluate(INT_MEASURE)
|
||||
note(f"{fr['draws']} draws / {fr['tris']} tris · room {fr['dims']['W']}x{fr['dims']['D']} m · "
|
||||
f"camera {fr['cam']} → {fr['target']}")
|
||||
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
||||
f"seed {SEED}, {r['shop']['name']} ({r['shop']['type']}), segment {seg}. "
|
||||
f"{r['fittings']} fittings placed · {r['keepers']} figures in the room "
|
||||
f"({r['browsers']} at Lane C browse points) · camera {fr['cam']} looking at {fr['target']} "
|
||||
f"(Lane C's own room.spawn line).\n"
|
||||
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris).")
|
||||
write_shot(pg, name, cap, m['rows'] if m['rows'] else None)
|
||||
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
||||
return dict(page=pg, browser=b, frame=fr, shop=r['shop'])
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def shot_pub_pair(p):
|
||||
"""Two things, deliberately separated.
|
||||
|
||||
The HERO is the pub on gig night — which is what a player sees, and what the round is for. But a
|
||||
gig-night frame is NOT a fair before/after for §41.4, because the band and the crowd ride the room
|
||||
group and are disposed with it: a `noKit` rebuild takes the R41 fittings AND the eleven people out
|
||||
of the picture, and a pair like that credits the furniture with the crowd. So the A/B pair is shot
|
||||
separately, on a QUIET midday pub with the gig layer off, where the only thing that moves between
|
||||
the arms is the kit."""
|
||||
head("4. pub_furnished (hero, gig night) + pub_kit_after/before (the honest A/B, quiet pub)")
|
||||
# ── the hero ──────────────────────────────────────────────────────────────────────────────────
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
|
||||
pg.evaluate("() => window.DBG.setSegment(5)")
|
||||
pg.wait_for_timeout(1500)
|
||||
r = enter_best(pg, 'pub', None)
|
||||
if not r.get('ok'):
|
||||
FAIL(f"pub_furnished: {r.get('why')}")
|
||||
else:
|
||||
fr = pg.evaluate(INT_FRAME, dict(target='centre', back=5.2, eye=1.62))
|
||||
m = pg.evaluate(INT_MEASURE)
|
||||
note(f"hero: {r['shop']['name']} · {r['fittings']} fittings · {fr['draws']} draws / {fr['tris']} tris "
|
||||
f"· {len(m['rows'])} figures")
|
||||
write_shot(pg, 'pub_furnished',
|
||||
f"PROCITY R41 §41.6 — pub_furnished (the hero: gig night)\n"
|
||||
f"seed {SEED}, {r['shop']['name']} (pub), segment 5, ?gigs=1. {r['fittings']} fittings "
|
||||
f"placed by §41.4 — the plywood bar, the DJ booth, the speaker stack, the jukebox, the "
|
||||
f"couch, the bistro table. The band and the crowd are the R13 gig layer, NOT R41 cargo: "
|
||||
f"the fittings claim is measured in the pub_kit pair, not here.\n"
|
||||
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris). "
|
||||
f"Camera {fr['cam']} → {fr['target']}.", m['rows'] if m['rows'] else None)
|
||||
if errs: FAIL(f'pub_furnished: {len(errs)} console error(s): {errs[:2]}')
|
||||
finally:
|
||||
b.close()
|
||||
# ── the honest A/B: quiet pub, gig layer OFF, the kit is the only variable ────────────────────
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
# gigs stays ON — ?gigs=0 removes the venue TYPES from the plan entirely (measured: "no pub
|
||||
# satisfied", 0 pub lots), so the control for "no crowd" is the CLOCK, not the flag. And the
|
||||
# clock has to be SEGMENT 4: this town's one pub keeps 17:00-23:00 (measured), so midday is a
|
||||
# locked door and segment 5 is the gig. 18:30 is open, quiet, and crowd-free.
|
||||
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
|
||||
pg.evaluate("() => window.DBG.setSegment(4)")
|
||||
pg.wait_for_timeout(1500)
|
||||
r = enter_best(pg, 'pub', None)
|
||||
if not r.get('ok'):
|
||||
FAIL(f"pub_kit_after: {r.get('why')}"); return
|
||||
fr = pg.evaluate(INT_FRAME, dict(target='centre', back=5.2, eye=1.62))
|
||||
if not fr.get('ok'):
|
||||
FAIL(f"pub_kit_after: {fr.get('why')}"); return
|
||||
m = pg.evaluate(INT_MEASURE)
|
||||
write_shot(pg, 'pub_kit_after',
|
||||
f"PROCITY R41 §41.6 — pub_kit_after (AFTER — the R41 kit)\n"
|
||||
f"seed {SEED}, {r['shop']['name']} (pub), 18:30 (segment 4) — open, gig not on, so no band and no crowd "
|
||||
f"on either arm and nothing but the furniture differs. {r['fittings']} fittings.\n"
|
||||
f"{fr['draws']} draws / {fr['tris']} tris. Camera {fr['cam']} → {fr['target']}, "
|
||||
f"identical on both arms.", m['rows'] if m['rows'] else None)
|
||||
sw = pg.evaluate(NOKIT_SWAP)
|
||||
if not sw.get('ok'):
|
||||
FAIL('pub_kit_before: the noKit rebuild failed'); return
|
||||
pg.wait_for_timeout(700)
|
||||
fr2 = pg.evaluate(NOKIT_FRAME, dict(cam=fr['cam'], target=fr['target'], eye=1.62))
|
||||
note(f"noKit control: {sw['fittings_before']} → {sw['fittings_after']} fittings · "
|
||||
f"{fr['draws']} → {fr2['draws']} draws ({fr2['draws'] - fr['draws']:+d}) · "
|
||||
f"{fr['tris']} → {fr2['tris']} tris")
|
||||
write_shot(pg, 'pub_kit_before',
|
||||
f"PROCITY R41 §41.6 — pub_kit_before (BEFORE — the CONTROL)\n"
|
||||
f"The SAME shop, the SAME seed, the SAME camera, rebuilt with Lane C's `noKit: true`, which "
|
||||
f"drops exactly the R41 specs and reproduces the pre-R41 room.\n"
|
||||
f"fittings {sw['fittings_before']} → {sw['fittings_after']} · "
|
||||
f"draws {fr['draws']} → {fr2['draws']} ({fr2['draws'] - fr['draws']:+d}) · "
|
||||
f"tris {fr['tris']} → {fr2['tris']}. That difference is what §41.4 bought in this room, "
|
||||
f"measured — and nothing else in the frame moved.\n"
|
||||
f"NOTE: the keeper rides the room group and is disposed with it, so the control frame has "
|
||||
f"no shopkeeper. Every OTHER difference in the picture is the kit.")
|
||||
if fr2['draws'] >= fr['draws']:
|
||||
FAIL(f"pub pair: the noKit control is not cheaper ({fr2['draws']} vs {fr['draws']}) — the pair proves nothing")
|
||||
if sw['fittings_after'] >= sw['fittings_before']:
|
||||
FAIL(f"pub pair: noKit did not drop any fitting ({sw['fittings_before']} → {sw['fittings_after']})")
|
||||
if errs: FAIL(f'pub_kit pair: {len(errs)} console error(s): {errs[:2]}')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def shot_wardrobe_pair(p):
|
||||
head('5. opshop_wardrobe + opshop_wardrobe_off — the 52 garments, and the canvas that ships default')
|
||||
cam = target = None
|
||||
for q, name in (('&stock=real', 'opshop_wardrobe'), ('', 'opshop_wardrobe_off')):
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'weather=0&magpie=0&washing=0' + q)
|
||||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||
pg.wait_for_timeout(1200)
|
||||
r = enter_best(pg, 'opshop', 'garment' if q else None)
|
||||
if not r.get('ok'):
|
||||
FAIL(f"{name}: {r.get('why')}"); return
|
||||
if q:
|
||||
fr = pg.evaluate(INT_FRAME, dict(target='garment', back=2.6, eye=1.55))
|
||||
if not fr.get('ok'): FAIL(f"{name}: {fr.get('why')}"); return
|
||||
cam, target = fr['cam'], fr['target']
|
||||
else:
|
||||
fr = pg.evaluate(NOKIT_FRAME, dict(cam=cam, target=target, eye=1.55))
|
||||
fr = dict(fr, cam=cam, target=target, ok=True)
|
||||
si = pg.evaluate("() => window.DBG.stockInfo()")
|
||||
m = pg.evaluate(INT_MEASURE)
|
||||
ids = [i for i in (si.get('renderedIds') or []) if str(i).startswith('wr_')]
|
||||
titles = (si.get('renderedTitles') or [])[:6]
|
||||
note(f"{name}: {r['shop']['name']} · base {si.get('base')} · {len(ids)} garment ids · "
|
||||
f"{fr['draws']} draws / {fr['tris']} tris")
|
||||
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
||||
f"seed {SEED}, {r['shop']['name']} (opshop), midday. Camera {fr['cam']} → {fr['target']}, "
|
||||
f"IDENTICAL on both arms.\n"
|
||||
+ (f"?stock=real — Lane C's wardrobe pack, turned on by F's one line: base "
|
||||
f"{si.get('base')}, {si.get('packItems')} named 1990s Australian garment layers off "
|
||||
f"ONE 246 KB atlas, {len(ids)} of them on rendered meshes "
|
||||
f"({', '.join(titles)}…).\n" if q else
|
||||
"DEFAULT BOOT (the control, and what ships) — no pack, so Lane C's procedural garment "
|
||||
"canvas, byte-identically. Nothing breaks without the wardrobe.\n")
|
||||
+ f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris).")
|
||||
write_shot(pg, name, cap, m['rows'] if m['rows'] else None)
|
||||
if q and not ids: FAIL(f'{name}: no wardrobe ids on rendered meshes — the shot proves nothing')
|
||||
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def shot_credits(p):
|
||||
head('6. credits_panel — Lane B\'s new attribution surface, on an OSM boot (the ODbL credit on screen)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'plansrc=osm&town=katoomba&weather=0&magpie=0&washing=0')
|
||||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||||
pg.wait_for_timeout(600)
|
||||
link = pg.evaluate("() => { const el = document.getElementById('pc-creditlink'); return el ? el.textContent.trim() : null; }")
|
||||
pg.keyboard.press('F2')
|
||||
pg.wait_for_timeout(1200)
|
||||
st = pg.evaluate(r"""() => {
|
||||
const el = document.getElementById('pc-credits');
|
||||
const t = el ? el.innerText : '';
|
||||
// NB `offsetParent` is null for a position:fixed element even when it is on screen —
|
||||
// the panel is fixed/inset:0, so that test reports "closed" on a perfectly open panel.
|
||||
return { open: !!(el && getComputedStyle(el).display !== 'none'),
|
||||
entries: (window.PROCITY.credits && window.PROCITY.credits.count) || (t.match(/\n/g) || []).length,
|
||||
osm: /OpenStreetMap contributors/.test(t),
|
||||
odbl: /ODbL/.test(t), mixamo: /Mixamo/i.test(t), chars: t.length }; }""")
|
||||
note(f"HUD link: {link!r} · panel open {st['open']} · OSM string {st['osm']} · ODbL {st['odbl']} · "
|
||||
f"Mixamo row {st['mixamo']} · {st['chars']} chars of panel text")
|
||||
cap = (f"PROCITY R41 §41.6 — credits_panel\n"
|
||||
f"seed {SEED}, ?plansrc=osm&town=katoomba — an OSM boot, so the obligation is live and the HUD "
|
||||
f"link itself carries it: {link!r}.\n"
|
||||
f"F2 opens Lane B's §41.5 surface. On screen: the © OpenStreetMap contributors credit verbatim "
|
||||
f"({st['osm']}), the ODbL 1.0 licence name ({st['odbl']}), and the Mixamo row that records why "
|
||||
f"R41's 46 clips owe no attribution ({st['mixamo']}).\n"
|
||||
f"Zero draws by construction (DOM), zero boot fetches (credits.json is fetched on first open).")
|
||||
write_shot(pg, 'credits_panel', cap)
|
||||
if not st['open']: FAIL('credits_panel: the panel did not open on F2')
|
||||
if not st['osm']: FAIL('credits_panel: the OSM attribution string is not on screen')
|
||||
if errs: FAIL(f'credits_panel: {len(errs)} console error(s): {errs[:2]}')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def main():
|
||||
srv = serve()
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
if not ONLY or 'street' in ONLY: shot_street(p)
|
||||
if not ONLY or 'browse' in ONLY:
|
||||
shot_interior(p, 'browse_interior', None, 'browsers', 'people', 3.0, 1.62, 2,
|
||||
"2. browse_interior — browsers examining and holding at Lane C's browse points",
|
||||
extra_q='&stock=real', dwell=25000)
|
||||
if not ONLY or 'record' in ONLY:
|
||||
shot_interior(p, 'record_dj_booth', 'record', 'djBooth', 'djBooth', 2.5, 1.45, 2,
|
||||
'3. record_dj_booth — the rigged 1200 + TTM-54i behind the counter')
|
||||
if not ONLY or 'pub' in ONLY: shot_pub_pair(p)
|
||||
if not ONLY or 'wardrobe' in ONLY: shot_wardrobe_pair(p)
|
||||
if not ONLY or 'credits' in ONLY: shot_credits(p)
|
||||
finally:
|
||||
srv.terminate()
|
||||
print('')
|
||||
if fails:
|
||||
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
|
||||
for f in fails: print(f' · {f}')
|
||||
return 1
|
||||
print(f'\033[32m● PASS\033[0m — the tour is in {OUTDIR.relative_to(ROOT)}, every humanoid frame carrying its R10 line')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@ -55,7 +55,7 @@ import { createPlayer } from './js/world/player.js';
|
||||
import { createHUD } from './js/world/hud.js';
|
||||
import { createMinimap } from './js/world/minimap.js';
|
||||
import { createDiscovery, fogKeyFor, createStreetLocator } from './js/world/discovery.js'; // [Lane B R39 §39.2] v9 THE FOG · [R40 §40.3] fog signage locator
|
||||
import { createInteriorMode, godverseBaseFor } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
|
||||
import { createInteriorMode, godverseBaseFor, WARDROBE_BASE } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge · [Lane F R41] the wardrobe base
|
||||
import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet)
|
||||
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
|
||||
import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1)
|
||||
@ -248,6 +248,9 @@ const DIG_ON = flagOn('dig');
|
||||
const PLAYTEST_ON = flagOn('playtest');
|
||||
const BUGTOUR = PLAYTEST_ON && params.get('bugtour') === '1';
|
||||
let playtest = null; // set by the dynamic import; the unlock/click guards below read .noteActive
|
||||
// [Lane B R41 §41.5 — THE CREDITS SURFACE] set by the dynamic import below on non-classic boots only;
|
||||
// the unlock guard reads .isOpen so opening the panel inside a shop doesn't walk you back out.
|
||||
let credits = null;
|
||||
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
|
||||
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
|
||||
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
|
||||
@ -272,6 +275,13 @@ let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G'
|
||||
// parody: fail-soft runs through the same path, and that is the tier-0 rung of the ladder, live.
|
||||
if (STOCK_REAL) {
|
||||
preloadStockPack('record'); preloadStockPack('book'); preloadStockPack('toy');
|
||||
// [Lane F R41 §41.6 — THE WARDROBE IS ON] Lane C's §41.4 op-shop pack, under its own base (the one
|
||||
// line LANE_C_NOTES §41 asked for). 52 named 1990s Australian garments, ONE 246 KB atlas, ONE material,
|
||||
// ONE draw per fitting — the rails and shelves of every op shop stop being a procedural canvas. Rides
|
||||
// the same STOCK_REAL gate as the three above, so a default boot and ?noassets=1 fetch nothing, and a
|
||||
// missing pack still falls soft to the canvas byte-identically. Base string comes from interior_mode's
|
||||
// exported constant so the preload and the room's lookup can never disagree (the R24 cache-key lesson).
|
||||
preloadStockPack('opshop', { base: WARDROBE_BASE });
|
||||
// [Lane F R26 §7 — THE MANIFEST KILLS THE PROBES] R24 preloaded a pack for EVERY keyed shop and ate a
|
||||
// 404 for each one that had no atlas. That was fail-soft working, but it was also console errors on
|
||||
// every godverse boot, and F's smoke could only tolerate them by NAMING them — a tolerance is a
|
||||
@ -640,7 +650,7 @@ addEventListener('blur', () => keys.clear());
|
||||
const startPanel = document.getElementById('pc-start');
|
||||
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
||||
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
|
||||
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive && !(playtest && playtest.noteActive)) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2), the sell card (R30 §9.4) or an F8 bug note ([Lane F R40] §40.6 — same contract)
|
||||
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive && !(playtest && playtest.noteActive) && !(credits && credits.isOpen)) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2), the sell card (R30 §9.4) or an F8 bug note ([Lane F R40] §40.6 — same contract) · [Lane B R41 §41.5] …or the credits panel — same contract, one more term
|
||||
// click the world to (re)grab the pointer when walking
|
||||
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && !(playtest && playtest.noteActive) && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card · [Lane F R40] not while typing a note
|
||||
|
||||
@ -794,6 +804,22 @@ if (PLAYTEST_ON) {
|
||||
})
|
||||
.catch((e) => console.warn('[procity] playtest harness load failed:', e));
|
||||
}
|
||||
// [Lane B R41 §41.5 — THE CREDITS SURFACE] PROCITY ships ODbL map data and an MIT engine and had no
|
||||
// attribution surface at all; this is it (F2, or the HUD link bottom-left). CLASSIC-GATED BY
|
||||
// CONSTRUCTION — the null-provider idiom, same as the R40 fog signage and the R40 playtest harness:
|
||||
// under ?classic=1 the module is never fetched, hud.setCredits() is never called, and classic's HUD
|
||||
// DOM / key handlers / fetch surface are byte-identical to R40. ZERO DRAWS (DOM chrome the composer
|
||||
// never renders) and ZERO boot fetches — assets/credits.json is fetched on FIRST OPEN, and not at all
|
||||
// under ?noassets=1. Every lane appends its own rows to that file; schema in LANE_B_NOTES §41.
|
||||
if (!CLASSIC) {
|
||||
import('./js/world/credits.js')
|
||||
.then((m) => {
|
||||
credits = m.createCredits({ noassets: NOASSETS, releasePointer: () => player.unlock() });
|
||||
window.PROCITY.credits = credits;
|
||||
hud.setCredits(credits, { osm: PLANSRC === 'osm' }); // the ODbL credit rides the link on OSM boots
|
||||
})
|
||||
.catch((e) => console.warn('[procity] credits surface load failed:', e));
|
||||
}
|
||||
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
|
||||
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
|
||||
// [Lane F R40 §40.6] …and under ?bugtour=1, which drives DBG.shot/enterShop for its stops.
|
||||
|
||||
@ -54,6 +54,23 @@ export function godverseBaseFor(shop) {
|
||||
return id ? `assets/stock_godverse/${id}/` : undefined;
|
||||
}
|
||||
|
||||
// [Lane F R41 §41.6 — THE WARDROBE, turned on] Lane C's §41.4 route-R5 pack: 52 named 1990s Australian
|
||||
// garment layers in ONE 246 KB atlas, serving the op shop's `garment` (rails) and `box` (shelves/tubs)
|
||||
// slots at zero geometry cost. It is a stock pack under its OWN base, so it needs the same two-sided
|
||||
// wiring the godverse packs needed — a boot preload (getStockPack is SYNC and the room is built the
|
||||
// instant the door opens) and the identical base string at build time. Same reason as R24: if the two
|
||||
// sides spelled the base even slightly differently the (type, base) cache key would miss and the op shop
|
||||
// would fall silently to the procedural canvas — which looks exactly like "no wardrobe" and would sail
|
||||
// past an eyeball. One constant, one resolver, both sides.
|
||||
//
|
||||
// PRECEDENCE, and it is deliberate: a shop carrying A's `godverseShopId` keeps its OWN pack (a real
|
||||
// shop's real stock outranks the town-wide art). No `opshop` entry exists in G's manifest today (15
|
||||
// shops: record/book/toy only, measured R41), so this arm is defensive, not live.
|
||||
export const WARDROBE_BASE = 'assets/wardrobe/';
|
||||
export function stockBaseFor(shop) {
|
||||
return godverseBaseFor(shop) || (shop && shop.type === 'opshop' ? WARDROBE_BASE : undefined);
|
||||
}
|
||||
|
||||
// [Lane F R27 §2 — THE TIER-2 READER] Live stock, per C's §8 ruling: direct wire, no lifecycle hook.
|
||||
// F already holds `currentAdapter` and hands it to `dig.open()` per dig, so a fresh read is picked up by
|
||||
// the NEXT dig for free. The read is fire-and-forget: if it hasn't landed, hasn't finished, or failed,
|
||||
@ -356,7 +373,10 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// resolved or was never going to exist; asking again at the door can only ask for the absent one.
|
||||
// F's own gate caught this leftover the moment the 404 tolerance came out — which is the argument
|
||||
// for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently.
|
||||
const gBase = stockReal ? godverseBaseFor(shop) : undefined;
|
||||
// [Lane F R41 §41.6] `stockBaseFor`, not `godverseBaseFor`: same string for a godverse shop, plus the
|
||||
// wardrobe base for an op shop. Everything else still resolves `undefined` → the town-wide packs,
|
||||
// byte-identically. Gated on stockReal exactly as before, so ?noassets fetches nothing.
|
||||
const gBase = stockReal ? stockBaseFor(shop) : undefined;
|
||||
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
|
||||
// [R27 §2] Tier 2, fire-and-forget. ONLY for a real-sourced shop: G's fence 404s a mint shop, and
|
||||
// asking anyway would be R26's blind probe wearing a server. The manifest already told us which is
|
||||
@ -563,7 +583,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// buyables and the pack, never from "it looked right"). Null when no room is open.
|
||||
get stockInfo() {
|
||||
if (!current || !currentShop) return null;
|
||||
const base = stockReal ? godverseBaseFor(currentShop) : undefined;
|
||||
const base = stockReal ? stockBaseFor(currentShop) : undefined; // [Lane F R41 §41.6] wardrobe-aware
|
||||
const pack = stockReal ? getStockPack(currentShop.type, base) : null;
|
||||
const renderedIds = [], renderedTitles = [], texUrls = new Set();
|
||||
let stockMeshes = 0;
|
||||
|
||||