F held the tag because my published atlas contract and my own loader were mutually
incompatible. That was mine. Closed, and published FIRST so G and E re-emit against it.
WHAT WAS WRONG: my R22 §7 invented `stock_shop_<godverseShopId>_index.json`; the loader
(stockpack.js:25) fetches `${base}stock_${type}_index.json`. An index named per the contract
can never be fetched by the code meant to load it. 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.
§7 REWRITTEN AGAINST THE CODE (LANE_C_PUB):
- Authority rule: stockpack.js IS the contract; doc vs loader disagreement = doc bug.
- Layout ratified as G shipped it: assets/stock_godverse/<godverseShopId>/stock_<type>_index.json
(`type` is load-bearing — SLOT_FOR picks the slot). stock_shop_* RETIRED.
-> E: the glob becomes stock_godverse/**/stock_*_index.json (the vacuous gate's actual cause).
- Runtime-read vs ignored stated exactly: reads atlases[] + items[{atlas,uv}] (uv top-left, the
loader flips V); ignores version/atlas_px/cell*/count/kind/tier/shop/crate/provenance.
- Provenance drift dies: runtime reads none of it, so the authority is E's validator + the 23
shipped town caches -> TOP-LEVEL `license` (US), attribution, generator, snapshot. British
`licence` retired; G lifts+renames those four, keeps its rich nested provenance{} as colour.
atlas_px stays REQUIRED (checked against real dims). cell* informational — C reads uv, never
cell; do NOT shrink the cell for VRAM (the dig renders covers close-up).
CODE (F's filed item): stockpack cache identity is now `${base}|${type}`;
getStockPack(type, base = DEFAULT_STOCK_BASE); new opts.stockBase on buildInterior = the seam F
wires. Omitted => town-wide packs, byte-identical to today. Pre-v5 callers untouched.
VERIFIED FRESH — and I fixed my own metric first: R23's "8/8 atlas-backed" only checked that a
map exists, but a parody canvas has a map too (same species as the vacuous gate). Re-asserted
falsifiably: every sleeve's texture source IS stock_godverse/3962749/stock_record_atlas_00/01.webp,
pack items == index items by id; negative control — the parody room's textures are (canvas, no src)
and touch the atlas zero times, so the metric discriminates. Collision FIXED (shop 120 + town 350
coexist). Fail-soft FIXED with a populated cache (missing base -> null -> parody). 98 draws with
real covers, pathOK, 0 carves, 0 console errors.
SCOPED NOT SLID: the LRU+dispose (§7.6) lands at v5.0-beta — one stocked shop can't accrete, and
safe eviction needs a room-exit hook (a live room holds the pack's shared material) that arrives
with F's per-shop preload. Written into the contract so it can't slide silently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
13 KiB
JavaScript
208 lines
13 KiB
JavaScript
// PROCITY Lane C — public API. The Vuntra move: every shop door opens into a unique, believable,
|
||
// seeded interior, generated on demand in <50ms, byte-identical every revisit (shop.seed), themed
|
||
// by shop.type. This is a standalone library + test page; Lane B wires it to real doors later via
|
||
// the procity:enterShop / procity:exitShop events.
|
||
//
|
||
// import { buildInterior } from './js/interiors/interiors.js';
|
||
// import * as THREE from 'three';
|
||
// const room = buildInterior(shop, THREE); // shop = a CityPlan shop record + its lot dims
|
||
// scene.add(room.group);
|
||
// // … player walks around, hits room.exits …
|
||
// room.dispose(); // frees all per-room geometry/material/textures
|
||
//
|
||
// buildInterior(shop, THREE, opts?) → {
|
||
// group, // THREE.Group, self-contained (lights included). Add to a scene.
|
||
// spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door)
|
||
// exits: [ { x, z, w, toStreet } ], // door(s) back to the street
|
||
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
|
||
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
|
||
// // stands, in room-local space, facing the customer.
|
||
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
|
||
// // browser rigs, facing the goods (same frame + ry as counter.stand)
|
||
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
|
||
// // room-tone), seeded per shop. musicKey may be null. gigKey set
|
||
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
|
||
// watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
|
||
// // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
|
||
// // frame + ry convention as counter.stand (rig-front = local −Z).
|
||
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses,backline }, // VENUE (else null) — the band stage. bandPoses[4]:
|
||
// // front-line trio (guitar/vocal/bass) on the deck + a drummer (role
|
||
// // 'drums', seated) up-stage on the riser. Each pose carries y (front
|
||
// // line = deckY, drummer = riserY). ry=π faces the audience. backline[]
|
||
// // (v3.1) = up-stage amp slots {x,z,ry,y} where Lane D plants the
|
||
// // guitar_amp GLB (primitive fallback under ?noassets).
|
||
// dims: { W, D, H, archetype, type }, // room metrics
|
||
// placement, // deterministic placement summary (deep-equal per seed)
|
||
// pathOK, // door→counter connectivity held (always true; carved if needed)
|
||
// dispose(), // release GPU resources; removes group from its parent
|
||
// }
|
||
//
|
||
// `shop` is tolerant. Recognized fields: { id, type, name, seed, storeys, lot:{w,d} }.
|
||
// • seed : uint32 — the ONLY randomness source (falls back to a hash of id/name if absent)
|
||
// • 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? }
|
||
// • stockBase (v5.0): which real-stock pack `stock:'real'` resolves. Omit → the town-wide packs
|
||
// (`assets/models/`, today's behaviour). Pass `assets/stock_godverse/<godverseShopId>/` 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
|
||
// recipe table would break the same-seed determinism guarantee for every later build.
|
||
|
||
import { Ctx } from './context.js';
|
||
import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js';
|
||
import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js';
|
||
import { layout } from './layout.js';
|
||
import { batchRoom } from './batch.js';
|
||
import { preloadManifest, upgradeRoom } from './glb.js';
|
||
import { getStockPack, makeStockAdapter } from './stockpack.js';
|
||
import { xmur3 } from '../core/prng.js';
|
||
|
||
export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
|
||
export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
|
||
export { preloadStockPack, getStockPack, makeStockAdapter } from './stockpack.js'; // ?stock=real
|
||
|
||
let glbManifest = null; // resolved manifest cache (once the depot answers)
|
||
const _warned = new Set();
|
||
function warnOnce(msg) { if (!_warned.has(msg)) { _warned.add(msg); console.warn('[interiors] ' + msg); } }
|
||
|
||
function normalizeShop(shop) {
|
||
const s = shop || {};
|
||
let seed = s.seed;
|
||
if (seed == null) seed = xmur3(`${s.id ?? ''}:${s.name ?? ''}:${s.type ?? ''}`)();
|
||
const lot = s.lot && s.lot.w && s.lot.d ? { w: +s.lot.w, d: +s.lot.d }
|
||
: (s.lotW && s.lotD ? { w: +s.lotW, d: +s.lotD } : null);
|
||
return {
|
||
id: s.id ?? 'shop',
|
||
type: s.type ?? 'opshop',
|
||
name: s.name ?? '',
|
||
seed: seed >>> 0,
|
||
storeys: s.storeys ?? 1,
|
||
lot,
|
||
raw: s,
|
||
};
|
||
}
|
||
|
||
// ── interior audio contract (round 11) ───────────────────────────────────────────────
|
||
// buildInterior returns room.audio = { musicKey, toneKey } — keys into Lane E's manifest.audio (a music
|
||
// bed + an interior room-tone). Lane F's interior_mode plays them on enter / fades on exit; Lane B's
|
||
// audio.js resolves key → file. Missing key / ?mute=1 / ?noassets=1 → silence (house audio law), so this
|
||
// is pure data: buildInterior stays synchronous and asset-free, it only names what SHOULD play.
|
||
// The maps mirror the manifest's per-type `types` arrays so the type→bed grouping has one source of truth.
|
||
const ROOMTONE_BY_TYPE = { // manifest.audio.ambience entries with scope:'interior'
|
||
milkbar: 'roomtone-milkbar',
|
||
video: 'roomtone-video', dept: 'roomtone-video', arcade: 'roomtone-video',
|
||
}; // default → 'roomtone-retail'
|
||
const MUSIC_BY_TYPE = { // manifest.audio.music dedicated beds
|
||
record: 'record-shop', // "record shop plays music" — always on
|
||
milkbar: 'milkbar', video: 'video-synth',
|
||
dept: 'arcade', arcade: 'arcade',
|
||
}; // default → null (no dedicated bed)
|
||
// The genre a venue kind plays — mirrors registry.SHOP_TYPES[kind].genre so C resolves the gigKey even
|
||
// standalone (the gig plan carries shop.genreKey; this is the fallback when a test builds a bare venue
|
||
// without A's conversion). ROUND13 §Lane C: pub → pubrock, band_room → grunge, rsl → covers.
|
||
const GENRE_BY_TYPE = { pub: 'pubrock', band_room: 'grunge', rsl: 'covers' };
|
||
|
||
function audioFor(type, ctx, opts, shop) {
|
||
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
|
||
let musicKey = MUSIC_BY_TYPE[type] || null;
|
||
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
|
||
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
|
||
// revisit and adding it never shifts another subsystem's seeded picks.
|
||
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
|
||
const audio = { musicKey, toneKey };
|
||
// Venue gig (R12/13): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
|
||
// while the gig is on. A quiet-night venue has no opts.gig → normal interior (room-tone + maybe radio).
|
||
// The genre resolves gig.genreKey → the venue's own shop.genreKey (A sets it) → the kind default. The
|
||
// manifest key IS the gigKey, canonical form `gig-<genreKey>` — no mapping table (ROUND13 debt #1). This
|
||
// string is byte-identical to citygen's `gigKeyFor(genreKey)` (selfcheck asserts it); it is hand-built,
|
||
// NOT imported, on purpose — the interiors lib stays standalone (never imports citygen). Keep the form,
|
||
// don't privately rename it (the R12 trap) and don't import citygen (breaks standalone).
|
||
const gig = opts && opts.gig;
|
||
if (gig && (gig.on || gig.genreKey)) {
|
||
const genreKey = gig.genreKey || (shop && shop.genreKey) || GENRE_BY_TYPE[type] || 'pubrock';
|
||
audio.gigKey = 'gig-' + genreKey;
|
||
}
|
||
return audio;
|
||
}
|
||
|
||
export function buildInterior(shop, THREE, opts) {
|
||
opts = opts || {}; // tolerate undefined AND explicit null
|
||
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
|
||
|
||
const norm = normalizeShop(shop);
|
||
const recipe = getRecipe(norm.type);
|
||
const ctx = new Ctx(THREE, norm.seed);
|
||
|
||
// room shape → dims → shell
|
||
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
||
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 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/<godverseShopId>/`) 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, opts.stockBase);
|
||
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 });
|
||
|
||
// 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
|
||
// kept, so bins stay raycast targets for ?dig=1. Runs BEFORE the GLB upgrade so its frame-hide sees
|
||
// the merged fixture meshes. opts.noBatch disables it (diagnostics only).
|
||
if (!opts.noBatch) batchRoom(ctx, shell.group);
|
||
|
||
// OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
|
||
// unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
|
||
// or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
|
||
let glbReady = Promise.resolve(); // resolves when GLB upgrades settle (immediately if off)
|
||
if (opts.useGLB) {
|
||
const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
|
||
if (manifest) glbReady = upgradeRoom(ctx, lay.placed, manifest, recipe.key);
|
||
else glbReady = preloadManifest(opts.manifestUrl).then(m => {
|
||
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m, recipe.key); }
|
||
});
|
||
}
|
||
|
||
const group = shell.group;
|
||
const buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
|
||
|
||
let disposed = false;
|
||
const api = {
|
||
group,
|
||
spawn: shell.spawn,
|
||
exits: shell.exits,
|
||
places: lay.places,
|
||
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
|
||
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
|
||
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
|
||
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
|
||
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
|
||
dims: { ...dims, archetype, type: recipe.key },
|
||
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
||
placement: lay.placement,
|
||
pathOK: lay.pathOK,
|
||
carved: lay.carved,
|
||
glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled
|
||
buildMs,
|
||
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
|
||
counts: () => ctx.counts(),
|
||
dispose() {
|
||
if (disposed) return;
|
||
disposed = true;
|
||
if (group.parent) group.parent.remove(group);
|
||
// drop lights/children refs, then free tracked GPU resources
|
||
ctx.disposeAll();
|
||
},
|
||
};
|
||
return api;
|
||
}
|