// 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` }; // [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` }; } // [R33 wantlist] `wants` is OPTIONAL, same law: array of {artist?|title?|sku?} string fields, at // least one present per entry — a want with nothing to hunt is malformed, not empty-but-fine. if (obj.wants != null) { if (!Array.isArray(obj.wants)) return { ok: false, why: 'wants not an array' }; for (let i = 0; i < obj.wants.length; i++) { const w = obj.wants[i]; if (!w || typeof w !== 'object' || Array.isArray(w)) return { ok: false, why: `wants[${i}] malformed` }; const f = ['artist', 'title', 'sku'].filter((k) => w[k] != null); if (!f.length || f.some((k) => typeof w[k] !== 'string' || !w[k])) return { ok: false, why: `wants[${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 = []; // [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried. let wants = []; const WANTS_CAP = 40; // [R33 travel] true when THIS boot adopted a save from a DIFFERENT town — the ride cost a day. let traveled = false; // [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 if (wants.length) p.wants = wants; // [R33] same convention return p; } function save() { if (!store) return false; 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(); pulls = Array.isArray(state.pulls) ? state.pulls.slice() : []; wants = Array.isArray(state.wants) ? state.wants.slice() : []; // [R33 — TRAVEL COSTS A DAY, charter system #6] The adopted save came from ANOTHER town (townKey // embeds plansrc/town/seed, so a new seed is a new town too): the ride ate a day. Same-town // adoption (reload, export→import round trip) costs nothing — the save-determinism gate's // byte-equality survives untouched. Day moves BEFORE prunePulls so yesterday's-town day-tagged // pulls drop here, and the shell reads game.day/traveled at boot (onDay stays un-fired, §30.1). if (state.town !== townKey) { day += 1; traveled = true; } 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() { 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; 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(payload()); }, 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); 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; }, // ── 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; }, // ── 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; }, // ── the wantlist seams (R33, charter system #6) ──────────────────────────────────────────── get wants() { return wants; }, // LIVE array — READ-ONLY by contract (like collection) get traveled() { return traveled; }, // this boot's save came from another town (+1 day) // w: {artist?|title?|sku?} strings. Dedupe is case-insensitive over the fields present. recordWant(w) { const e = {}; for (const k of ['artist', 'title', 'sku']) if (w && w[k]) e[k] = String(w[k]); if (!Object.keys(e).length) return false; if (game.findWant(e)) return false; // (named binding, not `this` — survives detached calls) wants.push(e); if (wants.length > WANTS_CAP) wants.splice(0, wants.length - WANTS_CAP); notify(); return true; }, // The matcher both UIs use: a want covers an item when every field the WANT names matches the // item's (case-insensitive) — sku exact, artist/title by value. Buying a wanted item clears it. findWant(o) { const low = (s) => String(s || '').toLowerCase(); return wants.find((w) => (!w.sku || w.sku === String(o.sku || '')) && (!w.artist || low(w.artist) === low(o.artist)) && (!w.title || low(w.title) === low(o.title)) ) || null; }, removeWant(w) { // identity removal, findWant supplies the identity const i = wants.indexOf(w); if (i < 0) return false; wants.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; }