diff --git a/C-progress.md b/C-progress.md index 3aa7b1a..2227950 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,63 @@ *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-16 (round 23) · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-07-17 (round 24) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-07-17 (round 24, THE ALPHA COMPLETION) — the contract fix (ledger #1, the root cause) + +R24 §Lane C. **The root cause was mine and it's closed.** F held the tag because my published atlas +contract and my own loader were mutually incompatible; the ruling — **the runtime is the authority** — is +now the section's first line. Published FIRST (half-day rule; G and E re-emit against it). + +**What was actually wrong.** My R22 §7 invented `stock_shop__index.json`. The loader +(`stockpack.js:25`) fetches `` `${base}stock_${type}_index.json` ``. **An index named per my contract can +never be fetched by my own code.** So G built runtime-compatible (correct), E built contract-compatible +(also correct — the contract said so), and E's validator globbed `stock_shop_*_index.json` → **zero files +→ vacuous pass**. Nobody was wrong; the contract was. I wrote it, so I own it. + +**§7 rewritten against the code (LANE_C_PUB):** +- **Authority rule:** `stockpack.js` IS the contract; if the doc and the loader disagree, the doc is a bug. +- **Layout ratified as G shipped it:** `assets/stock_godverse//stock__index.json`. + `type` is load-bearing (it picks the slot via `SLOT_FOR`: record→sleeve). `stock_shop_*` is **retired** + → **E: the validator glob becomes `stock_godverse/**/stock_*_index.json`** (that's the vacuous gate's + actual cause). +- **Runtime-read vs runtime-ignored, stated exactly.** Reads `atlases[]` + `items[{atlas, uv}]` (uv origin + **top-left**; the loader flips V — don't pre-flip). Ignores `version/atlas_px/cell*/count/kind/tier/shop/ + crate/provenance` entirely. +- **The provenance drift dies (§7.3):** since the runtime reads none of it, the authority is E's validator + + the 23 shipped town caches — both use **top-level `license` (US), `attribution`, `generator`, + `snapshot`**. British `licence` retired; **G lifts+renames those four**, keeps its rich nested + `provenance{}` (source/sha256/determinism) as colour. `atlas_px` stays REQUIRED (E checks it against real + dims — a genuine falsifiable check). `cell*` informational: **C reads `uv`, never `cell`** — and don't + shrink the cell for VRAM, the dig renders covers close-up. + +**Code landed (the `type+base` keying, F's filed item):** +- `stockpack.js`: cache identity is now `` `${base}|${type}` ``; `getStockPack(type, base = DEFAULT_STOCK_BASE)`. +- `interiors.js`: new **`opts.stockBase`** — the seam F wires (`buildInterior(shop, THREE, { stock:'real', + stockBase: 'assets/stock_godverse//' })`). Omitted ⇒ town-wide packs, byte-identical to today. +- Pre-v5 callers untouched (`getStockPack('record')` still means the town pack). + +**Verified fresh — and I had to fix my own metric first.** R23 I claimed "8/8 atlas-backed"; that checked +`material.map` exists — **but a parody canvas has a map too**. That's the same species as the vacuous gate +I'd just been handed. Re-asserted falsifiably: +- **Every sleeve samples G's atlas FILE** — texture sources are exactly `stock_godverse/3962749/ + stock_record_atlas_00.webp` / `_01.webp`; pack items ≡ index items **by id**. Titles are real + (*Yoshitoshi Artists – In House We Trust / Deep Dish – Penetrate Deeper*). +- **Negative control (what makes it a gate):** the parody room's stock textures are `(canvas, no src)` and + touch the godverse atlas **zero** times — the metric discriminates. +- **Collision FIXED:** shop pack (120 items) + town pack (350) coexist as distinct objects. +- **Fail-soft FIXED with a populated cache:** missing base ⇒ `null` ⇒ parody (the R23 correctness bug — + an unstocked shop inheriting a cached crate — is gone). +- Ceilings: **98 draws** with real covers via `opts.stockBase`; `pathOK`; 0 carves; **0 console errors**. + +**Scoped, not slid:** the **LRU + dispose** (§7.6) lands at **v5.0-beta**, not here — with one stocked shop +there's no accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared +material), which arrives with F's per-shop preload. Written into the contract so it can't slide silently. + +**→ F:** the seam is `opts.stockBase`; §7.5 has the call. **→ G:** layout ratified, re-emit provenance field +names only (§7.3). **→ E:** the glob (§7.1) is the vacuous gate's cause. --- diff --git a/docs/LANES/LANE_C_PUB.md b/docs/LANES/LANE_C_PUB.md index ae30454..f116b8b 100644 --- a/docs/LANES/LANE_C_PUB.md +++ b/docs/LANES/LANE_C_PUB.md @@ -7,9 +7,11 @@ > (ledger #2, the density widening; C→E handshake). The frozen venue contract (§0–§5) is unchanged. > **v4.0 amendment (2026-07-16):** **§7 added — the G2a per-shop stock-atlas contract** (C's interior > draw/tex ceilings, answering Lane G + E's review). §0–§6 unchanged. -> **v5.0-alpha amendment (2026-07-16):** **§7.2's VRAM number corrected + §7.8 added** — G's first real -> crate (120 records) proved my "≤8 MB" figure was derived from a wrong assumption, and the per-shop cache -> seam is now specified for G+F. **G's R23 atlas needs no re-emit.** §0–§6 unchanged. +> **v5.0-alpha REWRITE (2026-07-17, R24 ledger #1):** **§7 rewritten against the code — the runtime is the +> authority.** My R22 wording invented an index name my own loader could never fetch; that contradiction +> held F's tag. §7 now states what `stockpack.js` actually implements, extended per-shop, and the +> `type+base` cache keying is landed. **G's R23 atlas layout is ratified** (G re-emits provenance field +> names only, §7.3). §0–§6 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 @@ -226,99 +228,84 @@ mapping for the caches is this table.) --- -## 7. G2a — per-shop stock atlases: C's interior ceilings (v4.0, ledger #3) → Lane G + Lane E +## 7. Real-stock packs — THE RUNTIME IS THE AUTHORITY (v5.0-alpha, R24 ledger #1) → Lanes E/F/G -*Answering Fable's three priors + E's half (LANE_E_NOTES). **Verdict: all three priors hold — atlas-per-shop -is the right shape, and it's strictly BETTER for draws than today's type-packs.** The binding constraint is -**VRAM, not draws**, and E's dispose ask is granted: it's my seam and I own the fix.* +> **This section was the root cause of R23's held tag and has been rewritten against the code.** +> My R22 wording invented an index name (`stock_shop__index.json`) that **my own +> loader can never fetch** — so G built runtime-compatible, E built contract-compatible, and E's +> validator glob matched **zero files** (the vacuous gate). Nobody was wrong; the contract was. +> **Rule going forward: `web/js/interiors/stockpack.js` is the contract. If this doc and the loader +> disagree, the loader is right and this doc is a bug.** G's R23 atlas was already correct — it is +> hereby ratified as the layout, and **G re-emits only the provenance field names (§7.3).** -**1. Draw ceiling — ONE atlas per shop (hard cap 2).** -Real stock batches to **one draw per atlas** (shared material per atlas + per-item UV baked into geometry → -`batch.js` merges). A room shows exactly one shop's stock, so a per-shop atlas = **1 atlas = 1 draw** — better -than a type-pack, which can hit several atlases per room. **Rule: 1 atlas/shop; hard cap 2** (each extra = +1 -draw, and the R9 buyable-shelf path wants one atlas per shelf to stay one-mesh/one-draw). A 60+ item shop -spills to the shared type-pack rather than fragmenting the room (E's (b) — agreed). *Headroom: `?stock=real` -worst rooms today are record 41 / book 58 / toy 57 draws against the ≤350 law; my R22 23-town soak worst is -159. Draws are not the constraint.* +### 7.1 File layout (loader-exact) +The loader is `preloadStockPack(type, { base })` → it fetches **`stock__index.json`**. +- **`type` ∈ `record` | `book` | `toy`** — and it is *not* cosmetic: `type` picks the stock slot + (`SLOT_FOR`: record→`sleeve`, book→`spine`, toy→`box`). **A record shop's per-shop pack must be + type `record`**, or the adapter feeds nothing. +- **Town-wide packs** (unchanged): `assets/models/stock__index.json`. +- **Per-shop packs (v5): `assets/stock_godverse//stock__index.json`** — the base + is the per-shop directory; the filename is the same. *(This is exactly G's R23 layout.)* +- **RETIRED:** `stock_shop__index.json` — never loadable; do not emit, do not glob for it. + **→ Lane E:** `validate_atlas.py`'s glob must become `stock_godverse/**/stock_*_index.json` + (the current `stock_shop_*_index.json` is why it passed on 0 files). -**2. Texture/VRAM ceiling — the real limit. E's dispose ask: GRANTED, and it's mine to land.** -E's audit is correct and the cause is a deliberate C design note (`stockpack.js:11`): atlas textures are -"shared/cached in the pack (**NOT per-room**), like depot GLBs". That is **right for 3 bounded type-packs** -and **wrong for unbounded per-shop atlases** (23 towns × N shops). The ceiling: +### 7.2 Fields the RUNTIME reads (absence ⇒ pack `null` ⇒ parody) +- **`atlases: string[]`** — resolved against the **same base**; `depot:`/`https:` entries pass through. +- **`items: [...]`, non-empty** — each item needs: + - **`atlas`** — must be one of `atlases` (items are grouped by it so a bin batches to one draw). + - **`uv: [u0,v0,u1,v1]`, origin TOP-LEFT** — the loader flips V itself (`uvRect`). Do not pre-flip. +- Read by the dig/buy consumers (not by the loader): **`id`, `title`, `artist`, `price`, `price_band`**. -> **⚠️ CORRECTED v5.0-alpha — measurement beat my assumption.** I wrote "≤8 MB decoded" by assuming a -> "typical shop" is ~16 items @1024². **G's first real crate is 120 records** (Monster Robot Party, crate -> 550) — a real record shop is nothing like 16 items. At the contract's own 256² cell that's 2 × 2048² -> = **~32 MB decoded**, 4× my stated figure. The *structural* rules were right (≤2 atlases; 2048² for a -> 60+ item shop) — only the MB number was wrong, because it was derived from a shop that doesn't exist. -> **G's R23 atlas is compliant; no re-emit.** The corrected ceiling: +### 7.3 Fields the runtime IGNORES — and the provenance ruling (the drift dies here) +`version`, `atlas_px`, `cell`/`cell_w`/`cell_h`, `count`, `kind`, `tier`, `shop`, `crate`, +`provenance` — **`stockpack.js` reads none of them.** So the authority for their shape is not C's +loader but **E's `validate_atlas.py`**, and the house convention set by 23 shipped town caches: +- **Provenance lives at TOP LEVEL: `license`, `attribution`, `generator`, `snapshot`** — US spelling + `license`, exactly what E's validator reads and what every town cache already uses. + **British `licence` is retired. → Lane G: rename + lift those four to top level.** +- Rich detail **may** stay nested under `provenance{}` (G's `source` / `snapshot_sha256` / + `determinism` block is good — keep it). The four gate fields are the contract; the block is colour. +- **`atlas_px` REQUIRED** — E's validator checks it against the atlas's real pixel dimensions. That is + a genuine falsifiable check; keep it. +- **`cell` / `cell_w` / `cell_h` are informational.** **C reads `uv`, never `cell`** — so 256², a + 176×256 portrait book cell, anything: all fine. **Do not shrink the cell to save VRAM** — the dig + renders a cover close-up; resolution is the one thing the player actually sees. -- **Per shop: ≤2 atlases, ≤2048² each** (unchanged — G's 120-item crate lands exactly on the cap). -- **Resident: ONE shop's atlas set** (LRU = 1 shop, not 2) ⇒ **≤32 MB decoded peak**. Still bounded, and - still far better than the **181 MB unbounded** accretion E measured today. -- **≤1024² whenever the crate fits it** (≤64 items at 256²) — don't hand a 6-item shop a 2048². -- Cell size is E/G's call: **C reads `uv`, never `cell`** (so 256², 176×256 portrait, etc. are all fine). - Do **not** shrink the cell below 256² to save VRAM — the dig renders a cover close-up; resolution is the - one thing the player actually sees. -- **C lands the LRU cap + dispose in `stockpack.js`** (evicted shop's atlas texture + material disposed). - Type-packs keep today's shared cache (bounded, reused across rooms of a type) — unchanged. -- **Scheduling:** today's 181 MB accretion is **pre-existing**, and `?stock=real` is **opt-in** - (`index.html:196` — `params.get('stock')==='real' && !NOASSETS`), so it cannot touch the default boot and is - **not a v4.0-tag blocker**. I land the cap as **C's first v4.x item — before G2a ships**. G: build to the - ceiling; the loader will honour it. +### 7.4 Cache identity = **(type, base)** — landed R24 +`stockpack.js` keys `_packs`/`_resolved` by `` `${base}|${type}` ``; `getStockPack(type, base = +DEFAULT_STOCK_BASE)`. Keying by `type` alone made `base` a **no-op on a cache hit** (measured R23: +the 2nd `preloadStockPack('record', …)` returned the identical object). That was two bugs: +1. two stocked shops collide — the 2nd serves the 1st's crate; **and** +2. **it defeated fail-soft** — an *unstocked* shop of an already-cached type inherited that crate + instead of falling back to parody. (A correctness bug, not a mix-up.) +Both fixed and re-measured (§7.7). Pre-v5 callers are unaffected: no `base` ⇒ the town-wide pack. -**3. Index schema — E's (c) confirmed VERBATIM, keyed per shop.** -`stock_shop__index.json` = `{version, atlas_px, cell, atlases[], items:[{id,title,artist,price, -price_band,atlas,uv:[u0,v0,u1,v1]}]}`. `stockpack.js` consumes it unchanged (it already groups items by atlas -and shares a material per atlas). **UV origin stays top-left (image-natural) — C flips V for WebGL** (R7 law, -unchanged). Book cell 176×256 portrait is fine: **C reads `uv`, never `cell`**. +### 7.5 The seam (→ Lane F, ledger #6a) +```js +buildInterior(shop, THREE, { stock: 'real', stockBase: `assets/stock_godverse/${shop.godverseShopId}/` }) +``` +`opts.stockBase` omitted ⇒ the town-wide packs (today's behaviour, byte-identical). Preload the shop's +pack on entry with the same base. **C consumes whatever identity field A/G land (`godverseShopId`) — +C does not invent it.** -**4. Fail-soft + `?noassets` — unchanged law, confirmed.** -Index/atlas missing → `getStockPack` → null → **parody canvas + one warn** (R7, proven since). `?noassets` -fetches zero. A godverse town under `?noassets` stays silent-and-fine. `?stock=real` opt-in ⇒ G2a cannot -regress the default or classic boots. +### 7.6 Ceilings (C's, unchanged from the R23 correction) +- **Draws:** one atlas = **one draw**; **≤2 atlases per shop** (G's 120-record crate lands on the cap); + a 60+ item shop may use 2048²; spill beyond 2 → the town-wide pack. Room law ≤350 stands. +- **VRAM:** **≤2 atlases @≤2048²; LRU = 1 shop's atlas set resident ⇒ ≤32 MB peak.** + *(R23 correction: my "≤8 MB" assumed a ~16-item "typical shop". A real record shop is 120 records.)* +- **The LRU + dispose is C's, and it lands at v5.0-beta**, not here: with ONE stocked shop there is no + accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared material) — + that seam arrives with F's per-shop preload. Stated so it can't slide silently. -**5. Determinism — one ask back to G.** -The seeded pick per bin is C's and stays: same `shop.seed` → same crate contents, byte-identical revisits. -That indexes into `items[]`, so **G must emit `items[]` in a deterministic order** (sort by `id`) — otherwise -a re-emit silently reshuffles every crate. Same discipline as E's byte-identical cache re-runs. - -**6. Buy-anywhere (R9) — compatible, no change.** -`buildBuyableShelf` merges per-item-addressable covers from ONE atlas → one draw (`noBatch`). A per-shop atlas -satisfies that naturally (better than the type-pack's seeded `atlasPool` pick). Parody = not buyable stays. - -**7. Parody law — E's (d), unchanged and not C's to relax.** Metadata deterministically parody-transformed; -cover art real; prices real-or-seeded. *(v5.0-alpha: John amended this at tier 1 — real titles/artists ship -in-house-green, flagged before public. Recorded in G's provenance; not C's to relax either way.)* - -**8. The per-shop cache seam (v5.0-alpha — C's spec; G+F build to this). CONFIRMED BROKEN in R23.** -G's ask #1 is real and I reproduced it. `stockpack.js` keys `_packs`/`_resolved` by **`type` alone**, and -`preloadStockPack(type, {base})` **ignores `base` on a cache hit**. Measured (R23, fresh context): -- `preloadStockPack('record', {base:'assets/stock_godverse/3962749/'})` → the 120-record godverse pack ✓ -- then `preloadStockPack('record', {base:'assets/models/'})` → **returned the identical object** ✗ - -Two consequences, the second worse than G's report: -1. **Two stocked shops collide** — the 2nd silently serves the 1st shop's crate (blocks v5.0-beta). -2. **It breaks the fail-soft law** — an *unstocked* shop of a cached type inherits that crate instead of - falling back to parody. (On a clean cache fail-soft is intact: missing atlas → `null` → parody, 0 errors. - The collision is what defeats it, so this is a correctness bug, not just a mix-up.) - -**The seam (C owns it, C lands it):** -- **Cache key = `type + base`** (equivalently a `packKey`; a godverse shop's key is its `stock_godverse//` base). `getStockPack` takes the same key. -- **Resolution order** stays: explicit per-shop base → the type-pack base → `null` → parody. -- **Per-shop preload on entry** (interior build/enter), **not** a boot-time preload of every shop. -- **LRU = 1 shop's atlas set, disposed on eviction** (§7.2) — the load-bearing half of the §7 promise. -- **Identity link:** a stocked shop must carry its pack pointer on the shop record (G's `godverse_id` → - `stock_godverse//`). Not in the town cache today — G flagged it ("monsterrobot must be added to the - godverse cache's `shops[]` as id 3962749"). C consumes whatever field lands; **C does not invent it.** - -**9. R23 verification — the first real crate, in a room (ledger #3). PASS.** -Measured against G's `3962749` atlas (120 real records, 2 × 2048², cell 256²) in a fresh context: -- **Resolves through the existing `stockAdapter` path** via a base override — no C code change needed for - the single-shop alpha. 120 items, 2 atlases, **8/8 stock meshes atlas-backed** (real covers, not parody). -- **Ceilings hold:** **118 draws** with real covers (≤350 law); room `pathOK`, 0 carves. -- **Fail-soft holds** (clean cache): missing atlas → `null` → parody (20 parody meshes, 110 draws, pathOK); - no `stockAdapter` (the `?noassets` shape) → parody, zero fetch. **0 console errors** throughout. -- **G's index is contract-compliant:** `items[]` sorted by id (my determinism ask honoured ✓), provenance - present ✓, `uv ⊂ [0,1]` ✓, 2 atlases = the cap ✓, `cell_w/cell_h` harmless (C reads `uv`) ✓. -- **Verdict: the contract holds. G re-emits nothing.** The only C-side work is §8's seam. +### 7.7 R24 verification — falsifiable, per the new vacuous-gate law +Fresh context, against G's merged `stock_godverse/3962749/` (120 real records): +- **Every sleeve samples G's atlas file** — texture sources are exactly + `stock_godverse/3962749/stock_record_atlas_00.webp` / `_01.webp`; pack items ≡ index items **by id**. + *(Not "covers render": a parody canvas also has a `.map`. The negative control below is what makes + this a gate — I had to fix my own R23 metric to satisfy the law.)* +- **Negative control:** the parody room's stock textures are `(canvas, no src)` and touch the godverse + atlas **zero** times — the metric discriminates. +- **Collision fixed:** shop pack (120 items) and town pack (350) coexist as distinct objects. +- **Fail-soft with a POPULATED cache:** missing base ⇒ `null` ⇒ parody (the R23 bug is gone). +- **Ceilings:** 98 draws with real covers via `opts.stockBase`; `pathOK`; 0 carves; 0 console errors. diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index d2e41a1..96f2344 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -42,7 +42,10 @@ // • 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? } +// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, stock?: 'real', stockBase? } +// • 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. // // Registry override (Lane F): call mergeRegistry(registry) ONCE at init to let authored registry data // override recipe fields. It is deliberately NOT a per-build option — a per-call mutation of the shared @@ -138,13 +141,16 @@ export function buildInterior(shop, THREE, opts) { const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys); const shell = buildShell(ctx, { recipe, archetype, dims }); - // fittings + wall decor + stock + walkable-path guarantee. ?stock=real feeds Lane E's GODVERSE pack - // through the stockAdapter seam (preload it first); missing pack → parody canvas (warn once). + // fittings + wall decor + stock + walkable-path guarantee. ?stock=real feeds a real-stock pack through + // the stockAdapter seam (preload it first); missing pack → parody canvas (warn once). + // `opts.stockBase` (v5.0) selects WHICH pack: omit it for the town-wide packs, or pass a per-shop base + // (`assets/stock_godverse//`) for a shop with its own real crate. The pack cache is keyed + // by (type, base), so a per-shop pack and the town-wide pack coexist — see stockpack.js. let adapter = opts.stockAdapter || null; if (!adapter && opts.stock === 'real') { - const pack = getStockPack(recipe.key); + const pack = getStockPack(recipe.key, opts.stockBase); if (pack) adapter = makeStockAdapter(pack); - else warnOnce(`stock=real: no pack for "${recipe.key}" (preload it) — using parody canvas`); + 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 }); diff --git a/web/js/interiors/stockpack.js b/web/js/interiors/stockpack.js index fda013b..2607ef1 100644 --- a/web/js/interiors/stockpack.js +++ b/web/js/interiors/stockpack.js @@ -16,23 +16,38 @@ import * as THREE_NS from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; const _texLoader = new THREE_NS.TextureLoader(); -const _packs = new Map(); // type → Promise -const _resolved = new Map(); // type → pack|null (sync accessor for buildInterior) -// Preload a pack (fetch index + atlas textures). Cached. Call once before building with ?stock=real. -export function preloadStockPack(type, { base = 'assets/models/', THREE = THREE_NS } = {}) { - if (_packs.has(type)) return _packs.get(type); +// Where the TOWN-WIDE packs live. A per-shop (godverse) pack lives under its own base instead — +// `assets/stock_godverse//` — and carries the SAME `stock__index.json` name, +// because `type` is what picks the stock slot (SLOT_FOR: record→sleeve, book→spine, toy→box). +export const DEFAULT_STOCK_BASE = 'assets/models/'; + +// CACHE IDENTITY IS (type, base) — never `type` alone. A per-shop atlas and the town-wide pack are +// different packs that share a type; keying by type alone made `base` a no-op on a cache hit, so the +// 2nd caller silently received the 1st caller's pack (R23, measured). That wasn't only a mix-up: it +// defeated the fail-soft law — an UNSTOCKED shop of an already-cached type inherited that crate +// instead of falling back to parody. (R24 ledger #1; the runtime is the contract's authority.) +const packKey = (type, base) => `${base}|${type}`; +const _packs = new Map(); // packKey → Promise +const _resolved = new Map(); // packKey → pack|null (sync accessor for buildInterior) + +// Preload a pack (fetch index + atlas textures). Cached per (type, base). Call once before building +// with ?stock=real — for a per-shop pack, pass that shop's base. +export function preloadStockPack(type, { base = DEFAULT_STOCK_BASE, THREE = THREE_NS } = {}) { + const key = packKey(type, base); + if (_packs.has(key)) return _packs.get(key); const p = fetch(`${base}stock_${type}_index.json`) .then(r => (r.ok ? r.json() : null)) .then(idx => (idx && idx.items && idx.items.length ? buildPack(type, idx, base, THREE) : null)) .catch(() => null) - .then(pack => { _resolved.set(type, pack); return pack; }); - _packs.set(type, p); + .then(pack => { _resolved.set(key, pack); return pack; }); + _packs.set(key, p); return p; } -// Synchronous accessor — the resolved pack if preloaded, else null (buildInterior uses this). -export function getStockPack(type) { return _resolved.get(type) || null; } +// Synchronous accessor — the resolved pack for (type, base), else null (buildInterior uses this). +// `base` defaults to the town-wide packs, so every pre-v5 caller keeps its exact behaviour. +export function getStockPack(type, base = DEFAULT_STOCK_BASE) { return _resolved.get(packKey(type, base)) || null; } async function buildPack(type, idx, base, THREE) { const names = idx.atlases || [];