diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index a8fc536..6d987a2 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -117,6 +117,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback) let currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation: // no game, day 1, or a REAL-sourced crate — real stock never rotates) + let currentRealSourced = false; // [R32] this room's crate is sourcing:'real' — its pulls never expire let doorReturn = null; // { x, y, z, ry } on the street to restore on exit let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1) @@ -166,26 +167,40 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // [R30 §30.3] the riffle rotates with the day: salt the bin key when this room's stock rotates // (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7). const dayTag = currentStockDay != null ? '@d' + currentStockDay : ''; + // [R32 scarcity] the PULL key's day tag is broader than the SEED's: a rotating bin is pulled-from + // per (bin, day) even on day 1 (where the seed stays untagged by the day-1 convention), while a + // REAL-sourced crate stays untagged forever — sold means gone, so its pulls never expire. The seed + // input above is UNTOUCHED (A's rotation-boundary law); this tag exists only in the save's ledger. + const pullTag = (game && !currentRealSourced) ? '@d' + game.day : ''; + const pullPrefix = `${(currentShop && currentShop.id) ?? '?'}|${key}${pullTag}#`; dig.open({ seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16, shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name), shop: currentShop, stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody) + gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today // [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it. // dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change). // [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry // shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate); // sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index. + // [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's + // ledger — the slot is gone for the rest of the day even if the item is later sold on. getCash: wallet ? () => wallet.cash() : undefined, onBuy: wallet ? (o) => { const ok = wallet.buy(o); - if (ok && game) game.recordFind(currentShop, { - type: 'record', - sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined, - slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`, - title: o && (o.t || o.title), artist: o && (o.a || o.artist), - price: (o && o.price) || 0, - }); + if (ok && game) { + const sku = o && o.item && o.item.id != null ? String(o.item.id) : null; + game.recordFind(currentShop, { + type: 'record', + sku: sku ?? undefined, + slotId: sku != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`, + title: o && (o.t || o.title), artist: o && (o.a || o.artist), + price: (o && o.price) || 0, + band: (o && o.band) || undefined, + }); + game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0))); + } return ok; } : undefined, onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')), @@ -224,6 +239,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`, title: item.title, artist: item.artist, price: item.price || 20, + band: item.price_band || undefined, // [R32] the shelf find carries its band too (§9.1 basis) }); collapseBuyItem(mesh, best); // zero-area the bought quad in place flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`); @@ -346,6 +362,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // when: no game layer · day 1 (the day-1 convention) · a REAL-sourced crate (the godverse manifest // says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate. const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real'); + currentRealSourced = realSourced; // [R32] pull-ledger tagging reads this at dig-open time currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null; current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, stockDay: currentStockDay, @@ -471,6 +488,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null currentShop = null; currentAdapter = null; currentStockDay = null; + currentRealSourced = false; banner.style.display = 'none'; toast.style.display = 'none'; _toastT = 0; if (doorReturn) { @@ -491,6 +509,9 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card + // [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no + // dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked. + get digOffers() { return dig && dig.active ? dig.currentOffers() : null; }, // [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY // IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack diff --git a/web/js/world/save.js b/web/js/world/save.js index a71a7ce..a1d8919 100644 --- a/web/js/world/save.js +++ b/web/js/world/save.js @@ -42,6 +42,14 @@ export function validateSave(obj) { if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' }; for (let i = 0; i < obj.collection.length; i++) if (!validEntry(obj.collection[i])) return { ok: false, why: `collection[${i}] malformed` }; + // [R32 scarcity] `pulls` is OPTIONAL (absent on every alpha save — those stay valid forever); when + // present it must be an array of strings (exact-and-loud like everything else). An old loader that + // predates this field simply ignores and drops it on its next save — degraded, never rejected. + if (obj.pulls != null) { + if (!Array.isArray(obj.pulls)) return { ok: false, why: 'pulls not an array' }; + for (let i = 0; i < obj.pulls.length; i++) + if (typeof obj.pulls[i] !== 'string' || !obj.pulls[i]) return { ok: false, why: `pulls[${i}] malformed` }; + } return { ok: true, state: obj }; } @@ -58,12 +66,22 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul let day = 1; let cash = Math.max(0, Math.floor(+startCash || 0)); let collection = []; + // [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER + // (interior_mode: `${shopId}|${binKey}@d#`; real-sourced crates omit the day tag — real + // stock never rotates, so a real pull is gone for good). The save only stores and prunes them. + let pulls = []; + const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law) + const PULL_DAY_RE = /@d(\d+)#/; // ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ──────────── + function payload() { + const p = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }; + if (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved + return p; + } function save() { if (!store) return false; - const payload = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }; - try { store.setItem(SAVE_KEY, JSON.stringify(payload)); return true; } + try { store.setItem(SAVE_KEY, JSON.stringify(payload())); return true; } catch (e) { console.warn('[procity save] write failed (playing on in memory):', e && e.message || e); return false; } } @@ -71,6 +89,15 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul day = state.day; cash = Math.floor(state.cash); collection = state.collection.slice(); + pulls = Array.isArray(state.pulls) ? state.pulls.slice() : []; + prunePulls(); + } + + // Day-tagged pulls from another day can never match a bin key again (the consumer embeds the + // CURRENT day in rotating bins' keys) — dead weight, dropped. Untagged pulls are real-sourced + // (never rotate) and persist: sold means gone, the tier-2 spirit at tier 1. + function prunePulls() { + pulls = pulls.filter((k) => { const m = PULL_DAY_RE.exec(k); return !m || +m[1] === day; }); } function load() { @@ -137,13 +164,14 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul // save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into. sleep() { day += 1; + prunePulls(); // [R32] yesterday's day-tagged pulls are unreachable — drop them if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } } save(); return day; }, export() { - return JSON.stringify({ schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }); + return JSON.stringify(payload()); }, import(json) { // loud reject leaves CURRENT state untouched @@ -175,6 +203,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul else e.slotId = String((info && info.slotId) || 'slot#0'); if (info && info.title) e.title = String(info.title); if (info && info.artist) e.artist = String(info.artist); + if (info && info.band) e.band = String(info.band); // [R32] §9.1 sell basis — optional, alpha entries lack it collection.push(e); notify(); return e; @@ -188,6 +217,24 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul notify(); return true; }, + + // ── the pull seam (R32 scarcity — interior_mode records a bought dig slot; SELLING an item does + // NOT restock the bin, which is why this list is independent of the collection) ────────────── + recordPull(key) { + const k = String(key || ''); + if (!k || pulls.includes(k)) return false; + pulls.push(k); + if (pulls.length > PULLS_CAP) pulls.splice(0, pulls.length - PULLS_CAP); + return true; + }, + + // The consumer's read: every recorded slot for one bin, as the Set dig.open({gone}) takes — + // suffixes only ('sku:' | ''), prefix = `${shopId}|${binKey}${tag}#`. + pullsFor(prefix) { + const out = new Set(); + for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length)); + return out; + }, }; // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.