PROCITY/web/js/world/save.js
type-two 75e5e06288 Lane F R32 (v7.0-beta wave 1): per-day scarcity — pulls ride procity-save/1, the bin remembers
save.js: optional pulls: string[] on the SAME schema (validated exact-and-loud when present;
absent on every alpha save — old savers drop it silently, never reject; payload omits it when
empty so the alpha byte shape survives). recordPull/pullsFor seams; pruned on sleep/import —
day-tagged keys from other days are unreachable by construction (the consumer embeds the
current day), untagged keys are real-sourced and KEPT (sold means gone, tier-2 spirit at
tier 1). Cap 600 FIFO (delta law: saves stay tiny). Collection entries gain optional band
(recordFind stamps dig + shelf buys) — C's §9.1 sell basis reads it.
interior_mode.js: pull keys ${shopId}|${binKey}${pullTag}#${slot} — pullTag @d<game.day> for
rotating crates INCLUDING day 1 (the SEED stays untagged per the day-1 convention; the ledger
doesn't — a day-1 pull must not survive into day 2's identical-looking untagged key space),
'' for real-sourced (their pulls never expire). The seed input to binSeed is UNTOUCHED — A's
rotation boundary holds. Selling an item does NOT restock the bin (pulls are independent of the
collection — the leak the §30.6 filing predicted). digOffers getter = the honest-gate surface
(index/ask/band/sku) so the bible gate asserts identity, not looks.
2026-07-20 18:26:36 +10:00

246 lines
12 KiB
JavaScript

// 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` };
}
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 = [];
// [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER
// (interior_mode: `${shopId}|${binKey}@d<day>#<slot>`; 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;
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() : [];
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:<id>' | '<offer index>'), 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.
load();
if (typeof window !== 'undefined') window.addEventListener('beforeunload', () => { save(); });
return game;
}