From 39cfc3d3ed2f23e78ea2ea2593684dc01c325c5a Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:34:42 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20F=20R27=20(v5.0):=20the=20tier-2=20reade?= =?UTF-8?q?r=20=E2=80=94=20pick-then-omit,=20and=20the=20server=20killed?= =?UTF-8?q?=20mid-session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 THE READER, built to C's §8 ruling (direct wire, no hook) and to what the waves measured, not to the brief's letter: · `pack.gone` set, `price` updated IN PLACE — items[] never added to, removed from, or reordered, because its identity AND ORDER are the pick's stability (C §8.3). Filtering the array instead reshuffles the crate: C measured ONE sale changing 21 OTHER records. · fire-and-forget at room build, 1200ms abort, etag-cached, circuit-broken on first failure. The dig never blocks on the network (charter risk #1). No await anywhere near the door. · fires 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 knows which is which. TIER 2 IS OPT-IN (`?live=1` / `?live=`; absent → byte-identical to the beta). That 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. Tier 2 is a place you go, not a thing that happens to you. Auto-discovery is a v5.x question with a reachability contract. PROVEN IN THE BROWSER (formal smoke class still to land): · sold-means-gone, via G's make-fixture (production `gone` is EMPTY — 0 sales since 2026-07-01 — so a live assert would pass vacuously forever; G built the subject before the gate could be born blind): 3 sold → gone[3] → items[] 120→120 IDENTITY AND ORDER INTACT, 117 survivors, COLLATERAL 0. C called this: both pick-then-omit and filter-the-array hide the sold record, so "the gone item never renders" cannot tell them apart — the obvious assertion is vacuous and goes green. Collateral is the one that discriminates. · KILL THE SERVER MID-SESSION → enter took 13.1ms, worst frame gap 14ms, the crate still renders its own 120 real covers, and ZERO console errors. Charter law #1 proven by execution instead of assertion — the thing A correctly refused to retire from the risk list until this gate went green. Recorded, not hidden: after a mid-session kill the etag-cached `gone` persists for the session, so a killed server does not resurrect sold records mid-walk. That is tier-1-plus-last-known, not tier-1-exactly. It is the honest behaviour (a network blink must not re-stock a shop) and a server-down BOOT is still pure tier 1 — but it is a nuance the brief's "degrades to tier 1 exactly" doesn't cover, so it goes in writing rather than in the gaps. Co-Authored-By: Claude Opus 4.8 --- web/index.html | 15 ++++++++- web/js/world/interior_mode.js | 58 ++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) 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