diff --git a/C-progress.md b/C-progress.md index 85f9115..5d09e05 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,220 @@ *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* -Last updated: 2026-08-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` | 2–4 tubs/crates (dig) · 1–3 tub racks · 1–2 wall boards · window ledge · 1–2 pinned sleeves · **the rigged DJ booth** | 69 | 85 | 61 | **78** | +17 | 70 | +| `opshop` | 1–2 bargain tubs · 1–2 racks · wall board | 89 | 92 | 102 | **105** | +3 | 105 | +| `book` | library trolley · 1–2 racks · 1–2 wall boards | 55 | 69 | 51 | **61** | +10 | 95 | +| `video` | ex-rental bargain tub · 1–2 wall boards · window ledge | 65 | 76 | 54 | **63** | +9 | 63 | +| `pawn` | 1–2 flight cases/sub · **808 + SP-1200 bench** · wall board | 67 | 81 | 60 | **70** | +10 | 70 | +| `stall` | **2–4 diggable bins** · 1–2 racks | 76 | 89 | 71 | **82** | +11 | 82 | +| `dept` | 0–2 columns · 1–2 racks · wall board | 80 | 87 | 116 | **122** | +6 | 122 | +| `milkbar` | **bain-marie / pie warmer** · 1–3 floor props · air-con | 72 | 80 | 68 | **69** | +1 | 69 | +| `pub` | **bar = `plywood_bar`** · 2–3 stage-flank venue kit · 3–6 seats · 3–6 floor props · air-con · DJ booth | 48 | 64 | 45 | **59** | +14 | 59 | +| `band_room` | bar = `plywood_bar` · 2–4 venue kit · 2–3 floor props · DJ booth | 43 | 56 | 39 | **50** | +11 | 50 | +| `rsl` | bar = `plywood_bar` · 1–3 venue kit · 3–6 seats · 3–5 floor props (pokies, raffle drum) | 56 | 73 | 52 | **60** | +8 | 60 | +| `toy` | **none — untouched by design** | 67 | 67 | 86 | **86** | **+0** | 120 | + +**WORST ROOM IN THE GAME: 122 draws @ `dept`/auto, GLB on, with or without `?stock=real`.** +Law ≤350 → **margin 228**. It was 116 before this round, so §41.4 spent **6 draws at the worst room** and +at most 17 anywhere. `dept` deliberately gets the fewest additions of any type precisely because it owns +the worst room; the pub, which this round exists for, could afford 14 and still sit at 59. + +Cost discipline came free from E's work: **every one of the 110 is 1 draw** bar the four rigged heroes. +The record shop's booth is the round's only multi-draw fitting — `deck_1200_rigged` (5) + +`mixer_ttm54i_rigged` (2) + a static `deck_1200` (1) = **8 draws, one room, once**. + +### 3. Sweep + soak + +- **`drawSweep` 4 arms × 12 types × 6 archetypes = 288 rooms:** all pass ≤350, **0 console errors**. +- **Soak, 60 rooms build+dispose+rebuild:** avg **3.83 ms**, worst **10.3 ms** (budget 50) · leak geo **0** + / tex **0** · determinism **0** mismatches · path-fails **0** · carves **0** · throws **0**. +- **960-room corpus** (12 types × 5 archetypes × 8 seeds × kit/noKit, GLB on): **0 path-fails, 0 carves.** + +| type | R41 fittings/room | GLB props/room (max) | diggable bins/room | rooms with none | watchPoints | browsePoints | +|---|---:|---:|---:|---:|---:|---:| +| record | 7.90 | 20.8 (28) | **8.32** | 0/40 | — | 3.00 | +| stall | 3.92 | 11.7 (18) | **4.55** | **0/40** | — | 3.00 | +| pub | 8.40 | 13.0 (19) | — | — | **7.5 / cap 8** | 3.00 | +| band_room | 5.03 | 9.1 (11) | — | — | **8.0 / cap 8** | 3.00 | +| rsl | 5.78 | 10.1 (18) | — | — | **11.4 / cap 12** | 3.00 | +| opshop | 2.95 | 13.1 (19) | — | — | — | 3.00 | +| milkbar | 3.77 | 13.5 (18) | — | — | — | 3.00 | +| book / video / pawn / dept | 3.80 / 3.10 / 3.17 / 1.90 | 11.4 / 5.1 / 9.7 / 11.8 | — | — | — | 3.00 | + +**Furnishing did not cost the crowd.** watchPoints are computed after placement, so a furnished venue could +have starved its own audience; measured, pub holds 7.5 of 8, band_room 8.0 of 8, RSL 11.4 of 12. + +**A measured R39 hole closed on the way past.** R39 counted 48 market-stall rooms and found **21 (44%) with +no crate at all** — the recipe's `crate` is `zone:'centre'` and 3–6 trestles take the aisle first — so RED +TICKET DAY could name a stall you cannot dig in. `stall` now carries 2–4 kit bins with the full fallback +chain: **0 of 40 stall rooms have no bin, mean 4.55.** + +### 4. Four defects found by building this, all fixed + +1. **`buyMesh` was invisible on any fitting with a GLB.** R9's buy-anywhere shelves are merged, + per-item-addressable meshes tagged `{ noBatch, buyMesh }` and *not* `isStock` — so `glb.js`'s + frame-hide turned **every real, buyable item in the room invisible** the moment the shelf upgraded. + Live since R9 on `bookshelf`, `cubeShelf` and `clothesRack`; never seen because `?stock=real` is opt-in + and R9 measured it GLB-off. Found the instant 52 real garments went on a rail and the rail came back + empty. `hidePrimitive` now skips `buyMesh`. +2. **The DJ booths were nondeterministic.** Parts were attached inside each load's own `.then`, so the + booth's children landed in network-completion order. Two runs of the same 240 rooms differed on exactly + the three types that have a booth and on nothing else. Loads stay parallel; attachment is now a plain + in-order loop. +3. **The booth's gear floated at 0.92 m over bare floor.** A single-GLB swap hides the whole primitive, + but a booth's GLBs are the *gear* — the bench is the fitting. Gear stand-ins are tagged `boothGear` and + only those are hidden. Which exposed a fourth: **`batch.js` rebuilt `userData` from scratch and dropped + sub-tags on merge**, so the tag never survived to be read. It now carries `boothGear`/`tillPrimitive` + through a merge when *every* mesh in the material bucket has it. +4. **The door→counter path guarantee could empty a room.** The pull loop takes the *lowest-priority* + survivor each pass, not the one standing in the corridor, so it clears the cheap furniture first and + reaches the real blocker by attrition. Measured: `pub/wide/4242` came back with a bar, a stage and + **nothing else** — a wide pub puts the bar beside the stage and leaves one ~1 m gap. Pre-existing; ten + more fittings per pub made it impossible to miss. New step 3b walks the pulled list in reverse and puts + each fitting back **only if door→counter still reaches**: over 480 rooms, **129 pulled → 101 restored, + 28 genuinely lost**, and the guarantee is re-proved on every single restore. A room that pulled nothing + does nothing (byte-identical); a room that pulled N can only end with more fittings, never fewer. + +### 5. E's three traps + +| trap | what §41.4 does | +|---|---| +| `-long`/`-wide` pairs are the same mesh rotated 90° | `kit41.PAIRS` records all 9; **no pool contains both halves**, and `kitAudit()` asserts it. Facing is not a hand-tuned table either — `glb.js autoYaw` compares the manifest footprint against the slot the grid reserved and turns the GLB a quarter if the major axis is swapped. Pre-R41 kinds keep their hand-tuned `KIND_TO_YAW` exactly, so legacy rooms are unchanged on screen. | +| `vinyl_sleeve` depth floored to 0.01 m | wall dressing only — `sleeve_wall`, `wallMounted`, mountY 1.72, in **no floor pool**. | +| `beam_roof` 6.5 m · `shelf_board_long` 4.6 m · `wall_shelves_big` 4.65 m | in **no pool at all** (archetypes are 6–12 m wide). Listed in `kit41.OVERSIZE`, asserted by `kitAudit()`, and generalised as `spec.o.maxRun`. | + +**`kitAudit(manifest)` (on `PROCITY_C`) is the drift check, not a promise:** it compares all **96 +catalogue rows** against the live manifest. Result — **96/96 checked, 0 dimension errors, 0 missing, 0 pair +errors, 0 oversize; 67 pool/booth ids all resolve.** It found one real drift on the first run +(`sampler_s900` height 0.14 vs the manifest's 0.135), which is fixed. + +### 6. THE WARDROBE — route R5, zero geometry + +`web/assets/wardrobe/` (new): **`wardrobe_atlas_00.webp` 246,364 B + `stock_opshop_index.json` 17,347 B = +263,711 B (257.5 KB), ONE fetch, ONE material, ONE draw per fitting.** + +- **52 layers selected BY NAME** (ruling 3 — no wildcard copy) from the 441 at + `~/Documents/90sDJsim/web/world/media/doll/`. Each is cropped to its **alpha bbox** (the sources are + 704×1408 full-body layers that are ~90% empty; a top occupies the torso band only), scaled and + shelf-packed. **257.5 KB is 3.7% of the 52 sources' 6.74 MB and 0.49% of the 53 MB full set.** +- Garment cells are **128×152** — exactly the resolution of the procedural garment canvas they replace + (`stock.js makeGarment` is 128×160). This is a swap in art, not an increase in texture budget. +- **LAZY AND BOUNDED:** it is a stock pack under its own base, so it loads only under `?stock=real`. + A default boot fetches nothing; `?noassets=1` fetches nothing. Fail-soft: no pack → the procedural + canvas, byte-identically. +- **Where the names show:** garments come through `buildBuyableShelf`, so they are per-item addressable + and the **existing shelf buy-card path reads `artist — title`** with no new UI. + +**THE LICENCE CALL, made explicitly.** `pipeline/AUDIT.md` is standing law: *no real titles/authors/brands +ship*. The source library was hand-prompted **from** real 1990s labels, so a majority of the 441 carry a +live trademark in the filename **and in the artwork** — `top_i_adidas_track_jacket.png` renders a legible +trefoil and three stripes; `bottom_i_tech_fleece_pant.png` has a swoosh on the leg. So the screen was two +stage: every candidate was eyeballed on a contact sheet and **rejected if the artwork carried a mark** +(that took out adidas/nike/versace/dr-martens/champion/coogi/stussy/fila and the rest), and the surviving +ids are then labelled in a **parody voice**: `Paddy's Bootleg`, `Coogee Knits`, `Mombo`, `Stoosh`, +`Kaymart`, `Criss Colors`, `Kangaruu`, `Army Disposals`, `Rissole Classic`, `Nan Knitted It`. +**The parody is not a compromise, it is the period detail** — Paddy's Market genuinely sold "Tommy +Hillfigga", and the source library itself already ships `adidos_shelltops` and `doc_martys_8_eye`. +Titles stay literal and Australian: *Double Pluggers · Terry Towelling Hat · Trackie Dacks · Boardies · +Squiggle Jumper · Phat Pants · Acid Smiley Tee*. + +Two small plumbing changes made this work without touching the three existing packs: +`SLOT_FOR.opshop = ['garment','box']` (a pack may now serve more than one slot kind; the three pre-R41 +packs keep their single string and are handed `pack.items` **by reference**, so every code path that +matters compares reference-equal and behaves byte-identically), and an optional `index.material` block +(`alphaTest` + `DoubleSide`) — without it a cut-out tee is its own bounding box and the back of a hanging +garment is a hole. Packs that declare no block build the material they always did. + +### 7. DJ CONTROL NODES — recorded, NOT driven (for D/F, a later round) + +R41 **places** the rigged models and records their contracts. **Nothing drives them**, and `djBooth` ships +no animation hook on purpose so a placed node is never mistaken for a live one. The names are in +`kit41.CONTROL_NODES`, on the fitting as `fitting.controlNodes`, and on the group as +`group.userData.controlNodes`: + +| id | draws | control nodes | where it is | +|---|---:|---|---| +| `deck_1200_rigged` | 5 | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, booth `record_hero`, left deck | +| `mixer_ttm54i_rigged` | 2 | `Fader_CROSS` | record shop, booth `record_hero`, centre | +| `mixer_pmc05pro_rigged` | 2 | `Fader_CROSS` | catalogued, not placed | +| `mixer_shdj1200_rigged` | 2 | `Fader_CROSS` | catalogued, not placed | + +Two things a future driver must not rediscover the hard way: drive them by the **bare node name** (the +cap/stem/rib are parented under it), and **glTF drops the source's limit constraints, so clamp the travel +yourself.** Venue and pawn booths are deliberately **statics** — E's call, and correct: a static copy is +1 draw and looks the same standing still. + +### 8. Determinism + `?noassets` + +- **Determinism, two FRESH browser contexts** (never a reused tab), 240 rooms = 12 types × 5 archetypes × + 4 seeds, **GLB on + `?stock=real`**, comparing placement, GLB id list, browse/watch points, keeper stand + and stage: **byte-equal, sha256 `c26b4848…eae852` on both runs, 0 mismatched rooms** over 522,501 bytes + of payload. +- **`?noassets=1`, all 60 type × archetype rooms built:** **0 GLB fetches · 0 manifest fetches · 0 wardrobe + fetches · 0 stock-pack fetches.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` / + `tex-*.jpg` shell wallpapers, which are not R41 cargo and are the same 10 before and after. Worst + primitive room **126 draws @ opshop/hall, 0 GLB upgrades, 0 path-fails** — the asset-free build is a + real, correctly-sized, walkable shop floor, because `kit41.js` is a hardcoded table and `buildInterior` + is synchronous. Control shot: `docs/shots/laneC/r41_noassets_control.png`. + +### 9. Shots — `docs/shots/laneC/` + +`r41_record_shop.png` · `r41_record_dj_booth.png` · `r41_pub.png` · **`r41_pub_before.png`** (the same +seed and camera with `noKit:true` — the control) · `r41_opshop_wardrobe.png` · `r41_band_room.png` · +`r41_milkbar.png` · `r41_stall_bins.png` · `r41_noassets_control.png`. +Each carries a **1.75 m scale post** at a floor pose: no shot here contains a humanoid, so the R10 law does +not bite, but these are 110 brand-new assets at claimed real-metre scale and the bar is the proof. + +### 10. Files + +New: `web/js/interiors/kit41.js` · `web/assets/wardrobe/{wardrobe_atlas_00.webp, stock_opshop_index.json}`. +Changed: `fittings.js` (the kit builder + `djBooth` + counter `glbId`), `theme.js` (recipe pools), +`layout.js` (`stage` zone, `spec.gap`, `wall` as a fallback zone, restore pass, `noKit`), `glb.js` +(`fitting.glbId`, `glbParts`, `autoYaw`, the `buyMesh` fix), `stock.js` (`hang` real path, slot pools), +`stockpack.js` (multi-slot packs, `index.material`, `ry`), `batch.js` (sub-tag propagation), +`interiors.js` (`noKit`, `room.pulls`), `interior_test.html` (sweep fix, `noKit`/`stock` arms, wardrobe +preload, `kitAudit` hook). +**Not touched:** `web/assets/manifest.json`, `web/assets/models/**`, `pipeline/**`, `web/js/citizens/**`, +`web/js/world/**`, `web/index.html`, `tools/qa.sh`. --- diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md index 356c132..d250062 100644 --- a/docs/LANES/LANE_C_NOTES.md +++ b/docs/LANES/LANE_C_NOTES.md @@ -1,5 +1,78 @@ # 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 = { : [node…] }`, and the GLB instance beneath it is tagged +`userData.glbUpgrade` with `userData.kind === `. 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) **Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref diff --git a/docs/shots/laneC/r41_band_room.png b/docs/shots/laneC/r41_band_room.png new file mode 100644 index 0000000..76817c6 Binary files /dev/null and b/docs/shots/laneC/r41_band_room.png differ diff --git a/docs/shots/laneC/r41_milkbar.png b/docs/shots/laneC/r41_milkbar.png new file mode 100644 index 0000000..77db7c5 Binary files /dev/null and b/docs/shots/laneC/r41_milkbar.png differ diff --git a/docs/shots/laneC/r41_noassets_control.png b/docs/shots/laneC/r41_noassets_control.png new file mode 100644 index 0000000..3ca53bc Binary files /dev/null and b/docs/shots/laneC/r41_noassets_control.png differ diff --git a/docs/shots/laneC/r41_opshop_wardrobe.png b/docs/shots/laneC/r41_opshop_wardrobe.png new file mode 100644 index 0000000..db91853 Binary files /dev/null and b/docs/shots/laneC/r41_opshop_wardrobe.png differ diff --git a/docs/shots/laneC/r41_pub.png b/docs/shots/laneC/r41_pub.png new file mode 100644 index 0000000..3c39928 Binary files /dev/null and b/docs/shots/laneC/r41_pub.png differ diff --git a/docs/shots/laneC/r41_pub_before.png b/docs/shots/laneC/r41_pub_before.png new file mode 100644 index 0000000..d246e00 Binary files /dev/null and b/docs/shots/laneC/r41_pub_before.png differ diff --git a/docs/shots/laneC/r41_record_dj_booth.png b/docs/shots/laneC/r41_record_dj_booth.png new file mode 100644 index 0000000..434cf93 Binary files /dev/null and b/docs/shots/laneC/r41_record_dj_booth.png differ diff --git a/docs/shots/laneC/r41_record_shop.png b/docs/shots/laneC/r41_record_shop.png new file mode 100644 index 0000000..c070da6 Binary files /dev/null and b/docs/shots/laneC/r41_record_shop.png differ diff --git a/docs/shots/laneC/r41_stall_bins.png b/docs/shots/laneC/r41_stall_bins.png new file mode 100644 index 0000000..74b6387 Binary files /dev/null and b/docs/shots/laneC/r41_stall_bins.png differ diff --git a/web/assets/wardrobe/stock_opshop_index.json b/web/assets/wardrobe/stock_opshop_index.json new file mode 100644 index 0000000..f31e46f --- /dev/null +++ b/web/assets/wardrobe/stock_opshop_index.json @@ -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." +} \ No newline at end of file diff --git a/web/assets/wardrobe/wardrobe_atlas_00.webp b/web/assets/wardrobe/wardrobe_atlas_00.webp new file mode 100644 index 0000000..664c3c9 Binary files /dev/null and b/web/assets/wardrobe/wardrobe_atlas_00.webp differ diff --git a/web/interior_test.html b/web/interior_test.html index c26f7cd..ac90c06 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -74,6 +74,9 @@ import { createDig, binSeed } from './js/interiors/dig.js'; import { createWallet } from './js/interiors/wallet.js'; import { collapseBuyItem } from './js/interiors/stockpack.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. 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. 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 -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. ── const wallet = createWallet(1990); @@ -156,7 +168,9 @@ function rebuild() { const lot = parseLot($('lot').value); 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, - 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); // place the player at the spawn 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 // can't regress; F mirrors this in the qa harness. Measures every type × archetype, GLB off AND on. 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 = {}) { - const wasCurrent = current; - for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) if (s !== (current && current.group)) scene.remove(s); + if (current) { current.dispose(); current = null; clearDebug(); } + for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) scene.remove(s); let manifest = 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 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; 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); @@ -587,6 +615,8 @@ function frame() { // 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, + 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, shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak, SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST }; diff --git a/web/js/interiors/batch.js b/web/js/interiors/batch.js index 5439411..4c90f41 100644 --- a/web/js/interiors/batch.js +++ b/web/js/interiors/batch.js @@ -42,6 +42,14 @@ function flushBuckets(ctx, target, buckets, stockTag, bake) { for (const m of b.meshes) m.parent && m.parent.remove(m); const mm = new THREE.Mesh(merged, b.material); 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); saved += b.meshes.length - 1; } diff --git a/web/js/interiors/fittings.js b/web/js/interiors/fittings.js index 0e08116..f1e1d22 100644 --- a/web/js/interiors/fittings.js +++ b/web/js/interiors/fittings.js @@ -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 // (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 } }; - 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. @@ -510,6 +514,244 @@ function ampStack(ctx, o = {}, r) { 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.28–0.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 = { recordBin, crate, crateStack, listeningCorner, rummageBin, wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet, @@ -517,6 +759,13 @@ export const FITTINGS = { trestleTable, counter, glassCase, pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame, 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--` — 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). diff --git a/web/js/interiors/glb.js b/web/js/interiors/glb.js index 819ae5c..82eab90 100644 --- a/web/js/interiors/glb.js +++ b/web/js/interiors/glb.js @@ -66,48 +66,122 @@ export function preloadManifest(url = 'assets/manifest.json') { } 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. -// `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 (buildInterior exposes this as room.glbReady). +// [R41 §41.4] AUTO-FACING for the kit. The 110 new fittings carry no facing convention +// (manifest.conventions.facing: "Lane B/C rotate directional props"), and several of them are the SAME +// MESH ROTATED 90° from a sibling — so a hand-tuned yaw table would be 110 rows of guesswork. Instead the +// 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:` (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 // GLB validation before the depot is reachable. export function upgradeFitting(ctx, fitting, kind, manifest) { 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]; if (!entry || !entry.file) return Promise.resolve(); - const ref = manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`; - return loadGLB(ref).then(gltf => { - if (!gltf || ctx._disposed || !fitting.group.parent) return; // room rebuilt / disposed mid-flight - const THREE = ctx.THREE; - const inst = skeletonClone(gltf.scene); // clone-safe even if skinned - // Facing: rotate the directional prop to match the primitive the layout placed (before measuring). - 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); + // Facing: a pre-R41 kind keeps its hand-tuned yaw EXACTLY (KIND_TO_YAW[kind] || 0 — the R4/R6 on-screen + // validation); only a kit id, which has no tuned row and could be either half of a rotated pair, is + // auto-faced. Legacy rooms are therefore byte-identical on screen. + const yaw = fitting.glbId ? autoYaw(entry, fitting) : (KIND_TO_YAW[kind] || 0); + return seatGLB(ctx, fitting, entry, manifest, fitting.glbId ? id : kind, yaw).then(inst => { + if (inst) hidePrimitive(fitting); }); } +// 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. // 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), diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 479f266..599fcaa 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -200,7 +200,9 @@ export function buildInterior(shop, THREE, opts) { // [R39] opts.fittingOpts = { : {…} } — 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. // 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 // 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, pathOK: lay.pathOK, 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 buildMs, _debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx }, diff --git a/web/js/interiors/kit41.js b/web/js/interiors/kit41.js new file mode 100644 index 0000000..6ac12df --- /dev/null +++ b/web/js/interiors/kit41.js @@ -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 (2–5). 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 6–12 m wide, 7–14 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; +} diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js index 279fac7..b508fd3 100644 --- a/web/js/interiors/layout.js +++ b/web/js/interiors/layout.js @@ -101,7 +101,9 @@ function placeCounter(ctx, recipe, W, D, r) { } 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 }; } - 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; const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); const stand = keeperStand(x, z, front, W, D); @@ -315,11 +317,23 @@ function makeWallSlots(W, D, r) { } // ── free-standing candidate positions per zone ──────────────────────────────────────── -function zoneCandidates(zone, W, D, doorCx, r) { +function zoneCandidates(zone, W, D, doorCx, r, stage) { const out = []; if (zone === 'window') { 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 }); + } 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') { 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 }); @@ -331,7 +345,7 @@ function zoneCandidates(zone, W, D, doorCx, r) { } // ── 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 doorCx = shell.door.x; 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). const area = W * D; 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; // count scales with clutter × area, clamped to [min,max] 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) { ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec); } 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 { - 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 // 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 // AND already failed, so no other fitting's draws move. 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) { /* 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 .filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor .sort((a, b) => a.priority - b.priority); + const pulled = []; while (!pathOK) { const pool = removable(); if (!pool.length) break; 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); victim.removed = true; + pulled.push({ v: victim, at }); rebuildOcc(grid, placed); 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 // 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. @@ -453,6 +509,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts }) return { placed: placed.filter(p => !p.removed), places, decor: decor.meshes, grid, pathOK, carved, + pulls: { pulled: pulled.length, restored, lost: pulled.length - restored }, // [R41] path-loop cost 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 [] 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 ────────────────────────────────────────────────────────────── -function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) { - const cands = zoneCandidates(zone, W, D, doorCx, r); +function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec, stage) { + const cands = zoneCandidates(zone, W, D, doorCx, r, stage); for (const c of cands) { const ry = c.ry + (r() - 0.5) * 0.4; 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; } -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 // fall back to a free-standing aisle spot so a min≥1 fitting (e.g. a glass case) reliably lands. 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 - ? [{ 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, 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 - 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 - 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); const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); for (const side of cands) { @@ -544,7 +607,7 @@ function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx) return true; } // 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) ───────────────────────────────────────────────────────── @@ -627,3 +690,5 @@ function carveCorridor(grid, startIdx, targetIdx) { clear(tx, tz); } 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); diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js index f14f78f..b83f17b 100644 --- a/web/js/interiors/stock.js +++ b/web/js/interiors/stock.js @@ -86,6 +86,13 @@ function garmentPool(ctx, r, n = 8) { 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 ───────────────────────────────────────────────────────────────── export function fill(ctx, fitting, 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 src = opts.adapter && opts.adapter(opts.shop, 'sleeve'); 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); for (let i = 0; i < n; i++) { const z = -run / 2 + (i + 0.5) * step; let sl; 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); parent.add(sl); } else { @@ -174,7 +182,7 @@ function shelfLine(ctx, parent, slot, opts, r) { if (kind === 'spine') { // books / VHS: thin spines edge-out const step = (run - 0.04) / n; 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 = []; for (let i = 0; i < n; i++) { 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 step = run / n; 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 = []; for (let i = 0; i < n; i++) { const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); 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); 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. +// +// [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) { 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); for (let i = 0; i < n; i++) { const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1)); diff --git a/web/js/interiors/stockpack.js b/web/js/interiors/stockpack.js index 2607ef1..28f9a38 100644 --- a/web/js/interiors/stockpack.js +++ b/web/js/interiors/stockpack.js @@ -52,12 +52,21 @@ export function getStockPack(type, base = DEFAULT_STOCK_BASE) { return _resolved async function buildPack(type, idx, base, THREE) { const names = idx.atlases || []; 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) => { const url = /^depot:|^https?:/.test(name) ? name : base + name; let tex; try { tex = await _texLoader.loadAsync(url); } catch (e) { tex = null; } 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]; // 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 it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it); 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 { type, items: idx.items, atlases: nonEmpty.length ? nonEmpty : names, 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) material(item) { return materials[item.atlas] || materials[first]; }, // 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 -// shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves). -const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box' }; +// Each pack type feeds the stock slot kinds it belongs in, so a record shop's pack never lands on a +// bric-a-brac shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves). +// [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 -// (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) { if (!pack || !pack.items || !pack.items.length) return null; 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 @@ -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); if (pl.rz) g.rotateZ(pl.rz); 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); return g; } diff --git a/web/js/interiors/theme.js b/web/js/interiors/theme.js index b658325..bdf4c60 100644 --- a/web/js/interiors/theme.js +++ b/web/js/interiors/theme.js @@ -26,6 +26,13 @@ // stockKind : what stock.js renders into this shop's carriers // 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' }; const RECIPES = { @@ -44,6 +51,22 @@ const RECIPES = { { 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: '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', 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. { kind: 'rummageBin', zone: 'aisle', min: 1, max: 1, priority: 4, 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', 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: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 }, { 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', signFlavour: ['ALL BOOKS $2', 'FICTION A–M', '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: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 }, { 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', 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: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 }, { 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', 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: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 }, { 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', 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: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece { 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', 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: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 }, { 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 3–6 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', 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 // 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 }, + // [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: [ { 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: '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', signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 5–6', '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) venue: true, 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: [ { kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf (tinnies) { 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', 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 venue: true, venueSpec: { watchMax: 12, deckH: 0.4, deckDepth: 2.4, deckWidthFrac: 0.5 }, // a raised stage at one end + counterGlb: 'plywood_bar', fittings: [ { 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: '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', signFlavour: ['MEMBERS & GUESTS', 'BISTRO OPEN', 'BINGO THURS', 'MEAT RAFFLE 7PM', 'LEST WE FORGET'],