Lane C R41 §41.4: the shops get their stock — and the measuring instrument was wrong

110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in
pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now
honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a
110-row facing table.

THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every
historical interior number carried a phantom second room — 'toy', untouched this round,
'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and
with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228.
Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61,
pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69,
toy 86->86 (+0, the control).

The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a
bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record
shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured
44%-no-crate hole to 0/40.

WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material,
one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source
bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands
ship and the source library is prompted FROM real labels — the adidas jacket renders a
legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's
Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh).

FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB
fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were
nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js
dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the
path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) —
new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore.

Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0;
960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts
(sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock
fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops
the limit constraints — a driver must clamp travel itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 18:11:07 +10:00
parent 48033f48b1
commit 422f17263e
23 changed files with 2152 additions and 63 deletions

View File

@ -3,7 +3,220 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *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.* into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-08-03 (round 39) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-08-07 (round 41) · owner: PROCITY-C · reviewer: Fable
---
## 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` | 24 tubs/crates (dig) · 13 tub racks · 12 wall boards · window ledge · 12 pinned sleeves · **the rigged DJ booth** | 69 | 85 | 61 | **78** | +17 | 70 |
| `opshop` | 12 bargain tubs · 12 racks · wall board | 89 | 92 | 102 | **105** | +3 | 105 |
| `book` | library trolley · 12 racks · 12 wall boards | 55 | 69 | 51 | **61** | +10 | 95 |
| `video` | ex-rental bargain tub · 12 wall boards · window ledge | 65 | 76 | 54 | **63** | +9 | 63 |
| `pawn` | 12 flight cases/sub · **808 + SP-1200 bench** · wall board | 67 | 81 | 60 | **70** | +10 | 70 |
| `stall` | **24 diggable bins** · 12 racks | 76 | 89 | 71 | **82** | +11 | 82 |
| `dept` | 02 columns · 12 racks · wall board | 80 | 87 | 116 | **122** | +6 | 122 |
| `milkbar` | **bain-marie / pie warmer** · 13 floor props · air-con | 72 | 80 | 68 | **69** | +1 | 69 |
| `pub` | **bar = `plywood_bar`** · 23 stage-flank venue kit · 36 seats · 36 floor props · air-con · DJ booth | 48 | 64 | 45 | **59** | +14 | 59 |
| `band_room` | bar = `plywood_bar` · 24 venue kit · 23 floor props · DJ booth | 43 | 56 | 39 | **50** | +11 | 50 |
| `rsl` | bar = `plywood_bar` · 13 venue kit · 36 seats · 35 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`.**
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 36 trestles take the aisle first — so RED
TICKET DAY could name a stall you cannot dig in. `stall` now carries 24 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 612 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`.
--- ---

View File

@ -1,5 +1,78 @@
# LANE C — cross-lane notes (PROCITY-C) # LANE C — cross-lane notes (PROCITY-C)
## Round 41 §41.4 — the 110 fittings are placed. Three hand-offs, one warning.
Full tables in `C-progress.md` § Round 41 (§41.4). Shots: `docs/shots/laneC/r41_*.png`.
**Worst room in the game is now 122 draws @ `dept`/auto (GLB on), law ≤350 — margin 228.**
### → LANE F: one line turns the wardrobe on in the game, and one number needs restating
**1. The wardrobe pack.** 52 named 1990s Australian garment layers, one 240 KB atlas, feeding the op-shop
clothes rails and shelves at **zero geometry cost**. It is a stock pack under its own base, so `index.html`
needs the same one line the test page has, beside the existing three preloads:
```js
if (STOCK_REAL) preloadStockPack('opshop', { base: 'assets/wardrobe/' });
```
and, where a shop's interior is built, `stockBase: 'assets/wardrobe/'` when `shop.type === 'opshop'`
(`interior_mode.js` already passes `stockAdapter`; the adapter must be
`makeStockAdapter(getStockPack('opshop', 'assets/wardrobe/'))`). **Nothing breaks without it** — no pack
means the procedural garment canvas, byte-identically, which is also what `?noassets` and every default
boot get. Cache identity is `(type, base)`, so it coexists with the town-wide packs.
**2. `drawSweep` numbers moved, and it is the instrument, not the rooms.** The sweep cleared every interior
from the scene *except* `current` — the live room the page is showing — so every per-type number carried a
constant offset from an unrelated second room. R39/R40 prints of this function (worst 188, "margin 162")
include that phantom. The sweep now clears all interiors and rebuilds at the end. Re-baseline against the
corrected values before asserting anything tighter than ≤350.
**3. New gate surface, if you want it:** `PROCITY_C.kitAudit(manifest)` compares all 96 catalogue rows in
`kit41.js` against the live `manifest.json` and asserts Lane E's three traps (no pool ships both halves of
a rotated pair; no pool ships an oversize id; every pool/booth id resolves). Currently **96/96 checked, 0
dimension errors, 0 missing, 0 pair errors, 0 oversize**. It caught a real 5 mm drift on its first run. It
is cheap, it is non-vacuous (it has already fired), and it is exactly the check that goes stale silently.
**4. `opts.noKit`** on `buildInterior` drops every R41 spec and reproduces the pre-R41 room exactly — the
control arm for any before/after the round needs. `room.pulls = { pulled, restored, lost }` reports what
the door→counter guarantee cost that room; `lost > 0` means a genuine blocker.
### → LANE D: the DJ control nodes exist, nothing drives them, and that is deliberate
R41 **places** the rigged gear and **records** the contract. There is no driver and `djBooth` ships no
animation hook, so a placed node can never be mistaken for a live one. When you build the driver:
| id | control nodes | live where |
|---|---|---|
| `deck_1200_rigged` | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, `BOOTHS.record_hero`, left deck |
| `mixer_ttm54i_rigged` | `Fader_CROSS` | record shop, `BOOTHS.record_hero`, centre |
| `mixer_pmc05pro_rigged` · `mixer_shdj1200_rigged` | `Fader_CROSS` | catalogued, not placed |
Reach them from the room without guessing: any fitting group with `userData.djBooth` carries
`userData.controlNodes = { <id>: [node…] }`, and the GLB instance beneath it is tagged
`userData.glbUpgrade` with `userData.kind === <id>`. Two rules from E that a driver must honour: drive by
the **bare node name** (cap/stem/rib are parented under it), and **glTF drops the source's limit
constraints — clamp the travel yourself.** Venue and pawn booths are statics by design.
**Your poses are unchanged and still hold in a furnished room.** browsePoints stayed at 3.00/room across
all 12 types; watchPoints measured after furnishing: pub **7.5 of cap 8**, band_room **8.0 of 8**, RSL
**11.4 of 12**. `counter.stand` is untouched. The venues' bar is now `plywood_bar` instead of
`counter_till` — same fitting, same stand pose, same service strip, different model.
### ⚠ WARNING TO ANY LANE THAT HIDES A PRIMITIVE OR MERGES A MESH
Two latent bugs this round found the hard way, both of the same shape — **a tag that something depends on,
destroyed by a later pass**:
- `glb.js` hid every child of an upgraded fitting that was not `isStock`. R9's buy-anywhere shelves are
tagged `{ noBatch, buyMesh }` and *not* `isStock`, so **`?stock=real` + GLB made every buyable item in
the room invisible** — live since R9 on `bookshelf`/`cubeShelf`/`clothesRack`, never seen because R9
measured GLB-off.
- `batch.js` rebuilt `userData` from scratch when it merged a material bucket, dropping sub-tags such as
`tillPrimitive`. Any hide that reads a per-mesh flag after batching was already a no-op.
If you add a per-mesh flag that a later stage reads, check it survives `batchRoom` **and** the GLB swap.
## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live) ## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live)
**Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref **Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,903 @@
{
"version": 1,
"kind": "opshop",
"atlas_px": 1024,
"cell_w": 128,
"cell_h": 152,
"count": 52,
"atlases": [
"wardrobe_atlas_00.webp"
],
"items": [
{
"id": "wr_0000",
"title": "Acid Smiley Tee",
"artist": "Paddy's Bootleg",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.549805,
0.125,
0.658203
],
"aspect": 1.1532,
"src": "top_i_bootleg_acid_house_smiley_tee.png"
},
{
"id": "wr_0001",
"title": "Hip Hop Crew Tee",
"artist": "Paddy's Bootleg",
"price": 5,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.507812,
0.427734,
0.632812,
0.541016
],
"aspect": 1.1034,
"src": "top_i_bootleg_hip_hop_crew_tee.png"
},
{
"id": "wr_0002",
"title": "Rave Smileys Tee",
"artist": "Paddy's Bootleg",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.380859,
0.427734,
0.505859,
0.542969
],
"aspect": 1.0847,
"src": "top_i_bootleg_rave_smileys_tee.png"
},
{
"id": "wr_0003",
"title": "Alien Rave Tee",
"artist": "Paddy's Bootleg",
"price": 5,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.253906,
0.300781,
0.378906,
0.423828
],
"aspect": 1.0159,
"src": "top_i_paddys_bootleg_rave_tee.png"
},
{
"id": "wr_0004",
"title": "UV Crop Top",
"artist": "Paddy's Bootleg",
"price": 3,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.302734,
0.660156,
0.427734,
0.748047
],
"aspect": 1.4222,
"src": "top_i_paddy_s_market_uv_bootleg_crop_top.png"
},
{
"id": "wr_0005",
"title": "Skull Band Tee",
"artist": "Paddy's Bootleg",
"price": 6,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.380859,
0.549805,
0.505859,
0.651367
],
"aspect": 1.2308,
"src": "top_i_band_tee_bootleg.png"
},
{
"id": "wr_0006",
"title": "8-Ball Tee",
"artist": "Street Stall",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.126953,
0.300781,
0.251953,
0.424805
],
"aspect": 1.0079,
"src": "top_i_8_ball_tee.png"
},
{
"id": "wr_0007",
"title": "Splatter Smiley Tee",
"artist": "Doof Merch",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.507812,
0.300781,
0.632812,
0.421875
],
"aspect": 1.0323,
"src": "top_i_rave_splatter_tee.png"
},
{
"id": "wr_0008",
"title": "UV Circuit Long Sleeve",
"artist": "Doof Merch",
"price": 6,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.300781,
0.125,
0.425781
],
"aspect": 1.0,
"src": "top_i_uv_reactive_top.png"
},
{
"id": "wr_0009",
"title": "UV Spiral Crop Top",
"artist": "Doof Merch",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.429688,
0.660156,
0.554688,
0.746094
],
"aspect": 1.4545,
"src": "top_i_uv_reactive_crop_top_womens.png"
},
{
"id": "wr_0010",
"title": "Squiggle Jumper",
"artist": "Coogee Knits",
"price": 12,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.253906,
0.549805,
0.378906,
0.657227
],
"aspect": 1.1636,
"src": "top_i_classic_knit_sweater.png"
},
{
"id": "wr_0011",
"title": "Worm Knit Jumper",
"artist": "Coogee Knits",
"price": 10,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.507812,
0.549805,
0.632812,
0.647461
],
"aspect": 1.28,
"src": "top_i_mens_knit_sweater.png"
},
{
"id": "wr_0012",
"title": "Signature Knit",
"artist": "Coogee Knits",
"price": 12,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.634766,
0.427734,
0.759766,
0.541016
],
"aspect": 1.1034,
"src": "top_i_signature_knit_sweater.png"
},
{
"id": "wr_0013",
"title": "Puppy Jumper",
"artist": "Nan Knitted It",
"price": 3,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.634766,
0.300781,
0.759766,
0.420898
],
"aspect": 1.0407,
"src": "top_i_mens_cartoon_sweater.png"
},
{
"id": "wr_0014",
"title": "Squiggle Knit Dress",
"artist": "Coogee Knits",
"price": 14,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.0,
0.111328,
0.148438
],
"aspect": 0.75,
"src": "top_i_womens_knit_dress.png"
},
{
"id": "wr_0015",
"title": "Loud Hawaiian Shirt",
"artist": "Rissole Classic",
"price": 6,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.253906,
0.427734,
0.378906,
0.544922
],
"aspect": 1.0667,
"src": "top_i_loud_hawaiian_shirt.png"
},
{
"id": "wr_0016",
"title": "Loud Dog Shirt",
"artist": "Mombo",
"price": 8,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.380859,
0.300781,
0.505859,
0.422852
],
"aspect": 1.024,
"src": "top_i_mombo_loud_shirt.png"
},
{
"id": "wr_0017",
"title": "Loud Art Shirt",
"artist": "Mombo",
"price": 8,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.40332,
0.150391,
0.52832,
0.286133
],
"aspect": 0.9209,
"src": "top_i_womens_loud_shirt.png"
},
{
"id": "wr_0018",
"title": "Camo Field Jacket",
"artist": "Army Disposals",
"price": 15,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.761719,
0.549805,
0.886719,
0.644531
],
"aspect": 1.3196,
"src": "top_i_army_disposal_camo_jacket.png"
},
{
"id": "wr_0019",
"title": "Olive Parka",
"artist": "Army Disposals",
"price": 18,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.761719,
0.300781,
0.886719,
0.420898
],
"aspect": 1.0407,
"src": "top_i_army_disposal_parka_jacket.png"
},
{
"id": "wr_0020",
"title": "Heavy Hoodie",
"artist": "Stoosh",
"price": 9,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.427734,
0.125,
0.547852
],
"aspect": 1.0407,
"src": "top_i_stoosh_hoodie.png"
},
{
"id": "wr_0021",
"title": "Kangaroo Pocket Hoodie",
"artist": "Best & Cheapest",
"price": 7,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.657227,
0.150391,
0.782227,
0.27832
],
"aspect": 0.9771,
"src": "top_i_kangaroo_pocket_hoodie.png"
},
{
"id": "wr_0022",
"title": "Ribbed Singlet",
"artist": "Kaymart",
"price": 2,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.113281,
0.0,
0.201172,
0.148438
],
"aspect": 0.5921,
"src": "top_i_womens_ribbed_singlet.png"
},
{
"id": "wr_0023",
"title": "Glitter Baby Tee",
"artist": "Kaymart",
"price": 3,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.634766,
0.549805,
0.759766,
0.647461
],
"aspect": 1.28,
"src": "top_i_womens_glitter_baby_tee.png"
},
{
"id": "wr_0024",
"title": "Leather Jacket",
"artist": "Trash & Treasure",
"price": 25,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.761719,
0.427734,
0.886719,
0.539062
],
"aspect": 1.1228,
"src": "top_i_womens_leather_jacket.png"
},
{
"id": "wr_0025",
"title": "Colourblock Windbreaker",
"artist": "Trackside",
"price": 8,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.78418,
0.150391,
0.90918,
0.276367
],
"aspect": 0.9922,
"src": "top_i_womens_windbreaker.png"
},
{
"id": "wr_0026",
"title": "Star Track Jacket",
"artist": "Trackside",
"price": 9,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.126953,
0.549805,
0.251953,
0.658203
],
"aspect": 1.1532,
"src": "top_i_italia_track_jacket.png"
},
{
"id": "wr_0027",
"title": "Satin Cami",
"artist": "Club Rack",
"price": 3,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.203125,
0.0,
0.310547,
0.148438
],
"aspect": 0.7237,
"src": "top_i_cami_top.png"
},
{
"id": "wr_0028",
"title": "Silver Phat Pants",
"artist": "Doof Merch",
"price": 12,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.3125,
0.0,
0.436523,
0.148438
],
"aspect": 0.8355,
"src": "bottom_i_phat_pants.png"
},
{
"id": "wr_0029",
"title": "Flame Phat Pants",
"artist": "Paddy's Bootleg",
"price": 14,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.438477,
0.0,
0.551758,
0.148438
],
"aspect": 0.7632,
"src": "bottom_i_bootleg_uv_rave_phat_pants.png"
},
{
"id": "wr_0030",
"title": "Sun Phat Pants",
"artist": "Doof Merch",
"price": 12,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.553711,
0.0,
0.666992,
0.148438
],
"aspect": 0.7632,
"src": "bottom_i_womens_rave_phat_pant.png"
},
{
"id": "wr_0031",
"title": "Colourblock Baggy Jeans",
"artist": "Criss Colors",
"price": 16,
"price_band": "collector",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.668945,
0.0,
0.758789,
0.148438
],
"aspect": 0.6053,
"src": "bottom_i_baggy_colourblock_jeans.png"
},
{
"id": "wr_0032",
"title": "Baggy Jeans",
"artist": "Trash & Treasure",
"price": 8,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.760742,
0.0,
0.867188,
0.148438
],
"aspect": 0.7171,
"src": "bottom_i_mens_baggy_jeans.png"
},
{
"id": "wr_0033",
"title": "Wide Leg Jeans",
"artist": "Trash & Treasure",
"price": 8,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.869141,
0.0,
0.978516,
0.148438
],
"aspect": 0.7368,
"src": "bottom_i_womens_wide_leg_jeans.png"
},
{
"id": "wr_0034",
"title": "Camo Parachute Pants",
"artist": "Army Disposals",
"price": 10,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.150391,
0.095703,
0.298828
],
"aspect": 0.6447,
"src": "bottom_i_camo_parachute_pants.png"
},
{
"id": "wr_0035",
"title": "Camo Cargo Pants",
"artist": "Army Disposals",
"price": 10,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.097656,
0.150391,
0.199219,
0.298828
],
"aspect": 0.6842,
"src": "bottom_i_army_disposal_camo_cargo_pants.png"
},
{
"id": "wr_0036",
"title": "Boardies",
"artist": "Rissole Classic",
"price": 5,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.126953,
0.427734,
0.251953,
0.547852
],
"aspect": 1.0407,
"src": "bottom_i_boardies.png"
},
{
"id": "wr_0037",
"title": "Zigzag Boardshorts",
"artist": "Fang",
"price": 6,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.276367,
0.150391,
0.401367,
0.295898
],
"aspect": 0.8591,
"src": "bottom_i_fang_boardshort.png"
},
{
"id": "wr_0038",
"title": "Trackie Dacks",
"artist": "Trackside",
"price": 4,
"price_band": "bargain",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.201172,
0.150391,
0.274414,
0.298828
],
"aspect": 0.4934,
"src": "bottom_i_track_pants.png"
},
{
"id": "wr_0039",
"title": "Squiggle Knit Skirt",
"artist": "Coogee Knits",
"price": 9,
"price_band": "mid",
"slot": "garment",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.530273,
0.150391,
0.655273,
0.28125
],
"aspect": 0.9552,
"src": "bottom_i_womens_knit_skirt.png"
},
{
"id": "wr_0040",
"title": "Bucket Hat",
"artist": "Kangaruu",
"price": 6,
"price_band": "mid",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.751953,
0.101562,
0.829102
],
"aspect": 1.3165,
"src": "hat_i_kangaruu_bucket_hat.png"
},
{
"id": "wr_0041",
"title": "Terry Towelling Hat",
"artist": "Rissole Classic",
"price": 2,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.888672,
0.549805,
0.973633,
0.639648
],
"aspect": 0.9457,
"src": "hat_i_terry_towelling_hat.png"
},
{
"id": "wr_0042",
"title": "Crochet Bucket Hat",
"artist": "Nan Knitted It",
"price": 4,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.207031,
0.751953,
0.308594,
0.822266
],
"aspect": 1.4444,
"src": "hat_i_knit_bucket_hat.png"
},
{
"id": "wr_0043",
"title": "Olive Beanie",
"artist": "Army Disposals",
"price": 3,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.867188,
0.660156,
0.96875,
0.741211
],
"aspect": 1.253,
"src": "hat_i_army_disposal_beanie.png"
},
{
"id": "wr_0044",
"title": "Rainbow Patch Cap",
"artist": "Paddy's Bootleg",
"price": 5,
"price_band": "mid",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.0,
0.660156,
0.09668,
0.75
],
"aspect": 1.0761,
"src": "hat_i_paddys_bootleg_cap.png"
},
{
"id": "wr_0045",
"title": "Faded Snapback",
"artist": "Street Stall",
"price": 4,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.098633,
0.660156,
0.193359,
0.75
],
"aspect": 1.0543,
"src": "hat_i_snapback_faded.png"
},
{
"id": "wr_0046",
"title": "Double Pluggers",
"artist": "Rissole Classic",
"price": 2,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.556641,
0.660156,
0.658203,
0.743164
],
"aspect": 1.2235,
"src": "shoes_i_double_pluggers.png"
},
{
"id": "wr_0047",
"title": "Chunky Boots",
"artist": "Trash & Treasure",
"price": 20,
"price_band": "collector",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.660156,
0.660156,
0.761719,
0.743164
],
"aspect": 1.2235,
"src": "shoes_i_chunky_boots.png"
},
{
"id": "wr_0048",
"title": "Peace Sign Sneakers",
"artist": "Doof Merch",
"price": 14,
"price_band": "collector",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.763672,
0.660156,
0.865234,
0.742188
],
"aspect": 1.2381,
"src": "shoes_i_peace_sign_sneakers.png"
},
{
"id": "wr_0049",
"title": "Platform Sneakers",
"artist": "Club Rack",
"price": 16,
"price_band": "collector",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.103516,
0.751953,
0.205078,
0.829102
],
"aspect": 1.3165,
"src": "shoes_i_platform_sneakers_womens.png"
},
{
"id": "wr_0050",
"title": "Plain Tote",
"artist": "Best & Cheapest",
"price": 1,
"price_band": "bargain",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.195312,
0.660156,
0.248047,
0.75
],
"aspect": 0.587,
"src": "bag_plaintote.png"
},
{
"id": "wr_0051",
"title": "Eye Shoulder Bag",
"artist": "Club Rack",
"price": 7,
"price_band": "mid",
"slot": "small",
"atlas": "wardrobe_atlas_00.webp",
"uv": [
0.25,
0.660156,
0.300781,
0.75
],
"aspect": 0.5652,
"src": "bag_bag96.png"
}
],
"material": {
"alphaTest": 0.45,
"side": "double",
"roughness": 0.86
},
"source": "90sDJsim/web/world/media/doll \u2014 441 hand-prompted 1990-99 Australian garment layers; 52 selected BY NAME (ruling 3), cropped to alpha bbox, packed into one atlas. Labels are parody per the house law (AUDIT.md: no real brands ship); the parody IS the period voice."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -74,6 +74,9 @@ import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js'; import { createWallet } from './js/interiors/wallet.js';
import { collapseBuyItem } from './js/interiors/stockpack.js'; import { collapseBuyItem } from './js/interiors/stockpack.js';
import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js'; import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js';
// [R41 §41.4] the kit catalogue + its own drift check (kit41.kitAudit compares every hardcoded row
// against the live manifest and asserts the three traps Lane E flagged). Exposed for the harness.
import { KIT, POOLS, BOOTHS, CONTROL_NODES, kitAudit } from './js/interiors/kit41.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam. // ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real'; const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
@ -82,7 +85,16 @@ const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
// demonstrable from this page, which is what makes an F gate on either of them non-vacuous. // demonstrable from this page, which is what makes an F gate on either of them non-vacuous.
const OPSHOP_DIG = (() => { const v = new URLSearchParams(location.search).get('opshopdig'); return v != null && v !== '0'; })(); const OPSHOP_DIG = (() => { const v = new URLSearchParams(location.search).get('opshopdig'); return v != null && v !== '0'; })();
if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type
const realAdapterFor = (type) => (STOCK_REAL ? makeStockAdapter(getStockPack(type)) : null); // [R41 §41.4 — THE WARDROBE] The op-shop pack lives under its OWN base (`assets/wardrobe/`) because it is
// not a GODVERSE crate: it is 52 named 1990s Australian garment layers off one 240 KB atlas, feeding the
// clothes rails and the shelves. Cache identity is (type, base) since R24, so it coexists with the
// town-wide packs. ONE fetch, only under ?stock=real — a default boot and ?noassets fetch nothing.
// → LANE F: the same one line in index.html turns it on in the game (LANE_C_NOTES §41).
const WARDROBE_BASE = 'assets/wardrobe/';
if (STOCK_REAL) preloadStockPack('opshop', { base: WARDROBE_BASE });
const realAdapterFor = (type) => (STOCK_REAL
? makeStockAdapter(getStockPack(type, type === 'opshop' ? WARDROBE_BASE : undefined))
: null);
// ── buy loop v0: in-memory wallet + cash HUD + inventory (I). F wires the same wallet on the street. ── // ── buy loop v0: in-memory wallet + cash HUD + inventory (I). F wires the same wallet on the street. ──
const wallet = createWallet(1990); const wallet = createWallet(1990);
@ -156,7 +168,9 @@ function rebuild() {
const lot = parseLot($('lot').value); const lot = parseLot($('lot').value);
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot }; const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined, current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined,
stock: STOCK_REAL ? 'real' : undefined, fittingOpts: OPSHOP_DIG ? { rummageBin: { dig: true } } : undefined }); stock: STOCK_REAL ? 'real' : undefined,
stockBase: (STOCK_REAL && type === 'opshop') ? WARDROBE_BASE : undefined, // [R41] the wardrobe pack
fittingOpts: OPSHOP_DIG ? { rummageBin: { dig: true } } : undefined });
scene.add(current.group); scene.add(current.group);
// place the player at the spawn // place the player at the spawn
camera.position.set(current.spawn.x, 1.6, current.spawn.z); camera.position.set(current.spawn.x, 1.6, current.spawn.z);
@ -302,14 +316,28 @@ function move(dt) {
// ── draw sweep: worst-room draw count must be ≤350 (round-6 law, decision #2). Asserts so it // ── draw sweep: worst-room draw count must be ≤350 (round-6 law, decision #2). Asserts so it
// can't regress; F mirrors this in the qa harness. Measures every type × archetype, GLB off AND on. // can't regress; F mirrors this in the qa harness. Measures every type × archetype, GLB off AND on.
const DRAW_LAW = 350; const DRAW_LAW = 350;
// [R41 §41.4] THE SWEEP WAS MEASURING A ROOM THAT WASN'T THERE. It removed every interior in the scene
// EXCEPT `current` — the live room the page is showing — so every per-type number carried a constant
// offset from whatever room the page last built (seed 1990, type = the first entry, a record shop). It
// looked harmless while nothing changed; the moment §41.4 furnished the record shop, `toy` — a type this
// round never touches — "gained" 4 draws, in both modes. The sweep now clears the scene of ALL interiors
// and puts `current` back at the end (rebuild()), so a type's number is that type's room and nothing else.
// Numbers therefore drop slightly against R39/R40 prints of this same instrument: that is the phantom
// leaving, not the rooms getting cheaper.
async function drawSweep(opts = {}) { async function drawSweep(opts = {}) {
const wasCurrent = current; if (current) { current.dispose(); current = null; clearDebug(); }
for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) if (s !== (current && current.group)) scene.remove(s); for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) scene.remove(s);
let manifest = null; let manifest = null;
if (opts.glb) { manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); } if (opts.glb) { manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); }
// [R41] `opts.stock` measures the ?stock=real arm too — real covers/garments come off shared atlases as
// `buyMesh` meshes that batch.js deliberately leaves alone, so they are the one stock path whose draws
// scale with fittings rather than with materials. Worth its own number now that a clothes rail carries
// twelve of them. Needs the packs preloaded (the page does that under ?stock=real).
const measure = async (type, arch) => { const measure = async (type, arch) => {
const r = buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE, const r = buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE,
{ archetype: arch, useGLB: opts.glb, manifest }); { archetype: arch, useGLB: opts.glb, manifest, noKit: opts.noKit,
stock: opts.stock ? 'real' : undefined,
stockBase: (opts.stock && type === 'opshop') ? WARDROBE_BASE : undefined });
scene.add(r.group); if (opts.glb) await r.glbReady; scene.add(r.group); if (opts.glb) await r.glbReady;
camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8); camera.lookAt(0, 0.9, -r.dims.D * 0.15); camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8); camera.lookAt(0, 0.9, -r.dims.D * 0.15);
renderer.info.reset(); renderer.render(scene, camera); renderer.info.reset(); renderer.render(scene, camera);
@ -587,6 +615,8 @@ function frame() {
// expose for headless verification (screenshot harness, workflow checks) // expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer, window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
KIT, POOLS, BOOTHS, CONTROL_NODES, WARDROBE_BASE,
kitAudit: (m) => kitAudit(m || glbManifest), // [R41] catalogue-vs-manifest drift + trap asserts
DIG_ON, STOCK_REAL, OPSHOP_DIG, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter, DIG_ON, STOCK_REAL, OPSHOP_DIG, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak, shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak,
SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST }; SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST };

View File

@ -42,6 +42,14 @@ function flushBuckets(ctx, target, buckets, stockTag, bake) {
for (const m of b.meshes) m.parent && m.parent.remove(m); for (const m of b.meshes) m.parent && m.parent.remove(m);
const mm = new THREE.Mesh(merged, b.material); const mm = new THREE.Mesh(merged, b.material);
mm.userData = stockTag ? { isStock: true, batched: true } : { isFrame: true, batched: true }; mm.userData = stockTag ? { isStock: true, batched: true } : { isFrame: true, batched: true };
// [R41 §41.4] CARRY UNANIMOUS SUB-TAGS THROUGH THE MERGE. A frame mesh can be marked for later
// selective hiding (`boothGear` — the primitive DJ gear a GLB replaces; `tillPrimitive` — the till
// that `cash_register` replaces). Merging rebuilt userData from scratch and dropped those marks, so
// the hide that depends on them silently became a no-op. Only propagate when EVERY mesh in the
// bucket carries the tag: a bucket is one material, and a mark that isn't unanimous isn't a mark.
if (!stockTag)
for (const tag of ['boothGear', 'tillPrimitive'])
if (b.meshes.every(m => m.userData && m.userData[tag])) mm.userData[tag] = true;
target.add(mm); target.add(mm);
saved += b.meshes.length - 1; saved += b.meshes.length - 1;
} }

View File

@ -349,7 +349,11 @@ function counter(ctx, o = {}, r) {
// Counter-top attach points (local space, on the benchtop top surface) for GLB counter-top props // Counter-top attach points (local space, on the benchtop top surface) for GLB counter-top props
// (cash_register on the till spot; milkshake_mixer on the free left spot). y = benchtop top. // (cash_register on the till spot; milkshake_mixer on the free left spot). y = benchtop top.
const counterTop = { y: h + 0.03, till: { x: w / 2 - 0.35, z: 0 }, appliance: { x: -w / 2 + 0.4, z: -0.02 } }; const counterTop = { y: h + 0.03, till: { x: w / 2 - 0.35, z: 0 }, appliance: { x: -w / 2 + 0.4, z: -0.02 } };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g], counterTop }; // [R41] `o.glbId` overrides the default `counter_till` swap for one recipe — the pub's bar is Lane E's
// `plywood_bar` (1.51 × 0.82 × 1.05), which is what a band-room bar actually looks like. Recipe field
// `counterGlb`; absent ⇒ counter_till exactly as before.
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g], counterTop,
glbId: o.glbId || null };
} }
// Glass display case — timber base + tinted glass top, a few "treasures" inside. // Glass display case — timber base + tinted glass top, a few "treasures" inside.
@ -510,6 +514,244 @@ function ampStack(ctx, o = {}, r) {
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] }; return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
} }
// ── R41 §41.4 — THE KIT (Lane E's 110 new interior GLBs) ─────────────────────────────────
//
// ONE builder covers all 110. It picks an id from a pool (`kit41.js POOLS`) on the fitting's own seeded
// stream, reads that id's REAL footprint/height out of the catalogue, and builds a cheap primitive
// silhouette at exactly those metres. Two things follow, and they are the whole reason it is shaped this
// way:
// • `?noassets=1` gets a real, correctly-sized, walkable shop floor with ZERO fetches (the catalogue is
// a hardcoded table, and buildInterior is synchronous — see kit41.js's header on why not "read the
// manifest");
// • the occupancy grid reserves the GLB's own footprint, so the swap in glb.js needs no rescale and the
// path/browse guarantees computed before the depot answers stay true after it does.
//
// The chosen id rides out on `fitting.glbId`, which `glb.js upgradeFitting` prefers over the legacy
// per-kind `KIND_TO_GLB` map. Draw cost when the GLB is live: ONE, for every id in the catalogue.
import { KIT, POOLS, BOOTHS, CONTROL_NODES } from './kit41.js';
// Seeded muted palettes per form family — the placeholder should read as the right KIND of object even
// before (or without) its GLB, which is what makes ?noassets a playable build rather than a grey box.
const KITCOL = {
tub: ['#2c2f36', '#d8d5cc', '#2f5aa8', '#7a5a3a', '#5a6a55'],
rack: ['#6d4a30', '#8a7a5a', '#4a4f57', '#9c8d76'],
stack: ['#3a3f47', '#6d4a30', '#7a6a55'],
board: ['#9c8d76', '#c9c2b2', '#6d4a30'],
unit: ['#b8bcc4', '#8a8d94', '#5a4632', '#c8c2b4'],
cab: ['#2a2028', '#3a2a30', '#242a34'],
table: ['#6d4a30', '#4a3524', '#8a7a5a'],
seat: ['#6a2a2a', '#2a3a5a', '#4a3a2a', '#3a4a3a'],
barrel: ['#8a8d94', '#3a4a3a', '#6a5a3a', '#b0b4ba'],
post: ['#4a4f57', '#8a3a2a', '#8a8d94'],
slab: ['#22242a', '#3a3f47', '#6d5a3a'],
speaker: ['#16161a', '#1e1e24'],
deck: ['#26262c', '#3a3a42', '#b8bcc4'],
panel: ['#7a6a4a', '#4a4f57', '#c9c2b2'],
column: ['#c9c2b2', '#a8a49a', '#8a8d94'],
trolley: ['#8a8d94', '#4a4f57'],
};
// Primitive silhouettes. Each draws into `g` at base y=0, footprint centred on (0,0), "front" = +Z.
const KITFORM = {
tub(ctx, g, w, d, h, m, r) {
const th = 0.022, lip = h * 0.78;
ctx.box(w, th, d, m, 0, th / 2, 0, g); // floor
ctx.box(th, h, d, m, -w / 2 + th / 2, h / 2, 0, g); ctx.box(th, h, d, m, w / 2 - th / 2, h / 2, 0, g);
ctx.box(w, h, th, m, 0, h / 2, -d / 2 + th / 2, g); ctx.box(w, h * 0.86, th, m, 0, h * 0.43, d / 2 - th / 2, g);
ctx.box(w - 0.05, th, d - 0.05, m, 0, lip, 0, g); // goods ride up and crest the rim
},
rack(ctx, g, w, d, h, m, r) {
const leg = ctx.mat('#4a4038', 0.7);
const tiers = h > 0.95 ? 3 : 2;
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) =>
ctx.box(0.05, h, 0.05, leg, sx * (w / 2 - 0.04), h / 2, sz * (d / 2 - 0.04), g));
for (let i = 0; i < tiers; i++) ctx.box(w, 0.035, d, m, 0, 0.14 + i * ((h - 0.16) / Math.max(1, tiers - 1 || 1)), 0, g);
ctx.box(w, h * 0.5, 0.03, m, 0, h * 0.25, -d / 2 + 0.015, g); // back panel
},
stack(ctx, g, w, d, h, m, r) {
const n = 4, ch = h / n, th = 0.02;
for (let i = 0; i < n; i++) {
const dd = d * (1 - i * 0.14);
ctx.box(w, th, dd, m, 0, i * ch + th / 2, (d - dd) / 2 * -0.5, g);
ctx.box(w, ch * 0.9, th, m, 0, i * ch + ch * 0.45, -dd / 2, g);
ctx.box(th, ch * 0.9, dd, m, -w / 2, i * ch + ch * 0.45, (d - dd) / 2 * -0.5, g);
ctx.box(th, ch * 0.9, dd, m, w / 2, i * ch + ch * 0.45, (d - dd) / 2 * -0.5, g);
}
},
board(ctx, g, w, d, h, m, r) {
const t = Math.max(0.03, Math.min(h, 0.05));
ctx.box(w, t, Math.max(d, 0.22), m, 0, t / 2, 0.08, g); // the plank
if (h > 0.4) { ctx.box(w, h, 0.03, m, 0, h / 2, -0.02, g); ctx.box(w, t, Math.max(d, 0.22), m, 0, h - t / 2, 0.08, g); }
[-1, 1].forEach(s => ctx.box(0.04, Math.min(h, 0.24), 0.2, ctx.mat('#4a4038', 0.7), s * (w / 2 - 0.08), -0.06, 0.02, g));
},
unit(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
ctx.box(w + 0.03, 0.04, d + 0.03, ctx.mat('#3a3730', 0.7), 0, h, 0, g); // capping
ctx.box(w * 0.8, h * 0.5, 0.02, ctx.mat('#2a2a30', 0.6), 0, h * 0.42, d / 2 + 0.01, g);
},
cab(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
const lit = ctx.mat('#e8c05a', 0.4, { emissive: new ctx.THREE.Color('#4a3a10') });
ctx.box(w * 0.86, h * 0.34, 0.03, lit, 0, h * 0.62, d / 2 + 0.015, g); // lit glass
ctx.box(w * 0.7, 0.1, 0.05, ctx.mat('#1a1a1e', 0.5), 0, h * 0.34, d / 2 + 0.02, g);
},
table(ctx, g, w, d, h, m, r) {
ctx.box(w, 0.05, d, m, 0, h, 0, g);
const leg = ctx.mat('#4a4038', 0.7);
if (Math.abs(w - d) < 0.25) ctx.cyl(0.05, h, leg, 0, h / 2, 0, g); // round bistro: centre post
else [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) =>
ctx.box(0.06, h, 0.06, leg, sx * (w / 2 - 0.1), h / 2, sz * (d / 2 - 0.1), g));
if (Math.abs(w - d) < 0.25) ctx.cyl(w * 0.28, 0.04, leg, 0, 0.02, 0, g); // and a foot
},
seat(ctx, g, w, d, h, m, r) {
ctx.box(w, h * 0.5, d, m, 0, h * 0.25, 0, g); // base
ctx.box(w, h * 0.55, d * 0.22, m, 0, h * 0.62, -d / 2 + d * 0.11, g); // back
ctx.box(w, 0.1, d * 0.8, m, 0, h * 0.5, d * 0.06, g); // cushion
},
barrel(ctx, g, w, d, h, m, r) {
ctx.cyl(Math.min(w, d) / 2, h, m, 0, h / 2, 0, g);
ctx.cyl(Math.min(w, d) / 2 * 0.94, 0.03, ctx.mat('#3a3730', 0.6), 0, h, 0, g);
},
post(ctx, g, w, d, h, m, r) {
ctx.cyl(Math.min(0.06, Math.min(w, d) / 2), h, m, 0, h / 2, 0, g);
ctx.cyl(Math.min(w, d) / 2, h * 0.16, m, 0, h - h * 0.08, 0, g); // head
ctx.cyl(Math.min(w, d) / 2 * 0.9, 0.03, m, 0, 0.015, 0, g); // foot
},
slab(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
ctx.box(w + 0.02, 0.03, d + 0.02, ctx.mat('#b8bcc4', 0.4, { metalness: 0.6 }), 0, h * 0.7, 0, g);
},
speaker(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
const gr = ctx.mat('#0c0c10', 0.6);
ctx.box(w - 0.08, h * 0.3, 0.02, gr, 0, h * 0.3, d / 2, g);
ctx.box(w - 0.12, h * 0.14, 0.02, gr, 0, h * 0.78, d / 2, g);
},
deck(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
ctx.cyl(Math.min(w, d) * 0.32, 0.012, ctx.mat('#b8bcc4', 0.3, { metalness: 0.7 }), -w * 0.06, h + 0.006, 0, g);
ctx.box(w * 0.1, 0.012, d * 0.5, ctx.mat('#1a1a1e', 0.5), w * 0.36, h + 0.006, 0, g);
},
panel(ctx, g, w, d, h, m, r) {
ctx.box(w, h, Math.max(d, 0.03), m, 0, h / 2, 0, g);
ctx.box(w * 0.86, h * 0.7, 0.01, ctx.mat('#d9cfae', 0.8), 0, h * 0.52, Math.max(d, 0.03) / 2 + 0.006, g);
},
column(ctx, g, w, d, h, m, r) {
ctx.box(w, h, d, m, 0, h / 2, 0, g);
ctx.box(w + 0.08, 0.1, d + 0.08, m, 0, 0.05, 0, g);
ctx.box(w + 0.08, 0.1, d + 0.08, m, 0, h - 0.05, 0, g);
},
trolley(ctx, g, w, d, h, m, r) {
for (let i = 0; i < 3; i++) ctx.box(w, 0.03, d, m, 0, 0.14 + i * ((h - 0.16) / 2), 0, g);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => {
ctx.box(0.04, h, 0.04, m, sx * (w / 2 - 0.03), h / 2, sz * (d / 2 - 0.03), g);
ctx.cyl(0.05, 0.03, ctx.mat('#2a2a30', 0.5), sx * (w / 2 - 0.03), 0.05, sz * (d / 2 - 0.03), g);
});
},
};
// Where stock sits on each form (local coords), so a kit fitting is FULL, not an empty shell. Every slot
// is `pool:true` — the R39 trick: pooled white faces merge into the room's existing stock buckets, so a
// heaped tub costs ~0 extra draws (an un-pooled tinted fill cost one draw per item).
function kitSlots(form, w, d, h, kind, count) {
if (!kind) return [];
// A 12" SLEEVE IS 0.31 m SQUARE — a constant of the physical world, not a fraction of whatever tub it
// is standing in. Deriving it from the tub height (h * 1.3) put 0.42 m "records" in a 0.32 m crate and
// they read as posters; `recordBin`/`crate` have used 0.280.32 since v1 and this now matches them.
const SLEEVE_H = 0.30;
if (form === 'tub')
return [kind === 'sleeve'
? { kind: 'sleeve', x: 0, y: h * 0.72, z: 0, faceW: Math.min(0.31, w * 0.86), run: d * 0.84, height: SLEEVE_H, count, tilt: -0.32 }
: { kind: 'box', x: 0, y: h * 0.78, z: 0, ry: 0, run: w - 0.06, depth: d - 0.06, height: h * 0.8, count, lean: false, scatter: true, pool: true }];
if (form === 'rack' || form === 'trolley') {
// TIER HEIGHTS ARE FRACTIONS OF h, NOT AN EVEN LADDER, AND THEY SIT *IN* THE TUBS. Most of the rack
// pool is `3dstore` TUB RACKS — open tubs on a frame — so goods parked at y = h stand ON TOP of the
// tub rims and the tub itself reads as an empty wireframe box (it is thin-walled and open; at a
// grazing angle you see straight through it). 0.82h puts the sleeves inside the upper tub with the
// top third cresting the rim, which is what a record tub actually looks like, and it fills the hole.
const ys = form === 'trolley' ? [h * 0.92, h * 0.55, h * 0.18]
: (h > 0.95 ? [h * 0.86, h * 0.55, h * 0.22] : [h * 0.82, h * 0.28]);
return ys.map(y => (kind === 'sleeve'
? { kind: 'sleeve', x: 0, y, z: 0, faceW: Math.min(0.31, w * 0.9), run: d * 0.78, height: SLEEVE_H, count, tilt: -0.3 }
: { kind, x: 0, y: y + 0.02, z: 0, ry: 0, run: w - 0.1, depth: d * 0.68, height: 0.26, count, lean: kind === 'spine', pool: true }));
}
if (form === 'stack') {
const out = [];
for (let i = 0; i < 4; i++) out.push({ kind: 'sleeve', x: 0, y: (h / 4) * i + h / 4 * 0.62, z: 0, faceW: Math.min(0.31, w * 0.86), run: d * 0.6, height: SLEEVE_H * 0.85, count: Math.max(3, (count / 2) | 0), tilt: -0.28 });
return out;
}
if (form === 'board')
return [{ kind: kind === 'sleeve' ? 'box' : kind, x: 0, y: 0.06, z: 0.06, ry: 0, run: w - 0.14, depth: 0.2, height: 0.28, count, lean: false, pool: true }];
return [];
}
// Build one kit fitting. `o.pool` = an id list from kit41 POOLS (required — no pool ⇒ a plain shelf).
// o.dig true ⇒ userData.kind 'bin' (the dig fires on it). DEFAULT FALSE: R39 held "what an op shop
// yields" for John, so only recipes that already dig (record, stall) ever pass it.
// o.stock 'sleeve'|'box'|'spine'|'garment' — what stock.js fills it with (null ⇒ empty prop)
// o.wall true ⇒ wall-mounted (no floor footprint stamped); o.mountY = base height
// o.maxRun clamp: ids whose longest floor dimension exceeds this are filtered out of the pool BEFORE
// the seeded pick (TRAP 3, generalised — a 4.6 m board in a 6 m room).
function kitProp(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const pool = (o.pool || []).filter(id => KIT[id] && (!o.maxRun || Math.max(KIT[id][0], KIT[id][1]) <= o.maxRun));
const id = pool.length ? pool[(r() * pool.length) | 0] : null;
const row = id ? KIT[id] : [0.6, 0.4, 0.6, 'unit'];
// WALL FITTINGS ARE X-MAJOR BY HOUSE CONVENTION (pegboard/barredWindow/artFrame all are, and
// placeAtWall's slot.ry is what turns local +X along the wall). Several kit ids are authored Z-major
// (`shelf_board_short` is 0.48 × 1.95), so a wall row is transposed here — and glb.js's autoYaw then
// reads the same swapped footprint and gives the GLB the matching quarter turn. Floor props are NOT
// transposed: the placer's own ry orients them.
const swap = !!o.wall && row[1] > row[0];
const [w, d, h, form] = swap ? [row[1], row[0], row[2], row[3]] : row;
const pal = KITCOL[form] || KITCOL.unit;
const m = ctx.mat(pal[(r() * pal.length) | 0], form === 'speaker' || form === 'deck' ? 0.6 : 0.82,
form === 'barrel' || form === 'post' ? { metalness: 0.25 } : {});
(KITFORM[form] || KITFORM.unit)(ctx, g, w, d, h, m, r);
const slots = kitSlots(form, w, d, h, o.stock, o.items ?? 8);
const diggable = o.dig === true && (form === 'tub' || form === 'rack' || form === 'stack');
g.userData = diggable ? { kind: 'bin', interactable: true } : { kind: o.uk || 'prop' };
const fit = {
group: g, footprint: { w: w + 0.08, d: d + 0.08 }, height: h, slots,
places: diggable ? [g] : [], glbId: id,
};
if (o.wall) { fit.wallMounted = true; fit.mountY = o.mountY ?? 1.45; }
return fit;
}
// A DJ booth: a primitive bench with several GLBs standing ON it (`fitting.glbParts`, the one multi-GLB
// shape in the lane — DJ gear is desktop kit, a 1200 is 0.11 m tall). R41 PLACES the rigged models and
// records their control-node names; NOTHING DRIVES THEM this round (that is D/F's later work), and this
// builder deliberately ships no animation hook so nobody mistakes a placed node for a live one.
function djBooth(ctx, o = {}, r) {
const spec = BOOTHS[o.booth] || BOOTHS.venue;
const g = new ctx.THREE.Group();
const { w, d, h } = spec;
const body = ctx.mat(r() < 0.5 ? '#3a3128' : '#2a2a30', 0.8);
ctx.box(w, h, d, body, 0, h / 2, 0, g); // the bench
ctx.box(w + 0.06, 0.05, d + 0.06, ctx.mat('#1c1a18', 0.7), 0, h, 0, g); // bench top edge
ctx.box(w - 0.1, 0.28, d * 0.55, ctx.mat('#1a1a1e', 0.6), 0, h * 0.32, -d * 0.1, g); // rack space under
const nodes = {};
// primitive stand-ins on the bench top, drawn straight into `g` on ONE shared material so batch.js
// merges them (a child Group per part would have escaped the batcher and cost a draw each).
const gear = ctx.mat('#26262c', 0.6), plate = ctx.mat('#b8bcc4', 0.3, { metalness: 0.7 });
for (const p of spec.parts) {
const [pw, pd, ph] = KIT[p.id] || [0.3, 0.3, 0.1];
// `boothGear` marks the stand-in that a GLB part REPLACES. The bench underneath is deliberately not
// marked: a booth's GLBs are the gear only, so hiding the whole primitive (what a single-GLB swap
// does) left a 1200 and a mixer hovering at 0.92 m over bare floorboards. Measured on screen, R41.
ctx.box(pw, ph, pd, gear, p.x, h + ph / 2, p.z, g).userData.boothGear = true;
ctx.cyl(Math.min(pw, pd) * 0.3, 0.012, plate, p.x - pw * 0.06, h + ph + 0.006, p.z, g).userData.boothGear = true;
if (CONTROL_NODES[p.id]) nodes[p.id] = CONTROL_NODES[p.id];
}
g.userData = { kind: 'prop', djBooth: o.booth || 'venue', controlNodes: nodes };
return {
group: g, footprint: { w: w + 0.12, d: d + 0.12 }, height: h + 0.25, slots: [], places: [],
// consumed by glb.js: one GLB per part, planted on the bench top (y = h).
glbParts: spec.parts.map(p => ({ id: p.id, x: p.x, y: h, z: p.z, ry: p.ry || 0 })),
controlNodes: nodes,
};
}
export const FITTINGS = { export const FITTINGS = {
recordBin, crate, crateStack, listeningCorner, rummageBin, recordBin, crate, crateStack, listeningCorner, rummageBin,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet, wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
@ -517,6 +759,13 @@ export const FITTINGS = {
trestleTable, counter, glassCase, trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame, pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
stage, paSpeaker, ampStack, stage, paSpeaker, ampStack,
// [R41 §41.4] the kit. Registered under several NAMES that all build the same way, because
// layout.js keys each spec's seeded stream on `fit-<kind>-<i>` — two specs sharing a kind in one recipe
// would share a stream and therefore pick the SAME id. One name per pool per recipe keeps them distinct.
kitBin: kitProp, kitRack: kitProp, kitWall: kitProp, kitWindow: kitProp, kitSleeve: kitProp,
kitTrolley: kitProp, kitColumn: kitProp,
kitSeat: kitProp, kitFloor: kitProp, kitVenue: kitProp, kitHot: kitProp, kitGear: kitProp,
djBooth,
}; };
// Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash). // Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash).

View File

@ -66,48 +66,122 @@ export function preloadManifest(url = 'assets/manifest.json') {
} }
export function manifestReady() { return _manifestP && typeof _manifestP.then === 'function' ? _manifestP : Promise.resolve(null); } export function manifestReady() { return _manifestP && typeof _manifestP.then === 'function' ? _manifestP : Promise.resolve(null); }
// Upgrade one placed fitting in-place, if the manifest maps its kind. No-op when disabled/unavailable. // [R41 §41.4] AUTO-FACING for the kit. The 110 new fittings carry no facing convention
// `manifest` is the resolved object (or null). ctx is the room build context (for the _disposed guard). // (manifest.conventions.facing: "Lane B/C rotate directional props"), and several of them are the SAME
// Returns a promise that settles when the swap is done (or immediately when there is nothing to do), so // MESH ROTATED 90° from a sibling — so a hand-tuned yaw table would be 110 rows of guesswork. Instead the
// callers can await a room's GLB upgrades (buildInterior exposes this as room.glbReady). // yaw is MEASURED: the primitive placeholder was built at the manifest footprint, so if the GLB's own
// footprint is major-axis-swapped relative to the slot the grid reserved, it needs a quarter turn. Square
// props (aspect within 10%) are orientation-agnostic and get 0 — a rule, not a table.
function autoYaw(entry, fitting) {
const [ew, ed] = entry.footprint || [0, 0];
const fw = fitting.footprint.w, fd = fitting.footprint.d;
if (!ew || !ed || !fw || !fd) return 0;
if (Math.abs(ew - ed) / Math.max(ew, ed) < 0.1) return 0; // square: nothing to align
if (Math.abs(fw - fd) / Math.max(fw, fd) < 0.1) return 0; // slot is square: either way fits
return (ew > ed) === (fw > fd) ? 0 : Math.PI / 2;
}
// Load + seat one GLB inside a fitting group. Shared by the whole-fitting swap and the multi-part booth.
// `yaw` is decided by the caller (legacy kinds keep their hand-tuned KIND_TO_YAW; kit ids get autoYaw).
// `at` (optional) = { x, y, z } local placement — the booth's bench-top slots. Without it the GLB takes the
// fitting's own origin and is clamped to the footprint the occupancy grid reserved.
const refOf = (entry, manifest) => (manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`);
function seatGLB(ctx, fitting, entry, manifest, kind, yaw, at) {
return loadGLB(refOf(entry, manifest)).then(gltf => placeGLB(ctx, fitting, gltf, kind, yaw, at));
}
// Instantiate a loaded gltf into the fitting group. SPLIT OUT FROM THE FETCH ON PURPOSE (R41): the
// multi-part booths must attach their parts in DECLARATION order, not in whichever order the network
// finished — see upgradeFitting.
function placeGLB(ctx, fitting, gltf, kind, yaw, at) {
if (!gltf || ctx._disposed || !fitting.group.parent) return null;
const THREE = ctx.THREE;
const inst = skeletonClone(gltf.scene); // clone-safe even if skinned
inst.rotation.y = yaw || 0; // face before measuring
// Scale: GLBs are authored in metres (glb_law), so keep REAL-WORLD scale — only shrink if the prop
// would overflow the footprint the occupancy grid reserved (never inflate a small prop to fill a slot).
// A bench-top part is never clamped: it is already smaller than the bench by construction.
const bb = new THREE.Box3().setFromObject(inst);
const size = new THREE.Vector3(); bb.getSize(size);
const glbW = Math.max(size.x, size.z) || 1;
const s = at ? 1 : Math.min(1, Math.max(fitting.footprint.w, fitting.footprint.d) / glbW);
inst.scale.setScalar(s);
inst.position.set(at ? at.x : 0, (at ? at.y : 0) - bb.min.y * s, at ? at.z : 0); // plant on the surface
inst.userData = { glbUpgrade: true, kind };
fitting.group.add(inst);
return inst;
}
// Upgrade one placed fitting in-place, if the manifest maps its kind (or the fitting names its own id).
// No-op when disabled/unavailable. `manifest` is the resolved object (or null). ctx is the room build
// context (for the _disposed guard). Returns a promise that settles when the swap is done (or immediately
// when there is nothing to do), so callers can await a room's GLB upgrades (room.glbReady).
// Resolution: production uses `depot:<file>` (loaders.js → depot CDN / local-depot). A manifest may set // Resolution: production uses `depot:<file>` (loaders.js → depot CDN / local-depot). A manifest may set
// `localBase` (e.g. "assets/_local_glb/") to load straight from a served local dir — used for offline // `localBase` (e.g. "assets/_local_glb/") to load straight from a served local dir — used for offline
// GLB validation before the depot is reachable. // GLB validation before the depot is reachable.
export function upgradeFitting(ctx, fitting, kind, manifest) { export function upgradeFitting(ctx, fitting, kind, manifest) {
if (!manifest || !manifest.fittings) return Promise.resolve(); if (!manifest || !manifest.fittings) return Promise.resolve();
const id = KIND_TO_GLB[kind];
// [R41] MULTI-PART fittings (the DJ booths): several GLBs standing on one primitive bench. The bench
// frame is hidden exactly as a single swap hides it, so the booth reads as gear-on-a-desk either way.
if (fitting.glbParts && fitting.glbParts.length) {
const parts = fitting.glbParts
.map(p => ({ p, e: manifest.fittings[p.id] }))
.filter(o => o.e && o.e.file);
if (!parts.length) return Promise.resolve();
// FETCH IN PARALLEL, ATTACH IN DECLARATION ORDER. Attaching inside each load's own `.then` made the
// booth's children land in whichever order the network finished, which is not a function of the seed:
// two runs of the same 240 rooms differed on exactly the three types that have a booth (record, pawn,
// pub), and on nothing else. Same objects, same transforms, different child order — enough to break
// "byte-equal across two builds", which is the law this lane is held to. Load is concurrent; the
// second pass is a plain in-order loop, so it is deterministic by construction, not by luck.
return Promise.all(parts.map(o => loadGLB(refOf(o.e, manifest)))).then(gltfs => {
if (ctx._disposed) return;
let any = false;
for (let i = 0; i < parts.length; i++)
if (placeGLB(ctx, fitting, gltfs[i], parts[i].p.id, parts[i].p.ry || 0, parts[i].p)) any = true;
// Hide the GEAR stand-ins only — the bench they stand on IS the fitting and has no GLB of its own.
if (any) for (const c of fitting.group.children) if (c.userData && c.userData.boothGear) c.visible = false;
});
}
// [R41] a fitting may name its OWN manifest id (`fitting.glbId`, chosen on its seeded stream from a
// kit41 POOL). That is what turns one primitive builder into 110 distinct shop fittings. The legacy
// per-kind map is the fallback for the pre-R41 fittings, which name no id.
const id = fitting.glbId || KIND_TO_GLB[kind];
const entry = id && manifest.fittings[id]; const entry = id && manifest.fittings[id];
if (!entry || !entry.file) return Promise.resolve(); if (!entry || !entry.file) return Promise.resolve();
const ref = manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`; // Facing: a pre-R41 kind keeps its hand-tuned yaw EXACTLY (KIND_TO_YAW[kind] || 0 — the R4/R6 on-screen
return loadGLB(ref).then(gltf => { // validation); only a kit id, which has no tuned row and could be either half of a rotated pair, is
if (!gltf || ctx._disposed || !fitting.group.parent) return; // room rebuilt / disposed mid-flight // auto-faced. Legacy rooms are therefore byte-identical on screen.
const THREE = ctx.THREE; const yaw = fitting.glbId ? autoYaw(entry, fitting) : (KIND_TO_YAW[kind] || 0);
const inst = skeletonClone(gltf.scene); // clone-safe even if skinned return seatGLB(ctx, fitting, entry, manifest, fitting.glbId ? id : kind, yaw).then(inst => {
// Facing: rotate the directional prop to match the primitive the layout placed (before measuring). if (inst) hidePrimitive(fitting);
inst.rotation.y = KIND_TO_YAW[kind] || 0;
// Scale: GLBs are authored in metres (glb_law), so keep REAL-WORLD scale — only shrink if the prop
// would overflow the footprint the occupancy grid reserved (never inflate a small prop to fill a slot).
const bb = new THREE.Box3().setFromObject(inst);
const size = new THREE.Vector3(); bb.getSize(size);
const want = fitting.footprint;
const glbW = Math.max(size.x, size.z) || 1;
const s = Math.min(1, Math.max(want.w, want.d) / glbW);
inst.scale.setScalar(s);
inst.position.y -= bb.min.y * s; // plant on the floor (glb_law minY≈0)
// hide the primitive FRAME (fixture) but keep the procedural STOCK visible on top of the detailed
// GLB shelf/counter. Post-batch (batch.js) the children are merged meshes tagged isStock / isFrame;
// pre-batch they're individual meshes (stock tagged isStock, frame untagged). Hide everything that
// isn't stock and isn't the GLB we're adding.
for (const c of fitting.group.children) {
if (c.userData && (c.userData.isStock || c.userData.glbUpgrade)) continue;
c.visible = false;
}
inst.userData = { glbUpgrade: true, kind };
fitting.group.add(inst);
}); });
} }
// Hide the primitive FRAME (fixture) but keep the procedural STOCK visible on top of the detailed GLB
// shelf/counter. Post-batch (batch.js) the children are merged meshes tagged isStock / isFrame; pre-batch
// they're individual meshes (stock tagged isStock, frame untagged). Hide everything that isn't stock and
// isn't a GLB we added.
//
// [R41 §41.4 — A PRE-EXISTING BUG THIS ROUND WALKED INTO] `buyMesh` was missing from that list. R9's
// buy-anywhere shelves (stockpack.buildBuyableShelf) are merged, per-item-addressable meshes tagged
// `{ noBatch, buyMesh }` and NOT `isStock` — because batch.js already skips them on `noBatch` — so on any
// fitting whose kind has a GLB (bookshelf, cubeShelf, clothesRack) the swap turned every real,
// buyable item in the room INVISIBLE. It never showed up because `?stock=real` is opt-in and R9 measured
// it with GLBs off; R41 found it the moment 52 real garments went on a clothes rail and the rail came
// back empty. Fixed here rather than by tagging them isStock, because the tag is what tells batch.js
// how to treat a mesh and these must stay unbatched.
function hidePrimitive(fitting) {
for (const c of fitting.group.children) {
if (c.userData && (c.userData.isStock || c.userData.buyMesh || c.userData.glbUpgrade)) continue;
c.visible = false;
}
}
// Place counter-top GLB props (cash_register, milkshake_mixer) onto a counter fitting's benchtop. // Place counter-top GLB props (cash_register, milkshake_mixer) onto a counter fitting's benchtop.
// Additive: does NOT hide the counter primitive frame (upgradeFitting does that when counter is GLB-live). // Additive: does NOT hide the counter primitive frame (upgradeFitting does that when counter is GLB-live).
// `counterHasGlb`: when the counter itself upgrades to counter_till (which carries its OWN modelled till), // `counterHasGlb`: when the counter itself upgrades to counter_till (which carries its OWN modelled till),

View File

@ -200,7 +200,9 @@ export function buildInterior(shop, THREE, opts) {
// [R39] opts.fittingOpts = { <fittingKind>: {…} } — per-build overrides of a recipe's own fitting // [R39] opts.fittingOpts = { <fittingKind>: {…} } — per-build overrides of a recipe's own fitting
// opts (`spec.o`). Pure data, no stream reads; omitted ⇒ every recipe's declared defaults, i.e. // opts (`spec.o`). Pure data, no stream reads; omitted ⇒ every recipe's declared defaults, i.e.
// byte-identical to pre-R39. The one live use is `{ rummageBin: { dig: true } }` — see §39. // byte-identical to pre-R39. The one live use is `{ rummageBin: { dig: true } }` — see §39.
const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm, fittingOpts: opts.fittingOpts }); // [R41] opts.noKit — the control arm for §41.4's 110 new fittings (see layout.js). Diagnostics only.
const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm, fittingOpts: opts.fittingOpts,
noKit: opts.noKit });
// DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes // DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes
// inside each fitting group into a few merged geometries. Fitting groups (and their userData) are // inside each fitting group into a few merged geometries. Fitting groups (and their userData) are
@ -242,6 +244,8 @@ export function buildInterior(shop, THREE, opts) {
placement: lay.placement, placement: lay.placement,
pathOK: lay.pathOK, pathOK: lay.pathOK,
carved: lay.carved, carved: lay.carved,
pulls: lay.pulls, // [R41] { pulled, restored, lost } — what the door→counter path
// guarantee cost this room. lost > 0 means a real blocker.
glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled
buildMs, buildMs,
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx }, _debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },

290
web/js/interiors/kit41.js Normal file
View File

@ -0,0 +1,290 @@
// PROCITY Lane C — THE R41 FITTING CATALOGUE (§41.4).
//
// Lane E landed 110 new interior GLBs in `web/assets/manifest.json` (fittings 23 → 133): 40 purpose-built
// record/op-shop fittings from `3dstore`, 39 decimated pub/venue props, 31 DJ-gear models. This file is
// the ONE place that knows what they are, how big they are and which shop they belong in.
//
// WHY A HARDCODED TABLE AND NOT "READ THE MANIFEST":
// `?noassets=1` must run 100% primitive with ZERO fetches, and `buildInterior` is SYNCHRONOUS — the room
// is built the instant the door opens, long before any manifest promise could resolve. So the primitive
// placeholder has to know the prop's real-world footprint WITHOUT the manifest, or the occupancy grid
// reserves a slot the GLB doesn't fit. Every row below is transcribed from the manifest entry it names
// (LANE_E_NOTES §41 tables); `tools`-free drift is caught by `kitAudit()` at the bottom, which the test
// page runs against the live manifest and reports any mismatch.
//
// COST DISCIPLINE (the round's binding number): E fought this cargo from 14,140 draws down to 117 total,
// and the interior margin is 162 draws against the ≤350 law. **Every id below is 1 draw** except the four
// rigged DJ heroes (25). So a pool entry costs one draw when the GLB is live and ~0 when it isn't (the
// primitive frame batches). Spend by COUNT, not by asset.
//
// THE THREE TRAPS E FLAGGED, and what this file does about each:
// 1. `-long`/`-wide` pairs are THE SAME MESH ROTATED 90° (`tubs_4x2_wide`/`_vertical`, `tubs_3x3`/`_long`,
// `tubs_shelf_long`/`_wide`, `wooden_rack_long`/`_wide`, `counter_station`/`_side`). Only ONE of each
// pair is ever named in a POOL — shipping both into one room is paying twice for one prop. `PAIRS`
// records them and `kitAudit()` asserts no pool contains both halves.
// 2. `vinyl_sleeve` is a 3 mm sleeve whose footprint depth is FLOORED to 0.01 m. It is wall/surface
// dressing only — it appears in `sleeve_wall` (wallMounted) and in no floor pool.
// 3. `beam_roof` (6.5 m) and `shelf_board_long` (4.6 m) / `wall_shelves_big` (4.65 m) are LONGER THAN MOST
// INTERIORS ARE WIDE (archetypes run 612 m wide, 714 m deep, and a wall fitting must also clear the
// door corridor). They are in NO pool. `maxRun` on the wall pools is the general form of that rule.
//
// Row form: [id, w, d, h, form] — w×d = the manifest footprint in metres, h = manifest height, `form` names
// the primitive silhouette `fittings.js kitProp()` builds as the asset-free placeholder.
// ── the catalogue ────────────────────────────────────────────────────────────────────────
// form vocabulary (all built base-at-y=0, footprint centred on local (0,0), +Z = the "front"):
// tub shallow open tub / crate, goods ride up and crest the rim
// rack tiered open rack (legs + boards)
// stack vertical column of tubs
// board wall-hung shelf board (wallMounted)
// unit plain cabinet/box body
// cab tall lit cabinet (pokie / jukebox)
// table top + legs
// seat bench/couch (base + back)
// barrel cylinder (keg, bin, urn, drum, topiary)
// post slim vertical post with a small head
// slab low flat box (pallet, case, sub)
// speaker tall black cabinet with grille faces
// deck flat desktop unit with a disc/plate (turntables, samplers, mixers)
// panel thin upright board (window display, barrier, A-frame)
// column tall square structural post
// trolley shelved castored trolley
export const KIT = {
// ── 3dstore · floor record bins (kind `bin`) ──────────────────────────────────────────
tub_black: [0.36, 0.53, 0.229, 'tub'],
tub_white: [0.34, 0.53, 0.229, 'tub'],
tub_black_long: [0.53, 0.36, 0.229, 'tub'], // = tub_black rotated (PAIRS)
tub_white_long: [0.53, 0.36, 0.229, 'tub'], // = tub_white rotated (PAIRS)
crate_blue: [0.34, 0.30, 0.322, 'tub'],
bin_endoffunk: [0.36, 0.34, 0.34, 'tub'],
chiu_bin: [0.37, 0.43, 0.35, 'tub'],
chiu_bin_half: [0.24, 0.43, 0.35, 'tub'],
chiu_bin_low: [0.34, 0.43, 0.35, 'tub'],
rack_bin_wooden: [0.34, 0.40, 0.338, 'tub'],
mos_bin_bottom: [0.34, 0.40, 0.34, 'tub'],
mos_bin_lower_mid:[0.34, 0.36, 0.34, 'tub'],
mos_bin_upper_mid:[0.34, 0.26, 0.34, 'tub'],
mos_bin_top: [0.34, 0.20, 0.34, 'tub'],
// ── 3dstore · multi-tier racks (kind `rack`) ──────────────────────────────────────────
chiu_rack_long: [1.88, 0.43, 0.795, 'rack'],
chiu_rack_short: [1.38, 0.43, 0.795, 'rack'],
tubs_3x3: [0.53, 1.30, 0.555, 'rack'],
tubs_3x3_long: [1.30, 0.53, 0.51, 'rack'], // = tubs_3x3 rotated (PAIRS)
tubs_4x2_wide: [1.56, 0.53, 0.475, 'rack'],
tubs_4x2_vertical:[0.53, 1.56, 0.475, 'rack'], // = tubs_4x2_wide rotated (PAIRS)
tubs_shelf_long: [1.56, 0.55, 0.565, 'rack'],
tubs_shelf_wide: [0.55, 1.56, 0.575, 'rack'], // = tubs_shelf_long rotated (PAIRS)
wooden_rack_wide: [1.80, 0.78, 0.93, 'rack'],
wooden_rack_long: [0.78, 1.80, 0.81, 'rack'], // = wooden_rack_wide rotated (PAIRS)
bin_big_4x4: [0.59, 1.50, 1.255, 'rack'],
mos_stack: [0.37, 0.40, 1.275, 'stack'],
// ── 3dstore · wall / surface / structure ──────────────────────────────────────────────
wall_shelves_long:[2.60, 0.05, 1.10, 'board'],
shelf_board_short:[0.48, 1.95, 0.03, 'board'],
aircon_unit: [1.15, 0.25, 0.35, 'unit'],
vinyl_sleeve: [0.32, 0.01, 0.32, 'panel'], // TRAP 2 — 3 mm, wall dressing only
window_booth: [1.85, 0.09, 0.72, 'panel'],
library_trolley: [0.41, 0.88, 0.74, 'trolley'],
counter_station: [0.88, 0.50, 0.87, 'unit'],
counter_station_side:[0.50, 0.88, 0.87, 'unit'], // = counter_station rotated (PAIRS)
column_square: [0.22, 0.22, 2.60, 'column'],
column_wide: [0.36, 0.46, 2.60, 'column'],
// ── pub / venue props (kind `pub`) ────────────────────────────────────────────────────
pokie: [0.75, 0.78, 1.70, 'cab'],
jukebox: [0.97, 0.41, 1.52, 'cab'],
plywood_bar: [1.51, 0.82, 1.05, 'unit'],
bain_marie: [0.89, 0.76, 0.95, 'unit'],
pie_warmer: [0.61, 0.71, 0.55, 'unit'],
banquette: [1.55, 1.02, 0.85, 'seat'],
old_couch: [1.80, 0.95, 0.85, 'seat'],
lounge_chair: [0.69, 0.69, 0.80, 'seat'],
bistro_table: [1.18, 1.18, 0.92, 'table'],
picnic_table: [1.38, 1.41, 0.75, 'table'],
keg: [0.40, 0.40, 0.60, 'barrel'],
bin_round: [0.53, 0.51, 0.75, 'barrel'],
ash_urn: [0.32, 0.33, 0.75, 'barrel'],
raffle_drum: [0.37, 0.37, 0.90, 'barrel'],
topiary: [0.61, 0.61, 1.20, 'barrel'],
planter: [0.98, 0.96, 0.55, 'barrel'],
champ_bucket: [0.24, 0.20, 0.35, 'barrel'],
can_stack: [0.38, 0.32, 0.45, 'barrel'],
ashtray_post: [0.41, 0.41, 0.95, 'post'],
rope_post: [0.32, 0.32, 1.00, 'post'],
fire_ext: [0.34, 0.19, 0.60, 'post'],
concrete_plinth: [0.76, 0.74, 1.20, 'column'],
marble_sink: [1.15, 0.56, 0.85, 'unit'],
wheelie_bin: [0.72, 0.92, 1.10, 'unit'],
distro_box: [0.63, 0.87, 1.20, 'unit'],
flight_case: [0.60, 0.36, 0.75, 'slab'],
pallet: [0.79, 0.73, 0.20, 'slab'],
cable_snake: [0.45, 0.47, 0.20, 'slab'],
sub_bass: [0.87, 0.63, 0.70, 'slab'],
speaker_stack: [0.52, 0.52, 1.80, 'speaker'],
crowd_barrier: [1.97, 0.30, 1.10, 'panel'],
sandwich_board: [0.58, 0.56, 1.00, 'panel'],
smoke_machine: [0.17, 0.24, 0.38, 'slab'],
strobe_light: [0.22, 0.24, 0.40, 'slab'],
beer_umbrella: [2.68, 2.65, 2.30, 'post'],
firepit: [0.76, 0.56, 0.50, 'barrel'],
broken_basin: [0.59, 0.49, 0.60, 'unit'],
urinal_trough: [0.31, 0.26, 0.70, 'unit'],
// ── DJ gear (kind `djgear`) — desktop items; they ride a bench, they are not floor props ──
deck_1200_rigged: [0.51, 0.56, 0.11, 'deck'],
deck_1200: [0.45, 0.51, 0.12, 'deck'],
sl_deck_1: [0.25, 0.23, 0.04, 'deck'],
sl_deck_2: [0.22, 0.22, 0.04, 'deck'],
sl_deck_3: [0.25, 0.26, 0.04, 'deck'],
sl_deck_4: [0.25, 0.33, 0.04, 'deck'],
cdj_deck: [0.63, 0.42, 0.15, 'deck'],
mixer_ttm54i_rigged: [0.23, 0.42, 0.11, 'deck'],
mixer_pmc05pro_rigged:[0.26, 0.40, 0.11, 'deck'],
mixer_shdj1200_rigged:[0.26, 0.39, 0.11, 'deck'],
mixer_ttm54i: [0.23, 0.42, 0.11, 'deck'],
mixer_pmc06pro: [0.15, 0.47, 0.07, 'deck'],
mixer_pmc10: [0.48, 0.22, 0.22, 'deck'],
mixer_pmx9000: [0.48, 0.37, 0.18, 'deck'],
drum_808: [0.53, 0.47, 0.15, 'deck'],
drum_tr1000: [0.47, 0.37, 0.16, 'deck'],
sampler_mpc3000: [0.47, 0.59, 0.12, 'deck'],
sampler_s900: [0.47, 0.56, 0.135, 'deck'],
sampler_sp1200: [0.44, 0.62, 0.19, 'deck'],
headphones_hd25: [0.23, 0.16, 0.30, 'slab'],
cart_case: [0.22, 0.18, 0.21, 'slab'],
zip_drive: [0.14, 0.23, 0.04, 'slab'],
};
// TRAP 1 — the same mesh, rotated 90°. `kitAudit()` asserts no pool ships both halves of a pair.
export const PAIRS = [
['tub_black', 'tub_black_long'], ['tub_white', 'tub_white_long'],
['tubs_3x3', 'tubs_3x3_long'], ['tubs_4x2_wide', 'tubs_4x2_vertical'],
['tubs_shelf_long', 'tubs_shelf_wide'], ['wooden_rack_wide', 'wooden_rack_long'],
['chiu_rack_long', 'chiu_rack_short'], // same rack, two lengths — one per room reads better
['counter_station', 'counter_station_side'],
['mixer_ttm54i', 'mixer_ttm54i_rigged'], // the static copy of a hero: never both in one room
];
// TRAP 3 — ids longer than a small interior is wide. Named here so the rule is visible, not implied.
export const OVERSIZE = { beam_roof: 6.5, shelf_board_long: 4.6, wall_shelves_big: 4.65 };
// The four DRIVABLE models and their control-node contracts (LANE_E_NOTES §41). R41 PLACES them and
// records the names; it does NOT drive them — the driver is a later round's work (D/F). Drive by the BARE
// node name (the cap/stem/rib are parented under it); glTF drops the source's limit constraints, so any
// future driver must CLAMP THE TRAVEL ITSELF.
export const CONTROL_NODES = {
deck_1200_rigged: ['Platter_SPIN', 'Arm_YAW', 'Fader_PITCH', 'Btn_STARTSTOP'],
mixer_ttm54i_rigged: ['Fader_CROSS'],
mixer_pmc05pro_rigged: ['Fader_CROSS'],
mixer_shdj1200_rigged: ['Fader_CROSS'],
};
// ── the pools — WHICH shop gets WHAT ──────────────────────────────────────────────────────
// A pool is just an ordered id list; `kitProp` picks one on the fitting's own seeded stream. Ordered
// lists (not objects) so the seeded pick is stable regardless of JS key order.
export const POOLS = {
// record / stall floor bins — these mint `kind:'bin'` (diggable) only where the recipe says `dig:true`
record_bins: ['tub_black', 'tub_white', 'crate_blue', 'bin_endoffunk', 'chiu_bin', 'chiu_bin_low',
'rack_bin_wooden', 'mos_bin_bottom'],
// op-shop / bargain floor tubs — same silhouette family, dig OFF (R39: what an op shop yields is John's call)
bargain_tubs: ['tub_white', 'tub_black', 'crate_blue', 'chiu_bin_half', 'mos_bin_lower_mid', 'rack_bin_wooden'],
// browsable multi-tier racks (the shop-floor rows)
shop_racks: ['chiu_rack_long', 'tubs_shelf_long', 'tubs_4x2_wide', 'wooden_rack_wide', 'tubs_3x3_long',
'bin_big_4x4', 'mos_stack'],
// wall-hung shelving — `maxRun` on the spec is the general form of TRAP 3 (nothing longer than the wall)
wall_boards: ['wall_shelves_long', 'shelf_board_short'],
// wall services (no stock rides on these — an air-con with books on it is not a shop)
wall_service: ['aircon_unit'],
// record-shop wall dressing: sleeves pinned up (TRAP 2 — wall only, never the floor)
sleeve_wall: ['vinyl_sleeve'],
// shopfront window ledge / display
window_dress: ['window_booth'],
// book shop
library: ['library_trolley'],
// dept-store hall structure
columns: ['column_wide', 'column_square', 'concrete_plinth'],
// ── pub / cafe / milkbar ──
pub_seating: ['banquette', 'old_couch', 'lounge_chair', 'bistro_table', 'picnic_table'],
pub_floor: ['pokie', 'jukebox', 'keg', 'wheelie_bin', 'bin_round', 'ash_urn', 'ashtray_post',
'fire_ext', 'rope_post', 'sandwich_board', 'raffle_drum', 'can_stack', 'planter',
'topiary', 'marble_sink', 'pallet', 'champ_bucket', 'flight_case'],
rsl_floor: ['pokie', 'raffle_drum', 'bistro_table', 'planter', 'topiary', 'bin_round', 'ash_urn',
'rope_post', 'jukebox'],
milkbar_hot: ['bain_marie', 'pie_warmer'],
milkbar_floor: ['can_stack', 'bin_round', 'sandwich_board', 'champ_bucket'],
// stage-side venue kit
venue_kit: ['speaker_stack', 'sub_bass', 'flight_case', 'distro_box', 'cable_snake', 'crowd_barrier',
'smoke_machine', 'strobe_light', 'pallet'],
// pawn-shop "unredeemed pledges" — gear on the floor
pawn_gear: ['flight_case', 'cart_case', 'distro_box', 'sub_bass'],
};
// ── DJ booths — the ONE place a fitting carries several GLBs ────────────────────────────────
// DJ gear is DESKTOP kit (a 1200 is 0.11 m tall): it needs a surface, so the booth is a primitive bench
// with the models sitting ON it. `parts` are local offsets on the bench top; `glb.js` attaches one GLB per
// part. Draw cost is stated per booth because this is the round's only multi-draw fitting.
export const BOOTHS = {
// RECORD SHOP HERO — behind the counter. The only room in the game that gets the RIGGED pair.
// 5 (deck_1200_rigged) + 2 (mixer_ttm54i_rigged) + 1 (deck_1200 static) = 8 draws.
record_hero: {
w: 1.55, d: 0.62, h: 0.92, draws: 8,
parts: [
{ id: 'deck_1200_rigged', x: -0.48, z: 0, ry: 0 },
{ id: 'mixer_ttm54i_rigged', x: 0.00, z: 0, ry: 0 },
{ id: 'deck_1200', x: 0.48, z: 0, ry: 0 },
],
},
// VENUE BOOTH — statics only. E: "a static copy is 1 draw and looks the same standing still." 3 draws.
venue: {
w: 1.40, d: 0.58, h: 0.95, draws: 3,
parts: [
{ id: 'cdj_deck', x: -0.42, z: 0, ry: 0 },
{ id: 'mixer_pmx9000', x: 0.06, z: 0, ry: 0 },
{ id: 'sl_deck_3', x: 0.50, z: 0, ry: 0 },
],
},
// PAWN COUNTER — the unredeemed-pledges bench. Drum machine + sampler, statics. 2 draws.
pawn: {
w: 1.25, d: 0.55, h: 0.88, draws: 2,
parts: [
{ id: 'drum_808', x: -0.32, z: 0, ry: 0 },
{ id: 'sampler_sp1200', x: 0.30, z: 0, ry: 0 },
],
},
};
export const kitEntry = (id) => KIT[id] || null;
// ── the drift check ────────────────────────────────────────────────────────────────────────
// Measure the thing, not the note about the thing: this compares every row above against the LIVE
// manifest and against the trap rules. The test page exposes it as PROCITY_C.kitAudit(manifest).
// Returns { rows, checked, dimErrors[], missing[], pairErrors[], oversize[] } — empty arrays = clean.
export function kitAudit(manifest) {
const out = { rows: Object.keys(KIT).length, checked: 0, dimErrors: [], missing: [], pairErrors: [], oversize: [] };
const F = (manifest && manifest.fittings) || null;
if (F) {
for (const [id, row] of Object.entries(KIT)) {
const e = F[id];
if (!e) { out.missing.push(id); continue; }
out.checked++;
const near = (a, b) => Math.abs(a - b) < 0.005;
if (!near(row[0], e.footprint[0]) || !near(row[1], e.footprint[1]) || !near(row[2], e.height))
out.dimErrors.push({ id, table: [row[0], row[1], row[2]], manifest: [e.footprint[0], e.footprint[1], e.height] });
}
for (const id of Object.keys(OVERSIZE)) if (!F[id]) out.missing.push(id + ' (oversize row)');
}
// TRAP 1: no pool may contain both halves of a rotated pair.
for (const [name, pool] of Object.entries(POOLS))
for (const [a, b] of PAIRS)
if (pool.includes(a) && pool.includes(b)) out.pairErrors.push(`${name}: ${a} + ${b}`);
// TRAP 3: no pool (and no booth) may name an oversize id.
const boothIds = Object.values(BOOTHS).flatMap(b => b.parts.map(p => p.id));
for (const [name, pool] of Object.entries(POOLS))
for (const id of pool) if (OVERSIZE[id]) out.oversize.push(`${name}: ${id}`);
for (const id of boothIds) { if (OVERSIZE[id]) out.oversize.push(`booth: ${id}`); if (!KIT[id]) out.missing.push(`booth part ${id}`); }
for (const pool of Object.values(POOLS)) for (const id of pool) if (!KIT[id]) out.missing.push(`pool id ${id}`);
return out;
}

View File

@ -101,7 +101,9 @@ function placeCounter(ctx, recipe, W, D, r) {
} else { // 'corner' — back-east, facing into the room } else { // 'corner' — back-east, facing into the room
x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z }; x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z };
} }
const f = buildFitting('counter', ctx, { w: cw }, r); // [R41] `recipe.counterGlb` overrides the counter's GLB swap for one type (the venues' `plywood_bar`).
// Pure data — it reads no stream, so a recipe without it is byte-identical to pre-R41.
const f = buildFitting('counter', ctx, { w: cw, glbId: recipe.counterGlb }, r);
f.group.position.set(x, 0, z); f.group.rotation.y = ry; f.group.position.set(x, 0, z); f.group.rotation.y = ry;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
const stand = keeperStand(x, z, front, W, D); const stand = keeperStand(x, z, front, W, D);
@ -315,11 +317,23 @@ function makeWallSlots(W, D, r) {
} }
// ── free-standing candidate positions per zone ──────────────────────────────────────── // ── free-standing candidate positions per zone ────────────────────────────────────────
function zoneCandidates(zone, W, D, doorCx, r) { function zoneCandidates(zone, W, D, doorCx, r, stage) {
const out = []; const out = [];
if (zone === 'window') { if (zone === 'window') {
const z = D / 2 - 1.4; const z = D / 2 - 1.4;
for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI }); for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI });
} else if (zone === 'stage') {
// [R41 §41.4] THE STAGE FLANKS — venue-only. Before this, a PA stack or a crowd barrier drawn from the
// venue pool went into `aisle`, whose candidate grid spans the whole room, so the pub's gear landed
// wherever there was space: a 1.97 m crowd barrier ended up across the doorway. Gear belongs BESIDE
// the stage. Deliberately NOT in front of it: the strip from `stage.frontZ + 0.6` to `D/2 1.6` is
// where placeWatchPoints stands the crowd, and furniture there is furniture instead of an audience.
// No stage (a non-venue recipe that asks for this zone) ⇒ no candidates ⇒ the spec's fallbacks run.
if (!stage) return out;
const fx = Math.min(W / 2 - 0.7, stage.w / 2 + 0.75);
for (const sx of [-1, 1])
for (let z = stage.z - stage.d / 2; z <= stage.frontZ + 0.9; z += 0.8)
out.push({ x: sx * fx, z, ry: sx > 0 ? -Math.PI / 2 : Math.PI / 2 });
} else if (zone === 'aisle') { } else if (zone === 'aisle') {
for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7) for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7)
for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 }); for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 });
@ -331,7 +345,7 @@ function zoneCandidates(zone, W, D, doorCx, r) {
} }
// ── main entry ──────────────────────────────────────────────────────────────────────── // ── main entry ────────────────────────────────────────────────────────────────────────
export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts }) { export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts, noKit }) {
const { W, D } = dims; const { W, D } = dims;
const doorCx = shell.door.x; const doorCx = shell.door.x;
const grid = makeGrid(W, D); const grid = makeGrid(W, D);
@ -367,7 +381,13 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed). // 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
const area = W * D; const area = W * D;
const wallSlots = makeWallSlots(W, D, rWall); const wallSlots = makeWallSlots(W, D, rWall);
for (const spec of recipe.fittings) { // [R41 §41.4] `opts.noKit` is the KIT'S OWN CONTROL ARM, and it is exact rather than approximate: every
// R41 spec is appended LAST in its recipe, so dropping them here leaves each surviving spec the same
// draws off the same `lay-count`/`lay-place` streams in the same order — a `noKit` room is byte-identical
// to the pre-R41 room, which is what makes "the kit cost N draws" a measurement instead of a claim.
// Diagnostics only (like opts.noBatch); nothing in the game sets it.
const specs = noKit ? recipe.fittings.filter(s => !isKitSpec(s)) : recipe.fittings;
for (const spec of specs) {
if (spec.kind === 'counter') continue; if (spec.kind === 'counter') continue;
// count scales with clutter × area, clamped to [min,max] // count scales with clutter × area, clamped to [min,max]
const density = recipe.clutter * (area / 70); const density = recipe.clutter * (area / 70);
@ -384,9 +404,9 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
if (spec.zone === 'wall' || f.wallMounted) { if (spec.zone === 'wall' || f.wallMounted) {
ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec); ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec);
} else if (spec.zone === 'counter') { } else if (spec.zone === 'counter') {
ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec, W, D, doorCx); ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec, W, D, doorCx, stageInfo);
} else { } else {
ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec); ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec, stageInfo);
} }
// [R39] OPT-IN placement fallbacks, for a fitting a recipe promises will BE there (the op shop's // [R39] OPT-IN placement fallbacks, for a fitting a recipe promises will BE there (the op shop's
// rummage bin). Default is unchanged: no fallback list ⇒ a fitting that can't find its zone is // rummage bin). Default is unchanged: no fallback list ⇒ a fitting that can't find its zone is
@ -395,7 +415,15 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
// rooms. The chain runs on the SAME `rPlace` stream and only ever fires for a spec that opted in // rooms. The chain runs on the SAME `rPlace` stream and only ever fires for a spec that opted in
// AND already failed, so no other fitting's draws move. // AND already failed, so no other fitting's draws move.
if (!ok && spec.fallbackZones) if (!ok && spec.fallbackZones)
for (const z of spec.fallbackZones) { ok = placeFree(ctx, grid, f, z, W, D, doorCx, rPlace, placed, spec); if (ok) break; } for (const z of spec.fallbackZones) {
// [R41] 'wall' in a fallback chain has to route to placeAtWall — the wall is a SLOT POOL, not a
// candidate grid, so `zoneCandidates('wall')` returns [] and a 'wall' fallback was a silent
// no-op. No pre-R41 spec lists it, so this only ever adds a recovery that used to be missing.
ok = z === 'wall'
? placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec)
: placeFree(ctx, grid, f, z, W, D, doorCx, rPlace, placed, spec, stageInfo);
if (ok) break;
}
if (!ok && spec.anywhere) ok = placeAnywhere(ctx, grid, f, W, D, rPlace, placed, spec); if (!ok && spec.anywhere) ok = placeAnywhere(ctx, grid, f, W, D, rPlace, placed, spec);
if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ } if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ }
} }
@ -409,18 +437,46 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
const removable = () => placed const removable = () => placed
.filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor .filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor
.sort((a, b) => a.priority - b.priority); .sort((a, b) => a.priority - b.priority);
const pulled = [];
while (!pathOK) { while (!pathOK) {
const pool = removable(); const pool = removable();
if (!pool.length) break; if (!pool.length) break;
const victim = pool[0]; const victim = pool[0];
placed.splice(placed.indexOf(victim), 1); const at = placed.indexOf(victim);
placed.splice(at, 1);
if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group); if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
victim.removed = true; victim.removed = true;
pulled.push({ v: victim, at });
rebuildOcc(grid, placed); rebuildOcc(grid, placed);
pathOK = reaches(grid, spawnCell, targetCell); pathOK = reaches(grid, spawnCell, targetCell);
} }
if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); } if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
// 3b) [R41 §41.4] PUT BACK EVERYTHING THAT WASN'T THE BLOCKER.
// The pull loop above takes the LOWEST-PRIORITY survivor each pass, not the one standing in the
// corridor — so it clears the cheap furniture first and only reaches the actual blocker by attrition.
// Measured: `pub/wide/4242` came back with a bar, a stage and NOTHING ELSE — no tables, no PA, no R41
// kit — because a wide pub puts the bar beside the stage and leaves one ~1 m gap to squeeze through,
// and restoring that gap cost every innocent fitting in the room. That was already true before this
// round; ten more fittings per pub just made it impossible to miss.
// The fix is additive and self-checking: walk the pulled list in reverse, put each fitting back at its
// original index, and keep it ONLY if door→counter still reaches. A room that pulled nothing does
// nothing here (byte-identical); a room that pulled N can only end with MORE fittings than before,
// never fewer, and the path guarantee is re-proved on every single restore rather than assumed.
let restored = 0;
if (pathOK && pulled.length) {
for (let i = pulled.length - 1; i >= 0; i--) {
const { v, at } = pulled[i];
placed.splice(Math.min(at, placed.length), 0, v);
v.removed = false;
rebuildOcc(grid, placed);
if (reaches(grid, spawnCell, targetCell)) { restored++; continue; }
placed.splice(placed.indexOf(v), 1);
v.removed = true;
rebuildOcc(grid, placed);
}
}
// 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at // 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at
// placement time) so interactables belong only to fittings that actually made it into the room — // placement time) so interactables belong only to fittings that actually made it into the room —
// a fitting pulled by the path loop must not leave a phantom interactable in the returned list. // a fitting pulled by the path loop must not leave a phantom interactable in the returned list.
@ -453,6 +509,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
return { return {
placed: placed.filter(p => !p.removed), placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved, places, decor: decor.meshes, grid, pathOK, carved,
pulls: { pulled: pulled.length, restored, lost: pulled.length - restored }, // [R41] path-loop cost
spawnCell, targetCell, placement, browsePoints, spawnCell, targetCell, placement, browsePoints,
watchPoints, // venue: audience poses {x,z,ry,dance} facing the stage, cap by kind (pub/band_room 8, rsl 12); else [] watchPoints, // venue: audience poses {x,z,ry,dance} facing the stage, cap by kind (pub/band_room 8, rsl 12); else []
stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null) stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null)
@ -465,8 +522,8 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts })
} }
// ── placement strategies ────────────────────────────────────────────────────────────── // ── placement strategies ──────────────────────────────────────────────────────────────
function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) { function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec, stage) {
const cands = zoneCandidates(zone, W, D, doorCx, r); const cands = zoneCandidates(zone, W, D, doorCx, r, stage);
for (const c of cands) { for (const c of cands) {
const ry = c.ry + (r() - 0.5) * 0.4; const ry = c.ry + (r() - 0.5) * 0.4;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
@ -526,13 +583,19 @@ function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) {
return false; return false;
} }
function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx) { function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx, stage) {
// cases beside the till, on the goods side of the counter — try BOTH sides + a couple offsets, then // cases beside the till, on the goods side of the counter — try BOTH sides + a couple offsets, then
// fall back to a free-standing aisle spot so a min≥1 fitting (e.g. a glass case) reliably lands. // fall back to a free-standing aisle spot so a min≥1 fitting (e.g. a glass case) reliably lands.
const ry = counter.ry; const ry = counter.ry;
// [R41] `spec.gap` — how far along the bench to stand the neighbour. The hardcoded 1.4 m is the gap a
// glass case needs and is KEPT as the default, so every pre-R41 spec places byte-identically. It is far
// too small for a 1.55 m DJ booth beside a 2.2 m counter: their padded rects overlap by ~0.8 m, every
// candidate fails, and the record shop's hero prop landed in 1 room out of 15 (measured). An opt-in
// number, not a retuned constant — the fitting that needs more room asks for more room.
const gap = spec.gap || 1.4;
const cands = counter.ry === 0 const cands = counter.ry === 0
? [{ x: counter.x - 1.4, z: counter.z }, { x: counter.x + 1.4, z: counter.z }, { x: counter.x - 1.4, z: counter.z - 0.7 }, { x: counter.x + 1.4, z: counter.z - 0.7 }] ? [{ x: counter.x - gap, z: counter.z }, { x: counter.x + gap, z: counter.z }, { x: counter.x - gap, z: counter.z - 0.7 }, { x: counter.x + gap, z: counter.z - 0.7 }]
: [{ x: counter.x, z: counter.z - 1.4 }, { x: counter.x, z: counter.z + 1.4 }, { x: counter.x - 0.7, z: counter.z - 1.4 }, { x: counter.x - 0.7, z: counter.z + 1.4 }]; : [{ x: counter.x, z: counter.z - gap }, { x: counter.x, z: counter.z + gap }, { x: counter.x - 0.7, z: counter.z - gap }, { x: counter.x - 0.7, z: counter.z + gap }];
shuffle(r, cands); shuffle(r, cands);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
for (const side of cands) { for (const side of cands) {
@ -544,7 +607,7 @@ function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx)
return true; return true;
} }
// counter is boxed in — put it in the aisle instead (still front-of-room-ish) // counter is boxed in — put it in the aisle instead (still front-of-room-ish)
return placeFree(ctx, grid, f, 'aisle', W, D, doorCx, r, placed, spec); return placeFree(ctx, grid, f, 'aisle', W, D, doorCx, r, placed, spec, stage);
} }
// ── wall decor (art + signage) ───────────────────────────────────────────────────────── // ── wall decor (art + signage) ─────────────────────────────────────────────────────────
@ -627,3 +690,5 @@ function carveCorridor(grid, startIdx, targetIdx) {
clear(tx, tz); clear(tx, tz);
} }
const round = (v) => Math.round(v * 1000) / 1000; const round = (v) => Math.round(v * 1000) / 1000;
// The R41 kit's specs, by construction: every one of them is a `kit*` fitting or a `djBooth`.
const isKitSpec = (s) => s.kind === 'djBooth' || /^kit[A-Z]/.test(s.kind);

View File

@ -86,6 +86,13 @@ function garmentPool(ctx, r, n = 8) {
return ctx._garmentPool; return ctx._garmentPool;
} }
// Which pack items a slot may draw from. A multi-slot pack (R41's wardrobe) hands the caller an already
// filtered `pool`; a single-slot pack (record/book/toy) hands back `pack.items` by reference, and those
// keep the pre-R41 behaviour exactly — one seeded atlas bucket, one r() draw, one draw call.
function slotItems(pack, src, r) {
return (src && src.pool && src.pool !== pack.items) ? src.pool : pack.atlasPool(r);
}
// ── fill entry point ───────────────────────────────────────────────────────────────── // ── fill entry point ─────────────────────────────────────────────────────────────────
export function fill(ctx, fitting, opts, r) { export function fill(ctx, fitting, opts, r) {
for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r); for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r);
@ -118,12 +125,13 @@ function binFan(ctx, parent, slot, opts, r) {
const step = run / Math.max(1, n); const step = run / Math.max(1, n);
const src = opts.adapter && opts.adapter(opts.shop, 'sleeve'); const src = opts.adapter && opts.adapter(opts.shop, 'sleeve');
const pack = src && src.real ? src.pack : null; const pack = src && src.real ? src.pack : null;
const packItems = pack ? (src.pool || pack.items) : null; // [R41] slot-filtered when the pack has slots
const pool = pack ? null : facePool(ctx, 'card', r); const pool = pack ? null : facePool(ctx, 'card', r);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const z = -run / 2 + (i + 0.5) * step; const z = -run / 2 + (i + 0.5) * step;
let sl; let sl;
if (pack) { if (pack) {
sl = realSleeveMesh(ctx, pack, pick(r, pack.items), fw, h); // isStock + shared atlas material sl = realSleeveMesh(ctx, pack, pick(r, packItems), fw, h); // isStock + shared atlas material
sl.position.set(slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z); sl.position.set(slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z);
parent.add(sl); parent.add(sl);
} else { } else {
@ -174,7 +182,7 @@ function shelfLine(ctx, parent, slot, opts, r) {
if (kind === 'spine') { // books / VHS: thin spines edge-out if (kind === 'spine') { // books / VHS: thin spines edge-out
const step = (run - 0.04) / n; const step = (run - 0.04) / n;
if (pack) { // real book spines → one buyable, addressable mesh if (pack) { // real book spines → one buyable, addressable mesh
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw const poolItems = slotItems(pack, src, r); // single atlas → the whole slot merges to one draw
const pls = []; const pls = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const bh = clear * (0.8 + r() * 0.2); const bh = clear * (0.8 + r() * 0.2);
@ -213,12 +221,16 @@ function shelfLine(ctx, parent, slot, opts, r) {
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28); const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
const step = run / n; const step = run / n;
if (pack) { // real toy boxes → one buyable, addressable mesh if (pack) { // real toy boxes → one buyable, addressable mesh
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw const poolItems = slotItems(pack, src, r); // single atlas → the whole slot merges to one draw
const pls = []; const pls = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const x = slot.x - run / 2 + (i + 0.5) * step; const x = slot.x - run / 2 + (i + 0.5) * step;
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: bw * 0.88, h: bh }); const it = pick(r, poolItems);
// [R41] a cut-out item (a bucket hat, a pair of thongs) takes its OWN width from its atlas cell;
// a cover-art item has no `aspect` and keeps the fixed carton width it always had.
const iw = it.aspect ? Math.min(bw * 0.98, bh * it.aspect) : bw * 0.88;
pls.push({ item: it, x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: iw, h: bh });
} }
const meshes = buildBuyableShelf(ctx, pack, pls); const meshes = buildBuyableShelf(ctx, pack, pls);
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
@ -235,8 +247,30 @@ function shelfLine(ctx, parent, slot, opts, r) {
} }
// Garments hung along a rail. // Garments hung along a rail.
//
// [R41 §41.4 — ROUTE R5, THE WARDROBE, ZERO GEOMETRY] With the op-shop pack loaded (`?stock=real`), every
// garment on this rail is a real 1990s Australian layer off ONE shared atlas — so a rail of twelve is one
// draw, exactly like the record bins, and the op shop's clothes finally have names the buy card can show.
// The garment's WIDTH comes from its own atlas cell aspect, not a constant: a pair of phat pants is wider
// than a ribbed singlet, and forcing both into a 0.34 m plane was the tell that gave the old placeholder
// away. No pack ⇒ the pooled procedural canvas, unchanged, byte-for-byte (this is the ?noassets path).
function hang(ctx, parent, slot, opts, r) { function hang(ctx, parent, slot, opts, r) {
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52; const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
const src = opts.adapter && opts.adapter(opts.shop, 'garment');
const pack = src && src.real ? src.pack : null;
if (pack) {
const items = src.pool || pack.items;
const pls = [];
for (let i = 0; i < n; i++) {
const it = pick(r, items);
const gh = h * (0.9 + r() * 0.2);
const gw = gh * (it.aspect || 0.66);
pls.push({ item: it, x: slot.x - run / 2 + run * (i / (n - 1 || 1)), y: slot.y - 0.05 - gh / 2,
z: slot.z + (r() - 0.5) * 0.03, w: gw, h: gh, ry: (r() - 0.5) * 0.15 });
}
const meshes = buildBuyableShelf(ctx, pack, pls); // addressable → the shelf buy card reads it
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
}
const pool = garmentPool(ctx, r); const pool = garmentPool(ctx, r);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1)); const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1));

View File

@ -52,12 +52,21 @@ export function getStockPack(type, base = DEFAULT_STOCK_BASE) { return _resolved
async function buildPack(type, idx, base, THREE) { async function buildPack(type, idx, base, THREE) {
const names = idx.atlases || []; const names = idx.atlases || [];
const materials = {}; const materials = {};
// [R41 §41.4] `idx.material` — an OPTIONAL per-pack material block. The record/book/toy atlases are
// opaque cover art and carry none, so they build exactly the material they always did. The wardrobe
// atlas is 52 CUT-OUT garments on transparent ground: without alphaTest every tee is a rectangle, and
// without DoubleSide the back of a garment hanging on a rail is a hole. One field, no fork.
const mk = idx.material || null;
await Promise.all(names.map(async (name) => { await Promise.all(names.map(async (name) => {
const url = /^depot:|^https?:/.test(name) ? name : base + name; const url = /^depot:|^https?:/.test(name) ? name : base + name;
let tex; let tex;
try { tex = await _texLoader.loadAsync(url); } catch (e) { tex = null; } try { tex = await _texLoader.loadAsync(url); } catch (e) { tex = null; }
if (tex) { tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; } if (tex) { tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; }
materials[name] = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.72, map: tex || null }); materials[name] = new THREE.MeshStandardMaterial({
color: 0xffffff, roughness: (mk && mk.roughness) || 0.72, map: tex || null,
...(mk && mk.alphaTest ? { transparent: true, alphaTest: mk.alphaTest } : {}),
...(mk && mk.side === 'double' ? { side: THREE.DoubleSide } : {}),
});
})); }));
const first = names[0]; const first = names[0];
// Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a // Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a
@ -66,11 +75,20 @@ async function buildPack(type, idx, base, THREE) {
for (const name of names) byAtlas[name] = []; for (const name of names) byAtlas[name] = [];
for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it); for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it);
const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length); const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length);
// [R41] Items may declare which STOCK SLOT they serve (`it.slot`). The wardrobe pack holds two kinds in
// one atlas — garments that hang on a rail and small goods that sit on a shelf — because one atlas is
// one material is ONE DRAW for every piece of clothing in the room. Packs whose items carry no `slot`
// get a single bucket keyed by the pack's own canonical slot, i.e. exactly their old behaviour.
const bySlot = {};
for (const it of idx.items) (bySlot[it.slot || SLOT_FOR[type] || 'sleeve'] ||= []).push(it);
return { return {
type, type,
items: idx.items, items: idx.items,
atlases: nonEmpty.length ? nonEmpty : names, atlases: nonEmpty.length ? nonEmpty : names,
itemsByAtlas: byAtlas, itemsByAtlas: byAtlas,
bySlot,
// items serving one stock-slot kind (falls back to the whole pack, so a caller never gets nothing)
slotPool(slotKind) { const p = bySlot[slotKind]; return p && p.length ? p : idx.items; },
// shared material for an item's atlas (batching groups sleeves by this) // shared material for an item's atlas (batching groups sleeves by this)
material(item) { return materials[item.atlas] || materials[first]; }, material(item) { return materials[item.atlas] || materials[first]; },
// items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw) // items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw)
@ -80,16 +98,24 @@ async function buildPack(type, idx, base, THREE) {
}; };
} }
// Each pack type feeds exactly one stock slot kind so a record shop's pack never lands on a bric-a-brac // Each pack type feeds the stock slot kinds it belongs in, so a record shop's pack never lands on a
// shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves). // bric-a-brac shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves).
const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box' }; // [R41 §41.4] `opshop` serves TWO: garment (the clothes rails) and box (the shelves and bargain tubs) —
// the 441-layer wardrobe library is tops/bottoms AND hats/shoes/bags, and an op shop sells both off the
// same floor. Declared as an array; the three pre-R41 packs keep their single string.
const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box', opshop: ['garment', 'box'] };
// The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers // The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers
// (stock.binFan / stock.shelfLine / dig.js) pick a seeded subset. Other kinds → null → procedural. // (stock.binFan / stock.shelfLine / stock.hang / dig.js) pick a seeded subset. Other kinds → null →
// procedural. `pool` is the slot-filtered item list; a single-slot pack returns its whole item list, so
// the three pre-R41 packs are handed byte-identical data.
export function makeStockAdapter(pack) { export function makeStockAdapter(pack) {
if (!pack || !pack.items || !pack.items.length) return null; if (!pack || !pack.items || !pack.items.length) return null;
const want = SLOT_FOR[pack.type] || 'sleeve'; const want = SLOT_FOR[pack.type] || 'sleeve';
return (shop, slotKind) => (slotKind === want ? { real: true, pack } : null); const wants = Array.isArray(want) ? want : [want];
return (shop, slotKind) => (wants.includes(slotKind)
? { real: true, pack, pool: wants.length > 1 ? pack.slotPool(slotKind) : pack.items }
: null);
} }
// Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so // Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so
@ -115,6 +141,7 @@ function itemQuad(THREE, pack, pl) {
a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0);
if (pl.rz) g.rotateZ(pl.rz); if (pl.rz) g.rotateZ(pl.rz);
if (pl.rx) g.rotateX(pl.rx); if (pl.rx) g.rotateX(pl.rx);
if (pl.ry) g.rotateY(pl.ry); // [R41] hanging garments swing a few degrees on the rail
g.translate(pl.x, pl.y, pl.z); g.translate(pl.x, pl.y, pl.z);
return g; return g;
} }

View File

@ -26,6 +26,13 @@
// stockKind : what stock.js renders into this shop's carriers // stockKind : what stock.js renders into this shop's carriers
// signFlavour : seeded inside signage strings, themed // signFlavour : seeded inside signage strings, themed
// [R41 §41.4] The kit pools live in kit41.js; recipes name a pool, never an asset id, so a manifest change
// is one edit in one file. Every kit spec below is APPENDED LAST inside its recipe on purpose: layout.js
// draws counts from `lay-count` and placements from `lay-place` in array order, so a spec added at the END
// leaves every pre-existing fitting in that recipe the SAME draws, and therefore the same placement. (The
// R39 lesson, applied at ten times the size.)
import { POOLS } from './kit41.js';
export const ARCH = { cosy: 'cosy', gallery: 'gallery', wide: 'wide', hall: 'hall', pokey: 'pokey' }; export const ARCH = { cosy: 'cosy', gallery: 'gallery', wide: 'wide', hall: 'hall', pokey: 'pokey' };
const RECIPES = { const RECIPES = {
@ -44,6 +51,22 @@ const RECIPES = {
{ kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 }, { kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 },
{ kind: 'crateStack', zone: 'wall', min: 1, max: 2, priority: 2 }, // back-room dressing (crate_stack GLB) { kind: 'crateStack', zone: 'wall', min: 1, max: 2, priority: 2 }, // back-room dressing (crate_stack GLB)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: the 3dstore shop floor. Real record-shop fittings, not a scatter: bins to flick through in
// the aisle, tiered racks behind them, boards on the wall, a window ledge, sleeves pinned up. The
// record shop already digs (R39), so its bins mint `kind:'bin'` — this is the one type where more
// bins is unambiguously more game.
{ kind: 'kitBin', zone: 'aisle', min: 2, max: 4, priority: 4, fallbackZones: ['centre'],
o: { pool: POOLS.record_bins, dig: true, stock: 'sleeve', items: 12 } },
{ kind: 'kitRack', zone: 'aisle', min: 1, max: 3, priority: 4, fallbackZones: ['centre'],
o: { pool: POOLS.shop_racks, dig: true, stock: 'sleeve', items: 9 } },
{ kind: 'kitWall', zone: 'wall', min: 1, max: 2, priority: 3,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.5, stock: 'box', items: 5, maxRun: 3.0 } },
{ kind: 'kitWindow', zone: 'window', min: 0, max: 1, priority: 2, o: { pool: POOLS.window_dress } },
{ kind: 'kitSleeve', zone: 'wall', min: 1, max: 2, priority: 1,
o: { pool: POOLS.sleeve_wall, wall: true, mountY: 1.72 } }, // TRAP 2: vinyl_sleeve, wall only
// THE HERO: the rigged 1200 + a rigged TTM-54i behind the counter. 8 draws, one room, once.
{ kind: 'djBooth', zone: 'counter', min: 1, max: 1, priority: 6, gap: 2.35,
fallbackZones: ['aisle', 'centre'], anywhere: true, o: { booth: 'record_hero' } },
], ],
stockKind: 'sleeves', stockKind: 'sleeves',
signFlavour: ['NEW ARRIVALS', 'VINYL $5 EA', 'SORRY NO REFUNDS', '2ND HAND CDs', 'JAZZ · SOUL · FUNK'], signFlavour: ['NEW ARRIVALS', 'VINYL $5 EA', 'SORRY NO REFUNDS', '2ND HAND CDs', 'JAZZ · SOUL · FUNK'],
@ -72,6 +95,14 @@ const RECIPES = {
// diggable. WHAT IT YIELDS IS JOHN'S CALL — LANE_C_PUB §10. Do not flip the default in passing. // diggable. WHAT IT YIELDS IS JOHN'S CALL — LANE_C_PUB §10. Do not flip the default in passing.
{ kind: 'rummageBin', zone: 'aisle', min: 1, max: 1, priority: 4, { kind: 'rummageBin', zone: 'aisle', min: 1, max: 1, priority: 4,
fallbackZones: ['centre', 'window'], anywhere: true, o: { dig: false } }, fallbackZones: ['centre', 'window'], anywhere: true, o: { dig: false } },
// ── R41: bargain tubs + a wooden rack + a wall board. `dig` STAYS OFF — R39 measured the whole
// question ("op shops now dig RECORDS") and held the contents for John; nothing here reopens it.
{ kind: 'kitBin', zone: 'aisle', min: 1, max: 2, priority: 3, fallbackZones: ['centre', 'window'],
o: { pool: POOLS.bargain_tubs, stock: 'box', items: 10 } },
{ kind: 'kitRack', zone: 'wall', min: 1, max: 2, priority: 3, fallbackZones: ['aisle'],
o: { pool: POOLS.shop_racks, stock: 'box', items: 8 } },
{ kind: 'kitWall', zone: 'wall', min: 0, max: 1, priority: 2,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.5, stock: 'box', items: 5, maxRun: 3.0 } },
], ],
stockKind: 'garments', stockKind: 'garments',
signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'], signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'],
@ -110,6 +141,13 @@ const RECIPES = {
{ kind: 'spinnerRack', zone: 'centre', min: 1, max: 2, priority: 3 }, { kind: 'spinnerRack', zone: 'centre', min: 1, max: 2, priority: 3 },
{ kind: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 }, { kind: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: the returns trolley (3dstore `library_trolley`) + a rack of overflow + wall boards.
{ kind: 'kitTrolley', zone: 'aisle', min: 1, max: 1, priority: 3, fallbackZones: ['centre'],
o: { pool: POOLS.library, stock: 'spine', items: 9 } },
{ kind: 'kitRack', zone: 'wall', min: 1, max: 2, priority: 2, fallbackZones: ['aisle'],
o: { pool: POOLS.shop_racks, stock: 'spine', items: 9 } },
{ kind: 'kitWall', zone: 'wall', min: 1, max: 2, priority: 2,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.55, stock: 'spine', items: 8, maxRun: 3.0 } },
], ],
stockKind: 'spines', stockKind: 'spines',
signFlavour: ['ALL BOOKS $2', 'FICTION AM', 'RARE & COLLECTABLE', 'NO HAGGLING (haggle at till)', 'POETRY ↑'], signFlavour: ['ALL BOOKS $2', 'FICTION AM', 'RARE & COLLECTABLE', 'NO HAGGLING (haggle at till)', 'POETRY ↑'],
@ -130,6 +168,12 @@ const RECIPES = {
{ kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 }, { kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 },
{ kind: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 }, { kind: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: the ex-rental bargain tub by the door + wall boards + the window display.
{ kind: 'kitBin', zone: 'aisle', min: 1, max: 2, priority: 3, fallbackZones: ['centre', 'window'],
o: { pool: POOLS.bargain_tubs, stock: 'box', items: 10 } },
{ kind: 'kitWall', zone: 'wall', min: 1, max: 2, priority: 2,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.5, stock: 'box', items: 6, maxRun: 3.0 } },
{ kind: 'kitWindow', zone: 'window', min: 0, max: 1, priority: 2, o: { pool: POOLS.window_dress } },
], ],
stockKind: 'spines', stockKind: 'spines',
signFlavour: ['NEW RELEASES', 'OVERNIGHT $3', 'BE KIND REWIND', 'WEEKLY $2', 'DROP RETURNS HERE'], signFlavour: ['NEW RELEASES', 'OVERNIGHT $3', 'BE KIND REWIND', 'WEEKLY $2', 'DROP RETURNS HERE'],
@ -150,6 +194,14 @@ const RECIPES = {
{ kind: 'magazineRack', zone: 'wall', min: 1, max: 1, priority: 2 }, // magazine_rack GLB { kind: 'magazineRack', zone: 'wall', min: 1, max: 1, priority: 2 }, // magazine_rack GLB
{ kind: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 }, { kind: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: unredeemed pledges. Flight cases and a sub on the floor; an 808 and an SP-1200 on the
// bench behind the grille — statics, 2 draws, because a pawnbroker's gear is not a working booth.
{ kind: 'kitGear', zone: 'aisle', min: 1, max: 2, priority: 3, fallbackZones: ['centre'],
o: { pool: POOLS.pawn_gear } },
{ kind: 'djBooth', zone: 'counter', min: 1, max: 1, priority: 4, gap: 2.1,
fallbackZones: ['aisle', 'centre'], anywhere: true, o: { booth: 'pawn' } },
{ kind: 'kitWall', zone: 'wall', min: 0, max: 1, priority: 2,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.5, stock: 'box', items: 5, maxRun: 3.0 } },
], ],
stockKind: 'mixed', stockKind: 'mixed',
signFlavour: ['WE BUY ANYTHING', 'CASH LOANS', 'GOLD · TOOLS · GEAR', 'ID REQUIRED', 'UNREDEEMED PLEDGES'], signFlavour: ['WE BUY ANYTHING', 'CASH LOANS', 'GOLD · TOOLS · GEAR', 'ID REQUIRED', 'UNREDEEMED PLEDGES'],
@ -170,6 +222,14 @@ const RECIPES = {
{ kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 3 }, // lolly/treat display case (glass_case GLB) { kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 3 }, // lolly/treat display case (glass_case GLB)
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 }, { kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
{ kind: 'cubeShelf', zone: 'aisle', min: 0, max: 1, priority: 2 }, { kind: 'cubeShelf', zone: 'aisle', min: 0, max: 1, priority: 2 },
// ── R41: the hot box. `bain_marie` + `pie_warmer` are the two props that make a milk bar a milk bar
// (this is also PROCITY's cafe — there is no separate `cafe` type in SHOP_TYPE_IDS).
{ kind: 'kitHot', zone: 'wall', min: 1, max: 2, priority: 5, fallbackZones: ['aisle'],
o: { pool: POOLS.milkbar_hot } },
{ kind: 'kitFloor', zone: 'aisle', min: 1, max: 3, priority: 2, fallbackZones: ['window'],
o: { pool: POOLS.milkbar_floor } },
{ kind: 'kitWall', zone: 'wall', min: 0, max: 1, priority: 1,
o: { pool: POOLS.wall_service, wall: true, mountY: 2.35 } }, // the rattling air-con
], ],
stockKind: 'snacks', stockKind: 'snacks',
signFlavour: ['MILK · BREAD · SMOKES', 'PIES HOT', 'PAPERS IN', 'PHONECARDS', 'NO SCHOOL KIDS AT LUNCH'], signFlavour: ['MILK · BREAD · SMOKES', 'PIES HOT', 'PAPERS IN', 'PHONECARDS', 'NO SCHOOL KIDS AT LUNCH'],
@ -190,6 +250,14 @@ const RECIPES = {
{ kind: 'metalShelf', zone: 'wall', min: 2, max: 4, priority: 3 }, { kind: 'metalShelf', zone: 'wall', min: 2, max: 4, priority: 3 },
{ kind: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece { kind: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: SPEND CAREFULLY HERE. dept is the worst room in the game (GLB-on 188 pre-R41, i.e. it owns
// the whole 162-draw margin), so it gets the fewest additions of any type: columns for the hall and
// one rack row.
{ kind: 'kitColumn', zone: 'centre', min: 0, max: 2, priority: 3, o: { pool: POOLS.columns } },
{ kind: 'kitRack', zone: 'aisle', min: 1, max: 2, priority: 3,
o: { pool: POOLS.shop_racks, stock: 'box', items: 8 } },
{ kind: 'kitWall', zone: 'wall', min: 0, max: 1, priority: 2,
o: { pool: POOLS.wall_boards, wall: true, mountY: 1.6, stock: 'box', items: 6, maxRun: 3.0 } },
], ],
stockKind: 'mixed', stockKind: 'mixed',
signFlavour: ['MANCHESTER ↑', 'MENSWEAR', 'HABERDASHERY', 'GROUND FLOOR', 'STAFF ONLY'], signFlavour: ['MANCHESTER ↑', 'MENSWEAR', 'HABERDASHERY', 'GROUND FLOOR', 'STAFF ONLY'],
@ -208,6 +276,16 @@ const RECIPES = {
{ kind: 'crate', zone: 'centre', min: 2, max: 5, priority: 3 }, { kind: 'crate', zone: 'centre', min: 2, max: 5, priority: 3 },
{ kind: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 }, { kind: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 8 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 8 },
// ── R41: this closes a MEASURED HOLE, not just a dressing gap. R39 counted 48 stall rooms and found
// 21 of them (44%) with no crate at all — the recipe's `crate` is zone 'centre' and the 36 trestles
// take the aisle first, so RED TICKET DAY could name a stall you cannot dig in. These bins carry the
// full fallback chain (aisle → centre → window → anywhere), so a market stall always has something
// to riffle. Re-measured after the change; the number is in C-progress.md §41.4.
{ kind: 'kitBin', zone: 'aisle', min: 2, max: 4, priority: 4,
fallbackZones: ['centre', 'window'], anywhere: true,
o: { pool: POOLS.record_bins, dig: true, stock: 'sleeve', items: 12 } },
{ kind: 'kitRack', zone: 'aisle', min: 1, max: 2, priority: 3, fallbackZones: ['centre'],
o: { pool: POOLS.shop_racks, stock: 'box', items: 8 } },
], ],
stockKind: 'boxes', stockKind: 'boxes',
signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'], signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'],
@ -229,10 +307,33 @@ const RECIPES = {
// watchMax = crowd cap (== max watchPoints); deckH = deck height; deckDepth/deckWidthFrac size the // watchMax = crowd cap (== max watchPoints); deckH = deck height; deckDepth/deckWidthFrac size the
// deck. pub values == the R12 constants, so the pub stage is byte-identical (bar the new drums pose). // deck. pub values == the R12 constants, so the pub stage is byte-identical (bar the new drums pose).
venueSpec: { watchMax: 8, deckH: 0.32, deckDepth: 2.0, deckWidthFrac: 0.58 }, venueSpec: { watchMax: 8, deckH: 0.32, deckDepth: 2.0, deckWidthFrac: 0.58 },
// [R41] the bar is Lane E's `plywood_bar`, not the shop `counter_till`. Same fitting, same keeper
// stand, same service strip — only the model changes.
counterGlb: 'plywood_bar',
fittings: [ fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls { kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls
{ kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles) { kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41 — THE PUB FINALLY GETS FURNISHED. Before this round the whole room was a bar, a stage, two
// PA cabs and up to three trestles: the gig venue with almost nothing in it. Seating hugs the WALLS
// and the floor props sit at priority 2 so the door→bar path loop pulls them FIRST — the crowd's
// standing room (watchPoints) is computed after placement and must survive furnishing. Both numbers
// (draws AND watchPoints per venue) are re-measured in C-progress.md §41.4.
// ORDER MATTERS INSIDE THIS BLOCK (and only inside it — every pre-R41 spec is above and untouched):
// the venue kit goes FIRST because the stage flanks and the side-wall slots beside the stage are
// the same 0.4 m cells. Seating placed first took them, and the gear landed 5 times in 15 rooms;
// gear first, and it lands every time while the seating simply takes the next wall slot along.
{ kind: 'kitVenue', zone: 'stage', min: 2, max: 3, priority: 2, fallbackZones: ['wall', 'aisle'],
o: { pool: POOLS.venue_kit } },
{ kind: 'kitSeat', zone: 'wall', min: 3, max: 6, priority: 3, fallbackZones: ['aisle', 'centre'],
o: { pool: POOLS.pub_seating } },
{ kind: 'kitFloor', zone: 'wall', min: 3, max: 6, priority: 2, fallbackZones: ['aisle'],
o: { pool: POOLS.pub_floor } },
{ kind: 'kitWall', zone: 'wall', min: 0, max: 1, priority: 1,
o: { pool: POOLS.wall_service, wall: true, mountY: 2.4 } },
// the DJ rig beside the bar — STATICS (E: "a static copy is 1 draw and looks the same standing still")
{ kind: 'djBooth', zone: 'counter', min: 1, max: 1, priority: 4, gap: 2.2,
fallbackZones: ['stage', 'wall', 'aisle', 'centre'], anywhere: true, o: { booth: 'venue' } },
], ],
stockKind: 'snacks', stockKind: 'snacks',
signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'], signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'],
@ -251,9 +352,18 @@ const RECIPES = {
counterPos: 'corner', // a plank bar in the corner (keeper = barkeep) counterPos: 'corner', // a plank bar in the corner (keeper = barkeep)
venue: true, venue: true,
venueSpec: { watchMax: 8, deckH: 0.2, deckDepth: 1.6, deckWidthFrac: 0.62 }, // barely a riser, wide + shallow venueSpec: { watchMax: 8, deckH: 0.2, deckDepth: 1.6, deckWidthFrac: 0.62 }, // barely a riser, wide + shallow
counterGlb: 'plywood_bar', // a plank bar is literally what this recipe says it is
fittings: [ fittings: [
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf (tinnies) { kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf (tinnies)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: PA, sub, flight cases, a smoke machine and a distro box shoved against the wall — the tin
// shed's furniture is the gear. No seating: you stand and you sweat (the recipe's own words).
{ kind: 'kitVenue', zone: 'stage', min: 2, max: 4, priority: 3, fallbackZones: ['wall', 'aisle'],
o: { pool: POOLS.venue_kit } },
{ kind: 'kitFloor', zone: 'wall', min: 2, max: 3, priority: 2, fallbackZones: ['aisle'],
o: { pool: POOLS.pub_floor } },
{ kind: 'djBooth', zone: 'counter', min: 1, max: 1, priority: 4, gap: 2.2,
fallbackZones: ['stage', 'wall', 'aisle'], o: { booth: 'venue' } },
], ],
stockKind: 'snacks', stockKind: 'snacks',
signFlavour: ['ALL AGES SUN', 'PAY THE BAND', 'NO STAGE DIVING', 'MERCH OUT BACK', '$5 ON THE DOOR'], signFlavour: ['ALL AGES SUN', 'PAY THE BAND', 'NO STAGE DIVING', 'MERCH OUT BACK', '$5 ON THE DOOR'],
@ -271,10 +381,19 @@ const RECIPES = {
counterPos: 'corner', // the members' bar counterPos: 'corner', // the members' bar
venue: true, venue: true,
venueSpec: { watchMax: 12, deckH: 0.4, deckDepth: 2.4, deckWidthFrac: 0.5 }, // a raised stage at one end venueSpec: { watchMax: 12, deckH: 0.4, deckDepth: 2.4, deckWidthFrac: 0.5 }, // a raised stage at one end
counterGlb: 'plywood_bar',
fittings: [ fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 2, max: 4, priority: 2 }, // bistro tables round the edge { kind: 'trestleTable', zone: 'wall', min: 2, max: 4, priority: 2 }, // bistro tables round the edge
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf { kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 }, { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// ── R41: banquettes and bistro tables round the edge, the pokies and the meat-raffle drum the
// signage has been promising since R13. The 12-strong crowd cap is why everything hugs a wall.
{ kind: 'kitVenue', zone: 'stage', min: 1, max: 3, priority: 2, fallbackZones: ['wall', 'aisle'],
o: { pool: POOLS.venue_kit } },
{ kind: 'kitSeat', zone: 'wall', min: 3, max: 6, priority: 3, fallbackZones: ['aisle', 'centre'],
o: { pool: POOLS.pub_seating } },
{ kind: 'kitFloor', zone: 'wall', min: 3, max: 5, priority: 2, fallbackZones: ['aisle'],
o: { pool: POOLS.rsl_floor } },
], ],
stockKind: 'snacks', stockKind: 'snacks',
signFlavour: ['MEMBERS & GUESTS', 'BISTRO OPEN', 'BINGO THURS', 'MEAT RAFFLE 7PM', 'LEST WE FORGET'], signFlavour: ['MEMBERS & GUESTS', 'BISTRO OPEN', 'BINGO THURS', 'MEAT RAFFLE 7PM', 'LEST WE FORGET'],