// PROCITY Lane F — save.js [F-owned, round 30 / v7.0-alpha THE SAVE CORE] // The persistence foundation of THE GAME (V7 charter #1). Versioned localStorage save implementing // THE DELTA LAW: a save carries ONLY player deltas — cash, owned items, current town, day number. // The world is NEVER saved; it regenerates from seed (the seeded-everything law's biggest dividend: // saves are tiny and cannot corrupt the town). Contract published in LANE_F_NOTES §30.1 — B builds // the collection UI on it, C reads it at the sell counter. // // LAW (classic-pure, wired from birth): this module performs ZERO storage access at module scope. // Under ?classic=1 / ?game=0 createGame() is never called, so a classic boot touches localStorage // exactly never — the ledger-#5 classic-purity gate measures that rather than trusting it. // // The wallet facade: when the game is on, PROCITY.wallet IS game.wallet — the full Lane C v0 // interface (cash/start/canBuy/buy/sell/inventory/count/onChange, buy/sell semantics mirroring // wallet.js exactly) backed by game-owned cash, so a loaded save's cash is authoritative. The // debit/credit seam every consumer already uses (dig onBuy, shelf buy, the cover charge, C's // onSell) is therefore unchanged — the proven seam, new backing. export const SAVE_SCHEMA = 'procity-save/1'; export const SAVE_KEY = 'procity-save'; // ── validation — exact and loud (the delta law's falsifiable half) ───────────────────────────── // A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must // check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never // the town (world state isn't in the save, so it can't). function validEntry(e) { return !!e && typeof e === 'object' && typeof e.townKey === 'string' && e.townKey.length > 0 && (e.shopId != null || e.godverseShopId != null) && ((typeof e.sku === 'string' && e.sku.length > 0) || (typeof e.slotId === 'string' && e.slotId.length > 0)) && Number.isFinite(e.pricePaid) && e.pricePaid >= 0 && Number.isInteger(e.dayFound) && e.dayFound >= 1; } // → { ok:true, state } | { ok:false, why } export function validateSave(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, why: 'not an object' }; if (obj.schema !== SAVE_SCHEMA) return { ok: false, why: `schema ${JSON.stringify(obj.schema)} != ${SAVE_SCHEMA}` }; if (!Number.isInteger(obj.day) || obj.day < 1) return { ok: false, why: `day ${obj.day} (want int >= 1)` }; if (!Number.isFinite(obj.cash) || obj.cash < 0) return { ok: false, why: `cash ${obj.cash} (want finite >= 0)` }; if (typeof obj.town !== 'string' || !obj.town) return { ok: false, why: 'town missing' }; 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` }; return { ok: true, state: obj }; } // createGame({ townKey, startCash, storage?, onDay? }) // townKey — this boot's `${plansrc}/${town||'default'}@${seed}` (the shell builds it) // startCash — fresh-game cash (the shell passes Lane C's seeded wallet.start(), so a fresh game // opens with exactly the cash a pre-v7 boot did) // storage — injectable for tests; defaults to window.localStorage (touched only from here on) // onDay(day)— shell hook fired after sleep()/import() changes the day (gig weekNight re-key, // wake at dawn). NOT fired for the boot-time load — the shell reads game.day itself. export function createGame({ townKey, startCash = 0, storage = null, onDay = null } = {}) { const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null); let day = 1; let cash = Math.max(0, Math.floor(+startCash || 0)); let collection = []; // ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ──────────── 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; } catch (e) { console.warn('[procity save] write failed (playing on in memory):', e && e.message || e); return false; } } function adopt(state) { // state has already passed validateSave day = state.day; cash = Math.floor(state.cash); collection = state.collection.slice(); } function load() { if (!store) return false; let raw = null; try { raw = store.getItem(SAVE_KEY); } catch (e) { return false; } if (raw == null) return false; // fresh machine — fresh game, silently let obj = null; try { obj = JSON.parse(raw); } catch (e) { return reject(raw, 'not JSON: ' + (e && e.message)); } const v = validateSave(obj); if (!v.ok) return reject(raw, v.why); adopt(v.state); return true; } function reject(raw, why) { // LOUD, stashed, fresh start. The town never breaks. console.error(`[procity save] REJECTED (${why}) — fresh start. The rejected blob is stashed at ` + `localStorage['${SAVE_KEY}.rejected']; the world is seeded and untouched.`); try { store && store.setItem(SAVE_KEY + '.rejected', String(raw)); } catch (e) { /* stash is best-effort */ } return false; } // ── the wallet facade — Lane C's v0 interface, game-backed (semantics mirror wallet.js) ────── const listeners = new Set(); const notify = () => listeners.forEach((fn) => { try { fn(); } catch (e) {} }); const inv = []; // session inventory (v0 semantics — covers included) const wallet = { cash: () => cash, start: () => Math.max(0, Math.floor(+startCash || 0)), canBuy: (price) => (price || 0) <= cash, buy(o) { // mirrors wallet.js buy() exactly const price = (o && o.price) || 0; if (price > cash) return false; cash -= price; inv.push({ title: o.t || o.title || '?', artist: o.a || o.artist || '', price, band: (o.s || o.price_band || '') }); notify(); return true; }, sell(o, offer) { // mirrors wallet.js sell() (C §9.3): credit + drop v0 entry const amt = Math.max(0, Math.floor(+offer || 0)); cash += amt; let i = inv.indexOf(o); if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || '')); if (i >= 0) inv.splice(i, 1); notify(); return true; }, inventory: () => inv.slice(), count: () => inv.length, onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; const game = { get day() { return day; }, get cash() { return cash; }, get collection() { return collection; }, // LIVE array — READ-ONLY by contract (§30.1) townKey, wallet, save, load, // sleep(): day+1 → shell's onDay (gig weekNight = day % 7, wake at DAWN) → save. Increment-then- // save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into. sleep() { day += 1; 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() }); }, import(json) { // loud reject leaves CURRENT state untouched let obj = null; try { obj = JSON.parse(json); } catch (e) { console.error('[procity save] import REJECTED (not JSON):', e && e.message || e); return false; } const v = validateSave(obj); if (!v.ok) { console.error(`[procity save] import REJECTED (${v.why}) — state untouched`); return false; } adopt(v.state); save(); if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } } notify(); return true; }, // ── the buy seam (interior_mode calls this after a successful wallet.buy) ────────────────── // info: { type, sku?|slotId?, title?, artist?, price } — entry shape per §30.1. Each purchase is // its own entry (two copies of a sku are two entries; removal is per-entry identity). recordFind(shop, info) { const e = { townKey, shopId: shop && shop.id != null ? shop.id : null, type: (info && info.type) || null, // load-bearing: sellability is type = type (C §9.2) pricePaid: Math.max(0, Math.floor((info && info.price) || 0)), dayFound: day, }; if (shop && shop.godverseShopId != null) e.godverseShopId = shop.godverseShopId; if (info && info.sku != null) e.sku = String(info.sku); 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); collection.push(e); notify(); return e; }, // ── the sell seam (C's counter: onSell ⇒ wallet.sell(item, offer) + removeFind(entry)) ───── removeFind(entry) { // identity removal — the ONLY way collection shrinks const i = collection.indexOf(entry); if (i < 0) return false; collection.splice(i, 1); notify(); return true; }, }; // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point. load(); if (typeof window !== 'undefined') window.addEventListener('beforeunload', () => { save(); }); return game; }