diff --git a/C-progress.md b/C-progress.md index ceaa2e3..85f9115 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,99 @@ *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-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-08-03 (round 39) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-08-03 (round 39, v9-alpha THE CITY IS THE GAME) — §39.3 the op shop can't be dug + +Fable's finding, verified before commissioning: `kind:'bin'` — the thing the dig fires on — is minted at +exactly `fittings.js` `recordBin`/`crate` and used by exactly two recipes, `record` and market `stall`. +PROCITY is built on thriftgod's op-shop DNA and **you cannot dig in an op shop.** Brief: measure what a +player can actually do in every shop type, build the op shop a bin, **flag the contents, do not decide +them**, and spend against the GLB-path interior margin. + +### 1. THE TABLE (LANE_C_PUB §10) — the deliverable, and it is uncomfortable +Built by reading the recipes and the fitting kinds, then counting interactables in **48 rooms per type** +(6 archetypes x 8 seeds). Full table + the five structural facts in the contract; the short version: +- **`record`** digs (mean **4.6** bins/room, 0/48 empty) and is **the only room in the game where + anything can be sold**. **`stall`** digs — in 27 of 48 rooms; the other **21 (44%) have no crate at + all** (mean 0.92), because its `crate` is `zone:'centre'` and the 3–6 trestles take the aisle first. + **That is a measured correction to the round's binding fact #5** and it means RED TICKET DAY's roster, + which counts a stall by type, can name a stall you cannot dig in. +- **The other ten types offer nothing but looking.** No bins, and pull-and-buy is dark: `?stock=real` is + opt-in (`index.html:222`), so **on the boot every player gets there is not one `buyMesh` in the game**. + Even armed, `SLOT_FOR` has three entries and the pack is keyed by SHOP TYPE, so only book spines in a + `book` shop and toy boxes in a `toy` shop can ever be bought — the video store's identical spine + shelves cannot, because no `video` pack exists. +- **Four stock kinds can never be bought in any mode**: `garment`, `snack`, `magazine`, `treasure` — + `hang()`/`treasures()` never call `buildBuyableShelf`. **The op shop's clothes are unbuyable by + construction.** +- **`interactable: true` is not a verb.** `interior_mode` routes exactly three things (aimed `bin`, + counter, `buyMesh`). `case`, `fridge`, `returns` and the arcade cabinet set the flag and **nothing + reads them** — 8 dead glass cases in a dept-store hall, 4 dead fridges in a milk bar. +- **Sell** is `item.type === shopType`, and only two call sites stamp a type: a dig pull is **always + `'record'` wherever it happens**, a shelf buy is the shop's type. Hence: one sale point, default boot. + +### 2. THE MECHANISM — `rummageBin`, shipped, contents gated +A shallow galvanised dump tub (0.95 x 0.70 x 0.28) with flared sides on a castored steel frame, rim at +**0.76 m** — you rummage standing — heaped with stickered mixed goods and a hand-lettered card taped to +the front ("EVERYTHING $2"). Not a record crate recoloured: `crate` is 0.55 x 0.45 and deep because it +is sized to stand LPs in. Shot: [r39_opshop_rummage_bin.png](docs/shots/laneC/r39_opshop_rummage_bin.png). + +Three new **opt-in, pure-data** seams (absent ⇒ pre-R39 byte-identical): `recipe.fittings[i].o`, +`buildInterior opts.fittingOpts[kind]`, and `.fallbackZones` / `.anywhere` placement fallbacks. The last +one is not decoration — appended last, the bin's zone is the fullest it will ever be and it landed in +only **11 of 24** rooms without them. + +**72 rooms (6 archetypes x 6 seeds x GLB on/off):** placed **72/72** · reachable **72/72** (nearest +spawn-reachable walkable cell **0.77–1.15 m** from the bin, vs the dig's 2.5/3.2 m reach) · **0 carves** · +`pathOK` 72/72 · determinism 72/72 · **the rest of the room byte-identical to pre-R39 72/72** (the spec is +appended LAST, so the shared `lay-count`/`lay-place` streams hand every existing fitting the same draws). +End-to-end armed: aim → dig opens → **16 offers** → card → **BUY** debits the wallet → bin depletes to 15. +Negative control (default): tub present, **0 bins**, aim finds nothing, dig never opens. 0 console errors. +Soak 60 rooms: **leak geo 0 / tex 0**, determinism identical, avg 3.4 ms / worst 16.5 ms. + +### 3. SPENT AGAINST THE GLB PATH — and the margin was not six +**`C-progress.md:841`'s "GLB-on 344" is R9-era and stale.** Re-measured on today's tree with the lane's +own instrument before touching anything: **GLB-off 156 · GLB-on 319, both at `opshop/hall`.** So the +margin was **31**, not six. After the bin: + +| `drawSweep` | pre-R39 | R39 | Δ | +|---|---|---|---| +| GLB-off worst | 156 opshop/hall | **161** opshop/hall | +5 | +| GLB-on worst | 319 opshop/hall | **329** opshop/hall | +10 | +| the other 11 types, both modes | — | — | **0, byte-identical** | + +**Margin against ≤350: 21 draws.** Stated at the worst room the bin lands in, not an average. +A free win found while costing it: the scatter fill minted a *tinted* material per item (9 goods = 9 +draws in a room with no other scatter). New opt-in `slot.pool` keeps every face on the room's shared +pooled textures at white — the trick the spine/box rows already use — so the pile merges into buckets +the room already has. **12 goods now cost less than 9 did.** Existing scatter callers draw the same +values in the same order; proven by the 11 untouched types' sweep numbers. + +### 4. THE BIGGER FINDING — half the interior budget is a pre-pass (→ E/F, NOT shipped) +Lane E's `bookshelf` GLB ships **`transmission: 1`** on `mtl_10218_Bookshelves_v1`. One transmissive +material makes three.js run its transmission pre-pass — a second full render of the opaque scene — so +**every draw in that room is issued and counted twice.** Measured by zeroing it and re-rendering: +`opshop`/hall **191 → 104 (−46%)** · `opshop`/wide 142 → 80 · `book`/hall **101 → 51 (−50%)** · +`dept`/hall 116 → 116 (the control, no bookshelf). Those are the two worst GLB-on types in the sweep. +**→ E:** re-emit without `KHR_materials_transmission`. **→ F:** a `transmission = 0` scrub in +`glb.js upgradeFitting` is the cheap fence. **I have not shipped either** — it changes how another +lane's asset renders. Either turns a 21-draw margin into ~100. + +### 5. HELD FOR JOHN — the contents (LANE_C_PUB §10.6) +Not "op shops now dig", but "op shops now dig **records**". Across the 23 shipped caches (44 record · +36 stall · **147 opshop**): crate-shops/town **0–11 → 2–25**, towns with none **3 → 0**, diggable bins +**~235 → ~382 (+63%)**, under-band gems/day ~113 → ~183. **A** (parody records) costs zero code and is +what the market stall already does, but an op-shop find is stamped `type:'record'` and bowral/darwin/ +daylesford have no record shop — they could dig and have nowhere to sell. **B** (books/toys via the +existing packs) needs an `opshop` pack that cannot exist today *and* a second dig presentation, because +`dig.js` builds a crate of 0.31 m square sleeves. **C** (a bric-a-brac class) costs most and is the only +one that closes the sell hole, since type = type would make op shops buy their own bric-a-brac back. +**Recommendation: play arm A behind the flag for one session, then rule; C is the destination; B is not +worth doing alone.** Also held, same reason: the one-line `fallbackZones` fix that would give market +stalls the crates their recipe already promises. --- diff --git a/docs/LANES/LANE_C_PUB.md b/docs/LANES/LANE_C_PUB.md index b6bfc35..e1df13f 100644 --- a/docs/LANES/LANE_C_PUB.md +++ b/docs/LANES/LANE_C_PUB.md @@ -32,6 +32,11 @@ > **v7.0-alpha amendment (2026-07-18, R30 ledger #2):** **§9 added — the sell-counter contract** (the > no-pump law made structural: `offer(p) = min(p−1, max(1, floor(p·0.5)))` — strictly below the buy side > for every p ≥ 1, by construction). §0–§8 unchanged. +> **v9.0-alpha amendment (2026-08-03, R39 §39.3):** **§10 added — WHAT YOU CAN ACTUALLY DO IN A SHOP:** +> the honest per-type verb table, the five structural facts under it, a measured correction to the +> round's binding fact #5 (**44% of market-stall rooms contain no crate**), the op shop's own +> **rummage bin** (shipped; contents gated, held for John), and the finding that **one asset's +> `transmission: 1` is costing ~46–50% of the interior draw budget**. §0–§9 unchanged. *The district ships **three** venue archetypes behind `?gigs=1` — `pub`, `band_room`, `rsl`. Lane A converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off @@ -547,3 +552,192 @@ const sell = createSell(); sell.open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) // → bool (false = nothing sellable) sell.close(); sell.dispose(); sell.active // mirrors dig.js ``` + +--- + +## 10. WHAT YOU CAN ACTUALLY DO IN A SHOP (v9-alpha, R39 §39.3) → everyone + +> **This section is a measurement, not a design.** Fable's brief asked for the honest per-type +> inventory of player verbs, built by reading the recipes and the fitting kinds and then counting +> them in built rooms — not from memory. It is uncomfortable. It is also, as far as I can tell, the +> first time anyone has written down what this game currently offers, shop type by shop type. + +### 10.1 THE TABLE — the DEFAULT BOOT (`web/index.html`, no flags), 12 types + +Counts are from built rooms: **6 archetypes × 8 seeds = 48 rooms per type**, headless. + +| type | dig a bin? | pull-and-buy off a shelf? | sell at the counter? | anything else | +|---|---|---|---|---| +| `record` | **YES** — 2–8 bins/room, mean **4.6**, 0/48 rooms empty | no — records are the dig, by design | **YES** — the only shop in the game where anything can be sold | — | +| `opshop` | **no** *(R39: it now HAS a bin; contents unruled — §10.4)* | no — there is no `opshop` stock pack, and none is possible today | no — nothing in the game is ever `type:'opshop'` | look at clothes you cannot buy | +| `book` | no | **only under `?stock=real`** — ~37 buy meshes / ~412 items per room | only for items already `type:'book'`, which also needs `?stock=real` | — | +| `toy` | no | **only under `?stock=real`** — ~34 meshes / ~62 items per room | ditto, `type:'toy'` | a glass case (interactable, no consumer) | +| `video` | no | **no** — `vhsAisle` has `'spine'` slots, but the pack is resolved by SHOP TYPE and there is no `video` pack | no | returns slot + arcade cabinet: both `interactable:true`, **both dead** | +| `pawn` | no | no | no | glass cases + pegboard — dressing only | +| `milkbar` | no | no | no | fridge + glass case: `interactable:true`, **dead** | +| `dept` | no | no | no | glass cases, escalator — dressing only | +| `stall` | **YES, but** — **21 of 48 rooms have ZERO crates**; mean **0.92**, max 3 | no | **no** — a stall dig records `type:'record'`; the stall is `type:'stall'`, so the matcher fails | — | +| `pub` | no | no | no | on a gig night: pay the cover, watch the band | +| `band_room` | no | no | no | ditto | +| `rsl` | no | no | no | ditto | + +Every type additionally: walk in, walk out, look, a keeper standing at the counter, up to 3 browser +rigs, a seeded radio/room-tone, and (with a wantlist) a ~45% keeper rumour on the banner. + +### 10.2 The five structural facts behind that table (verified in the code, R39) + +1. **`?stock=real` is opt-in** — `index.html:222` `params.get('stock') === 'real'`, and + `interior_mode.js:360` gates the adapter on it. No adapter ⇒ `stock.js` never calls + `buildBuyableShelf` ⇒ **on the boot every player gets, there is not one `buyMesh` anywhere in the + game.** The R9 buy-anywhere path is real, tested and shipped, and it is *dark by default*. +2. **The pack is chosen by SHOP TYPE, and `SLOT_FOR` has three entries** (`stockpack.js:85`: + `record→sleeve · book→spine · toy→box`). `buildInterior` asks `getStockPack(recipe.key)`. So the + only shelves that can *ever* be buyable are book spines in a `book` shop and toy boxes in a `toy` + shop — the video store's identical spine shelves cannot be, because no `video` pack exists. +3. **Four stock kinds can never be bought in any mode**: `garment`, `snack`, `magazine`, `treasure`. + `hang()` and `treasures()` never call `buildBuyableShelf`, and no pack type maps to them. **The op + shop's actual stock — the clothes — is unbuyable by construction.** +4. **`sellableIn` is `item.type === shopType`** (`sell.js:37`, fail-closed) and only two call sites + ever stamp a `type`: `interior_mode.js:206` (a dig pull — **always `'record'`, wherever it + happened**) and `:251` (a shelf buy — `currentShop.type`). Therefore on a default boot the sell + card can open in exactly one kind of room: a record shop. +5. **`interactable: true` is not a verb.** `interior_mode`'s E routes exactly three things: an aimed + `kind:'bin'`, the counter (via `nearCounter`), and a `buyMesh`. `case`, `fridge`, `returns` and + the arcade cabinet all set `interactable: true` and **nothing reads them** — 5 glass cases per + pawn room, 8 per dept room, 4 fridges per milk bar, all inert. That flag is currently a comment. + +### 10.3 The correction to the binding fact (R39, measured — the honest direction) + +The round's binding fact #5 says *"only record shops and market stalls have a diggable crate."* True +of the **recipes**. Not true of the **rooms**: the stall's `crate` is `zone:'centre'`, and the stall's +3–6 trestle tables take the aisle first, so the centre candidates are blocked. **21 of 48 stall rooms +(44%) contain no crate at all**; mean 0.92 per room against the record shop's 4.6. +So RED TICKET DAY's roster, which counts a stall as a crate-shop by type, **overcounts**: a roster can +name a stall you cannot dig in. One line fixes it (`fallbackZones` / `anywhere` on the stall's crate +spec — the machinery landed this round), but it adds crates to the world, so it is **held with the +contents question** and not shipped. + +### 10.4 THE RUMMAGE BIN — the op shop's own floor bin (SHIPPED, contents gated) + +`fittings.js rummageBin` + the `opshop` recipe. A shallow galvanised dump tub (0.95 × 0.70 × 0.28) +with flared sides on a castored steel frame, rim at **0.76 m** — you rummage standing, not crouched — +heaped with stickered mixed goods and a hand-lettered card taped to the front panel +("EVERYTHING $2"). Deliberately not a record crate recoloured: `crate` is 0.55 × 0.45 and deep +because it is sized to stand LPs in, and nothing stands up in this. + +```js +// the shipped default — furniture. kind:'prop', no bin, no dig prompt, no `places` entry. +buildInterior(shop, THREE) +// armed — kind:'bin', the dig fires. ONE opt. (interior_test.html: ?opshopdig=1) +buildInterior(shop, THREE, { fittingOpts: { rummageBin: { dig: true } } }) +``` + +**New, generic, opt-in seams (all pure data, no stream reads, absent ⇒ pre-R39 byte-identical):** +- `recipe.fittings[i].o` — opts handed to the fitting builder. +- `buildInterior opts.fittingOpts[kind]` — per-build override of the above. F's gate handle. +- `recipe.fittings[i].fallbackZones` / `.anywhere` — placement fallbacks for a fitting a recipe + *promises* will be there. Without them the bin landed in only **11 of 24** archetype×seed rooms, + because it is appended last and its zone is the fullest it will ever be. With them: **72/72**. + +**Guarantees (72 rooms = 6 archetypes × 6 seeds × GLB on/off, headless):** +- placed **72/72** · **0 carves** · `pathOK` **72/72** · determinism **72/72** +- **reachable 72/72** — the nearest walkable cell that is flood-reachable from the spawn sits + **0.77–1.15 m** from the bin, against the dig's 2.5 m proximity / 3.2 m aimed reach +- **the rest of the room is byte-identical to pre-R39 in 72/72.** The spec is appended LAST, so the + shared `lay-count` / `lay-place` streams hand every existing op-shop fitting the same draws it + always had; only the tub is added. +- end-to-end, armed: aim resolves → dig opens → **16 offers** → pull card → **BUY** debits the wallet + → the bin depletes to 15. Negative control, default: tub present, **0 bins**, aim finds nothing, + the dig never opens. Zero console errors on both arms. + +**Draw cost — measured on the GLB path, which is the default boot (`index.html:349`).** + +| `drawSweep` (the ≤350 law's own instrument) | pre-R39 | R39 | Δ | +|---|---|---|---| +| GLB-**off** worst | 156 (opshop/hall) | **161** (opshop/hall) | **+5** | +| GLB-**on** worst | 319 (opshop/hall) | **329** (opshop/hall) | **+10** | +| every other type, both modes | — | — | **0 — byte-identical, 11/11** | + +**Margin against the ≤350 law: 21 draws.** Per-room the tub costs **+3…+9** GLB-off and **+6…+18** +GLB-on (the doubling is not the tub — see §10.5). It is the worst-case room it lands in, which is why +this is stated against `opshop/hall` and not an average. Soak: 60 rooms, **leak geo 0 / tex 0**, +determinism identical, avg 3.4 ms / worst 16.5 ms. + +**A free win found while costing it (`stock.js`, shipped).** The scatter fill minted a *tinted* +material per item, so a pile of 9 goods in a room with no other scatter cost **9 draws** — one bucket +each. New opt-in `slot.pool` keeps every face on the room's shared pooled textures at white, exactly +as the spine and shelf-box rows already do, so the pile merges into buckets the room already has. +The tub went from 9 goods / 12 draws to **12 goods / 3–9 draws**. Existing scatter callers +(`trestleTable`, `pegboard`) draw the same values in the same order and are byte-identical — proven +by all 11 other types' `drawSweep` numbers not moving. + +### 10.5 THE INTERIOR BUDGET IS ~2× WHAT IT NEEDS TO BE — one asset material (→ Lanes E/F) + +Costing the tub turned up something worth more than the tub. **Lane E's `bookshelf` GLB ships +`transmission: 1`** on `mtl_10218_Bookshelves_v1`. One transmissive material in a scene makes +three.js run its transmission pre-pass — **a second full render of the opaque scene into a render +target** — so *every* draw in that room is issued and counted twice. + +Measured, seed 1990, isolated room, by setting `transmission = 0` on that material and re-rendering: + +| room | GLB-on draws | with `transmission: 0` | saved | +|---|---|---|---| +| `opshop`/hall (the game's worst interior) | 191 | **104** | **−87 (−46%)** | +| `opshop`/wide | 142 | **80** | −62 (−44%) | +| `book`/hall | 101 | **51** | **−50 (−50%)** | +| `dept`/hall (no bookshelf) | 116 | 116 | 0 — the control | + +`bookshelf` is in the `opshop` (1–2) and `book` (5–8) recipes, which are the two worst GLB-on types +in the sweep (329 and 243). **Roughly half the interior draw budget is being spent on a pre-pass +nobody asked for, triggered by a flag on one asset**, and a bookshelf rendering as glass is almost +certainly not the intent. **→ E:** re-emit without `KHR_materials_transmission`. **→ F:** the cheap +runtime fence is a `transmission = 0` scrub in `glb.js upgradeFitting`; I have NOT shipped it, because +it changes how another lane's asset renders and that is not mine to decide in passing. Either fix +turns a 21-draw margin into a ~100-draw one. + +### 10.6 THE CONTENTS — HELD FOR JOHN. Do not flip the default without this ruling. + +The mechanism is shipped and gated. **What is IN the bin is the question**, because it is not "op +shops now dig", it is "op shops now dig **records**". Measured against the 23 shipped town caches +(1,236 shops: **44 record · 36 stall · 147 opshop**): + +| | today | with the op-shop bin armed | +|---|---|---| +| crate-**shops** per town | 0–11 | **2–25** | +| towns with **none** | **3** (bowral, darwin, daylesford) | **0** | +| diggable **bins**, all 23 towns¹ | ~235 | ~382 (**+63%**) | +| under-band gems/day, all towns² | ~113 | ~183 | + +¹ record 4.6 bins/room × 44 + stall 0.92 × 36 + opshop 1.0 × 147. ² `dig.js:48` — 10% gem roll on a +collector/grail band ⇒ 3% per sleeve × 16 sleeves/bin. + +**A · parody records (what the mechanism inherits).** **Cost: zero code** — `proceduralSleeves` +already serves everything, and *the market stall already does exactly this*, so this is a change of +scale, not of kind. BIBLE bands untouched (`record` bands exist). RED TICKET DAY gets a roster in all +23 towns. **The catch:** an op-shop find is stamped `type:'record'`, so it can only be sold in a +record shop — and bowral, darwin and daylesford have **zero** record shops. Those three towns would +be able to dig and have nowhere to sell. The first pull from an armed bin, seed 42, was a `grail`-band +record asking **$72**, in the Salvos bric-a-brac tub. That is either the op-shop fantasy made +mechanical or the moment records stop being special; it is a taste call, not a measurement. + +**B · books and toys through the existing pull-and-buy pack.** Not free. (i) The pack is resolved by +shop type, so an op shop asks for `stock_opshop_index.json`, which does not exist — it needs either an +E-emitted pack or a recipe-declares-its-pack-types change in C. (ii) **The dig scene is +record-shaped**: `dig.js` builds a crate of 0.31 m square sleeves and a card that says +artist/title/condition. A paperback in it renders as a square LP sleeve. So B costs a second dig +presentation. In its favour: the BIBLE already has `book` and `toy` bands (`bible.js:28-29`), so the +price fences hold with **zero new numbers**, and the sell path is clean once F stamps the item's own +type instead of the hardcoded `'record'`. + +**C · a new bric-a-brac class.** Highest cost — a new BIBLE band row, a parody generator or pack, a +card treatment, gates. Also the only option that *fixes* A's asymmetry: `sellableIn` is type = type, +so bric-a-brac would sell **in op shops**, which is both correct and diegetic, and the op shop becomes +its own loop instead of a second-rate record shop. + +**My recommendation.** Ship the flag (done), and **let John play arm A for one session before anyone +decides** — it is zero code and it answers the only question that matters, which is whether digging an +op shop is fun. Then: **C is the destination**, because it is what stops "records" being the answer to +every question in this game and it closes the three-town sell hole. **B is not worth doing alone** — +its cost lands in the wrong place (a record-shaped dig scene showing a book), and its good half (the +band table, the type stamp) is inherited by C anyway. If John wants RED TICKET DAY running in all 23 +towns *this round*, A does it today for nothing, and the three-town sell hole is the price. diff --git a/docs/shots/laneC/r39_opshop_room.png b/docs/shots/laneC/r39_opshop_room.png new file mode 100644 index 0000000..b8aaecc Binary files /dev/null and b/docs/shots/laneC/r39_opshop_room.png differ diff --git a/docs/shots/laneC/r39_opshop_rummage_bin.png b/docs/shots/laneC/r39_opshop_rummage_bin.png new file mode 100644 index 0000000..00edc9e Binary files /dev/null and b/docs/shots/laneC/r39_opshop_rummage_bin.png differ diff --git a/web/interior_test.html b/web/interior_test.html index 85c3bde..c26f7cd 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -77,6 +77,10 @@ import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './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'; +// [R39 §39.3] `?opshopdig=1` arms the op shop's rummage bin as a real `kind:'bin'` (the mechanism is +// built and measured; WHAT IS IN IT is John's call, so the default stays furniture). Both arms are +// 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); @@ -151,7 +155,8 @@ function rebuild() { const arch = archSel.value === 'auto' ? undefined : archSel.value; 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 }); + current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined, + stock: STOCK_REAL ? 'real' : undefined, 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); @@ -582,7 +587,7 @@ 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, - DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter, + DIG_ON, STOCK_REAL, OPSHOP_DIG, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter, shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak, SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST }; diff --git a/web/js/interiors/fittings.js b/web/js/interiors/fittings.js index 3afef82..0e08116 100644 --- a/web/js/interiors/fittings.js +++ b/web/js/interiors/fittings.js @@ -1,7 +1,7 @@ // PROCITY Lane C — parametric fittings kit. Ported from 90sDJsim/web/world/fittings.js (the kit was // "written to be lifted") and extended with the fittings the CITY_SPEC registry needs: cube/metal/ // book/VHS shelving, glass case, fridge, magazine rack, spinner rack, armchair, escalator prop, -// returns slot, barred window, listening corner, crate. +// returns slot, barred window, listening corner, crate, rummage bin (the op shop's own floor bin, R39). // // Every builder is `(ctx, o, r) → Fitting` where: // ctx : the shared Ctx (tracked box/mat/cyl for leak-free disposal, canvas textures) @@ -92,6 +92,86 @@ function crateStack(ctx, o = {}, r) { return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: n * ch, slots: [], places: [] }; } +// ─────────────────────────────────────────────────────────────────────────────────── +// OP SHOP +// +// [R39 — THE OP SHOP CAN'T BE DUG] Until this round `kind:'bin'` — the thing the dig fires on — was +// minted in exactly two places (recordBin, crate) and used by exactly two recipes (record, stall). +// The op shop, the type this game is built out of, had clothes racks, shelves, a trestle and a +// crateStack its own comment calls back-room dressing: **no floor bin at all.** This is the op shop's +// own bin, and it is deliberately NOT a record crate recoloured: +// • WIDER THAN DEEP and shallow (0.95 × 0.70 × 0.28 tub) — a dump bin, not a sleeve bin. `crate` is +// 0.55 × 0.45 and deep, because it is sized to stand LPs up in; nothing stands up in this. +// • RIM AT 0.76 m — you rummage standing with your elbows in, not crouched over a floor crate. +// • FLARED SIDES on a castored steel frame — the church-hall / charity-shop dump tub silhouette. +// • A HAND-LETTERED CARD taped to the front panel ("EVERYTHING $2") — the one thing no record bin +// in this game has, and the thing that makes it read op shop from across the room. +// CONTENTS ARE NOT DECIDED HERE. `o.dig` (default **false**) is what mints `kind:'bin'`; with it off +// this is honest op-shop furniture and the game's economy is untouched. See LANE_C_PUB §10: what a +// dig in an op shop yields is a John taste call, and turning it on ships "op shops dig RECORDS". +const RUMMAGE_CARD = ['EVERYTHING $2', 'BRIC-A-BRAC 50¢', '$2 EACH', 'FILL A BAG $5', 'ANY 3 FOR $5']; +const RUMMAGE_TUB = ['#8d949a', '#6e7f86', '#7c8a6a', '#9a8f7a']; // galvo, faded teal, sage, cream-grey +function rummageBin(ctx, o = {}, r) { + const g = new ctx.THREE.Group(); + const w = o.w || 0.95, d = o.d || 0.70; + const legH = 0.48, tubH = 0.28, th = 0.02, flare = 0.24; // rim = legH + tubH = 0.76 m + const tub = ctx.mat(RUMMAGE_TUB[(r() * RUMMAGE_TUB.length) | 0], 0.6, { metalness: 0.25 }); + const frame = MAT.metal(ctx); + // castored steel frame: four legs + a lower cross rail (the rail is where the card is wired on) + [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => { + ctx.box(0.04, legH, 0.04, frame, sx * (w / 2 - 0.07), legH / 2, sz * (d / 2 - 0.07), g); + ctx.cyl(0.035, 0.03, frame, sx * (w / 2 - 0.07), 0.015, sz * (d / 2 - 0.07), g); // castor + }); + ctx.box(w - 0.1, 0.03, 0.03, frame, 0, 0.2, d / 2 - 0.07, g); + ctx.box(w - 0.1, 0.03, 0.03, frame, 0, 0.2, -(d / 2 - 0.07), g); + // the tub: outer floor + four panels flared out at the rim, plus a raised inner plate so the goods + // ride up and crest the rim (the same trick `crate` uses for its sleeves — a full bin, not a hole) + ctx.box(w, th, d, tub, 0, legH, 0, g); + ctx.box(w - 0.06, th, d - 0.06, tub, 0, legH + 0.10, 0, g); + const side = (bw, bh, bd, x, y, z, rx, rz) => { + const m = ctx.box(bw, bh, bd, tub, x, y, z, g); m.rotation.x = rx; m.rotation.z = rz; return m; + }; + side(w, tubH, th, 0, legH + tubH / 2, d / 2, flare, 0); + side(w, tubH, th, 0, legH + tubH / 2, -d / 2, -flare, 0); + side(th, tubH, d, -w / 2, legH + tubH / 2, 0, 0, -flare); + side(th, tubH, d, w / 2, legH + tubH / 2, 0, 0, flare); + // the hand-lettered card, taped to the front panel — the op-shop tell. Sits ON the flared panel's + // own face: rotating a +Z plane by `flare` about X tilts its normal to (0, −sin, cos), so the card + // is offset along that normal rather than along +Z, or it z-fights the panel it is taped to. + const card = ctx.plane(0.52, 0.20, cardMat(ctx, RUMMAGE_CARD[(r() * RUMMAGE_CARD.length) | 0]), g); + card.position.set(0, legH + tubH / 2 - Math.sin(flare) * 0.018, d / 2 + Math.cos(flare) * 0.018); + card.rotation.x = flare; + // mixed goods, tipped in — the trestle-table scatter, not a fan of sleeves. `pool` keeps every face + // on the room's shared pooled textures, so a full tub is ~0 extra draws (see stock.js shelfLine). + const slots = [{ kind: 'box', x: 0, y: legH + 0.11, z: 0, ry: 0, run: w - 0.16, depth: d - 0.16, + height: 0.22, count: o.items ?? 12, lean: false, scatter: true, pool: true }]; + // `dig` is the CONTENTS gate, not a geometry switch — off ⇒ kind 'prop' ⇒ no bin, no dig prompt, + // no `places` entry, and the room is byte-identical to a room with any other prop in this slot. + const diggable = o.dig === true; + g.userData = diggable ? { kind: 'bin', interactable: true } : { kind: 'prop' }; + return { group: g, footprint: { w: w + 0.16, d: d + 0.16 }, height: legH + tubH, + slots, places: diggable ? [g] : [] }; +} + +// A biro-on-cardboard price card (the op shop's own signage voice — not the shop's printed signs). +// The font SHRINKS TO FIT: a fixed size clipped "EVERYTHING $2" to "VERYTHING $" on screen, which is +// the one thing on this prop the player can actually read. +function cardMat(ctx, text) { + const W = 192, H = 72, PAD = 14; + const cv = document.createElement('canvas'); cv.width = W; cv.height = H; + const x = cv.getContext('2d'); + x.fillStyle = '#d9c9a4'; x.fillRect(0, 0, W, H); // cardboard + x.strokeStyle = 'rgba(90,70,40,.45)'; x.lineWidth = 2; x.strokeRect(4, 4, W - 8, H - 8); + const s = String(text); + let px = 30; + do { x.font = `bold ${px}px "Comic Sans MS",cursive,Arial`; px -= 1; } + while (px > 9 && x.measureText(s).width > W - PAD * 2); + x.fillStyle = '#1c2f6a'; x.textAlign = 'center'; x.textBaseline = 'middle'; + x.fillText(s, W / 2, H / 2 + 1); + const m = ctx.mat('#ffffff', 0.85); m.map = ctx.canvasTexture(cv); m.needsUpdate = true; + return m; +} + // A listening corner: a low bench + a boxy hi-fi + headphones on a stand. function listeningCorner(ctx, o = {}, r) { const g = new ctx.THREE.Group(); @@ -431,7 +511,7 @@ function ampStack(ctx, o = {}, r) { } export const FITTINGS = { - recordBin, crate, crateStack, listeningCorner, + recordBin, crate, crateStack, listeningCorner, rummageBin, wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet, clothesRack, trestleTable, counter, glassCase, diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 1236160..479f266 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -42,7 +42,11 @@ // • type : any CITY_SPEC type or thriftgod alias (see theme.js); defaults to 'opshop' // • lot : { w, d } metres — room adapts to it; if absent, archetype ranges decide the size // • storeys: ceiling height driver (1 → 3.2m … up to 4.5m) -// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, stock?: 'real', stockBase? } +// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, stock?: 'real', stockBase?, +// fittingOpts? } +// • fittingOpts (R39): { [fittingKind]: {…} } — per-build override of a recipe's own fitting opts. +// `{ rummageBin: { dig: true } }` turns the op shop's rummage bin into a DIGGABLE bin (§39). Omit +// ⇒ recipe defaults ⇒ byte-identical to pre-R39. // • stockBase (v5.0): which real-stock pack `stock:'real'` resolves. Omit → the town-wide packs // (`assets/models/`, today's behaviour). Pass `assets/stock_godverse//` for a shop // with its own real crate. The pack cache is keyed by (type, base) — see stockpack.js + LANE_C_PUB §7. @@ -193,7 +197,10 @@ export function buildInterior(shop, THREE, opts) { if (pack) adapter = makeStockAdapter(pack); else warnOnce(`stock=real: no pack for "${recipe.key}" @${opts.stockBase || 'default base'} (preload it) — using parody canvas`); } - const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm }); + // [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 }); // 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 diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js index f9d58cb..279fac7 100644 --- a/web/js/interiors/layout.js +++ b/web/js/interiors/layout.js @@ -331,7 +331,7 @@ function zoneCandidates(zone, W, D, doorCx, r) { } // ── main entry ──────────────────────────────────────────────────────────────────────── -export function layout(ctx, { recipe, dims, shell, adapter, shop }) { +export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts }) { const { W, D } = dims; const doorCx = shell.door.x; const grid = makeGrid(W, D); @@ -374,7 +374,12 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) { let n = Math.round(frange(rCount, spec.min, spec.max + 0.99) * Math.min(1.3, density)); n = Math.max(spec.min, Math.min(spec.max, n)); for (let i = 0; i < n; i++) { - const f = buildFitting(spec.kind, ctx, {}, ctx.stream(`fit-${spec.kind}-${i}`)); + // [R39] fitting opts: recipe-declared (`spec.o`) with a per-build override (`opts.fittingOpts`, + // keyed by fitting kind) on top. PURE DATA — reads no stream, so a spec that declares none is + // byte-identical to pre-R39 and an override can never shift another fitting's seeded pick. + const fo = fittingOpts && fittingOpts[spec.kind]; + const o = spec.o || fo ? { ...(spec.o || {}), ...(fo || {}) } : {}; + const f = buildFitting(spec.kind, ctx, o, ctx.stream(`fit-${spec.kind}-${i}`)); let ok = false; if (spec.zone === 'wall' || f.wallMounted) { ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec); @@ -383,6 +388,15 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) { } else { ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec); } + // [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 + // dropped, exactly as before, and the density self-caps. Measured: the bin is appended last, so + // its zone is the fullest it will ever be — `aisle` alone landed it in only 11/24 archetype×seed + // 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; } + 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) */ } } } @@ -466,6 +480,30 @@ function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) { return false; } +// [R39] Last resort for an opt-in spec (`anywhere: true`): the first free rect on a seeded sweep of +// the open floor. Respects `reserved` through areaFree, so it can never land in the door corridor or +// the strip in front of the till. Deterministic (the cell list is built in grid order, then shuffled +// on `rPlace`). Never called unless the spec asked for it AND every zone already failed. +function placeAnywhere(ctx, grid, f, W, D, r, placed, spec) { + const cells = []; + for (let cz = 1; cz < grid.rows - 1; cz++) for (let cx = 1; cx < grid.cols - 1; cx++) { + if (grid.occ[cz * grid.cols + cx] !== 0 || grid.reserved[cz * grid.cols + cx]) continue; + cells.push([(cx + 0.5) * grid.cw - W / 2, (cz + 0.5) * grid.cd - D / 2]); + } + shuffle(r, cells); + for (const [x, z] of cells) { + const ry = (r() - 0.5) * 0.4; + const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); + const rect = rectCells(grid, x, z, hw, hd); + if (!areaFree(grid, rect)) continue; + f.group.position.set(x, 0, z); f.group.rotation.y = ry; + stampRect(grid, rect); + placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind }); + return true; + } + return false; +} + function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) { const backOff = f.wallMounted ? 0.06 : f.footprint.d / 2 + 0.05; for (const slot of wallSlots) { diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js index 6c0c46f..f14f78f 100644 --- a/web/js/interiors/stock.js +++ b/web/js/interiors/stock.js @@ -152,8 +152,16 @@ function shelfLine(ctx, parent, slot, opts, r) { if (scatter) { // tables / pegboards: random small goods for (let i = 0; i < n; i++) { const s = 0.09 + r() * 0.11; - const m = ctx.mat(pick(r, CARD), 0.85); - if (r() < 0.5) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; } + // [R39] `slot.pool` — keep every face on a POOLED texture at white, exactly as the spine and + // shelf-box rows already do, so a scattered pile MERGES into the room's existing stock buckets + // instead of minting one bucket (= one draw) per item. Measured on the rummage bin: 9 tinted + // scatter items cost 9 draws in a room with no other scatter; pooled they cost 0–1. Absent ⇒ + // the pre-R39 tinted mix, and the stream shape is unchanged either way (both branches draw + // tint, then the coin, then the face only when mapped) — trestle/pegboard are byte-identical. + const tint = pick(r, CARD); + const mapped = r() < 0.5 || !!slot.pool; + const m = ctx.mat(slot.pool ? '#ffffff' : tint, 0.85); + if (mapped) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; } const b = ctx.box(s, slot.wall ? s * 1.4 : s, slot.wall ? 0.03 : s, m, slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2), slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent); diff --git a/web/js/interiors/theme.js b/web/js/interiors/theme.js index 52df37e..b658325 100644 --- a/web/js/interiors/theme.js +++ b/web/js/interiors/theme.js @@ -16,9 +16,13 @@ // floorBias : biased pick from carpet/lino/board floor names // clutter : 0..1 density multiplier (some shops crammed, some sparse) // counterPos : 'door' | 'forward' | 'corner' | 'back' — where the till lives -// fittings : the mix. Each {kind, zone, min, max, priority}. Counts scale with clutter+area. +// fittings : the mix. Each {kind, zone, min, max, priority, o?}. Counts scale with clutter+area. // priority: higher = more essential; flood-fill removes low-priority first if the // door→counter path is ever blocked. Counter is always highest. +// o (R39, optional): opts handed to the fitting builder. Pure data, reads no stream. +// A per-build override lives at buildInterior opts.fittingOpts[kind]. +// fallbackZones / anywhere (R39, optional): opt-in placement fallbacks for a fitting +// the recipe PROMISES will be there. Absent ⇒ pre-R39 behaviour (drop it). // stockKind : what stock.js renders into this shop's carriers // signFlavour : seeded inside signage strings, themed @@ -60,6 +64,14 @@ const RECIPES = { { kind: 'trestleTable', zone: 'aisle', min: 1, max: 2, 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 }, + // [R39 — THE OP SHOP CAN'T BE DUG] the op shop's own floor bin. APPENDED LAST on purpose: the + // recipe loop draws counts from `lay-count` and placements from `lay-place` in array order, so a + // spec added at the end leaves every existing op-shop fitting's draws — and therefore its + // placement — byte-identical, and only adds the tub. `dig:false` is the CONTENTS gate: off, this + // is furniture (kind 'prop'); `{ rummageBin:{dig:true} }` in opts.fittingOpts makes every op shop + // 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 } }, ], stockKind: 'garments', signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'],