John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1807 lines
132 KiB
Markdown
1807 lines
132 KiB
Markdown
# LANE C — INTERIORS · progress (PROCITY-C)
|
||
|
||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||
|
||
Last updated: 2026-08-07 (round 42) · owner: PROCITY-C · reviewer: Fable
|
||
|
||
---
|
||
|
||
## Round 42 (§42.2b) — MAKE THE DECKS READ: the composition half
|
||
|
||
Lane E fixed the asset in §42.2a (chassis → Technics silver, the record → black vinyl, the inside-out
|
||
platter turned right way out). This is the other half of the same fault: **the booth still did not read
|
||
as a DJ setup from where a player stands, because the bench was a black slab in a place the player was
|
||
not looking, turned so they saw its end.** Nothing was driven, no mechanic was added (ruling 5). What
|
||
changed is colour, structure, orientation and placement.
|
||
|
||
**Everything green: 0 throws · 0 path-fails · 0 carves · 0 determinism-fails · 0 leaks · 0 console
|
||
errors · worst warm build 13.0 ms (budget 50) · worst room 123 draws against the ≤350 law, UNCHANGED.**
|
||
|
||
### 0. FIRST: the 122-vs-123 reconciliation — neither of us was wrong, the instrument was unpinned
|
||
|
||
F's §41.6 close read **123 @ `dept`/auto** where this lane published **122**, on the same tree, and F's
|
||
note said "the difference is C's measurement environment". That is exactly right, and here is the
|
||
environment: **`renderer.info.render.calls` counts what survives FRUSTUM CULLING, and the sweep camera
|
||
holds a fixed VERTICAL fov (70) while taking its aspect from the browser window.** A wider window sees
|
||
more of the room and therefore reads more draws.
|
||
|
||
Measured on ONE tree, running **F's own `SWEEP` function from `tools/qa/r41_integration.py` verbatim**,
|
||
changing nothing but the viewport:
|
||
|
||
| viewport | camera aspect | GLB on | GLB off | whose harness |
|
||
|---|---:|---:|---:|---|
|
||
| 1024×768 | 1.333 | 120 @ `dept/auto` | 88 @ `stall/hall` | — |
|
||
| 1280×1024 | 1.250 | 116 @ `dept/auto` | 88 @ `stall/hall` | — |
|
||
| **1280×800** | **1.600** | **122** @ `dept/auto` | 92 @ `opshop/hall` | **C's R41 harness** |
|
||
| 1440×900 | 1.600 | 122 @ `dept/auto` | 92 @ `opshop/hall` | — |
|
||
| **1280×720** | **1.778** | **123** @ `dept/auto` | 93 @ `opshop/hall` | **F's R41 harness** |
|
||
| 1920×1080 | 1.778 | 123 @ `dept/auto` | 93 @ `opshop/hall` | — |
|
||
| 2560×1080 | 2.370 | 124 @ `dept/auto` | 95 @ `opshop/hall` | (21:9 bound) |
|
||
|
||
Corroborating detail: the phantom control decomposes identically at both aspects — 197−123 = **+74** on
|
||
F's harness, 196−122 = **+74** on mine. Same bug, same magnitude, two readings of the same tree.
|
||
|
||
**THE TRUE NUMBER IS 123, and the margin is 227** — F's number, and I am adopting it, because the law
|
||
has to be the worst a *player* can produce and a player's window is wide (16:9 is the common case; 21:9
|
||
reads 124). My 122 was a 16:10 reading.
|
||
|
||
**Fixed, not just explained.** `drawSweep` now pins its own measurement aspect at **16:9**
|
||
(`MEASURE_ASPECT` in `interior_test.html`) and restores the window's aspect afterwards, so the number
|
||
stops depending on who ran it. **Control demonstrated:** `drawSweep({ glb: true, aspect: 1.25 })` on the
|
||
same tree reads **116** — the instrument can still be made to lie, on purpose, on demand.
|
||
|
||
*(Two things ruled out on the way, so the aspect finding is not a guess: I re-ran F's arm read-only and
|
||
it reproduced 123/93/197 exactly; and E's "+0 draws on all 46 repainted assets" was checked
|
||
independently by counting glTF primitives in `web/assets/models/` against the pristine copies in
|
||
`pipeline/.props_orig/r42/` — **46/46 identical primitive counts**, so the repaint cannot be the cause.)*
|
||
|
||
### 1. E's numbers, reproduced before building on them
|
||
|
||
| E's claim | reproduced here | how |
|
||
|---|---|---|
|
||
| chassis `#b9bdc2` rel. luminance **0.506** | **0.5060** | WCAG relative luminance from the hex |
|
||
| vs `djBooth` bench `#3a3128` (0.0325) = **6.74:1** | **6.74:1** | ditto — exact |
|
||
| R41 chassis (linear 0.05) vs the same bench = **1.21:1** | **1.21:1** | ditto |
|
||
| `deck_1200_rigged` has no plinth; ~43 mm missing | **confirmed, and sharpened** | see §4 |
|
||
| 46 repainted assets are **+0 draws** | **confirmed** | 46/46 identical glTF primitive counts vs `.props_orig/r42` |
|
||
|
||
**And the conclusion I drew from them is not E's.** E's advice was "the bench should stay dark, a
|
||
lighter bench reduces the ratio". True, and not the binding constraint: 6.74:1 and 4.73:1 are both far
|
||
above the 3:1 WCAG threshold for a graphical object, so deck-to-bench contrast was already solved by
|
||
E's half. The fault that survived is a claim about pixels, so I measured pixels.
|
||
|
||
### 2. THE MEASUREMENT: "a plain black box" is a statement about pixels
|
||
|
||
Six booths rendered at eye height, 2.0 m off the bench's own face, counting inside the booth's own
|
||
projected screen rect (`pixels.py`, scratch). BEFORE = the R41 lane-C tree **with E's §42.2a repaint
|
||
already live** — so this isolates my half and nothing else.
|
||
|
||
| room | booth px < rel.lum 0.05 | bench mass < 0.05 | distinct 4-bit colours | mean saturation |
|
||
|---|---:|---:|---:|---:|
|
||
| `record`/auto/1990 | 46.4% → **28.7%** | 53.8% → **32.4%** | 507 → 359 | 0.385 → 0.454 |
|
||
| `record`/gallery/4242 | 65.1% → **10.3%** | 73.7% → **7.8%** | 172 → 526 | 0.220 → 0.401 |
|
||
| `record`/pokey/7 | 88.4% → **17.1%** | 93.1% → **7.8%** | 150 → 408 | 0.227 → 0.455 |
|
||
| `pub`/auto/1990 | 20.1% → 70.0% ⚠ | 16.4% → 67.6% ⚠ | 154 → 177 | 0.214 → 0.295 |
|
||
| `band_room`/auto/1990 | 65.7% → **20.6%** | 64.4% → **5.9%** | 121 → 439 | 0.285 → 0.467 |
|
||
| `pawn`/auto/1990 | 59.5% → **13.4%** | 69.4% → **5.2%** | 261 → 425 | 0.299 → 0.429 |
|
||
| **mean, all six** | **57.5% → 26.7%** | **61.8% → 21.1%** | **228 → 389** | **0.272 → 0.417** |
|
||
| mean, excluding the pub | 65.0% → 18.0% | 70.9% → 11.8% | 242 → 431 | — |
|
||
|
||
⚠ **The pub row is an occlusion artefact and I am not hiding it.** A 1.8 m `speaker_stack` stands
|
||
between the 2 m camera and that booth, so the crop is mostly speaker. It is a real composition
|
||
complaint about that one room (see §5), not a measurement of the bench.
|
||
|
||
### 3. WHAT CHANGED, and why each
|
||
|
||
**a. The bench stops being one rectangle.** `djBooth` was three boxes (body, top edge, a dark cavity)
|
||
and is now seven: recessed plinth · two end cheeks · apron under · apron over · a back panel · the
|
||
worktop. **Footprint (`w+0.12 × d+0.12`), height (`h+0.25`) and therefore every room's occupancy grid,
|
||
path guarantee and placement are untouched by the new geometry** — every part is inside the volume R41
|
||
already reserved.
|
||
|
||
**b. Colour, decided by measurement not by taste.** The R41 bench was a 50/50 seeded coin flip between
|
||
`#3a3128` and `#2a2a30` — two near-blacks. Now: carcass `#5a4630` / `#6b4526` (stained pine, red-brown
|
||
timber — E's own R42 pub palette family), **worktop kept at `#1c1a18`**, plinth and bay interior
|
||
`#211d19`.
|
||
|
||
| pair | rel. luminance | WCAG |
|
||
|---|---:|---:|
|
||
| chassis `#b9bdc2` vs R41 body `#3a3128` | 0.5060 / 0.0325 | 6.74:1 (E's number) |
|
||
| chassis vs R42 carcass `#5a4630` | 0.5060 / 0.0677 | **4.73:1** |
|
||
| chassis vs R42 carcass `#6b4526` | 0.5060 / 0.0752 | **4.44:1** |
|
||
| **chassis vs the worktop it actually stands on `#1c1a18`** | 0.5060 / 0.0105 | **9.19:1** |
|
||
| black vinyl `#17171a` vs alloy platter `#c9ced3` | 0.0087 / 0.6126 | 11.29:1 |
|
||
|
||
**The deck's frame is the worktop, not the bench body** — the deck is a 0.11 m object sitting on a
|
||
0.05 m slab — so the number that governs whether the deck reads went **up** (6.74 → 9.19), while the
|
||
mass below it stopped being a void.
|
||
|
||
**c. An open record bay across the bench front.** The strongest single "this is a record shop's DJ
|
||
booth" signal available, and nearly free. `djBooth` now exposes a stock `slot`, so `stock.fill` puts
|
||
seeded records in it: pooled procedural sleeves by default, and **real GODVERSE covers under
|
||
`?stock=real`** (the record pack serves the `sleeve` slot). Pawn's bay gets `box` stock instead — a
|
||
pawnbroker's bench must not read as a DJ booth.
|
||
*Built wrong first and caught by looking:* the bay was a solid dark box, which put an opaque front
|
||
plane in front of the records; it is now an actual opening (cheeks + aprons + back panel).
|
||
|
||
**d. Headphones on the mixer.** `stackOn` in the booth part spec: a part may ride another part, y
|
||
resolved off the `KIT` table so the primitive stand-in and the GLB agree. It costs **no footprint** —
|
||
the bench is full edge to edge (0.51 + 0.23 + 0.45 across 1.55 m leaves two 0.14 m gaps and an HD25 is
|
||
0.23 wide), so the only free surface was upward, which is also where a real pair of cans lives.
|
||
|
||
**e. Turn the booth to face the player, and put it somewhere they look.** `spec.faceDoor`, on all four
|
||
`djBooth` specs. Two parts, and the second only exists because the first moved the problem:
|
||
|
||
* **the yaw** — aim local +Z at the spawn point (`door x`, `D/2 − 1.5`), with the zone's own yaw as
|
||
fallback. `placeNearCounter` copies the counter's yaw, and a `corner` counter is at −π/2, which
|
||
stands the booth square to a side wall.
|
||
* **the candidate order** — aiming it made its deep axis narrow, which made a counter-side candidate
|
||
"fit" that never had, and the record shop's hero landed 2.5 m to the player's LEFT at the door, out
|
||
of frame. So a `faceDoor` spec's candidate list is now **sorted by `doorViewScore`**: angle off the
|
||
view axis, penalised under 2.2 m and over 7 m. Pure geometry, no stream draws, applied as a stable
|
||
sort to the already-shuffled list, so determinism holds.
|
||
|
||
**Measured over 190 → 189 booth rooms (4 types × 6 archetypes × 8 seeds):**
|
||
|
||
| | before | after |
|
||
|---|---:|---:|
|
||
| face within 45° of the player's entry line | 94 (49%) | **173 (92%)** |
|
||
| face within 60° | 117 (62%) | **176 (93%)** |
|
||
| booth within 35° of the door view axis | 105 (55%) | **142 (75%)** |
|
||
| booth within 60° | 153 (81%) | **156 (83%)** |
|
||
| facing away (≥120°) | 0 | 0 |
|
||
|
||
**f. A pre-existing bug this exposed: a counter-side fitting could be planted inside a wall.**
|
||
`areaFree` deliberately allows border cells (shelves hug walls) and `cellOf` clamps anything past the
|
||
edge back onto the border — but `placeNearCounter`'s candidates are `counter ± gap` with no bound,
|
||
unlike every other placer. Measured on the record shop, seed 1990: the corner counter sits at z = −D/4,
|
||
so `counter.z − 2.35` puts the booth at z = −4.35 in a room whose back wall is z = −4.0 — **0.77 m of a
|
||
0.86 m half-depth buried in plaster.** R41 never saw it because at the counter's own yaw the booth was
|
||
too deep to fit there at all. Fixed with an `inRoom` bound in `placeNearCounter`, for every
|
||
`zone:'counter'` fitting; a rejected candidate is not a lost fitting (this placer already falls back to
|
||
the aisle).
|
||
|
||
**g. The venue booth's fallback order.** `stage` was first, and the stage flanks are exactly where the
|
||
PA lives — on `pub`/auto/1990 the booth landed **0.24 m from a 1.8 m `speaker_stack`** that hides it
|
||
completely. Open floor is now tried first, the stage flanks kept as a fallback rather than a preference.
|
||
|
||
### 4. THE PLINTH WORKAROUND — clearly marked, REMOVE IT IN R43
|
||
|
||
`kit41.BOOTH_PLINTH_FIX = { deck_1200_rigged: -0.043 }`, applied **only to the GLB**, never to the
|
||
primitive stand-in. E filed the fault; measured again here from the shipped GLB rather than taken on
|
||
report (`web/assets/models/procity_fit_deck_1200_rigged_01.glb`, node transforms applied):
|
||
|
||
| | `deck_1200_rigged` | `deck_1200` (the static twin, same source) |
|
||
|---|---:|---:|
|
||
| vertices | 8,740 | 11,138 |
|
||
| within 30 mm of the model's y-min | **9 (0.10%)** | 729 (6.55%) |
|
||
| within 43 mm | 69 (0.79%) | 739 (6.63%) |
|
||
| **1st percentile of vertex y** | **0.0524** | 0.0140 |
|
||
|
||
99% of the rigged deck sits 52 mm above the floor its own bounding box claims, and `glb.js` plants a
|
||
part at `y − bb.min.y`. **−0.043 m drops the feet onto the worktop and buries the nine stray vertices
|
||
inside the solid bench, where nothing can see them.** When R43 re-runs `rig_join.py` and the model gets
|
||
its plinth back, delete the constant and the `p.dy` term in `djBooth`'s `glbParts` map. Both are tagged
|
||
`[R42 §42.2b — WORKAROUND, REMOVE WHEN R43 FIXES THE ASSET]`.
|
||
|
||
**A second, smaller thing found by the same look, and this one is a real fix not a workaround:** the
|
||
worktop is a 0.05 m slab **centred** at y = h, so its top face is at h + 0.025 — and R41 planted every
|
||
booth part at y = h, i.e. **25 mm sunk into its own bench**. Invisible while nothing moved; the moment
|
||
the plinth correction went in it buried the whole chassis and left a black disc floating on the bench.
|
||
All parts (stand-in and GLB) now stand on `TOP = h + 0.025`.
|
||
|
||
### 5. THE DRAW TABLE — worst room by shop type, F's 16:9 aspect, before → after
|
||
|
||
Both columns measured on this instrument at the same pinned aspect; BEFORE is the frozen R41 lane-C
|
||
tree with E's §42.2a repaint live.
|
||
|
||
| type | GLB off | GLB on | GLB on + `?stock=real` | `noKit` (the control arm) |
|
||
|---|---|---|---|---|
|
||
| `record` **(booth)** | 89 → **89** | 81 → **82** (+1) | 73 → **74** (+1) | 63 → 63 (+0) |
|
||
| `pub` **(booth)** | 64 → **65** (+1) | 59 → **57** (−2) | 59 → **57** (−2) | 45 → 45 (+0) |
|
||
| `band_room` **(booth)** | 57 → **60** (+3) | 51 → **55** (+4) | 51 → **55** (+4) | 39 → 39 (+0) |
|
||
| `pawn` **(booth)** | 83 → **83** | 72 → **72** (+0) | 72 → **72** (+0) | 64 → 64 (+0) |
|
||
| `opshop` | 93 → 93 | 105 → 105 | 105 → 105 | 102 → 102 |
|
||
| `toy` | 68 → 68 | 86 → 86 | 120 → 120 | 86 → 86 |
|
||
| `book` | 71 → 71 | 64 → 64 | 98 → 98 | 53 → 53 |
|
||
| `video` | 79 → 79 | 65 → 65 | 65 → 65 | 55 → 55 |
|
||
| `milkbar` | 83 → 83 | 70 → 70 | 70 → 70 | 70 → 70 |
|
||
| **`dept`** | 88 → 88 | **123 → 123** | **123 → 123** | 116 → 116 |
|
||
| `stall` | 89 → 89 | 82 → 82 | 82 → 82 | 72 → 72 |
|
||
| `rsl` | 73 → 73 | 60 → 60 | 60 → 60 | 52 → 52 |
|
||
| **WORST ROOM** | **93 @ `opshop/hall`** | **123 @ `dept/auto`** | **123 @ `dept/auto`** | 116 @ `dept/auto` |
|
||
|
||
**WORST ROOM IN THE GAME: 123 draws @ `dept`/auto, unchanged. Law ≤350 → MARGIN 227, unchanged.**
|
||
`dept` has no booth, so §42.2b spent **zero draws at the worst room**; the most spent anywhere is +4 at
|
||
`band_room`, which sits at 55. **The `noKit` arm is +0 on all twelve types** — the control for the
|
||
control: everything I touched lives inside a kit spec, and dropping the kit reproduces the pre-R41 room
|
||
exactly as it did in §41.4.
|
||
|
||
The bay's records are the reason `record` moved by 1 and not by 9: `slot.pool` puts the whole row on
|
||
**three shared pooled face materials**, so it batches to at most three meshes — and in a record shop
|
||
those are buckets the room's own bins already have, so it merges to zero. Under `?stock=real` the whole
|
||
bay is one atlas material. New GLB parts: `headphones_hd25` on `record_hero` and `venue` (+1 draw each);
|
||
`pawn` gets none.
|
||
|
||
### 6. SWEEP · SOAK · DETERMINISM · `?noassets`
|
||
|
||
- **`drawSweep`, 4 arms × 12 types × 6 archetypes = 288 rooms:** all ≤350, **0 console errors**.
|
||
Aspect control (`aspect: 1.25`): **116 @ `dept/auto`** — the instrument demonstrably moves.
|
||
- **Soak, 60 rooms build+dispose+rebuild:** avg **3.61 ms**, worst **13.0 ms** (budget 50) · leak geo
|
||
**0** / tex **0** · determinism **0** mismatches · path-fails **0** · carves **0**. (Three independent
|
||
runs of the whole suite: worst build 17.3 / 9.2 / 13.0 ms — build time is machine noise, every other
|
||
number in this section was byte-identical across all three.)
|
||
- **576-room corpus** (12 types × 6 archetypes × 8 seeds): **0 path-fails, 0 carves, 0 throws.**
|
||
- **Determinism, two FRESH browser contexts**, 240 rooms (12 types × 5 archetypes × 4 seeds), GLB on +
|
||
`?stock=real`, comparing placement, GLB id list, booth pose (position + yaw to 5 dp), browse/watch
|
||
points, keeper stand and stage: **byte-equal, sha256 `7c78674a…22c0` on both runs, 462,006 bytes.**
|
||
- **`?noassets` arm, all 60 type × archetype rooms:** **0 GLB · 0 manifest · 0 wardrobe · 0 stock-pack
|
||
requests.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` / `tex-*.jpg` shell
|
||
wallpapers — the same 10 as R41. Worst primitive room 71 draws, 20 booths built, 0 errors.
|
||
**CONTROL:** the same 60 rooms with the manifest handed in fetch **50 GLBs** — the gate is not vacuous.
|
||
- **`kitAudit(manifest)`: 96/96 rows checked, 0 dimension errors, 0 missing, 0 pair errors, 0 oversize.**
|
||
It covers the new booth parts too (`headphones_hd25` is a catalogue row and a booth part id).
|
||
- **Booth count 190 → 189 over 576 rooms.** All three misses are `pub` rooms where the door→counter
|
||
path guarantee **pulled** the booth and the §41.4 restore pass could not put it back (`pulled 7–9,
|
||
restored 6–8, lost 1`) — the guarantee doing its job, not a placement failure. R41 lost two of the
|
||
same class. **watchPoints did not suffer: `pub` 361 → 363, `band_room` 384/384, `rsl` 560.**
|
||
|
||
### 7. `assets/wardrobe/` was spelled in two files (F's tidy)
|
||
|
||
`'assets/wardrobe/'` was written out in `web/interior_test.html` **and** `web/js/world/interior_mode.js`
|
||
— a string that has to match a directory on disk, in two places. It is now declared once, in
|
||
`stockpack.js` next to the loader that resolves it, re-exported from `interiors.js`, and imported by the
|
||
test page. **→ LANE B/F: `interior_mode.js` can drop its own copy for one import line** (LANE_C_NOTES
|
||
§42); I did not touch that file, it is not in this lane's editable set.
|
||
|
||
### 8. Shots — `docs/shots/laneC/`
|
||
|
||
`r42_dj_booth_door_ab.png` — **the deliverable**: the DOOR view, before/after, same seed, same camera
|
||
(the room's own spawn), E's repaint live in both · `r42_dj_booth_before_after.png` — the round's origin
|
||
(`r41_record_dj_booth.png` as committed) against the answer · `r42_dj_booth_door.png` (the after door
|
||
view alone) · `r42_record_shop.png` (record shop wide) · `r42_dj_booth_variants.png` (band room · pawn ·
|
||
a second record shop) · `r42_dj_booth_arms.png` (`?noassets` primitive · default · `?stock=real`).
|
||
Every booth-scale shot carries a **1.75 m scale post**; no shot here contains a humanoid.
|
||
|
||
### 9. Files
|
||
|
||
Changed: `kit41.js` (`BOOTHS` bay/`stackOn`/`dy`, `BOOTH_PLINTH_FIX`), `fittings.js` (`djBooth` rebuilt,
|
||
booth palette, `TOP` surface fix), `stock.js` (`binFan` `slot.axis` + `slot.pool`), `layout.js`
|
||
(`faceDoorYaw`, `doorViewScore`, the `inRoom` bound, `placeAnywhere` signature), `theme.js` (`faceDoor`
|
||
on the four booth specs, venue fallback order), `stockpack.js` + `interiors.js` (`WARDROBE_BASE` once),
|
||
`interior_test.html` (`MEASURE_ASPECT`, the import). `glb.js` deliberately **unchanged** — the booth
|
||
bakes its own part heights, so the plinth workaround needed no edit to the loader.
|
||
**Not touched:** `web/assets/models/**`, `web/assets/manifest.json`, `pipeline/**`, `web/js/citizens/**`,
|
||
`web/js/world/**`, `web/index.html`, `tools/qa.sh`.
|
||
|
||
---
|
||
|
||
## Round 41 (§41.4) — THE SHOPS GET THEIR STOCK
|
||
|
||
Lane E's 110 new interior fittings are placed, the pub is furnished, the record shop has a rigged 1200
|
||
behind the counter, and 52 hand-prompted 1990s Australian garments hang on the op-shop rails at **zero
|
||
geometry cost**. New file `web/js/interiors/kit41.js` is the catalogue; `fittings.js` grew one generic
|
||
builder that covers all 110 and a DJ-booth builder; the recipes name pools, never asset ids.
|
||
|
||
**Everything green: 0 throws · 0 path-fails · 0 carves · 0 determinism-fails · 0 leaks · worst warm build
|
||
10.3 ms (budget 50) · worst room 122 draws against the ≤350 law.**
|
||
|
||
### 0. FIRST: the instrument was measuring a room that wasn't there
|
||
|
||
`drawSweep` — this lane's own ≤350 instrument, the one F mirrors in `r39_transmission.py` — cleared every
|
||
interior from the scene **except `current`**, the live room the page happens to be showing. So every
|
||
per-type number carried a constant offset from a *second, unrelated room* (seed 1990, type = the first
|
||
entry in the list, i.e. a record shop). It was invisible while nothing changed. The moment §41.4 furnished
|
||
that record shop, `toy` — a type this round does not touch — "gained" 4 draws in both modes.
|
||
|
||
Fixed before anything was costed: the sweep now clears **all** interiors and rebuilds `current` at the end.
|
||
**Every number below is on the corrected instrument.** Against R39/R40 prints of the same function the
|
||
values are lower; that is the phantom leaving, not rooms getting cheaper. The old worst reading of 188 @
|
||
dept was 116 of dept and ~72 of a record shop nobody was looking at.
|
||
|
||
### 1. The control arm — `opts.noKit`, and why it is exact
|
||
|
||
Every R41 spec is **appended last** inside its recipe, so `layout.js` hands each surviving spec the same
|
||
draws off the same `lay-count`/`lay-place` streams in the same order. `buildInterior(..., { noKit: true })`
|
||
drops exactly those specs and reproduces the pre-R41 room. That is what makes "the kit costs N draws" a
|
||
measurement rather than a claim — and `toy`, which gets no kit by design, comes out **+0 in all three
|
||
modes**, which is the control for the control.
|
||
|
||
### 2. Draws by shop type — worst room per type, seed 1990, 5 archetypes + auto
|
||
|
||
| type | fittings placed (R41 additions) | OFF pre | OFF R41 | **ON pre** | **ON R41** | Δ | stock=real R41 |
|
||
|---|---|---:|---:|---:|---:|---:|---:|
|
||
| `record` | 2–4 tubs/crates (dig) · 1–3 tub racks · 1–2 wall boards · window ledge · 1–2 pinned sleeves · **the rigged DJ booth** | 69 | 85 | 61 | **78** | +17 | 70 |
|
||
| `opshop` | 1–2 bargain tubs · 1–2 racks · wall board | 89 | 92 | 102 | **105** | +3 | 105 |
|
||
| `book` | library trolley · 1–2 racks · 1–2 wall boards | 55 | 69 | 51 | **61** | +10 | 95 |
|
||
| `video` | ex-rental bargain tub · 1–2 wall boards · window ledge | 65 | 76 | 54 | **63** | +9 | 63 |
|
||
| `pawn` | 1–2 flight cases/sub · **808 + SP-1200 bench** · wall board | 67 | 81 | 60 | **70** | +10 | 70 |
|
||
| `stall` | **2–4 diggable bins** · 1–2 racks | 76 | 89 | 71 | **82** | +11 | 82 |
|
||
| `dept` | 0–2 columns · 1–2 racks · wall board | 80 | 87 | 116 | **122** | +6 | 122 |
|
||
| `milkbar` | **bain-marie / pie warmer** · 1–3 floor props · air-con | 72 | 80 | 68 | **69** | +1 | 69 |
|
||
| `pub` | **bar = `plywood_bar`** · 2–3 stage-flank venue kit · 3–6 seats · 3–6 floor props · air-con · DJ booth | 48 | 64 | 45 | **59** | +14 | 59 |
|
||
| `band_room` | bar = `plywood_bar` · 2–4 venue kit · 2–3 floor props · DJ booth | 43 | 56 | 39 | **50** | +11 | 50 |
|
||
| `rsl` | bar = `plywood_bar` · 1–3 venue kit · 3–6 seats · 3–5 floor props (pokies, raffle drum) | 56 | 73 | 52 | **60** | +8 | 60 |
|
||
| `toy` | **none — untouched by design** | 67 | 67 | 86 | **86** | **+0** | 120 |
|
||
|
||
**WORST ROOM IN THE GAME: 122 draws @ `dept`/auto, GLB on, with or without `?stock=real`.**
|
||
> **[CORRECTED IN R42 §42.2b — the true number is 123, margin 227.]** Every figure in this §41.4 table
|
||
> was read at a 16:10 viewport, and `drawSweep`'s camera holds a fixed VERTICAL fov while inheriting the
|
||
> window's aspect — so a wider window sees more of the room and reads more draws. At 16:9 (F's harness,
|
||
> and the sweep's pinned aspect from R42 on) this table reads 1–4 higher per type and the worst room is
|
||
> 123. The rooms did not change; the instrument was unpinned. Reconciliation in § Round 42 (§42.2b) §0.
|
||
|
||
Law ≤350 → **margin 228**. It was 116 before this round, so §41.4 spent **6 draws at the worst room** and
|
||
at most 17 anywhere. `dept` deliberately gets the fewest additions of any type precisely because it owns
|
||
the worst room; the pub, which this round exists for, could afford 14 and still sit at 59.
|
||
|
||
Cost discipline came free from E's work: **every one of the 110 is 1 draw** bar the four rigged heroes.
|
||
The record shop's booth is the round's only multi-draw fitting — `deck_1200_rigged` (5) +
|
||
`mixer_ttm54i_rigged` (2) + a static `deck_1200` (1) = **8 draws, one room, once**.
|
||
|
||
### 3. Sweep + soak
|
||
|
||
- **`drawSweep` 4 arms × 12 types × 6 archetypes = 288 rooms:** all pass ≤350, **0 console errors**.
|
||
- **Soak, 60 rooms build+dispose+rebuild:** avg **3.83 ms**, worst **10.3 ms** (budget 50) · leak geo **0**
|
||
/ tex **0** · determinism **0** mismatches · path-fails **0** · carves **0** · throws **0**.
|
||
- **960-room corpus** (12 types × 5 archetypes × 8 seeds × kit/noKit, GLB on): **0 path-fails, 0 carves.**
|
||
|
||
| type | R41 fittings/room | GLB props/room (max) | diggable bins/room | rooms with none | watchPoints | browsePoints |
|
||
|---|---:|---:|---:|---:|---:|---:|
|
||
| record | 7.90 | 20.8 (28) | **8.32** | 0/40 | — | 3.00 |
|
||
| stall | 3.92 | 11.7 (18) | **4.55** | **0/40** | — | 3.00 |
|
||
| pub | 8.40 | 13.0 (19) | — | — | **7.5 / cap 8** | 3.00 |
|
||
| band_room | 5.03 | 9.1 (11) | — | — | **8.0 / cap 8** | 3.00 |
|
||
| rsl | 5.78 | 10.1 (18) | — | — | **11.4 / cap 12** | 3.00 |
|
||
| opshop | 2.95 | 13.1 (19) | — | — | — | 3.00 |
|
||
| milkbar | 3.77 | 13.5 (18) | — | — | — | 3.00 |
|
||
| book / video / pawn / dept | 3.80 / 3.10 / 3.17 / 1.90 | 11.4 / 5.1 / 9.7 / 11.8 | — | — | — | 3.00 |
|
||
|
||
**Furnishing did not cost the crowd.** watchPoints are computed after placement, so a furnished venue could
|
||
have starved its own audience; measured, pub holds 7.5 of 8, band_room 8.0 of 8, RSL 11.4 of 12.
|
||
|
||
**A measured R39 hole closed on the way past.** R39 counted 48 market-stall rooms and found **21 (44%) with
|
||
no crate at all** — the recipe's `crate` is `zone:'centre'` and 3–6 trestles take the aisle first — so RED
|
||
TICKET DAY could name a stall you cannot dig in. `stall` now carries 2–4 kit bins with the full fallback
|
||
chain: **0 of 40 stall rooms have no bin, mean 4.55.**
|
||
|
||
### 4. Four defects found by building this, all fixed
|
||
|
||
1. **`buyMesh` was invisible on any fitting with a GLB.** R9's buy-anywhere shelves are merged,
|
||
per-item-addressable meshes tagged `{ noBatch, buyMesh }` and *not* `isStock` — so `glb.js`'s
|
||
frame-hide turned **every real, buyable item in the room invisible** the moment the shelf upgraded.
|
||
Live since R9 on `bookshelf`, `cubeShelf` and `clothesRack`; never seen because `?stock=real` is opt-in
|
||
and R9 measured it GLB-off. Found the instant 52 real garments went on a rail and the rail came back
|
||
empty. `hidePrimitive` now skips `buyMesh`.
|
||
2. **The DJ booths were nondeterministic.** Parts were attached inside each load's own `.then`, so the
|
||
booth's children landed in network-completion order. Two runs of the same 240 rooms differed on exactly
|
||
the three types that have a booth and on nothing else. Loads stay parallel; attachment is now a plain
|
||
in-order loop.
|
||
3. **The booth's gear floated at 0.92 m over bare floor.** A single-GLB swap hides the whole primitive,
|
||
but a booth's GLBs are the *gear* — the bench is the fitting. Gear stand-ins are tagged `boothGear` and
|
||
only those are hidden. Which exposed a fourth: **`batch.js` rebuilt `userData` from scratch and dropped
|
||
sub-tags on merge**, so the tag never survived to be read. It now carries `boothGear`/`tillPrimitive`
|
||
through a merge when *every* mesh in the material bucket has it.
|
||
4. **The door→counter path guarantee could empty a room.** The pull loop takes the *lowest-priority*
|
||
survivor each pass, not the one standing in the corridor, so it clears the cheap furniture first and
|
||
reaches the real blocker by attrition. Measured: `pub/wide/4242` came back with a bar, a stage and
|
||
**nothing else** — a wide pub puts the bar beside the stage and leaves one ~1 m gap. Pre-existing; ten
|
||
more fittings per pub made it impossible to miss. New step 3b walks the pulled list in reverse and puts
|
||
each fitting back **only if door→counter still reaches**: over 480 rooms, **129 pulled → 101 restored,
|
||
28 genuinely lost**, and the guarantee is re-proved on every single restore. A room that pulled nothing
|
||
does nothing (byte-identical); a room that pulled N can only end with more fittings, never fewer.
|
||
|
||
### 5. E's three traps
|
||
|
||
| trap | what §41.4 does |
|
||
|---|---|
|
||
| `-long`/`-wide` pairs are the same mesh rotated 90° | `kit41.PAIRS` records all 9; **no pool contains both halves**, and `kitAudit()` asserts it. Facing is not a hand-tuned table either — `glb.js autoYaw` compares the manifest footprint against the slot the grid reserved and turns the GLB a quarter if the major axis is swapped. Pre-R41 kinds keep their hand-tuned `KIND_TO_YAW` exactly, so legacy rooms are unchanged on screen. |
|
||
| `vinyl_sleeve` depth floored to 0.01 m | wall dressing only — `sleeve_wall`, `wallMounted`, mountY 1.72, in **no floor pool**. |
|
||
| `beam_roof` 6.5 m · `shelf_board_long` 4.6 m · `wall_shelves_big` 4.65 m | in **no pool at all** (archetypes are 6–12 m wide). Listed in `kit41.OVERSIZE`, asserted by `kitAudit()`, and generalised as `spec.o.maxRun`. |
|
||
|
||
**`kitAudit(manifest)` (on `PROCITY_C`) is the drift check, not a promise:** it compares all **96
|
||
catalogue rows** against the live manifest. Result — **96/96 checked, 0 dimension errors, 0 missing, 0 pair
|
||
errors, 0 oversize; 67 pool/booth ids all resolve.** It found one real drift on the first run
|
||
(`sampler_s900` height 0.14 vs the manifest's 0.135), which is fixed.
|
||
|
||
### 6. THE WARDROBE — route R5, zero geometry
|
||
|
||
`web/assets/wardrobe/` (new): **`wardrobe_atlas_00.webp` 246,364 B + `stock_opshop_index.json` 17,347 B =
|
||
263,711 B (257.5 KB), ONE fetch, ONE material, ONE draw per fitting.**
|
||
|
||
- **52 layers selected BY NAME** (ruling 3 — no wildcard copy) from the 441 at
|
||
`~/Documents/90sDJsim/web/world/media/doll/`. Each is cropped to its **alpha bbox** (the sources are
|
||
704×1408 full-body layers that are ~90% empty; a top occupies the torso band only), scaled and
|
||
shelf-packed. **257.5 KB is 3.7% of the 52 sources' 6.74 MB and 0.49% of the 53 MB full set.**
|
||
- Garment cells are **128×152** — exactly the resolution of the procedural garment canvas they replace
|
||
(`stock.js makeGarment` is 128×160). This is a swap in art, not an increase in texture budget.
|
||
- **LAZY AND BOUNDED:** it is a stock pack under its own base, so it loads only under `?stock=real`.
|
||
A default boot fetches nothing; `?noassets=1` fetches nothing. Fail-soft: no pack → the procedural
|
||
canvas, byte-identically.
|
||
- **Where the names show:** garments come through `buildBuyableShelf`, so they are per-item addressable
|
||
and the **existing shelf buy-card path reads `artist — title`** with no new UI.
|
||
|
||
**THE LICENCE CALL, made explicitly.** `pipeline/AUDIT.md` is standing law: *no real titles/authors/brands
|
||
ship*. The source library was hand-prompted **from** real 1990s labels, so a majority of the 441 carry a
|
||
live trademark in the filename **and in the artwork** — `top_i_adidas_track_jacket.png` renders a legible
|
||
trefoil and three stripes; `bottom_i_tech_fleece_pant.png` has a swoosh on the leg. So the screen was two
|
||
stage: every candidate was eyeballed on a contact sheet and **rejected if the artwork carried a mark**
|
||
(that took out adidas/nike/versace/dr-martens/champion/coogi/stussy/fila and the rest), and the surviving
|
||
ids are then labelled in a **parody voice**: `Paddy's Bootleg`, `Coogee Knits`, `Mombo`, `Stoosh`,
|
||
`Kaymart`, `Criss Colors`, `Kangaruu`, `Army Disposals`, `Rissole Classic`, `Nan Knitted It`.
|
||
**The parody is not a compromise, it is the period detail** — Paddy's Market genuinely sold "Tommy
|
||
Hillfigga", and the source library itself already ships `adidos_shelltops` and `doc_martys_8_eye`.
|
||
Titles stay literal and Australian: *Double Pluggers · Terry Towelling Hat · Trackie Dacks · Boardies ·
|
||
Squiggle Jumper · Phat Pants · Acid Smiley Tee*.
|
||
|
||
Two small plumbing changes made this work without touching the three existing packs:
|
||
`SLOT_FOR.opshop = ['garment','box']` (a pack may now serve more than one slot kind; the three pre-R41
|
||
packs keep their single string and are handed `pack.items` **by reference**, so every code path that
|
||
matters compares reference-equal and behaves byte-identically), and an optional `index.material` block
|
||
(`alphaTest` + `DoubleSide`) — without it a cut-out tee is its own bounding box and the back of a hanging
|
||
garment is a hole. Packs that declare no block build the material they always did.
|
||
|
||
### 7. DJ CONTROL NODES — recorded, NOT driven (for D/F, a later round)
|
||
|
||
R41 **places** the rigged models and records their contracts. **Nothing drives them**, and `djBooth` ships
|
||
no animation hook on purpose so a placed node is never mistaken for a live one. The names are in
|
||
`kit41.CONTROL_NODES`, on the fitting as `fitting.controlNodes`, and on the group as
|
||
`group.userData.controlNodes`:
|
||
|
||
| id | draws | control nodes | where it is |
|
||
|---|---:|---|---|
|
||
| `deck_1200_rigged` | 5 | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, booth `record_hero`, left deck |
|
||
| `mixer_ttm54i_rigged` | 2 | `Fader_CROSS` | record shop, booth `record_hero`, centre |
|
||
| `mixer_pmc05pro_rigged` | 2 | `Fader_CROSS` | catalogued, not placed |
|
||
| `mixer_shdj1200_rigged` | 2 | `Fader_CROSS` | catalogued, not placed |
|
||
|
||
Two things a future driver must not rediscover the hard way: drive them by the **bare node name** (the
|
||
cap/stem/rib are parented under it), and **glTF drops the source's limit constraints, so clamp the travel
|
||
yourself.** Venue and pawn booths are deliberately **statics** — E's call, and correct: a static copy is
|
||
1 draw and looks the same standing still.
|
||
|
||
### 8. Determinism + `?noassets`
|
||
|
||
- **Determinism, two FRESH browser contexts** (never a reused tab), 240 rooms = 12 types × 5 archetypes ×
|
||
4 seeds, **GLB on + `?stock=real`**, comparing placement, GLB id list, browse/watch points, keeper stand
|
||
and stage: **byte-equal, sha256 `c26b4848…eae852` on both runs, 0 mismatched rooms** over 522,501 bytes
|
||
of payload.
|
||
- **`?noassets=1`, all 60 type × archetype rooms built:** **0 GLB fetches · 0 manifest fetches · 0 wardrobe
|
||
fetches · 0 stock-pack fetches.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` /
|
||
`tex-*.jpg` shell wallpapers, which are not R41 cargo and are the same 10 before and after. Worst
|
||
primitive room **126 draws @ opshop/hall, 0 GLB upgrades, 0 path-fails** — the asset-free build is a
|
||
real, correctly-sized, walkable shop floor, because `kit41.js` is a hardcoded table and `buildInterior`
|
||
is synchronous. Control shot: `docs/shots/laneC/r41_noassets_control.png`.
|
||
|
||
### 9. Shots — `docs/shots/laneC/`
|
||
|
||
`r41_record_shop.png` · `r41_record_dj_booth.png` · `r41_pub.png` · **`r41_pub_before.png`** (the same
|
||
seed and camera with `noKit:true` — the control) · `r41_opshop_wardrobe.png` · `r41_band_room.png` ·
|
||
`r41_milkbar.png` · `r41_stall_bins.png` · `r41_noassets_control.png`.
|
||
Each carries a **1.75 m scale post** at a floor pose: no shot here contains a humanoid, so the R10 law does
|
||
not bite, but these are 110 brand-new assets at claimed real-metre scale and the bar is the proof.
|
||
|
||
### 10. Files
|
||
|
||
New: `web/js/interiors/kit41.js` · `web/assets/wardrobe/{wardrobe_atlas_00.webp, stock_opshop_index.json}`.
|
||
Changed: `fittings.js` (the kit builder + `djBooth` + counter `glbId`), `theme.js` (recipe pools),
|
||
`layout.js` (`stage` zone, `spec.gap`, `wall` as a fallback zone, restore pass, `noKit`), `glb.js`
|
||
(`fitting.glbId`, `glbParts`, `autoYaw`, the `buyMesh` fix), `stock.js` (`hang` real path, slot pools),
|
||
`stockpack.js` (multi-slot packs, `index.material`, `ry`), `batch.js` (sub-tag propagation),
|
||
`interiors.js` (`noKit`, `room.pulls`), `interior_test.html` (sweep fix, `noKit`/`stock` arms, wardrobe
|
||
preload, `kitAudit` hook).
|
||
**Not touched:** `web/assets/manifest.json`, `web/assets/models/**`, `pipeline/**`, `web/js/citizens/**`,
|
||
`web/js/world/**`, `web/index.html`, `tools/qa.sh`.
|
||
|
||
---
|
||
|
||
## Update 2026-08-03 (round 39, v9-alpha THE CITY IS THE GAME) — §39.3 the op shop can't be dug
|
||
|
||
Fable's finding, verified before commissioning: `kind:'bin'` — the thing the dig fires on — is minted at
|
||
exactly `fittings.js` `recordBin`/`crate` and used by exactly two recipes, `record` and market `stall`.
|
||
PROCITY is built on thriftgod's op-shop DNA and **you cannot dig in an op shop.** Brief: measure what a
|
||
player can actually do in every shop type, build the op shop a bin, **flag the contents, do not decide
|
||
them**, and spend against the GLB-path interior margin.
|
||
|
||
### 1. THE TABLE (LANE_C_PUB §10) — the deliverable, and it is uncomfortable
|
||
Built by reading the recipes and the fitting kinds, then counting interactables in **48 rooms per type**
|
||
(6 archetypes x 8 seeds). Full table + the five structural facts in the contract; the short version:
|
||
- **`record`** digs (mean **4.6** bins/room, 0/48 empty) and is **the only room in the game where
|
||
anything can be sold**. **`stall`** digs — in 27 of 48 rooms; the other **21 (44%) have no crate at
|
||
all** (mean 0.92), because its `crate` is `zone:'centre'` and the 3–6 trestles take the aisle first.
|
||
**That is a measured correction to the round's binding fact #5** and it means RED TICKET DAY's roster,
|
||
which counts a stall by type, can name a stall you cannot dig in.
|
||
- **The other ten types offer nothing but looking.** No bins, and pull-and-buy is dark: `?stock=real` is
|
||
opt-in (`index.html:222`), so **on the boot every player gets there is not one `buyMesh` in the game**.
|
||
Even armed, `SLOT_FOR` has three entries and the pack is keyed by SHOP TYPE, so only book spines in a
|
||
`book` shop and toy boxes in a `toy` shop can ever be bought — the video store's identical spine
|
||
shelves cannot, because no `video` pack exists.
|
||
- **Four stock kinds can never be bought in any mode**: `garment`, `snack`, `magazine`, `treasure` —
|
||
`hang()`/`treasures()` never call `buildBuyableShelf`. **The op shop's clothes are unbuyable by
|
||
construction.**
|
||
- **`interactable: true` is not a verb.** `interior_mode` routes exactly three things (aimed `bin`,
|
||
counter, `buyMesh`). `case`, `fridge`, `returns` and the arcade cabinet set the flag and **nothing
|
||
reads them** — 8 dead glass cases in a dept-store hall, 4 dead fridges in a milk bar.
|
||
- **Sell** is `item.type === shopType`, and only two call sites stamp a type: a dig pull is **always
|
||
`'record'` wherever it happens**, a shelf buy is the shop's type. Hence: one sale point, default boot.
|
||
|
||
### 2. THE MECHANISM — `rummageBin`, shipped, contents gated
|
||
A shallow galvanised dump tub (0.95 x 0.70 x 0.28) with flared sides on a castored steel frame, rim at
|
||
**0.76 m** — you rummage standing — heaped with stickered mixed goods and a hand-lettered card taped to
|
||
the front ("EVERYTHING $2"). Not a record crate recoloured: `crate` is 0.55 x 0.45 and deep because it
|
||
is sized to stand LPs in. Shot: [r39_opshop_rummage_bin.png](docs/shots/laneC/r39_opshop_rummage_bin.png).
|
||
|
||
Three new **opt-in, pure-data** seams (absent ⇒ pre-R39 byte-identical): `recipe.fittings[i].o`,
|
||
`buildInterior opts.fittingOpts[kind]`, and `.fallbackZones` / `.anywhere` placement fallbacks. The last
|
||
one is not decoration — appended last, the bin's zone is the fullest it will ever be and it landed in
|
||
only **11 of 24** rooms without them.
|
||
|
||
**72 rooms (6 archetypes x 6 seeds x GLB on/off):** placed **72/72** · reachable **72/72** (nearest
|
||
spawn-reachable walkable cell **0.77–1.15 m** from the bin, vs the dig's 2.5/3.2 m reach) · **0 carves** ·
|
||
`pathOK` 72/72 · determinism 72/72 · **the rest of the room byte-identical to pre-R39 72/72** (the spec is
|
||
appended LAST, so the shared `lay-count`/`lay-place` streams hand every existing fitting the same draws).
|
||
End-to-end armed: aim → dig opens → **16 offers** → card → **BUY** debits the wallet → bin depletes to 15.
|
||
Negative control (default): tub present, **0 bins**, aim finds nothing, dig never opens. 0 console errors.
|
||
Soak 60 rooms: **leak geo 0 / tex 0**, determinism identical, avg 3.4 ms / worst 16.5 ms.
|
||
|
||
### 3. SPENT AGAINST THE GLB PATH — and the margin was not six
|
||
**`C-progress.md:841`'s "GLB-on 344" is R9-era and stale.** Re-measured on today's tree with the lane's
|
||
own instrument before touching anything: **GLB-off 156 · GLB-on 319, both at `opshop/hall`.** So the
|
||
margin was **31**, not six. After the bin:
|
||
|
||
| `drawSweep` | pre-R39 | R39 | Δ |
|
||
|---|---|---|---|
|
||
| GLB-off worst | 156 opshop/hall | **161** opshop/hall | +5 |
|
||
| GLB-on worst | 319 opshop/hall | **329** opshop/hall | +10 |
|
||
| the other 11 types, both modes | — | — | **0, byte-identical** |
|
||
|
||
**Margin against ≤350: 21 draws.** Stated at the worst room the bin lands in, not an average.
|
||
A free win found while costing it: the scatter fill minted a *tinted* material per item (9 goods = 9
|
||
draws in a room with no other scatter). New opt-in `slot.pool` keeps every face on the room's shared
|
||
pooled textures at white — the trick the spine/box rows already use — so the pile merges into buckets
|
||
the room already has. **12 goods now cost less than 9 did.** Existing scatter callers draw the same
|
||
values in the same order; proven by the 11 untouched types' sweep numbers.
|
||
|
||
### 4. THE BIGGER FINDING — half the interior budget is a pre-pass (→ E/F, NOT shipped)
|
||
Lane E's `bookshelf` GLB ships **`transmission: 1`** on `mtl_10218_Bookshelves_v1`. One transmissive
|
||
material makes three.js run its transmission pre-pass — a second full render of the opaque scene — so
|
||
**every draw in that room is issued and counted twice.** Measured by zeroing it and re-rendering:
|
||
`opshop`/hall **191 → 104 (−46%)** · `opshop`/wide 142 → 80 · `book`/hall **101 → 51 (−50%)** ·
|
||
`dept`/hall 116 → 116 (the control, no bookshelf). Those are the two worst GLB-on types in the sweep.
|
||
**→ E:** re-emit without `KHR_materials_transmission`. **→ F:** a `transmission = 0` scrub in
|
||
`glb.js upgradeFitting` is the cheap fence. **I have not shipped either** — it changes how another
|
||
lane's asset renders. Either turns a 21-draw margin into ~100.
|
||
|
||
### 5. HELD FOR JOHN — the contents (LANE_C_PUB §10.6)
|
||
Not "op shops now dig", but "op shops now dig **records**". Across the 23 shipped caches (44 record ·
|
||
36 stall · **147 opshop**): crate-shops/town **0–11 → 2–25**, towns with none **3 → 0**, diggable bins
|
||
**~235 → ~382 (+63%)**, under-band gems/day ~113 → ~183. **A** (parody records) costs zero code and is
|
||
what the market stall already does, but an op-shop find is stamped `type:'record'` and bowral/darwin/
|
||
daylesford have no record shop — they could dig and have nowhere to sell. **B** (books/toys via the
|
||
existing packs) needs an `opshop` pack that cannot exist today *and* a second dig presentation, because
|
||
`dig.js` builds a crate of 0.31 m square sleeves. **C** (a bric-a-brac class) costs most and is the only
|
||
one that closes the sell hole, since type = type would make op shops buy their own bric-a-brac back.
|
||
**Recommendation: play arm A behind the flag for one session, then rule; C is the destination; B is not
|
||
worth doing alone.** Also held, same reason: the one-line `fallbackZones` fix that would give market
|
||
stalls the crates their recipe already promises.
|
||
|
||
---
|
||
|
||
## Update 2026-07-18 (round 30, v7.0-alpha THE SAVE AND THE CRATE) — the sell counter: cash comes back, always less
|
||
|
||
R30 ledger #2 — contract first (LANE_C_PUB §9, its own commit), implementation second. F's save core
|
||
(ledger #1) had **not landed** when this was built: no `save.js`, no `window.PROCITY.game` anywhere in
|
||
the tree — so everything below is built to the R30-published shape and verified against a **stub** of
|
||
exactly that shape (noted loudly below).
|
||
|
||
### The contract (§9) — the one number and why
|
||
**`sellOffer(p) = min(p − 1, max(1, floor(p × 0.5)))`, never < 0.**
|
||
- **0.5** is the secondhand-dealer margin — the keeper pays half of what he'll sticker it at.
|
||
- **`min(p − 1, …)` is the no-pump law made STRUCTURAL:** the offer is strictly below `pricePaid` for
|
||
every `p ≥ 1`, so buy-then-immediately-sell loses ≥ $1 per round trip **by construction** — no
|
||
balance retune can break F's monotonic-loss gate without violating the contract. At `p = 1` the
|
||
offer is $0 and SELL disables (the keeper won't buy what he can't sticker).
|
||
- **Alpha basis = `pricePaid`** (every alpha buy is at asking price, so it IS the buy-side price the
|
||
player faced; there are no guide bands yet). **Beta swaps the basis to `bandLow`, not the shape.**
|
||
- Matching is **type = type, strict, fail-closed**: an untyped item sells NOWHERE (a matcher passing
|
||
untyped items would "match" everywhere — vacuous-gate law applied to a matcher).
|
||
|
||
### What shipped (C's files only)
|
||
- **`web/js/interiors/sell.js`** — `createSell()` + pure `sellOffer` / `sellableIn` / `nearCounter`.
|
||
DOM-only (zero draws, zero GPU), the dig's manila price-sticker mirrored to the left with a green
|
||
offer sticker; one item at a time, `‹ ›` cycling, ✕/Esc; till rings from the counter emitter (R28
|
||
one-shot pattern). Same consumer pattern as `dig.js`: the single side-effect is `onSell(item,
|
||
offer)` — **C never mutates `game.collection`** (read-only law, §9.3).
|
||
- **`web/js/interiors/wallet.js`** — `wallet.sell(item, offer)`: the credit mirror of `buy()`.
|
||
This file never prices; the offer comes in from `sellOffer`.
|
||
- **`web/interior_test.html`** — E-at-counter routing (bin → dig · counter → sell · shelf → buy) gated
|
||
on `window.PROCITY.game` presence (the in-game classic-pure law, exactly); `?sellstub=1` stubs the
|
||
published game shape for verification until F's core lands.
|
||
|
||
### Verified (fresh context, no-store server on :8144 — NOT :8130, zero console errors throughout)
|
||
- **The sale:** record shop seed 1990, at the bench (`nearCounter` true at 0.9 m, false across the
|
||
room) → card `THE FIBROS — "Servo at Midnight" · you paid $24 · offer $12` → SELL → **cash 163 →
|
||
175 (+$12 < $24 paid)**, collection 4 → 3, sold item gone, toast + till fired.
|
||
- **Type fence:** stub carried 2 records + 1 book + 1 untyped item. Record shop cards **exactly the 2
|
||
records**; book shop cards **exactly the book** ($7 on $15); the untyped item cards **nowhere**.
|
||
- **The disabled edge:** the $1 record offers $0 → button reads NOT WORTH BUYING, disabled.
|
||
- **No-pump round-trip (C's mini-gate, precursor to F's #5):** buy-then-sell through the real wallet +
|
||
card path, prices {2,3,5,9,24,60,61,137} → **8/8 strictly negative** (−1,−2,−3,−5,−12,−30,−31,−69).
|
||
- **The gate discriminated first** (vacuous-gate law, proven live): my first loop pushed a 999-priced
|
||
item into the collection **without checking `wallet.buy` succeeded** — broke wallet, no debit, then
|
||
a +$499 "sale". A pump — from an item never paid for. **Finding for F below.**
|
||
- **Fail-soft:** boot with no stub (= no game layer) → E at the counter does nothing, `open()` false,
|
||
zero DOM, zero errors. `dispose()` leaves 0 panels/0 toasts in the DOM.
|
||
- **Budgets:** draws with the card open ≡ closed (16 ≡ 16 — DOM is zero-draw); `buildInterior` and all
|
||
room code untouched this round; 50-room soak re-run green in the same session: **avg 3.4 ms · worst
|
||
8.2 ms · leak geo 0 / tex 0 · determinism identical · worst draws 73 ≤ 350**.
|
||
|
||
### Findings / asks (→ F, for the save core + gates)
|
||
1. **The collection item needs `type`** (registry stock type, stamped at buy time). The R30 shape
|
||
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
|
||
it **nothing is sellable anywhere** (my matcher is fail-closed on purpose). `title`/`artist` are
|
||
strongly asked too (B's collection UI needs them; my card falls back to the sku label — honest, ugly).
|
||
2. **`pricePaid` must only ever be written by a SUCCESSFUL debit.** The no-pump law's structure
|
||
assumes pricePaid was actually paid — my broken first loop showed a collection entry that skipped
|
||
the wallet debit converts straight into a money pump. F's no-pump gate should include this negative
|
||
control: a collection entry minted without a debit must be impossible (or the gate must fail).
|
||
3. **The removal handoff:** in-game, the sold item must leave the collection **via the game API** —
|
||
`onSell` is where F calls it. My harness splices its own stub (its array, its right); C's modules
|
||
never touch game state. When F publishes the API name, §9.3 references it verbatim.
|
||
|
||
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
|
||
|
||
### The trap my own parked note set
|
||
v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the
|
||
bed"*. **Both halves were wrong**, and the second one is dangerous:
|
||
|
||
1. **The shape.** The engine holds no reference to the room group — it would have to traverse a scene it
|
||
cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing**
|
||
`playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing.
|
||
2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero
|
||
hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) — and since rooms are built
|
||
in room-local coords at the origin, that is *the room's own centre, forever*. Measured with
|
||
`OfflineAudioContext` on a real pub stage bed:
|
||
|
||
| wiring | RMS at door | RMS at stage | ratio |
|
||
|---|---|---|---|
|
||
| panner, listener never set (**the engine as it ships**) | 0.106905 | 0.106905 | **1.0000** |
|
||
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
|
||
|
||
It **plays, throws nothing, warns nothing, and never changes by one sample as you cross the room** —
|
||
while failing at the only thing it exists to do. *"Routed through a panner?"* yes. *"Audible in a
|
||
venue?"* yes. *"In a shop?"* yes. All green, all worthless. **The only assertion that can see this is a
|
||
DELTA: gain at the door ≠ gain at the stage.** Vacuous-gate law, 4th application — and this one had no
|
||
author to blame but me. There is no listener code anywhere to teach a lane otherwise.
|
||
|
||
**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all
|
||
hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it.
|
||
|
||
### What shipped (C's files only)
|
||
- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**.
|
||
`r` (reach) is the **room diagonal, not a constant** — measured diagonals 9.81–15.63 m, and scaling to
|
||
the room keeps door→stage proximity in a tight **0.42–0.59** band from a pokey band_room to a hall pub.
|
||
`floor` is the room-filling residue, **never 0** — a PA fills a pub; what changes as you walk in is the
|
||
*balance*, not the presence. Swing **2.65–4.52 dB** (John R20: *subtle changes are great*).
|
||
- **The till rings from the counter** — *audible positioning in a normal shop with **no engine change***,
|
||
via B's already-public `playSfx(key,{gain})`: C does its own distance math, hands B a scalar. Measured
|
||
through the real BUY button in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**;
|
||
negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and
|
||
had **never once played**. Routing, not content — exactly as the brief said.
|
||
- **`?mute=1` now silences the crate riffle.** See below.
|
||
|
||
**Why the venue half is an ask, not a ship:** a **one-shot** resolves its gain once, at fire time, from a
|
||
position C already knows. A **bed** must be re-gained every frame inside the engine's `update()` loop —
|
||
B's file. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C
|
||
did not touch audio.js. The letter of "consulted not modified" could have been satisfied by reaching
|
||
through B's exposed `state.layers` diag to rewire its graph from outside — **that backdoor is strictly
|
||
worse than the edit it avoids** (invisible to B, breaks on any change of B's). Refused.
|
||
|
||
### Two bugs found on the way
|
||
- **F drops emitters on the one night they matter.** `interior_mode.js:289` rebuilds the spec on a gig
|
||
night: `{ musicKey: ra.gigKey, toneKey: WALLA_KEY }` — every unnamed field dies. Measured on a real
|
||
gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive: false`**. Silent, fail-
|
||
soft, no error. -> F: don't just spread `ra` — pass emitters **deliberately, gig-night only**, because on
|
||
a quiet night the radio and room-tone genuinely *are* room-filling.
|
||
- **`?mute=1` never silenced the riffle (R5 → R28).** `dig.js` opened a **second, private AudioContext**
|
||
straight to `destination`, breaking both of audio.js's stated laws (*ONE AudioContext*; *mute forces
|
||
silence*). It survived three epochs because **no gate asserts mute ⇒ silence**: the engine returns its
|
||
silent surface and looks honest *from its own side*, while the sound came from somewhere it never knew
|
||
existed. *A gate that asks the engine whether it is muted can only ever answer yes.* Fixed by reading
|
||
B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a
|
||
positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**.
|
||
Blips stay procedural on purpose — zero-fetch, deterministic, and they still work under `?noassets=1`
|
||
where a sampled riffle cannot. Only mute was broken; only mute was fixed.
|
||
|
||
### Harness honesty (again)
|
||
First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per
|
||
call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every
|
||
time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436
|
||
where it must return 1.0. The gains happened to match the formula exactly — I still refused them and
|
||
re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a
|
||
number from a rig you have watched lie, even when you like the number.* The control is what caught it.
|
||
|
||
QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above).
|
||
|
||
---
|
||
|
||
## Update 2026-07-17 (round 27, v5.0 THE LIVE CRATE) — the tier-2 seam + LANE_C_PUB v5-FROZEN
|
||
|
||
R27 §Lane C (ledgers #2 + #6). Wave 1 — ran after F tagged `v5.0-beta` (`78848ed`, 7/0/0).
|
||
|
||
### The seam ruling (§8): bless the direct wire — but the *form* was the real question
|
||
Fable asked: publish a post-build hook, or bless the direct wire? **Answer: bless it — no lifecycle hook.**
|
||
F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to `dig.open()` **per dig** (`:111`),
|
||
so the live read lands, F updates the pack, and the next riffle picks it up. Read not landed / slow / dead ⇒
|
||
the adapter is simply the tier-1 pack ⇒ the dig never blocks. No C lifecycle change.
|
||
|
||
**But the question underneath it — *how* `gone` applies — is C's, and the obvious wiring is wrong.**
|
||
`pickRealOffers` indexes `pack.items[(rr()*len)|0]`, so **filtering the array reshuffles the crate**.
|
||
Measured across 5 crates: **one record selling changed 21 OTHER records** (6/2/2/6/5 collateral). Pick-then-
|
||
omit — draw from the stable full list, drop gone *after* — has **0 collateral**: a crate with 3 sold shows
|
||
13, not a different 16. Selling one record must remove one record, not re-stock the shop.
|
||
|
||
**And the trap: both forms hide the sold record.** So F's planned sandbox assertion — "a real `gone` item
|
||
never renders" — **goes green on the wrong wiring**. It cannot see the 21. → **F: assert on collateral (the
|
||
survivors are unchanged across a `gone` delta), not absence.** That's the vacuous-gate law's third
|
||
application, and the cheapest to miss, because it passes.
|
||
|
||
**Landed** (`dig.js`): `pack.gone` (Set|array) omitted at pick time; **F's invariant published** — update
|
||
item *fields* in place (live price) freely, **never add/remove/reorder `items[]`**; that identity+order IS
|
||
the seeded pick's stability (the same lesson as R25's positional ids, one layer up).
|
||
**Determinism boundary sits exactly here:** server down ⇒ byte-identical tier 1 (gate-able); server up ⇒ the
|
||
crate legitimately changes — tier 2 is non-deterministic *by design*, so **no gate may assert dig determinism
|
||
with the server up**, and `pack.gone` never enters a seeded stream or a golden.
|
||
**Scoped honestly:** the bin fan is decorative (a different seeded pick from the dig's offers), so a sold
|
||
cover may linger there until rebuild — that's when I'd publish a post-build hook, and the epoch's claim
|
||
doesn't need it, because *the crate you riffle is true*.
|
||
|
||
**Verified** — pure-function measurement for the collateral claim, then end-to-end through the real dig's
|
||
public surface (pull → Esc → scroll → pull): sold record gone · **survivors 4/4 · collateral 0** · `items[]`
|
||
untouched at 120 · live in-place price shows on the next pull. *My first dig harness returned the same title
|
||
4× (I never un-pulled), so its "survivors" number was a harness artifact — I re-ran it properly rather than
|
||
report a metric I'd just watched lie.*
|
||
|
||
### Docs freeze (§6): LANE_C_PUB is **v5.0-FROZEN**, whole document
|
||
Marked, with the case law kept at the top rather than buried: three of this doc's amendments exist because a
|
||
rule of mine met a case written *after* it (§7's naming vs its own loader; §7.2a's `sku_` before mint) — and
|
||
both times the lane building to my rule took the red. The header now says it plainly: **when a line here
|
||
meets a case it didn't foresee, the line is the stale thing — amend it, don't bend the case.**
|
||
|
||
---
|
||
|
||
## Update 2026-07-17 (round 26 wave 4) — the mint-id fence: my rule was the stale thing
|
||
|
||
F's fourth hold traced to **my** §7.2a. I wrote "`sku_`-prefixed for real-stock packs" in R25 — **before mint
|
||
existed** — so it names one space where there are two. E implemented it faithfully and its validator did
|
||
exactly what I told it to: demand `sku_` on 14 mint crates. **The check wasn't wrong; my rule was.** Same
|
||
shape as R23, and I'll say it plainly: this is twice now that a contract line of mine met a case written
|
||
after it and the lane building to it took the red.
|
||
|
||
**Amended (§7.2a): the id form is sourcing-scoped, and the prefix IS the fence.**
|
||
| `sourcing` | id form | the space it names |
|
||
|---|---|---|
|
||
| `real` | `sku_<POS id>` | the shop's POS inventory — a physical copy on a shelf |
|
||
| `mint` | `mint_<dealgod listing id>` | a dealgod listing — a plausible stand-in, not that shop's stock |
|
||
|
||
**Why G was right to refuse the easy green.** Filing dealgod listing ids under `sku_` would have gone green
|
||
in one line and recreated the R24 collision **inside the economy layer** — where **tier-2's sold-means-gone
|
||
looks up POS skus**. R24's collision entered the *wrong shop*; the same collision in the money layer **sells
|
||
the wrong record** — a real copy, out of a real shop, to satisfy a minted stand-in. A prefix that lies costs
|
||
money, not a gate. Blessed as design: disjoint namespaces, and no gate, lookup or economy path may treat
|
||
them as one (same standing note as plan-ids vs godverse-ids).
|
||
|
||
**Verified against every crate in the tree before handing E the line:** 15 crates (1 real + 14 mint) —
|
||
**0 fails** under the amended rule (prefix matches `sourcing`, ids unique within pack), and **0 ids appear in
|
||
both spaces** across 344 distinct ids. The fence already holds in the data; only my wording didn't.
|
||
|
||
**→ E:** assert the form against `sourcing` (you already read it at line 178) — `sku_` for real, `mint_` for
|
||
mint; a crate wearing the other's prefix FAILS. That's **stronger** than the check it replaces, not looser.
|
||
**→ F:** 6/1/0 should flip to 7/0/0. Doc-only from C; no code, no atlas bytes, no goldens.
|
||
|
||
---
|
||
|
||
## Update 2026-07-17 (round 26, v5.0-beta EVERY CRATE DIFFERENT) — three contract lines + the price sticker
|
||
|
||
R26 §Lane C, wave 1 (ledgers #1, #4, #6). Contract lines published FIRST — G and E build to them.
|
||
|
||
### The brief said `tier`. It can't be `tier` — that name is taken twice (§7.2b)
|
||
G's atlases already emit **`tier: 1`** = the **charter's ladder rung** (0 parody · 1 static-real · 2 live),
|
||
and `kind` is already the stock type. **A mint atlas is also rung 1** — same mechanism (a statically baked
|
||
per-shop atlas); only the data's *origin* differs. Real-vs-mint is an **orthogonal axis, not a rung**.
|
||
Overloading `tier` would put two meanings in one name — the same species as the plan-id/godverse-id
|
||
collision that just became a standing note, and as R25's positional ids. **Ruling: top-level
|
||
`sourcing: "real" | "mint"`, REQUIRED; `tier` stays the numeric rung.** A mint index MUST NOT carry
|
||
POS-snapshot fields (the separation IS the honesty); gates read `sourcing`, and an index without it FAILS.
|
||
|
||
### `condition` isn't in the index — the ledger assumed it was (§7.2c)
|
||
Ledger #4 said the card gets "the fields the atlas index already carries (artist/title/price/**condition**)".
|
||
It carries artist/title/price/price_band — **not condition**. G's *snapshot* has `condition` + `sleeve_cond`
|
||
and drops them at emit, so `pickRealOffers` mapped `cond: ''` and the card rendered dangling separators
|
||
around a hole. That *was* the "database row". Ruled: `condition`/`sleeve_cond` are **optional** index fields,
|
||
emitted when the source has them; **no invented grades** on mint items — a plausible-but-fictional grade is
|
||
exactly what `sourcing` exists to prevent.
|
||
|
||
### The atlas manifest (§7.2d) — existence declared, never probed
|
||
`web/assets/stock_godverse/index.json` is the authority for which shops carry atlases; **no entry ⇒ no
|
||
fetch ⇒ zero 404s**, so the zero-console-errors law holds with no attribution exception. Shape + both-ways
|
||
consistency (atlas without manifest entry FAILS, and vice versa) in §7.2d. A tree with no godverse atlases
|
||
and no manifest is a legitimate silent zero-shop state, not an error.
|
||
|
||
### The price sticker (ledger #4, code)
|
||
The pull panel is now the shop's **price sticker**, not a data readout: manila card, slight rotation +
|
||
shadow, typewriter caps, italic title, a rubber-stamped condition grade, and **the round yellow $ sticker
|
||
the sleeve art already uses** (dig.js's own canvas language — the panel now echoes it instead of fighting
|
||
it). Fields compose only when present: **a missing field never leaves a stray separator.**
|
||
|
||
**Verified — five card shapes, both star branches, fresh context:**
|
||
| case | card |
|
||
|---|---|
|
||
| real grail | `$28` · Deep Dish · *Penetrate Deeper* · `VG+/VG` · **★ GRAIL** |
|
||
| real standard, graded | `$15` · `VG+` · `STANDARD` |
|
||
| real ungraded (today's index / mint) | `$15` · `COLLECTOR` — grade omitted cleanly |
|
||
| parody | `$15` · THE VERANDAHS · *Tinny* · `NM` · `1973 PUNK` |
|
||
| parody rare (seed 4) | `$16` · HILLS HOIST · *Servo at Midnight* · `VG` · `1973 POP` · **★ RARE** |
|
||
|
||
**The verification caught a defect in my own card:** a real *grail* rendered **"GRAIL ★ GRAIL"** — `s` carried
|
||
the price_band *and* `rare` derived from that same band. (Parody is immune: its `s` is a genre.) Fixed at the
|
||
mapping — grail becomes the ★ stamp and leaves `s` empty. And I did not leave the parody-rare branch on
|
||
assumption: scanned seeds until a rare pull appeared (seed 4) and asserted it says RARE, not GRAIL. An
|
||
unexercised branch is an unproven one.
|
||
|
||
**Budgets:** the card is DOM — zero draws, zero tris, no 3D change. Ceilings untouched.
|
||
**→ G:** `sourcing` + `condition`/`sleeve_cond` + the manifest. **→ E:** assert `sourcing` (absent ⇒ FAIL),
|
||
the mint/POS-field separation both ways, and manifest↔files both ways. **→ F:** consult the manifest before
|
||
fetching — that's what retires the 404 attribution exception.
|
||
|
||
---
|
||
|
||
## Update 2026-07-17 (round 25, the tag earned) — sku-derived slot ids (ledger #3, C's one line)
|
||
|
||
R25 §Lane C: one contract line, published first so G re-emits against it. **§7.2a added** — F's id-collision
|
||
finding made structural, while there are two atlases to migrate instead of fifty.
|
||
|
||
**The collision, measured:** both packs number items positionally from zero, so **all 120 godverse ids are
|
||
also generic-pack ids — 120/120 overlap.** An id-equality gate cannot discriminate which pack a rendered
|
||
item came from; that's exactly why the brief's own #7a spec was vacuous. (The vacuous-gate law's first
|
||
application broke the law — and my own R23 metric had the same disease. It's a persistent species.)
|
||
|
||
**The rule (§7.2a):** `items[].id` MUST derive from the source's stable key, never from position, and MUST
|
||
be unique across ALL packs. Real-stock packs: **`id = "sku_" + <POS sku>`** — G's snapshot already carries
|
||
`sku` per item (e.g. `20260314070442`), it's just dropped at emit, so this is index-only. Generic packs keep
|
||
`rec_NNNN` as their namespace; real-stock must not emit into it.
|
||
|
||
**I gave it two reasons, and the second is the one that outlives the gate.** The collision is the presenting
|
||
symptom. The deeper defect is that **position is not identity**: a positional id renumbers on every re-bake,
|
||
so selling one record out of crate 550 silently makes `rec_0050` a *different* record. Anything holding an
|
||
id across snapshots — a wallet purchase, a saved dig, a gate assertion, and above all **tier-2's
|
||
sold-means-gone** — would point at the wrong item. The POS sku is stable because it's the shop's own
|
||
identity for that copy (`release_id` identifies the pressing, not the copy). So this line isn't only a gate
|
||
fix; it's the identity tier-2 will need, bought now for free.
|
||
|
||
**Scope kept honest:** index-only, no atlas bytes, no town cache, no goldens. `id` isn't read by the loader
|
||
(§7.3) — the dig/buy consumers and the gates read it, so no C code changes. **→ G:** re-emit with `sku_`
|
||
ids. **→ E:** assert the form (present, unique within the pack, `sku_`-prefixed for real-stock).
|
||
|
||
*Process note:* F's six files were staged in the shared tree mid-flight; committed by explicit pathspec, so
|
||
F's session is untouched.
|
||
|
||
---
|
||
|
||
## Update 2026-07-17 (round 24, THE ALPHA COMPLETION) — the contract fix (ledger #1, the root cause)
|
||
|
||
R24 §Lane C. **The root cause was mine and it's closed.** F held the tag because my published atlas
|
||
contract and my own loader were mutually incompatible; the ruling — **the runtime is the authority** — is
|
||
now the section's first line. Published FIRST (half-day rule; G and E re-emit against it).
|
||
|
||
**What was actually wrong.** My R22 §7 invented `stock_shop_<godverseShopId>_index.json`. The loader
|
||
(`stockpack.js:25`) fetches `` `${base}stock_${type}_index.json` ``. **An index named per my contract can
|
||
never be fetched by my own code.** So G built runtime-compatible (correct), E built contract-compatible
|
||
(also correct — the contract said so), and E's validator globbed `stock_shop_*_index.json` → **zero files
|
||
→ vacuous pass**. Nobody was wrong; the contract was. I wrote it, so I own it.
|
||
|
||
**§7 rewritten against the code (LANE_C_PUB):**
|
||
- **Authority rule:** `stockpack.js` IS the contract; if the doc and the loader disagree, the doc is a bug.
|
||
- **Layout ratified as G shipped it:** `assets/stock_godverse/<godverseShopId>/stock_<type>_index.json`.
|
||
`type` is load-bearing (it picks the slot via `SLOT_FOR`: record→sleeve). `stock_shop_*` is **retired**
|
||
→ **E: the validator glob becomes `stock_godverse/**/stock_*_index.json`** (that's the vacuous gate's
|
||
actual cause).
|
||
- **Runtime-read vs runtime-ignored, stated exactly.** Reads `atlases[]` + `items[{atlas, uv}]` (uv origin
|
||
**top-left**; the loader flips V — don't pre-flip). Ignores `version/atlas_px/cell*/count/kind/tier/shop/
|
||
crate/provenance` entirely.
|
||
- **The provenance drift dies (§7.3):** since the runtime reads none of it, the authority is E's validator
|
||
+ the 23 shipped town caches — both use **top-level `license` (US), `attribution`, `generator`,
|
||
`snapshot`**. British `licence` retired; **G lifts+renames those four**, keeps its rich nested
|
||
`provenance{}` (source/sha256/determinism) as colour. `atlas_px` stays REQUIRED (E checks it against real
|
||
dims — a genuine falsifiable check). `cell*` informational: **C reads `uv`, never `cell`** — and don't
|
||
shrink the cell for VRAM, the dig renders covers close-up.
|
||
|
||
**Code landed (the `type+base` keying, F's filed item):**
|
||
- `stockpack.js`: cache identity is now `` `${base}|${type}` ``; `getStockPack(type, base = DEFAULT_STOCK_BASE)`.
|
||
- `interiors.js`: new **`opts.stockBase`** — the seam F wires (`buildInterior(shop, THREE, { stock:'real',
|
||
stockBase: 'assets/stock_godverse/<id>/' })`). Omitted ⇒ town-wide packs, byte-identical to today.
|
||
- Pre-v5 callers untouched (`getStockPack('record')` still means the town pack).
|
||
|
||
**Verified fresh — and I had to fix my own metric first.** R23 I claimed "8/8 atlas-backed"; that checked
|
||
`material.map` exists — **but a parody canvas has a map too**. That's the same species as the vacuous gate
|
||
I'd just been handed. Re-asserted falsifiably:
|
||
- **Every sleeve samples G's atlas FILE** — texture sources are exactly `stock_godverse/3962749/
|
||
stock_record_atlas_00.webp` / `_01.webp`; pack items ≡ index items **by id**. Titles are real
|
||
(*Yoshitoshi Artists – In House We Trust / Deep Dish – Penetrate Deeper*).
|
||
- **Negative control (what makes it a gate):** the parody room's stock textures are `(canvas, no src)` and
|
||
touch the godverse atlas **zero** times — the metric discriminates.
|
||
- **Collision FIXED:** shop pack (120 items) + town pack (350) coexist as distinct objects.
|
||
- **Fail-soft FIXED with a populated cache:** missing base ⇒ `null` ⇒ parody (the R23 correctness bug —
|
||
an unstocked shop inheriting a cached crate — is gone).
|
||
- Ceilings: **98 draws** with real covers via `opts.stockBase`; `pathOK`; 0 carves; **0 console errors**.
|
||
|
||
**Scoped, not slid:** the **LRU + dispose** (§7.6) lands at **v5.0-beta**, not here — with one stocked shop
|
||
there's no accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared
|
||
material), which arrives with F's per-shop preload. Written into the contract so it can't slide silently.
|
||
|
||
**→ F:** the seam is `opts.stockBase`; §7.5 has the call. **→ G:** layout ratified, re-emit provenance field
|
||
names only (§7.3). **→ E:** the glob (§7.1) is the vacuous gate's cause.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 23, v5.0-alpha THE FIRST REAL CRATE) — the crate in the room (ledger #3)
|
||
|
||
R23 §Lane C. **Verdict: the contract holds — G's atlas needs no re-emit.** Verified against G's
|
||
`stock_godverse/3962749/` (120 real records from Monster Robot Party's DEEP HOUSE crate 550, 2 × 2048²,
|
||
cell 256²) in a fresh context. Ran **before** Fable's merge deliberately — if the contract failed, that
|
||
blocks the merge. It didn't.
|
||
|
||
**PASS — the first real crate renders in a PROCITY room:**
|
||
- **Resolves through the existing `stockAdapter` path** via a base override — no C code change needed for
|
||
the single-shop alpha. 120 items · 2 atlases · **8/8 stock meshes atlas-backed** (real covers). The
|
||
sleeves are legible on screen: *peperecordings · DEEPCHILD · "39" · mff FREAKS · The Angels Instinct feat
|
||
Charlie Ashcroft* — a real deep-house crate.
|
||
- **Ceilings hold:** **118 draws** with real covers (≤350 law), `pathOK`, 0 carves.
|
||
- **Fail-soft holds** (clean cache): missing atlas → `null` → parody (20 parody meshes, 110 draws); no
|
||
adapter (the `?noassets` shape) → parody, zero fetch. **0 console errors.**
|
||
- **G's index is compliant:** `items[]` sorted by id (my R22 determinism ask honoured), provenance present,
|
||
`uv ⊂ [0,1]`, 2 atlases = exactly my cap, `cell_w/cell_h` harmless (C reads `uv`, never `cell`).
|
||
|
||
**I got a number wrong, and the first real data caught it (LANE_C_PUB §7.2 CORRECTED).** My R22 ceiling
|
||
said "≤8 MB decoded" — derived from assuming a "typical shop" is ~16 items @1024². **A real record shop is
|
||
120 records.** At the contract's own 256² cell that's 2 × 2048² ≈ **32 MB**, 4× my figure. The *structural*
|
||
rules held (≤2 atlases; 2048² for 60+ items — G landed exactly on the cap); only the MB number was wrong,
|
||
because it described a shop that doesn't exist. **Corrected:** ≤2 atlases/shop @≤2048², **LRU = 1 shop's
|
||
atlas set resident ⇒ ≤32 MB peak** (still bounded; still far better than the 181 MB unbounded accretion E
|
||
measured). And explicitly: **don't shrink the cell below 256² to save VRAM** — the dig shows a cover
|
||
close-up; resolution is the one thing the player actually sees.
|
||
|
||
**G's ask #1 CONFIRMED — and worse than reported (LANE_C_PUB §8: C's spec, C's fix).** `stockpack.js` keys
|
||
`_packs`/`_resolved` by **`type` alone**, so `base` is **ignored on a cache hit**. Measured:
|
||
`preloadStockPack('record', {base:'…/3962749/'})` then `preloadStockPack('record', {base:'assets/models/'})`
|
||
**returned the identical object**. Consequences: (1) two stocked shops collide — the 2nd serves the 1st's
|
||
crate (blocks v5.0-beta, as G said); (2) **it breaks the fail-soft law** — an *unstocked* shop of a cached
|
||
type inherits that crate instead of parody. The second is a correctness bug, not a mix-up. Seam spec
|
||
published (cache key = `type + base`; per-shop preload on entry; LRU=1 + dispose; **C consumes whatever
|
||
identity field lands — C does not invent it**). **C owns and lands it** — the §7 promise coming due.
|
||
|
||
**Two gaps block F's gate #7a (neither is C's; both flagged):** (1) **the crate has no street** —
|
||
`redhill_real` doesn't exist and monsterrobot isn't in any town's `shops[]` (G's ask #2 → E/Fable);
|
||
(2) the shell's `interior_mode.js:192` calls `getStockPack(shop.type)` with `base` fixed to
|
||
`assets/models/`, so it can't reach a per-shop pack until §8's seam + the identity field land (C+F).
|
||
|
||
*Process note:* the shared tree was checked out on `lane-G-godverse` for Fable's review, so I committed
|
||
these C docs from a temporary `git worktree` on `main` — the shared checkout was never touched, and G's
|
||
branch keeps the disjoint namespace the two-studio merge law depends on.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 22, v4.0 THE EPOCH CLOSE) — 23-town door-soak (#5) + the G2a contract (#3)
|
||
|
||
R22 §Lane C, both items. Wave 3 — ran **after A pinned the 36 goldens** (`3face97`) and E's tri-diet fix
|
||
(`d81e499`), so this soaks the final v4.0 world.
|
||
|
||
### Ledger #5 — sampled door-soak across all 23 towns
|
||
|
||
The five-town harness scaled: **~10 doors/town, sample takes one of every type present then stride-fills**, so
|
||
every archetype is covered town-set-wide. **238 doors · 1,133 in-plan shops · 23 towns.**
|
||
|
||
| town | shops | doors | worst draws | | town | shops | doors | worst draws |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| adelaide | 110 | 11 | 158 | | katoomba | 72 | 10 | 149 |
|
||
| ballarat | 19 | 10 | 154 | | launceston | 30 | 10 | 137 |
|
||
| bendigo | 35 | 10 | 141 | | marrickville | 26 | 10 | 149 |
|
||
| bowral | 30 | 10 | 156 | | newcastle | 34 | 10 | 152 |
|
||
| braddon | 19 | 10 | 158 | | **newtown_godverse** | 18 | 10 | 134 |
|
||
| brunswick | 92 | 11 | 149 | | newtown | 67 | 11 | 149 |
|
||
| castlemaine | 23 | 10 | 150 | | northbridge | 58 | 11 | 149 |
|
||
| darwin | 12 | 10 | 154 | | toowoomba | 12 | 10 | 138 |
|
||
| daylesford | 26 | 10 | 153 | | westend | 41 | 10 | 146 |
|
||
| **fitzroy (mecca)** | **139** | 10 | **159** | | fremantle | 79 | 11 | 152 |
|
||
| geelong | 62 | 11 | 145 | | glebe | 51 | 11 | 151 |
|
||
| hobart | 78 | 11 | 157 | | | | | |
|
||
|
||
**Verdict: 23/23 PASS — 0 throws · 0 carves · 0 path-fails · 0 over-budget; worst 159 draws (fitzroy) ≤350.**
|
||
- **All 12 archetypes covered town-set-wide** (band_room, book, dept, milkbar, opshop, pawn, pub, record, rsl,
|
||
stall, toy, video) — the whole registry.
|
||
- **23/23 towns landed 3 venues** — A's cluster bias + district placement hold from the 12-shop thin tail
|
||
(darwin, toowoomba) to the 139-shop mecca (fitzroy). Fitzroy, 2× anything absorbed before, is the *cheapest*
|
||
end of nothing — worst draw 159, same band as everywhere else: interiors don't care about town scale.
|
||
- **Lane G's `newtown_godverse` passes** (18 shops, 8 types, worst 134) — the merged town needs nothing from C.
|
||
|
||
### Ledger #3 — the G2a per-shop atlas contract (C's half) → published in LANE_C_PUB **§7**
|
||
|
||
Answered Fable's priors + E's half. **All three priors hold; atlas-per-shop is strictly better for draws than
|
||
today's type-packs. The binding constraint is VRAM, not draws — and E's dispose ask is granted: it's my seam.**
|
||
- **Draws:** 1 atlas/shop = **1 draw** (shared material + baked UV → batch.js merges). Rule: 1, hard cap 2;
|
||
60+ item shops spill to the type-pack. Headroom is enormous (stock rooms 41–58 draws vs the ≤350 law).
|
||
- **VRAM (the real ceiling):** E's 181 MB audit is right and the cause is my own design note
|
||
(`stockpack.js:11` caches atlases "NOT per-room, like depot GLBs") — correct for 3 bounded type-packs,
|
||
**wrong for unbounded per-shop atlases**. Ruling: **≤2 resident shop atlases, ≤8 MB decoded, ≤1024² typical**
|
||
(2048² only for 60+ items); **C lands the LRU cap + dispose in `stockpack.js`**. Scheduling: the accretion is
|
||
pre-existing and `?stock=real` is **opt-in** (`index.html:196`) so it can't touch the default boot — **not a
|
||
v4.0 blocker**; I land it as **C's first v4.x item, before G2a ships**.
|
||
- **Schema:** E's index confirmed verbatim, keyed per shop; UV origin stays top-left (C flips V); C reads `uv`
|
||
never `cell` (so book's 176×256 portrait is fine). **Fail-soft/parody/`?noassets`:** unchanged law.
|
||
- **One ask back to G:** emit `items[]` in **deterministic order (sort by id)** — C's seeded per-bin pick
|
||
indexes into it, so a re-emit would silently reshuffle every crate.
|
||
|
||
**C→F: 23-town interior verdict GREEN** (nothing gates the tag from C). Docs-only. Both C items closed for v4.0.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 21, v4.0-beta close) — the five-town density door-soak (ledger #3)
|
||
|
||
R21 §Lane C (wave 2 — ran **after A's venue cluster-adjacency bias** landed, `110c828`, so the soak reads the
|
||
relocated world + A's re-pinned goldens). The carried R20 #6: **every one of the five beta towns at its new
|
||
density**, deterministically sampled ~25 doors/town — the sample takes **one of every type present** then
|
||
stride-fills, so the texture classes' first real appearance (milkbar/dept/stall on real shops) is always covered.
|
||
|
||
| town | shops (in plan) | sampled | venues | types (all sampled) | throws | carves | path-fails | over-budget | worst draws |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| `katoomba_real` | 72 | 25 | 3 | 10 | 0 | 0 | 0 | 0 | **157** |
|
||
| `newtown_real` | 67 | 25 | 3 | 11 | 0 | 0 | 0 | 0 | 155 |
|
||
| `fremantle_real` | 79 | 25 | 3 | 11 | 0 | 0 | 0 | 0 | 153 |
|
||
| `bendigo_real` | 35 | 25 | 3 | 10 | 0 | 0 | 0 | 0 | 154 |
|
||
| `castlemaine_real` | 23 | 23 (all) | 3 | 8 | 0 | 0 | 0 | 0 | 156 |
|
||
|
||
**123 doors · 0 throws · 0 carves · 0 path-fails · 0 over-budget · worst 157 draws (≤350).** In every town
|
||
`typesInTown === typesSampled` — every archetype present was entered, including all three venue kinds
|
||
(pub/band_room/rsl) and the texture classes. **All five towns landed 3 venues** → A's cluster bias + district
|
||
placement hold at density.
|
||
|
||
**§6 mapping verified live (John's SUBTLE directive, working by construction):** katoomba's cache is **20
|
||
heroes + 60 texture** — exactly E's 3× cap, heroes untouched. In-plan (after A's counted drops): 17 heroes
|
||
(opshop 10 · book 4 · record 2 · video 1) + 52 texture (milkbar 29 · dept 18 · stall 5) + 3 venues = 72.
|
||
The texture classes' **first real appearance** renders right: **"Blue Mountains Chocolate Company"** — a real
|
||
Katoomba `shop=chocolate` — now wears `milkbar` (70s geo wallpaper, cube shelves of price-tagged goods, counter,
|
||
wall rack): a believable confectionery counter shop. The widening reads as texture, not takeover.
|
||
|
||
*(Cache→plan deltas — e.g. katoomba 80→72 — are A's overflow/fragmentation drops, counted by cause in A's R20
|
||
accounting; not an interior concern, and every surviving door builds clean.)* **C→F: density verdict GREEN.**
|
||
Docs-only. C otherwise on-call; my §6 mapping + this soak close both C items for the beta.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 20, v4.0-beta FIVE TOWNS) — the class→archetype mapping (ledger #2, C's half)
|
||
|
||
R20 §Lane C (I lead — E builds to it, half-day rule): **published the OSM shop-class → interior archetype
|
||
mapping** in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) **§6** (new v4.0-beta amendment; the frozen venue
|
||
contract §0–§5 untouched). This is the density widening — real towns fetch **general retail**, not just the
|
||
thriftgod secondhand classes, so the main street holds its real bakery/cafe/chemist/clothes shops.
|
||
|
||
**Design (John's directive: SUBTLE — texture, not takeover):**
|
||
- **No new archetypes.** General retail wears **existing** recipes: food/grocer/newsagent/chemist → `milkbar`
|
||
(the canonical AU counter shop); clothes/hardware/big-retail → `dept`; produce/florist → `stall`.
|
||
- **Secondhand stays the hero** — charity/antiques→opshop, books→book, music→record, video/games→video,
|
||
pawn→pawn, toys→toy; never subsampled.
|
||
- **Unknown/unlisted → `opshop`** (R18-proven safe). Included a copy-paste JSON map for E's `build_towns.py`
|
||
+ a tasteful fetch list (skip vehicle/funeral/industrial/pure-services like hairdresser/bank).
|
||
- **Guardrail for E:** if a town's general retail would visually drown the secondhand strip, seed-subsample
|
||
the texture classes toward ~2–3× the secondhand count and count the drops in `norm.dropped`.
|
||
|
||
**Mechanic note:** `plan_osm` keeps only registry types (unknowns flatten to opshop), so E bakes a **registry
|
||
type** per the map into each cache's `shop.type` — no C code change, all targets are existing recipes/registry
|
||
entries, budgets unchanged. **C→E handshake: §6 is live.**
|
||
|
||
**Pre-validated the mapping (measurement over folklore, before E builds to it):** milkbar/dept/stall/opshop all
|
||
build valid at the katoomba lot (8.5×14) — 0 carves, ≤143 draws, pathOK. Rendered `dept` (my one flagged case
|
||
— the escalator centrepiece): reads as a clean **dressed-retail floor** (cube shelves of goods + clothes rack +
|
||
counter + "GROUND FLOOR/HABERDASHERY" signage) in the 7.9×11.7 hall, escalator does *not* dominate — so
|
||
`clothes`/`hardware` → `dept` is confirmed, not a concern.
|
||
|
||
**Pending (ledger #6, after E re-emits the 5 widened caches):** the five-town sampled door-soak — every town,
|
||
sampled doors at the new density, all types resolve, no carve, budgets; one table, five rows. I'll watch that
|
||
`dept` reads right on small real lots (the escalator centrepiece). Docs-only this commit; door-soak follows E.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 19, v4.0-alpha CLOSE) — re-soak katoomba on REAL roads (against A's in-flight pass)
|
||
|
||
R19 §Lane C (on-call, ledger #5): E's roads landed (`dbb52c2` — katoomba_real is now **schema v2, 484 real
|
||
roads**), so I re-ran the 20-door soak against the real street graph — closing the R18 "re-run when roads land"
|
||
caveat. **Honest caveat: the working tree also carried Lane A's UNCOMMITTED R19 fragmentation pass** (plan_osm.js
|
||
WIP — `JOIN_TOL`/`BRIDGE_MAX`, junction-protected Douglas–Peucker, shopless-island cull / shop-island bridge),
|
||
so the dev server served A's *in-progress* finalization, not a frozen state. Read this as "interiors survive
|
||
A's real-graph pipeline as it stands mid-round"; a clean confirm follows A's commit.
|
||
|
||
**Robust result (holds whatever A's WIP does):** every shop the plan produced builds a **valid interior** —
|
||
0 throws · 0 carves · 0 path-fails · 0 over-budget · worst **147** draws (min 115); 3 gig venues
|
||
(pub/band_room/rsl — **district placement holds on the real graph**); plan deterministic (byte-identical
|
||
re-gen). Lot dims **unchanged** (7.5–8.5 × 14) → interiors are dim-identical to R18 (road-agnostic confirmed;
|
||
my R18 degenerate-lot proof already spans anything the real graph can seat).
|
||
|
||
**Shop count observed = 19 (NOT filed as a defect).** The plan yielded 19 shops here, but against A's in-flight
|
||
fragmentation code that's expected, not a bug — A's ruling explicitly culls shopless islands and bridges/
|
||
drops-with-count shop-bearing ones, so the final count is A's to pin (could land at 20 connected or an explicit
|
||
19 with a `norm.dropped` count). I'm not attributing it to A as a finding; I only confirm interiors build clean
|
||
whichever way it resolves. (If A's committed pass surprises with a shop that *can't* build an interior, that
|
||
would be the finding — none did in this run.)
|
||
|
||
**Final C spot-confirm pending A's COMMITTED finalization + golden pin** (per Fable's sequencing, C confirms
|
||
after A). When A pins `katoomba_real`'s real-roads golden I'll re-run once against the frozen state. Docs-only
|
||
round for C.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 18, v4.0-alpha REAL ROADS) — interior spot-soak of katoomba_real (ledger #6)
|
||
|
||
R18 §Lane C (small): enter every `katoomba_real` shop and assert valid interiors + no carve + budgets.
|
||
Built the town plan the way the shell does — `registerTownCache(katoomba_real)` → `generatePlanFor(seed,
|
||
'osm', {town, gigs:true})` (gigs on = the default boot) → `buildInterior` per shop (lot dims resolved from
|
||
`plan.lots`). **All 20 doors PASS:**
|
||
|
||
| metric | result |
|
||
|---|---|
|
||
| shops entered | **20 / 20 built** |
|
||
| throws · carves · path-fails · over-budget | **0 · 0 · 0 · 0** |
|
||
| worst draws | **153** (shop 16, opshop/hall) ≤ 350 |
|
||
| types resolved | opshop · book · record · video + venues pub · rsl · band_room |
|
||
| gig venues (default boot) | 3 (ids 3/5/19) — all built valid gig interiors (stage + watchPoints) |
|
||
| draw range | 108–153 |
|
||
|
||
**Degenerate-lot stress (preempts A's real-road re-seating):** 32 combos (8 extreme lots {2×3, 1×1, 3×30,
|
||
40×4, 100×100, **0×0**, 4.2×4.1, 2.5×18} × 4 types incl. venues) → **0 throws · 0 carves · 0 path-fails**;
|
||
every room floors at **≥4×4 m** (`computeDims Math.max(4, lot−0.6)`). So whatever frontage the real-graph
|
||
lift seats shops on, the interior contract absorbs it.
|
||
|
||
**Caveat (honest):** audited against the CURRENT `katoomba_real.json` — schema v1, `roads: none`, so it boots
|
||
the **marched fallback** (E's katoomba roads cache isn't committed yet; A's lift `a78dc76` is in). Interiors
|
||
are **road-agnostic** (they consume lot dims + type + seed, never road geometry), and the degenerate-lot proof
|
||
covers any lot the real graph produces, so the conclusion is stable. **Interior lane contributes 0 failures to
|
||
the alpha's failure list.** A one-line re-run once E's `roads[]` cache lands (lot dims may shift with real
|
||
frontage) — I'll re-confirm if asked, but the interior contract already absorbs the range. C otherwise on-call.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 17, v3.2 THE TAIL) — backline handshake CLOSED + real-town type fallback
|
||
|
||
R17 §Lane C (ledger #3): close the R16-pending render-verify of D's wired amp. D landed the wiring
|
||
(`ab7a8d0`): `band.js _addBackline` reads `stage.backline[0]` with an immediate primitive `ampPrim()` fallback
|
||
(asset-free) + GLB upgrade. **Handshake CLOSED — verified fresh (seed 20261990), 3 archetypes × 2 modes:**
|
||
- amp plants at `backline[0]` **exactly** (offset 0.00 GLB / 0.01 prim — rounding); **1 amp per stage** (no
|
||
more C primitive → the double-amp is gone).
|
||
- **no interpenetration** — amp↔nearest-band AABB edge gaps all positive: pub **0.40/0.44 m**, band_room
|
||
**0.27/0.29 m** (tightest; the bass extends downstage away from the up-stage amp), rsl **0.54/0.57 m**
|
||
(GLB/primitive). Rendered band_room gig confirms the read (amp up-stage-right behind the bassist, clear).
|
||
|
||
**On-call (proactive, for A/E's real-map scout):** confirmed C's interior type fallback eats real OSM shop
|
||
types. 18 real `shop=` values (hairdresser/bakery/convenience/supermarket/chemist/butcher/… + ''/null/
|
||
undefined/`SHOP`/`Op_Shop`/`antiques`) all build with **0 throws, all pathOK**, resolving to `opshop`
|
||
(theme.js `canonicalType`) or a mapped alias — so a player entering any real-town shop gets a valid interior.
|
||
Venue types (pub/band_room/rsl) are recognized; everything else degrades to opshop. No C change needed for v4.
|
||
|
||
Docs-only round (verification confirmed shipped behavior). C otherwise on-call.
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 16, v3.1 THE FLIP) — the backline handoff (C's half of ledger #5)
|
||
|
||
R16 §Lane C (ledger #5, C's half): unify the amp. **`room.stage.backline[]`** now exposes 2 seeded up-stage
|
||
amp slots `{x,z,ry,y}` (pure data, no meshes), and C's **primitive ampStacks are dropped** from `placeStage` —
|
||
Lane D plants E's `guitar_amp` GLB at `backline[0]` (its own primitive fallback under `?noassets`), one amp
|
||
system. Shape published in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3 (v3.1 amendment marker; rest of the
|
||
frozen doc unchanged). **C→D handshake: backline shape is live.**
|
||
|
||
- `backline[0]` = stage-right `(stage.x + stage.w·0.36, deckY, stage.z − stage.d·0.22)` == D's R15-verified
|
||
amp pose exactly, so wiring D's plant to it moves **nothing** on screen. `backline[1]` = stage-left spare
|
||
(mirror). ry=π, y=deckY.
|
||
- **Verified fresh (port-isolated, seed 20261990):** both slots up-stage, on-deck, clear of all 4 bandPoses
|
||
(nearest-bass **0.87 m pub / 0.70 m band_room / 0.92 m rsl**) and all watchPoints (≥1.95 m); determinism
|
||
byte-identical (placement + stage + watchPoints); 0 leftover ampStack prop groups; drawSweep glb-off venues
|
||
**pub 122 · band_room 120 · rsl 131** (was 126/124/135 — the dropped primitives shaved ~4 draws each), worst
|
||
161 (opshop/hall, unchanged). ≤350 ✓.
|
||
- **Determinism/golden safety:** removing the ampStack loop only drops draws on the `stage` rng sub-stream,
|
||
which nothing downstream reads; the `placement` snapshot excludes ampStacks (never tracked), so it's
|
||
byte-identical. Interiors aren't hashed into any golden; the `?classic=1` boot has no venues → untouched.
|
||
- Disjoint from F's flip (interiors don't read flags) — landed early so D isn't blocked. **Verify with D
|
||
once wired** (ledger #5 D-half): re-render each archetype, confirm the GLB at `backline[0]` doesn't
|
||
interpenetrate (pending D's R16 commit).
|
||
|
||
**Code:** `layout.js` placeStage (backline[] + drop the amp loop), `interiors.js` API doc. `theme.js` untouched
|
||
(recipes carry no ampStack fitting — the primitives were placeStage-planted, not recipe fittings).
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 15, v3.0: closing the release) — verified D's amp fix
|
||
|
||
R15 §Lane C (one item): verify D's `guitar_amp` reposition (ledger #4). D moved the plant (commit `66aec0f`)
|
||
to C's §3 corrected pose exactly — `band.js:236 amp.position.set(stage.x + stage.w·0.36, deckY, stage.z −
|
||
stage.d·0.22)`. **Verified (fresh port-isolated context, seed 20261990, D's real GigCrew + GLB backline):**
|
||
matches §3 (both venues); **bass clearance 0.87 m (pub) / 0.92 m (rsl)** — the R14 interpenetration (0.29 m)
|
||
is gone; on-deck within footprint; flanks C's primitive ampStack (opposite side, 4.4 m pub / 3.5 m rsl) as a
|
||
clean up-stage backline; no clash with band/crowd/watchPoints. Rendered pub gig confirms the read (amp up-stage
|
||
behind the bassist, primitive up-stage-left, band clear). LANE_C_PUB §3 updated RESOLVED. Freeze otherwise
|
||
stands; no other C change this round. Nothing else blocks on C — F runs the release (continuity wiring + smoke,
|
||
alias deletion, week soak, tour, tag `v3.0`).
|
||
|
||
---
|
||
|
||
## Update 2026-07-16 (round 14, v3.0: the release) — venue audit + docs freeze (light round)
|
||
|
||
R14 §Lane C: interior release audit, the `guitar_amp` ruling, and the docs freeze. C's interior code was
|
||
**unchanged** since R13, so venue geometry is stable by construction; the audit re-verified it against the
|
||
current tree (D's R13 continuity + RY-fix + real instrument GLBs landed) and froze the contracts. Ran an
|
||
adversarial audit **workflow** (10 agents: conformance + D-integration/amp + docs-freeze edit list, then a
|
||
verify pass) — it earned its keep (caught the amp defect I'd first waved through + two stale comments).
|
||
|
||
**Empirical re-verify (fresh context, port-isolated server):**
|
||
- Venue contract **byte-identical to R13**: caps 8/8/12, deckY 0.32/0.20/0.40, riserY 0.48/0.36/0.56, gigKeys
|
||
gig-pubrock/grunge/covers, quiet-night = no gigKey, determinism true.
|
||
- `drawSweep` glb-off: band_room 124 · pub 126 · rsl 135 (worst 161 opshop/hall) ≤350 — unchanged.
|
||
- **Gig-night draws with D's real `GigCrew` + GLB instruments** (localdepot): pub **60** · band_room **52** ·
|
||
rsl **71** ≤350. Asset-free worst RSL 237. All 4 instruments load (guitar/bass/mic/drums).
|
||
- **Release-law confirmed** (workflow, CONFIRMED): D's continuity + the RY fix ride a disjoint rng namespace
|
||
(`rng(citySeed,kind,id)`) from C's `ctx.stream(salt)`; the RY fix is a constant Y-rotation in `buildFigure`
|
||
(no rng). Cannot perturb C's placement or any golden.
|
||
|
||
**`guitar_amp` ruling (debt for C) → Lane D:** **NOT blessed as-is — reposition up-stage.** D plants the GLB
|
||
at `(stage.x+stage.w·0.32, deckY, stage.z+0.15)` — that `+0.15` is *down-stage*, landing the 0.5 m amp 0.29 m
|
||
from the bass rig (**interpenetrates the bassist**, confirmed by render + 3 audit agents), and it's redundant
|
||
with C's own primitive ampStacks (`placeStage` plants 1–2 up-stage at `(±stage.w·0.28, deckY, stage.z −
|
||
stage.d·0.18)`). Corrected pose in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3: move up-stage-right ≈
|
||
`(stage.w·0.36, deckY, stage.z − stage.d·0.22)`, clear of the bass and offset from C's primitives → a clean
|
||
flanking backline. Full amp unification (C exposes `stage.backline[]`, GLB replaces primitive) = v3.1.
|
||
Cosmetic + `?gigs=1` opt-in → does not block the tag.
|
||
|
||
**Tri observation → E/F (not a C gate):** E's decimated instrument GLBs are **14k tris each** (5 on a stage =
|
||
70k; peds ~2.7k each), so a settled RSL gig-night interior is ~**182k tris**. The interior gate is *draws*
|
||
(passes at ≤71); the 200k tris is the *street* budget, so no violation. But F's gig-night smoke reports ~34k
|
||
because it measures **before** the async instrument GLBs load — the real settled scene is ~5× heavier. A
|
||
camera-distance instrument LOD is a v3.1 candidate.
|
||
|
||
**Docs freeze (v3.0-FROZEN markers added):**
|
||
- `LANE_C_PUB.md` — retitled + frozen marker; §0 ry convention reframed past-tense (the +Z fleet was fixed at
|
||
the source in D's `rigs.js buildFigure`, R13 — no per-consumer flips); 4-piece stated as shipped (veto
|
||
language removed); the amp ruling added (§3); budgets updated to R14; in-flight handoffs removed.
|
||
- `LANE_C_AUDIO.md` — frozen marker + a scope note (this covers base interior audio; gig audio `gigKey`
|
||
lives in LANE_C_PUB); the deferred `audioEmitter` idea re-targeted to v3.1.
|
||
|
||
**Code (comment-only, zero runtime/golden/rng impact):** fixed two stale R12 inline return-shape comments in
|
||
`interiors.js` (`watchPoints` 6..8 → 8/8/12 caps; `stage` `bandPoses[3]` → `[4]` + `riserY`) + one in
|
||
`layout.js`; added a note at the gigKey hand-build explaining it's deliberately standalone (byte-identical to
|
||
citygen's `gigKeyFor`, not imported — keeps the interiors lib citygen-free). `theme.js` untouched.
|
||
|
||
---
|
||
|
||
## Update 2026-07-15 (round 13, v3.0-beta: the district) — two more rooms + the drummer
|
||
|
||
R13 §Lane C. The district: two new venue archetypes + the 4-piece band + the RY-facing audit. Interfaces
|
||
(re)published in [docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — **read §0 first (the pinned ry
|
||
convention + the keeper/browser back-to-front finding for Lane D).** Committed C-only by pathspec (the tree
|
||
had E/B/D changes landing concurrently — [[procity-shared-tree-hazard]]).
|
||
|
||
**Shipped (C-owned files only): `theme.js`, `layout.js`, `interiors.js`.**
|
||
- **`band_room` + `rsl` recipes** (`theme.js`, both `venue:true`), reusing the pub venue machinery with a new
|
||
`venueSpec` field. `band_room` = small/scuzzy tin shed (cosy/pokey bias, low 3.4 m ceiling, deck barely a
|
||
riser `deckY 0.2`, near-empty standing floor). `rsl` = carpet-and-flock club (hall/wide bias, tall 4.0 m,
|
||
proper stage `deckY 0.4`, bistro tables, the biggest crowd cap). A converts a shop with `shop.type = kind`;
|
||
C keys the recipe off it. `SHOP_TYPES` now auto-enrols both in `drawSweep`/`soak`.
|
||
- **4-piece band (debt #3):** `placeStage` grows `stage.bandPoses` to **4** — front-line trio
|
||
(guitar/vocal/bass, `y=deckY`) + a **drummer** (`role:'drums'`, `seated:true`, up-stage on the drum riser,
|
||
`y=riserY=deckY+0.16`). Each pose now carries its own `y`; `stage.riserY` added. Applies to all three
|
||
archetypes (the pub gains a drummer too). Pure geometry — no new rng draw, determinism intact.
|
||
- **Per-kind venue tuning:** `placeStage`/`placeWatchPoints` read `recipe.venueSpec` → deck size/height +
|
||
**crowd cap** (pub/band_room **8**, rsl **12** — the crowd-cap stress case). Pub values == the R12
|
||
constants, so the pub stage geometry is byte-identical (bar the new drummer).
|
||
- **Robust gigKey (debt #1):** `audio.gigKey = 'gig-'+genreKey`, genre resolving `opts.gig.genreKey` →
|
||
`shop.genreKey` (A sets it) → a kind default. Matches E's renamed manifest beds `gig-pubrock`/`gig-grunge`/
|
||
`gig-covers` exactly (verified: manifest has all three, zero stale `pubrock-live`). One key, no mapping.
|
||
|
||
**RY-facing audit (debt #4) — see LANE_C_PUB.md §0 for the full write-up + fix. Headline:**
|
||
- Canonical convention pinned: **rig-front = local −Z at ry=0** (every C pose + the box placeholder use it).
|
||
- **The GLB fleet mesh faces +Z (opposite).** Verified in a fresh context two ways: toe-bone probe
|
||
(`toe−foot = (0,+0.99)` on 5 peds) and dotting a fleet rig planted at `counter.stand.ry` against the
|
||
intended customer-facing (`−0.99` = 180° backward; `+π` → `+0.99`).
|
||
- **Finding: keepers + browsers have been facing 180° backward whenever the GLB fleet is loaded** —
|
||
`keepers.js` spawns at `pose.ry` with no flip, and the mesh front is +Z. The R12 band reads right only
|
||
via `band.js RY_FLIP=π`. Placeholder (−Z) and GLB (+Z) are opposite, so it's inconsistent both ways.
|
||
- **Recommended fix for Lane D (visual-only, no golden moves):** normalise at the source — rotate the clone
|
||
`inner` by π in `rigs.js buildFigure` so every GLB rig faces −Z; then `keepers.js` stays flip-free (now
|
||
correct) and `band.js` DROPS `RY_FLIP`. Repro below.
|
||
|
||
**→ Lane F (flags_check.py): two smokes are RED by design — bump `band == 3` → `== 4`.** My 4th bandPose
|
||
makes the band a 4-piece (D's `band.js` already iterates `bandPoses`, so it spawns 4 today), which trips F's
|
||
R12 assertions verbatim as the round-13 instructions anticipated ("F's band==3 smoke moves to 4"):
|
||
- `tools/flags_check.py:674` — `if night['band'] == 3` → `== 4`.
|
||
- `tools/flags_check.py:794` — `if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0` → `== 4`. *(The
|
||
noassets gig still happens — band 4, crowd 8, 0 fetches, 0 console errors; silent-and-happy holds. It only
|
||
"fails" because 3 is hardcoded.)* These are the ONLY qa reds from C; the other 5 gates pass.
|
||
|
||
**→ Lane D:** lift each band member to `pose.y` (front line `deckY` unchanged; drummer sits at `riserY`);
|
||
`seated:true` on the drums pose → seated bob; raise `band.js CROWD_CAP` to the watchPoint cap so the RSL's 12
|
||
fill (I measured the full 12). And apply the §0 facing fix. Repro for the audit (paste in the interior test page):
|
||
```js
|
||
const rigs = await import('./js/citizens/rigs.js'); const THREE = await import('three');
|
||
const fleet = rigs.loadPedFleet('models/peds/'); await fleet.whenReady;
|
||
const sp = rigs.spawnRig(fleet.all[2], { ry: 0, height: 1.75 }); sp.fig.updateWorldMatrix(true, true);
|
||
let foot, toe, wp = new THREE.Vector3();
|
||
sp.inner.traverse(o => { if (o.isBone){ o.getWorldPosition(wp);
|
||
if (/LeftFoot$/i.test(o.name)) foot = wp.clone(); if (/LeftToeBase$/i.test(o.name)) toe = wp.clone(); }});
|
||
console.log('fleet forward at ry=0:', toe.clone().sub(foot).setY(0).normalize()); // → (0, +1) = +Z
|
||
```
|
||
|
||
**Budgets (measured, fresh context, seed 20261990):**
|
||
- Empty shells (`drawSweep` glb-off): band_room 124 · pub 126 · rsl 135 (worst town-wide 161 opshop/hall) ≤350.
|
||
- **Gig-night incl. full crowd + 4-piece:** worst **RSL 237 draws** asset-free (16 rigs) / 77 GLB, 40.9k tris.
|
||
pub 176/56, band_room 173/53. ≤350 draws / 200k tris. ✓
|
||
- 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0. `soak(60)` in
|
||
the page: PASS 60 rooms, leak 0/0, determinism identical, worst draws 85. qa figure-scale gate: 24 figs
|
||
human-sized. Shot: [docs/shots/laneC/rsl_gig_r13.jpg](docs/shots/laneC/rsl_gig_r13.jpg) — RSL, 4-piece
|
||
facing the crowd, crowd facing the stage (facing reads correct via band.js's flip).
|
||
|
||
**Drummer veto (John):** default is Fable's 4-piece ruling (executed). To revert to a front-line trio, drop
|
||
`bandPoses[3]` in `layout.js placeStage` (1 line) + F reverts the smoke to `== 3` — say so before D wires it.
|
||
|
||
---
|
||
|
||
## Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints
|
||
|
||
R12 §Lane C (venues & gigs, behind `?gigs=1`). Interface published in
|
||
[docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — the "publish interface first" deliverable D/F build on.
|
||
|
||
- **`pub` archetype** (`theme.js`, `venue:true`): low **stage** + PA cabinets + amps at the back wall (new
|
||
`stage`/`paSpeaker`/`ampStack` fittings, GLB slots `stage`/`pa_speaker`/`amp` for E), bar counter (`corner`
|
||
— the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.
|
||
- **`room.watchPoints[]`** — 6–8 seeded audience floor poses `{x,z,ry,dance,slotIndex}` facing the stage
|
||
centre (`counter.stand` ry convention). `dance` seeded **~⅓ true** (John: "a bit of both"). Walkable,
|
||
reachable, clear of keeper/browse/stage. **`room.stage`** = `{x,z,w,d,deckY,frontZ,bandPoses[3]}` so D
|
||
drops the band on the deck.
|
||
- **`room.audio.gigKey`** — F passes `opts.gig` (gig state); a gig-on pub returns `gigKey='gig-'+genreKey`
|
||
(`gig-pubrock` for the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the
|
||
live bed `gig-pubrock`.)
|
||
- **Flags-off byte-identical**: venue code runs only for `recipe.venue`, on its own `stage`/`watch` sub-
|
||
streams — non-pub rooms have `watchPoints:[]`, `stage:null`, unchanged audio + placement.
|
||
- Verified: 40-seed pub sweep — 0 throws · **0 carves** (PA `areaFree`-guarded off the counter strip) · 0
|
||
path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails,
|
||
**leak geo0/tex0**, worst 19 ms. Non-pub rooms confirmed unchanged.
|
||
|
||
---
|
||
|
||
## Update 2026-07-15 (round 11, audio) — `room.audio` contract
|
||
|
||
R11 §Lane C ("one contract answer"). **Confirmed** Fable's proposal: `buildInterior` now returns
|
||
`room.audio = { musicKey, toneKey }`, seeded per shop — F's interior_mode plays it, B's audio.js
|
||
resolves the keys. Full contract + F wiring recipe: [docs/LANES/LANE_C_AUDIO.md](docs/LANES/LANE_C_AUDIO.md).
|
||
|
||
- `toneKey` → a `manifest.audio.ambience` interior room-tone (always set); `musicKey` → a
|
||
`manifest.audio.music` bed, or **null** (no music). Keys, not files — buildInterior stays synchronous
|
||
and asset-free; silence on missing/`?mute=1`/`?noassets=1` (house audio law).
|
||
- Maps mirror the manifest's per-type `types` arrays (one source of truth): record/milkbar/video/dept/
|
||
arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3
|
||
keeps a general radio on (the `general`-tagged milk-bar bed).
|
||
- Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest
|
||
`types` membership; deterministic per shop. `qa.sh --strict` green.
|
||
- Optional visual-source prop (`userData.audioEmitter`) deferred to v2.2 (in the note) — out of round scope.
|
||
|
||
---
|
||
|
||
## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
|
||
|
||
Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake +
|
||
working code; C2 widens buying beyond the dig.
|
||
|
||
**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes
|
||
`room.browsePoints[]` — 1–3 deterministic floor poses in front of browsable fittings, facing the goods,
|
||
where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as
|
||
`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law
|
||
is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
|
||
spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
|
||
`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
|
||
gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
|
||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed
|
||
first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via
|
||
fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
|
||
`path` overlay + a HUD count.
|
||
|
||
**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**;
|
||
records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged,
|
||
per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
|
||
atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a
|
||
pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
|
||
inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one
|
||
draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack.
|
||
Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
|
||
closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 ·
|
||
book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
|
||
determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg).
|
||
|
||
**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first
|
||
`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
|
||
valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
|
||
|
||
---
|
||
|
||
## Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock
|
||
|
||
Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.
|
||
|
||
**C1 — buy loop v0** (new `web/js/interiors/wallet.js`). A shallow, **runtime-only** economy: seeded
|
||
starting cash, purchases, an in-memory bag. Reload resets; world generation is **untouched** (purchases
|
||
never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never
|
||
writes back into gen).
|
||
- `createWallet(seed)` → `{ cash, start, canBuy, buy(offer), inventory, count, onChange }`. Start is
|
||
seeded ~$60–200 (`mulberry32`). `buy` deducts + records on success, **returns false unchanged if broke**.
|
||
`onChange` lets F's street shell subscribe the same wallet to a cash chip.
|
||
- **Test page** (`interior_test.html`): a `#cashChip` HUD (`$163 · 🛍 N`, street + interior) + an
|
||
inventory panel toggled with **I**. The dig's pull card gets a **BUY IT** button (price + band); buying
|
||
pulls the sleeve into the bag, decrements cash, updates the chip, and **depletes that bin** (bought bins
|
||
offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy). `digSoak` now buys up to 2/visit.
|
||
- Offer shape is the dig offer (`{a,t,price,s}`) OR a pack item (`{title,artist,price,price_band}`) — one
|
||
`buy()` eats both.
|
||
|
||
**C2 — book spines + toy boxes wired to `?stock=real`.** E shipped the generalized packs
|
||
(`stock_book_*` 311 items / 4 atlases, `stock_toy_*` 273 items / 5 atlases, alongside `stock_record_*`
|
||
350/6). `stockpack.js` now keys each pack to its slot via `SLOT_FOR = {record:'sleeve', book:'spine',
|
||
toy:'box'}`; `stock.js shelfLine` consults the adapter and builds real cover planes for book spines
|
||
(bookshelves) and toy boxes (cube shelves) — **static shelf stock, no dig** for those, fail-soft per pack.
|
||
Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one
|
||
draw per atlas.
|
||
|
||
**C3 — validated** (all three packs live, `?stock=real`):
|
||
- **Draws ≤350** (the law): record **41**, book **42**, toy **51** worst over seeds {1990,7,42(,101)}.
|
||
- **Determinism**: same seed → identical draw calls + mesh count, all three types (0 fails).
|
||
- **Leak-free**: 0 geometry/texture delta over 20 build/dispose cycles; **buy-soak** 12 record-dig visits,
|
||
5 bought (wallet depletes to broke → exercises the can't-buy path), `leakGeo 0 / leakTex 0`; resting geo
|
||
back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed.
|
||
- **Reload resets** the wallet (fresh boot → seeded start again); **flag-off + `?noassets` untouched**.
|
||
- Shot: [buyloop_r8.jpg](docs/shots/laneC/buyloop_r8.jpg) — toy boxes, book spines, record dig → BUY card.
|
||
|
||
**→ F**: two new warn-level smokes (hooks in [LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md)): (1) **buy loop** —
|
||
`createWallet` deduct/broke/inventory is pure and headless-testable; (2) **book/toy packs** — enter book or
|
||
toy shop with `?stock=real`, assert `isStock` meshes on a shared atlas material (drawSweep ≤350). Buy UI is
|
||
test-page-local; the reusable piece for the street shell is `wallet.js` + `onChange`.
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 7) — `?stock=real`: real GODVERSE sleeves in the crates
|
||
|
||
Round-7 §Lane C: the `stockAdapter` seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody-
|
||
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind `?stock=real`.
|
||
- **Contract handshake** (task 1, unblocked E): confirmed E's index schema as-is in
|
||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) — `{ items:[{id,title,artist,price,price_band,atlas,uv}] }`,
|
||
UV origin top-left (C flips V for WebGL), fail-soft, per-type packs.
|
||
- **New `stockpack.js`**: `preloadStockPack`/`getStockPack` (cached) + `makeStockAdapter`. Atlas texture +
|
||
material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's
|
||
baked UV → all real sleeves in a room **batch to one draw** (record: 59 draws).
|
||
- **Consumers**: `stock.js binFan` builds real cover planes (else parody canvas); `dig.js` riffles real
|
||
covers and the pull card shows **title / artist / $price / band**; `interiors.js` wires `opts.stock='real'`
|
||
through the seam (warn-once + parody fallback if the pack is absent); test page reads `?stock=real`.
|
||
- **Validated (sample 24-pack)**: determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles —
|
||
atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop
|
||
ok → parody), `?dig=1&stock=real` leak-free. Flag-off + `?noassets` untouched (procedural path byte-identical).
|
||
- Built against E's **staged sample** (real index format, synthetic covers); real Discogs covers drop in
|
||
unchanged when E gets the DB (external — John/Fable). Shot: [stock_real_r7.jpg](docs/shots/laneC/stock_real_r7.jpg).
|
||
|
||
**→ F**: new smoke for your strict harness — `?stock=real` enter record shop + open dig → assert real
|
||
(non-parody) sleeves; the record bin's real sleeves are cover planes with `userData.isStock` on a shared
|
||
atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law)
|
||
|
||
Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). **Diagnosed** the two
|
||
drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item —
|
||
then batched (new `web/js/interiors/batch.js`):
|
||
- **stock.js**: boxes are now single-material; every stock mesh tagged `userData.isStock`.
|
||
- **batch.js** (`batchRoom`, called from interiors.js before the GLB upgrade): two-tier merge —
|
||
room-level merge of all `isStock` across every fitting (grouped by material, world transforms baked)
|
||
+ per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB
|
||
upgrade can hide the fixture). `mergeGeometries` from vendored BufferGeometryUtils.
|
||
- **glb.js**: frame-hide is now by `isStock` tag (not frame-count), so batched fixtures hide and the
|
||
room-level stock rides on the GLB.
|
||
|
||
**Result** (drawSweep, all 9 types × 6 archetypes, seed 1990):
|
||
- GLB-off worst **152** (opshop/hall), GLB-on worst **313** (opshop/hall) — book 1,245 → **189**. **≤350 ✓**.
|
||
- Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit
|
||
target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical.
|
||
- **Sweep now ASSERTS ≤350** (`drawSweep()` in the test page, folded into the soak; `window.PROCITY_C.
|
||
drawSweep` for F's harness — see LANE_C_NOTES for the hook).
|
||
|
||
**C2 — map the 3 orphaned fittings** (published R5, unconsumed). In `glb.js`: `glassCase→glass_case`,
|
||
`magazineRack→magazine_rack`, and a NEW `crateStack` primitive (stack of crates) → `crate_stack`.
|
||
Recipe placements: crateStack in record + opshop (back-room dressing), glassCase added to milkbar,
|
||
magazineRack added to pawn (+ moved milkbar's magazineRack window→wall to dodge the door-counter).
|
||
Also **fixed `placeNearCounter`** — it picked one random side and gave up, so counter-zone fittings
|
||
(the glass case) rarely landed; now it tries both sides + offsets, then falls back to a free aisle spot.
|
||
All 3 load + render planted/scaled/facing right (yaw 0). Appearance: glassCase **12/12** (toy/pawn/milkbar),
|
||
magazineRack **12/12** (milkbar/pawn), crateStack ~11/12 (record/opshop). Contact sheet:
|
||
[glb_orphans_r6.jpg](docs/shots/laneC/glb_orphans_r6.jpg). drawSweep still ≤350 with them on (worst 177
|
||
GLB-on). **C3**: dig soak re-run after batching + new fittings — riffle works on batched bins, leak-free.
|
||
|
||
**Round 6 complete for Lane C**: C1 (batching, `35981ad`) + C2/C3 (orphans + dig re-verify) committed.
|
||
Draw law satisfied (≤313 was the tightest; ≤177 after C2), determinism/leak/dig all green, qa strict green.
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · **F can tag v1.1**
|
||
|
||
Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in `glb.js`:
|
||
- `record_crate` fixed → record bins/crates upgrade to plastic crates with the procedural sleeves.
|
||
- `counter → 'counter_till'` (the real ~1.6 m counter-with-till). Keeper-stand pose unaffected.
|
||
- **Double-till resolved**: cash_register attach dropped when counter_till is live (it has its own till);
|
||
falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
|
||
- Sweep + soak (GLB on): `0 throws / 0 path / 0 det / leak-free / worst 10.4ms`; **0 double tills**.
|
||
Primitive path unchanged (glb.js-only edit). Contact sheet: [glb_v1_1_r5.jpg](docs/shots/laneC/glb_v1_1_r5.jpg).
|
||
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
|
||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). **→ F: C1 committed, v1.1 can be tagged.**
|
||
|
||
**C2 — `?dig=1` crate-riffle (v2, behind flag).** Ported 90sDJsim `dig.js` → `web/js/interiors/dig.js`:
|
||
walk to a record bin, press **E**, riffle its sleeves (scroll/drag), click the front to pull, **BUY IT**
|
||
(STUBBED — toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin
|
||
via the `stockAdapter(shop,'sleeve')` seam (procedural fallback now — parody 90s-AU sleeves). Own scene +
|
||
camera, procedural fwip/thunk audio, **leak-free** (per-open resources freed on close → dig soak 20×
|
||
`leakGeo 0 / leakTex 0`). Flag-off (`?dig=0`) is **byte-identical** — `buildInterior` is untouched; the dig
|
||
is wired only in the test page (F owns the shell seam — hook documented in LANE_C_NOTES §"input/mode hook").
|
||
Shot: [dig_riffle_r5.jpg](docs/shots/laneC/dig_riffle_r5.jpg).
|
||
|
||
**C3 — interior re-measure after Lane E's bake (`c522464`).** E baked the 6 heavy hero props to ≤8k tris.
|
||
Re-measured every interior (cache-busted to bypass the dev browser's stale HTTP cache): **record 43.6k**
|
||
(the C3 target, ~273k pre-bake), **milkbar 38.9k** (was the flagged 468k → over-budget flag CLOSED), all
|
||
rooms ≤107k tris. `book` is the draw-call outlier (1,245 draws from ~9 bookshelf GLBs + primitive spines) —
|
||
tris fine, interiors render one-at-a-time so gate 3 unaffected; instancing spines is the future lever.
|
||
Full table in [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md §C3).
|
||
|
||
**Round 5 complete for Lane C**: C1 (v1.1 re-map) + C2 (`?dig=1`) + C3 (re-measure) all committed.
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 4) — hero props into rooms
|
||
|
||
Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14
|
||
fittings), sweep/soak with them on, screenshot each, delete the stale `_local_glb/`, measure a hero
|
||
room. **All done.** Full report for Lane E: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md);
|
||
contact sheet [docs/shots/laneC/glb_heroprops_r4.jpg](docs/shots/laneC/glb_heroprops_r4.jpg).
|
||
|
||
- **New mappings** (`glb.js`): `arcade_cabinet`→new `arcadeCabinet` primitive (video focal prop),
|
||
`listening_booth`→`listeningCorner` (record), `drinks_fridge`→`fridge` (milkbar). All load, `glb_law`
|
||
compliant, yaw 0 (no correction). A new **counter-top attach** (`attachCounterTop`) puts `cash_register`
|
||
on every counter's till slot (hides the primitive till) and `milkshake_mixer` on the milk-bar benchtop —
|
||
the counter primitive + keeper stand are untouched.
|
||
- **Focal-prop reliability fix**: the door-counter path loop was pulling priority-4 focal props (arcade
|
||
appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first →
|
||
**24/24 appearance**, still 0 carves / 0 path-fails.
|
||
- **Validation**: primitive sweep 810 builds `{throws 0, pathFail 0, detFail 0}`; GLB-on soak
|
||
`{throws 0, path 0, det 0, leakGeo 0, leakTex 0, worst 7.3ms}`; all 5 new props confirmed in-room.
|
||
- **Room tris measured** (task 4): milk-bar GLB-on ≈ **468k tris** (over budget) driven by `drinks_fridge`
|
||
(77k) + `milkshake_mixer` (73k) — **reported to Lane E** as the #1 bake-pass target (not fixed, per
|
||
integrator decision #2; interiors render one-at-a-time so gate 3 / the street budget is unaffected).
|
||
- **Cleanup**: deleted `web/assets/_local_glb/` (R3 validation copy); test page now loads GLBs via the
|
||
live `depot:` path (`?localdepot=1` still works for offline). `localBase` hook kept in `glb.js`.
|
||
- **Standby (C task 5)**: when Lane E re-exports `record_crate` (broken) + a ~2 m counter-with-till, I
|
||
re-map both. Not landed at commit time.
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 3) — GLB hero-prop visual validation
|
||
|
||
Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip `useGLB` on and validate Lane E's fitting GLBs in real
|
||
rooms. Done — full report for Lane E in [docs/LANES/LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md).
|
||
|
||
**Verdict:** 5 of 9 fitting GLBs are good and now live behind `useGLB` (wire_shelf, clothes_rack,
|
||
bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed
|
||
(`record_crate` **broken — won't load**; `counter` **too long/no till** → primitive keeps the till +
|
||
keeper stand). record bins stay primitive until Lane E re-exports the crate.
|
||
|
||
**Soak with `useGLB` on** (50 rooms, all types): `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
|
||
leakTex 0 · worst build 10.5ms · 256 upgrades applied` — GLB resources dispose leak-free on room exit.
|
||
|
||
**glb.js changes (my file):**
|
||
- `manifest.localBase` — load GLBs from a served local dir for offline validation (production still uses
|
||
`depot:<file>`; forward-compatible with Lane E's local-depot mode).
|
||
- **Real-metre scale, clamped** to the reserved footprint (was fit-to-width, which squashed tall props);
|
||
matches `glb_law` (metres). Per-kind **yaw correction** (`KIND_TO_YAW`) for the Z-major assets
|
||
(bookshelf/cube/work_table need +90°) per the manifest `facing` convention ("Lane B/C rotate directional props").
|
||
- `room.glbReady` (interiors.js) — a promise that resolves when GLB swaps settle, so callers can await
|
||
before screenshot/leak-check (Lane F can use it in the §3.4 wiring).
|
||
- Test page (`interior_test.html`) gains a **GLB props** toggle + a `GLB props: N upgraded` HUD line.
|
||
Local validation needs the GLBs copied in: `cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/`
|
||
(untracked; real GLBs live on the depot). Screenshot grid: [docs/shots/laneC/glb_validation_grid.jpg](docs/shots/laneC/glb_validation_grid.jpg).
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3)
|
||
|
||
Lane F's runbook asked (LANE_F_NOTES §8): *"does `places` tag the counter slot with a stable
|
||
`kind:'counter'` so keepers find it?"* **Yes** — the counter interactable has always carried
|
||
`mesh.userData.kind === 'counter'` (+ `interactable:true`). But Lane D's keeper needs more than a
|
||
tag: `keepers.spawn(group,{x,z,ry})` needs *where the shopkeeper stands and which way it faces*
|
||
(keepers.js:28 — "`ry` = the counter's outward facing"). I computed the customer-facing side
|
||
internally but never surfaced it — so Lane D would have read the bench's raw transform and stood a
|
||
keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive):
|
||
|
||
- **New: `room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} }`** (interiors.js). `pose` is the
|
||
bench; **`stand` is the keeper pose** — behind the bench on the service side, facing the customer,
|
||
clamped inside the room, in **room-local space** (the same space Lane D adds the keeper into via
|
||
`interior.group`). `ry` uses the rig-front = local −Z convention keepers.js expects.
|
||
- **The same `{x,z,ry}` also rides the counter interactable**: `place.userData.keeperStand` — for the
|
||
`places`-iteration path Lane D/F described. Deep-equal to `room.counter.stand`.
|
||
|
||
**Lane D/F wiring (drop-in for §3.2/§3.3):**
|
||
```js
|
||
const room = buildInterior(shop, THREE);
|
||
const s = room.counter.stand; // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand
|
||
keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type });
|
||
// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose()
|
||
```
|
||
|
||
Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak):
|
||
`stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) ·
|
||
stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 ·
|
||
pathFail 0 · every room has a counter+stand`. No change to layout/placement of existing rooms.
|
||
|
||
**Minor known cosmetic (corner counters):** a corner counter sits ~0.55 m off the east wall, so the
|
||
service gap (~0.25 m) is narrower than a person — the keeper stands *at* the till with a small hidden
|
||
overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and
|
||
side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can
|
||
nudge corner counters ~0.2 m off the wall — but that changes those rooms' committed placement +
|
||
screenshots, so it's a decision, not a bug.
|
||
|
||
**Two contract-drift corrections for the F runbook** (§2's Lane-C line was written against an earlier
|
||
draft of my API): (1) there is **no `opts.registry`** — registry override is the one-time global
|
||
`mergeRegistry(registry)` (a per-build registry mutation broke same-seed determinism; removed in the
|
||
review pass). (2) `recipe.counterPos` is a **placement-strategy string** (`door|forward|back|corner`),
|
||
**not** a world position — use `room.counter.stand` for the keeper pose.
|
||
|
||
---
|
||
|
||
## What shipped
|
||
|
||
All files under my lane's ownership (`web/js/interiors/*`, `web/interior_test.html`) — no other lane's
|
||
files touched. Lane A's `core/registry.js` hadn't landed, so the lane is **fully standalone** (its 9
|
||
type recipes live in `theme.js`) with a `mergeRegistry()` hook for Lane F to wire the registry later.
|
||
|
||
| file | role |
|
||
|---|---|
|
||
| [interiors.js](web/js/interiors/interiors.js) | **public API** — `buildInterior(shop, THREE, opts)` |
|
||
| [theme.js](web/js/interiors/theme.js) | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
|
||
| [shell.js](web/js/interiors/shell.js) | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
|
||
| [fittings.js](web/js/interiors/fittings.js) | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
|
||
| [layout.js](web/js/interiors/layout.js) | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, **guaranteed door→counter flood-fill path** |
|
||
| [stock.js](web/js/interiors/stock.js) | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + `stockAdapter` hook for BaseGod data |
|
||
| [context.js](web/js/interiors/context.js) | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free `disposeAll()` |
|
||
| [glb.js](web/js/interiors/glb.js) | **optional** GLB hero-prop upgrade — reads Lane E's `manifest.json`, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
|
||
| [interior_test.html](web/interior_test.html) | standalone test page (below) |
|
||
|
||
## Public API (the contract Lane B/F build against)
|
||
|
||
```js
|
||
import { buildInterior } from './js/interiors/interiors.js';
|
||
import * as THREE from 'three';
|
||
|
||
const room = buildInterior(shop, THREE, opts?);
|
||
scene.add(room.group);
|
||
// … player walks; on hitting room.exits[i], fire procity:exitShop …
|
||
room.dispose(); // frees ALL per-room GPU resources; removes group from parent
|
||
|
||
// room = {
|
||
// group, // THREE.Group, self-contained (lights included)
|
||
// spawn: { x, z, ry }, // enter just inside the door, facing −Z (into the shop)
|
||
// exits: [ { x, z, w, toStreet } ], // door back to street
|
||
// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
|
||
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose
|
||
// dims: { W, D, H, archetype, type },
|
||
// placement, // deterministic summary (deep-equal per seed)
|
||
// pathOK, buildMs, counts(), dispose()
|
||
// }
|
||
```
|
||
|
||
`shop` is tolerant — recognized fields `{ id, type, name, seed, storeys, lot:{w,d} }`:
|
||
- **seed** (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
|
||
- **type** — any CITY_SPEC type (`record opshop toy book video pawn milkbar dept stall`) **or** a
|
||
thriftgod/Overpass alias (`music charity pawnbroker market video_games …`); defaults to `opshop`.
|
||
- **lot** `{w,d}` metres — the room adapts to it (never exceeds it); absent → archetype ranges decide.
|
||
- **storeys** drives ceiling height 3.2–4.5m.
|
||
- `opts`: `{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }`.
|
||
Registry override is a **one-time global** — `import { mergeRegistry } from interiors.js; mergeRegistry(registry)`
|
||
at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).
|
||
|
||
**Coordinate convention** (shared with a future Lane B wiring): `+Z` = door/street side, `−Z` = back,
|
||
origin = room centre, floor `y = 0`. Everything is in the returned `group`'s local space.
|
||
|
||
### For Lane B (door wiring, later)
|
||
`buildInterior` is a pure function — call it on `procity:enterShop`, add `room.group` to the scene,
|
||
teleport the player to `room.spawn`. On `procity:exitShop` (player reaches `room.exits[0]`) call
|
||
`room.dispose()` and restore the street. The street window fake is Lane B's; I own the inside view
|
||
(a bright canvas street backdrop plane is already behind the glazing).
|
||
|
||
### For content phase (BaseGod / GODVERSE)
|
||
Pass `opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null`. Returning a texture
|
||
skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/
|
||
fittings changes needed. `slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }`.
|
||
|
||
## Test page — `web/interior_test.html`
|
||
|
||
Serve: `cd web && python3 -m http.server 8130` → open `/interior_test.html`.
|
||
- seed input · type dropdown (9) · archetype override (auto + 5) · lot `w×d`/auto · re-roll · seed ±
|
||
- first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
|
||
- debug toggles: **wireframe** (F), **occupancy grid** (G), **path** (spawn/counter markers)
|
||
- **▶ 50-room soak test**: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs
|
||
baseline, and determinism (same seed → identical placement)
|
||
|
||
## Acceptance — all met
|
||
|
||
| requirement | result |
|
||
|---|---|
|
||
| Same seed twice → identical room (deep-equal placement) | ✅ 0 mismatches / 810 builds |
|
||
| All 9 types × 5 archetypes render sensibly | ✅ contact sheet `docs/shots/laneC/_grid_9types_5archetypes.jpg` |
|
||
| Build < 50ms/room | ✅ steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops — not a real per-room cost) |
|
||
| Dispose leak-free (info.memory → baseline) | ✅ soak leak: geo 0, tex 0 |
|
||
| Flood-fill door→counter always exists | ✅ 0 path-fails, 0 carves / 810 builds |
|
||
| Runs with zero assets & zero network | ✅ seeded flat-colour fallback under every texture; map only assigned on load |
|
||
|
||
**Full sweep** (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
|
||
`throws 0 · pathFail 0 · determinismFail 0 · carved 0`. Warm perf: all < 50ms (worst 33.2ms).
|
||
**Soak** (50 rooms): `avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0`.
|
||
|
||
## Key decisions & notes for review
|
||
|
||
- **90sDJsim `fittings.js` ported** (it was written to be lifted): `clothesRack`, `shelfUnit`,
|
||
`recordBin`, `table`, `counter`, `pegboard`, garment-silhouette canvas trick. Extended with the
|
||
fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner,
|
||
magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
|
||
- **thriftgod `buildShop` ported**: the 5 archetypes, the shuffled wall-slot system, bins with fanned
|
||
sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
|
||
- **Determinism** via independent seeded sub-streams keyed by a salt string (`ctx.stream('wallpaper')`
|
||
etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
|
||
- **Leak-free** via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
|
||
are freed in `dispose()`; globally-cached file textures (`core/loaders.loadTex`) are shared and not
|
||
disposed (bounded cache). Dispose-before-texture-load race is guarded (`_disposed`).
|
||
- **Perf**: shared-geometry cache (`ctx.boxGeo/planeGeo/cylGeo`) collapses the hundreds of identical
|
||
stock items in a room down to ~80–110 geometries; spine density tuned so a book barn is ~600 meshes.
|
||
- **GLB upgrades**: `web/assets/manifest.json` (Lane E) is read if present, primitive fallback always.
|
||
Not present yet → 100% primitives, which is why the test page runs asset-free.
|
||
|
||
## Integration with sibling lanes (landed in parallel)
|
||
- **Lane A `core/registry.js`** — its `SHOP_TYPE_IDS` match my 9 recipe keys exactly. I kept the
|
||
interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail),
|
||
with `mergeRegistry()` as the one-time override seam for Lane F.
|
||
- **Lane E `web/assets/manifest.json`** — its `fittings` section (9 depot GLBs with footprints) is now
|
||
wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet;
|
||
verified the fallback (unreachable depot → primitives persist, no crash, no leak).
|
||
- **Lane D `citizens/keepers.js`** — its `keepers.spawn(group,{x,z,ry,shopId,type})` now has a clean
|
||
source: `room.counter.stand` (also on `place.userData.keeperStand`). See the *Update (b)* section at
|
||
the top. Verified the pose is on the service side, faces the customer, and is deterministic.
|
||
|
||
## Adversarial review — 5 findings, all fixed
|
||
|
||
Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee /
|
||
three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and
|
||
re-verified (830-build sweep + 50-room soak green after each fix):
|
||
|
||
| # | sev | file | defect | fix |
|
||
|---|---|---|---|---|
|
||
| 1 | med | interiors.js | per-build `opts.registry` permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states | removed per-build option; registry override is now a one-time global `mergeRegistry()` (Lane F setup) |
|
||
| 2 | low | interiors.js | explicit `null` opts threw (`opts = {}` default only covers `undefined`) | normalize `opts = opts \|\| {}` |
|
||
| 3 | low | shell.js | empty `archetypeBias` array → `bias[0][0]` on `[]` throws | fall back to uniform bias when missing **or** empty |
|
||
| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in `places` (dangling after dispose) | collect `places` from survivors at the end, not incrementally at placement |
|
||
| 5 | low | layout.js | `rebuildOcc` re-stamped wall-mounted fittings that `placeAtWall` never stamped → phantom floor obstacles after a blocker-pull | mark wall-mounted `noStamp`; skip in `rebuildOcc` **and** exclude from the removable pool |
|
||
|
||
Verify evidence (post-fix): `nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical ·
|
||
emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0,
|
||
leakTex 0}`. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
|
||
|
||
## Not done (out of scope for v1 / depends on other lanes)
|
||
- Real item data / economy (content phase — `stockAdapter` hook is ready).
|
||
- GLB hero props are **wired** (`glb.js`, opt-in) but not visually validated end-to-end — needs the
|
||
depot GLBs actually uploaded (Lane E) + `useGLB` flipped on by Lane B/F. Primitive path is the tested baseline.
|
||
- Interior-mapping shader for street windows (Lane B stretch goal).
|
||
- Back-room beyond the blocked doorway (v2).
|