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