diff --git a/web/index.html b/web/index.html
index 7737e5f..6599447 100644
--- a/web/index.html
+++ b/web/index.html
@@ -194,6 +194,16 @@ const DIG_ON = params.has('dig') && params.get('dig') !== '0';
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
+// [Lane F R27 §2 — TIER 2, OPT-IN] `?live=1` → the default GODVERSE base; `?live=` → that base;
+// absent → NO tier 2 at all, byte-identical to the beta. Opt-in is deliberate and is the R26 lesson
+// applied to a server: a default boot that probed for a GODVERSE host would ask a question with no
+// answer on every machine that isn't John's, and "it only logs an error when the server's away" is
+// exactly the blind probe the manifest was built to end. Tier 2 is a place you go, not a thing that
+// happens to you. Auto-discovery, if ever, is a v5.x question with a reachability contract behind it.
+const LIVE_ARG = NOASSETS ? null : params.get('live');
+const LIVE_BASE = !LIVE_ARG || LIVE_ARG === '0' ? null
+ : (LIVE_ARG === '1' ? 'http://127.0.0.1:8778' : LIVE_ARG.replace(/\/$/, ''));
+let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G's manifest (set below)
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
@@ -222,6 +232,8 @@ if (STOCK_REAL) {
const e = byId.get(s.godverseShopId);
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
}
+ // [R27 §2] the same manifest already knows real vs mint — the tier-2 reader asks only real shops.
+ STOCK_SOURCING = (id) => (byId.get(id) || {}).sourcing || null;
}
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
}
@@ -238,7 +250,8 @@ const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ p
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
- rosterOf: (id) => citizens.tonightRoster(id), gigState }); // [R15] identity continuity: the crowd IS tonight's roster
+ rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
+ liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null) }); // [R27] tier 2
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js
index a41cd53..f26d400 100644
--- a/web/js/world/interior_mode.js
+++ b/web/js/world/interior_mode.js
@@ -47,9 +47,58 @@ export function godverseBaseFor(shop) {
return id ? `assets/stock_godverse/${id}/` : undefined;
}
+// [Lane F R27 §2 — THE TIER-2 READER] Live stock, per C's §8 ruling: direct wire, no lifecycle hook.
+// F already holds `currentAdapter` and hands it to `dig.open()` per dig, so a fresh read is picked up by
+// the NEXT dig for free. The read is fire-and-forget: if it hasn't landed, hasn't finished, or failed,
+// the adapter is simply the tier-1 pack. THE DIG NEVER BLOCKS ON THE NETWORK (charter risk #1).
+//
+// What it may touch is deliberately tiny, and C's §8.3 is the reason: `items[]` identity and ORDER are
+// the pick's stability. So we set `pack.gone` (C picks from the full list and omits after — filtering the
+// array instead reshuffles the crate: C measured ONE sale changing 21 other records) and we update
+// `price` IN PLACE. We never add, remove, or reorder items[]. Live values are consumed at pick time and
+// never persisted — no golden, no seeded stream (charter law #2, the determinism boundary sits here).
+const LIVE_TIMEOUT_MS = 1200; // beyond this the shop is tier 1 for this dig; the player never waits
+const _liveCache = new Map(); // godverseShopId → { etag, gone:Set, prices:Map } (etag-cached per §4)
+let _liveDown = false; // circuit breaker: one failure and we stop asking until reload
+
+async function readLiveStock(base, shop, pack) {
+ if (_liveDown || !base || !shop.godverseShopId) return null;
+ const id = shop.godverseShopId;
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), LIVE_TIMEOUT_MS);
+ try {
+ const prev = _liveCache.get(id);
+ const r = await fetch(`${base}/godverse/v1/shop/${id}/stock`,
+ { signal: ctl.signal, headers: prev ? { 'If-None-Match': prev.etag } : {} });
+ if (r.status === 304 && prev) { applyLive(pack, prev); return prev; }
+ if (!r.ok) return null; // 404 = not a real shop. Tier 1. Silently.
+ const d = await r.json();
+ const live = { etag: d.etag, gone: new Set(d.gone || []),
+ prices: new Map((d.items || []).map((i) => [i.id, i.price])) };
+ _liveCache.set(id, live);
+ applyLive(pack, live);
+ return live;
+ } catch (e) {
+ // Dead / unreachable / slow / aborted → tier 1 EXACTLY, and we stop asking. The offline law is not
+ // a fallback path here, it is the same path: the pack was already complete before we asked.
+ _liveDown = true;
+ return null;
+ } finally { clearTimeout(timer); }
+}
+
+function applyLive(pack, live) {
+ if (!pack) return;
+ pack.gone = live.gone; // C omits these AFTER the draw (§8.2)
+ for (const it of (pack.items || [])) { // live price, IN PLACE (§8.3) — no reshuffle
+ const p = live.prices.get(it.id);
+ if (p != null) it.price = p;
+ }
+}
+
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
- occupancyOf = null, gigState = null, rosterOf = null }) {
+ occupancyOf = null, gigState = null, rosterOf = null,
+ liveBase = null, sourcingOf = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@@ -214,6 +263,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently.
const gBase = stockReal ? godverseBaseFor(shop) : undefined;
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
+ // [R27 §2] Tier 2, fire-and-forget. ONLY for a real-sourced shop: G's fence 404s a mint shop, and
+ // asking anyway would be R26's blind probe wearing a server. The manifest already told us which is
+ // which, so we ask only where an answer exists. No await — the room is already built and complete.
+ if (stockReal && gBase && liveBase && sourcingOf && sourcingOf(shop.godverseShopId) === 'real') {
+ const pack = getStockPack(shop.type, gBase);
+ if (pack) readLiveStock(liveBase, shop, pack);
+ }
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
// 2–4 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior