godbayHammer(entry, townKey): THE ONE-HAMMER LAW — seeded by ITEM IDENTITY (godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>), NEVER by resolution day: a passed-in item re-lists to the same result forever, so there is no retry farm anywhere in the economy (the keeper's offer is a formula, the auction is one hammer per item per life). Fixed 4-draw stream. Rolls: 15% passed in (comes home free) · hammer uniform across the item's band (grail's open top = 2×low; unbanded alpha finds auction around pricePaid — honest, never invented) · 12% bidding war ×1.5–2.0 · 12% commission, net floor $1. EV ≈ 0.8× band midpoint — UNDER the asking-price EV of in-band stock: random flipping bleeds, only FINDS profit (charter law #2 at the auction house); the keeper's half-of-low stays the instant floor, the gavel the bounded ceiling (2×high×0.88). Band table imported from C's bible.js and PRNG from core/prng.js — one authority each, no mirrors. listings: optional on procity-save/1 (entry shape + listDay ≥ 1, the pulls/wants back-compat law). listFind (identity — the entry LEAVES the crate, it cannot also sell at a counter) · resolveAuctions() runs in adopt() AND sleep(), so sleep, travel, and day-moving imports all hammer what they pass · takeAuctionNews() drains the transient morning paper (NOT saved — the ledger is cash/collection; the paper is a toast).
389 lines
20 KiB
JavaScript
389 lines
20 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';
|
||
|
||
// ── GODBAY v0 — the offline seeded auction (R34, charter system #4; laws in ROUND34_INSTRUCTIONS) ──
|
||
import { xmur3, mulberry32 } from '../core/prng.js';
|
||
import { BANDS as GODBAY_BANDS } from '../interiors/bible.js'; // C's pure band table — one authority, no mirror
|
||
|
||
const GODBAY_FEE = 0.12; // the house always eats
|
||
const GODBAY_PASS = 0.15; // passed in — no bids, item comes home
|
||
const GODBAY_WAR = 0.12; // a bidding war: hammer ×1.5–2.0
|
||
|
||
// THE ONE-HAMMER LAW: seeded by ITEM IDENTITY, never by the resolution day — a passed-in item
|
||
// re-lists to the same result forever, so there is no retry farm anywhere in the economy.
|
||
// → { sold, hammer, net } | { sold:false }. Fixed draw count (4), outcome-independent.
|
||
export function godbayHammer(entry, townKey) {
|
||
const id = `godbay:${townKey}:${entry.sku || entry.slotId}:${entry.dayFound}:${entry.pricePaid}`;
|
||
const r = mulberry32(xmur3(id)());
|
||
const passRoll = r(), posRoll = r(), warRoll = r(), warSizeRoll = r();
|
||
const bands = GODBAY_BANDS[entry.type] || GODBAY_BANDS.record;
|
||
const range = bands[entry.band] || null;
|
||
// an unbanded entry (alpha-era find) auctions around what was paid — honest, never invented
|
||
const lo = range ? range[0] : Math.max(1, entry.pricePaid);
|
||
const hi = range ? (range[1] == null ? lo * 2 : range[1]) : Math.max(1, entry.pricePaid);
|
||
if (passRoll < GODBAY_PASS) return { sold: false };
|
||
let hammer = lo + Math.floor(posRoll * (hi - lo + 1));
|
||
if (warRoll < GODBAY_WAR) hammer = Math.floor(hammer * (1.5 + warSizeRoll * 0.5));
|
||
const net = Math.max(1, Math.floor(hammer * (1 - GODBAY_FEE)));
|
||
return { sold: true, hammer, net };
|
||
}
|
||
|
||
// ── 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` };
|
||
}
|
||
// [R34 GODBAY] `listings` is OPTIONAL, same law: collection-shaped entries + an int listDay ≥ 1.
|
||
if (obj.listings != null) {
|
||
if (!Array.isArray(obj.listings)) return { ok: false, why: 'listings not an array' };
|
||
for (let i = 0; i < obj.listings.length; i++) {
|
||
const l = obj.listings[i];
|
||
if (!validEntry(l) || !Number.isInteger(l.listDay) || l.listDay < 1)
|
||
return { ok: false, why: `listings[${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;
|
||
// [R34 GODBAY] consigned finds ({...entry, listDay}) + this boot's transient morning report.
|
||
let listings = [];
|
||
let auctionNews = []; // NOT saved — the morning paper, not the ledger
|
||
// [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<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
|
||
if (wants.length) p.wants = wants; // [R33] same convention
|
||
if (listings.length) p.listings = listings; // [R34] same convention
|
||
return p;
|
||
}
|
||
|
||
// [R34 GODBAY] resolve every listing the day has moved past — sleep, travel, and day-moving imports
|
||
// all funnel here. One hammer per item (identity-seeded), so WHEN it resolves never changes WHAT.
|
||
function resolveAuctions() {
|
||
if (!listings.length) return;
|
||
const still = [];
|
||
for (const l of listings) {
|
||
if (l.listDay >= day) { still.push(l); continue; } // resolves the day AFTER listing
|
||
const title = l.title || l.sku || l.slotId || 'the item';
|
||
const res = godbayHammer(l, l.townKey || townKey);
|
||
if (res.sold) {
|
||
cash += res.net;
|
||
auctionNews.push(`⚖ SOLD “${title}” — hammer $${res.hammer}, $${res.net} after fees`);
|
||
} else {
|
||
const { listDay, ...entry } = l; // passed in — comes home unchanged
|
||
collection.push(entry);
|
||
auctionNews.push(`⚖ passed in — “${title}” came home`);
|
||
}
|
||
}
|
||
listings = still;
|
||
}
|
||
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; }
|
||
listings = Array.isArray(state.listings) ? state.listings.slice() : [];
|
||
resolveAuctions(); // [R34] a day-moving adopt (travel/import) resolves what it passed
|
||
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;
|
||
resolveAuctions(); // [R34] GODBAY hammers overnight — before onDay so the toast carries it
|
||
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;
|
||
},
|
||
|
||
// ── 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;
|
||
},
|
||
|
||
// ── GODBAY seams (R34) ─────────────────────────────────────────────────────────────────────
|
||
get listings() { return listings; }, // LIVE, READ-ONLY by contract
|
||
|
||
// Consign a collection entry (identity, like removeFind): it leaves the crate for the auction
|
||
// house and resolves the NEXT day. false ⇒ not in the collection, nothing moves.
|
||
listFind(entry) {
|
||
const i = collection.indexOf(entry);
|
||
if (i < 0) return false;
|
||
collection.splice(i, 1);
|
||
listings.push({ ...entry, listDay: day });
|
||
notify();
|
||
return true;
|
||
},
|
||
|
||
// Drain the morning paper (transient — resolution wrote it, the shell toasts it once).
|
||
takeAuctionNews() {
|
||
const n = auctionNews;
|
||
auctionNews = [];
|
||
return n;
|
||
},
|
||
};
|
||
|
||
// 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;
|
||
}
|