minimap.js drew every lot in the plan from the first frame (493 shops on the synthetic, one M press, against 5,330 m / 4.8 game-days of walking to earn it). It now draws what has been walked past AND FRAMES ITSELF TO IT — a shop lot goes 0.83x1.11 px -> 29.4x39.2 px on bowral_real, 35.3x. ZERO DRAWS, measured as an A/B: two port-isolated no-store servers, HEAD vs treatment, the same 15 stations x 4 yaws compared as JSON before either sweep runs. Draw calls IDENTICAL on 60/60 samples at noon and at night; with the gig layer off both sides (the venue queue's async rigs were the only noise) NIGHT is identical in draws AND triangles. Map mode cannot cost a draw structurally: the frame loop never calls composer.render() in the map branch. - discovery.js (new): a 25 m PROXIMITY PROBE, not onChunkBuilt (R=2 => the outermost live chunk corner is 181 m out), on a plan-derived spatial hash — 1.09-1.37 us a probe, every 6th street frame = every 0.46 m at WALK. Plus a side test: you must be in FRONT of the shopfront. Control with a red arm: walking the synthetic's two service laneways, radius-only reveals 93 shops, the shipped probe 59 — 34 refused through the back wall; on the arcade it refuses 0 of 17. - save.js: optional `known` block under the delta law, keyed by TOWN (re-derived: 23 caches x 3 seeds, shop/edge ids and lot geometry all identical; the synthetic alone keys @<seed>). Bounded by the plan (max 493 shops / 2,593 edges) at 8,192, 64 towns FIFO, ints only. Whole corpus fully learned = 137,547 B (2.6% of quota); 482 B after a 500 m outing. Eight reject arms demonstrated. - classic BY CONSTRUCTION: createMinimap(plan, null) is the pre-R39 map and the file has no flag test at all. ?classic=1 / ?fog=0 / ?game=0 map canvases are hash-identical to HEAD's. - Lane A's address layer consumed (getTownCache + createAddresses): street names along walked streets, deduped; the synthetic gets district labels from the same line. No town-type branch. FILED, NOT FIXED: index.html:384 (Lane D patronage door points) and :450 (the R32 spawn) use (-sin ry, -cos ry) — the BACK of the building. buildings.js:410/583-597, dbg.js:53 and minimap.js all use local +Z. Measured against each lot's own frontEdge the +sin point is 0.00 m from the kerb and the shipped one 15.25 m the wrong side, 493/493 and 72/72 shops. At the shipped spawn 0 of the shops within 25 m are in front of you on all four towns tested; at the corrected one, all of them. Two characters, but it moves the default boot's opening pose mid-round — Fable's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
500 lines
26 KiB
JavaScript
500 lines
26 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 };
|
||
}
|
||
|
||
// ── [R39 THE FOG] the discovery ledger's structural bounds (see validateSave's `known` block) ───
|
||
export const KNOWN_TOWNS_CAP = 64; // distinct fog keys in one save (?town= accepts any string)
|
||
export const KNOWN_IDS_CAP = 8192; // ids per town per kind — 3.2× the biggest town in the corpus
|
||
export const KNOWN_ID_MAX = 1e6; // a plan index, not free text
|
||
|
||
// ── 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` };
|
||
}
|
||
}
|
||
// [R39 THE FOG — Lane B] `known` is OPTIONAL, same law: the per-town discovery ledger,
|
||
// { "<fogKey>": { s:[shopId…], e:[edgeId…] } }. Player state, never world state: it names what the
|
||
// player has walked past, and the town it names still regenerates from seed with nothing remembered.
|
||
// BOUNDED THREE WAYS, and the bound is checked here rather than trusted:
|
||
// • towns — the only axis a hostile/edited save can grow without walking (?town= takes any string),
|
||
// capped at KNOWN_TOWNS_CAP;
|
||
// • ids per town — self-bounding by the plan (measured max: 493 shops on the synthetic, 2,593 edges
|
||
// on bendigo_real), so KNOWN_IDS_CAP at 8,192 is >3× the biggest town in the corpus and no
|
||
// legitimate save can ever reach it;
|
||
// • the ids themselves — non-negative integers under KNOWN_ID_MAX (plan indices, not free text).
|
||
// Over-cap or malformed ⇒ the WHOLE blob is rejected loudly, exactly like every other field.
|
||
if (obj.known != null) {
|
||
if (typeof obj.known !== 'object' || Array.isArray(obj.known)) return { ok: false, why: 'known not an object' };
|
||
const towns = Object.keys(obj.known);
|
||
if (towns.length > KNOWN_TOWNS_CAP) return { ok: false, why: `known has ${towns.length} towns (cap ${KNOWN_TOWNS_CAP})` };
|
||
for (const t of towns) {
|
||
if (!t) return { ok: false, why: 'known has an empty town key' };
|
||
const v = obj.known[t];
|
||
if (!v || typeof v !== 'object' || Array.isArray(v)) return { ok: false, why: `known[${JSON.stringify(t)}] malformed` };
|
||
for (const f of ['s', 'e']) {
|
||
if (v[f] == null) continue;
|
||
if (!Array.isArray(v[f])) return { ok: false, why: `known[${JSON.stringify(t)}].${f} not an array` };
|
||
if (v[f].length > KNOWN_IDS_CAP) return { ok: false, why: `known[${JSON.stringify(t)}].${f} has ${v[f].length} ids (cap ${KNOWN_IDS_CAP})` };
|
||
for (let i = 0; i < v[f].length; i++) {
|
||
const n = v[f][i];
|
||
if (!Number.isInteger(n) || n < 0 || n > KNOWN_ID_MAX) return { ok: false, why: `known[${JSON.stringify(t)}].${f}[${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+)#/;
|
||
// [R39 THE FOG] the discovery ledger: fogKey → { s:Set<shopId>, e:Set<edgeId> }. ONE source of
|
||
// truth — the map and the probe both read these Sets through knownStore(), so nothing mirrors it.
|
||
// Entries are created on the first ADD, never on a read: a boot that discovers nothing writes no
|
||
// `known` key at all, and the save's bytes are exactly what they were before this round.
|
||
let known = new Map();
|
||
let knownVersion = 0; // bumps on every add AND on adopt() — the map's cache key
|
||
|
||
// ── 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
|
||
const k = knownPayload();
|
||
if (k) p.known = k; // [R39] same convention — absent until something is learned
|
||
return p;
|
||
}
|
||
|
||
function knownPayload() {
|
||
let any = false;
|
||
const o = {};
|
||
for (const [k, v] of known) { // Map preserves insertion order ⇒ so does the JSON
|
||
const e = {};
|
||
if (v.s.size) e.s = [...v.s];
|
||
if (v.e.size) e.e = [...v.e];
|
||
if (e.s || e.e) { o[k] = e; any = true; }
|
||
}
|
||
return any ? o : null;
|
||
}
|
||
|
||
// The town axis is the only one a save can grow along without walking (?town= takes any string), so
|
||
// it is the only one that needs an eviction rule: oldest fog key out, FIFO, like PULLS_CAP.
|
||
function trimKnownTowns() {
|
||
while (known.size > KNOWN_TOWNS_CAP) known.delete(known.keys().next().value);
|
||
}
|
||
|
||
// [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() : [];
|
||
// [R39 THE FOG] the adopted ledger REPLACES this session's — an import is someone else's memory of
|
||
// the town, not a merge. Rebuilt into Sets here so knownStore()'s reads stay O(1) and no consumer
|
||
// ever holds the save's own arrays. knownVersion bumps, so an open map re-renders on the next frame.
|
||
known = new Map();
|
||
if (state.known && typeof state.known === 'object') {
|
||
for (const [k, v] of Object.entries(state.known)) {
|
||
const s = new Set(Array.isArray(v && v.s) ? v.s : []);
|
||
const e = new Set(Array.isArray(v && v.e) ? v.e : []);
|
||
if (s.size || e.size) known.set(k, { s, e });
|
||
}
|
||
}
|
||
trimKnownTowns();
|
||
knownVersion++;
|
||
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;
|
||
},
|
||
|
||
// ── the fog seam (R39, v9 Layer 2) ─────────────────────────────────────────────────────────
|
||
// knownStore(fogKey) → the KNOWN STORE contract, bound to one town. discovery.js writes it, the
|
||
// minimap reads it, and a NULL store (which is what ?classic=1 / ?game=0 / ?fog=0 hand the map) is
|
||
// the pre-R39 behaviour by construction — neither module carries a flag test.
|
||
// addShop/addEdge(id) → true when it is NEW (so a probe can count what it learned)
|
||
// hasShop/hasEdge(id) → O(1) shopIds()/edgeIds() → sorted copies (gates + the map)
|
||
// counts() → {shops, edges, towns} version() → bumps on every change, including import()
|
||
knownStore(fogKey) {
|
||
const fk = String(fogKey || '');
|
||
const bag = (make) => {
|
||
let b = known.get(fk);
|
||
if (!b && make) { known.set(fk, b = { s: new Set(), e: new Set() }); trimKnownTowns(); }
|
||
return b || null;
|
||
};
|
||
const add = (kind, id) => {
|
||
const n = Math.trunc(+id);
|
||
if (!Number.isInteger(n) || n < 0 || n > KNOWN_ID_MAX) return false;
|
||
const b = bag(true);
|
||
if (b[kind].size >= KNOWN_IDS_CAP || b[kind].has(n)) return false; // cap = the validator's, honoured at write
|
||
b[kind].add(n); knownVersion++;
|
||
return true;
|
||
};
|
||
const hasIn = (kind, id) => { const b = bag(false); return !!b && b[kind].has(Math.trunc(+id)); };
|
||
const idsOf = (kind) => { const b = bag(false); return b ? [...b[kind]].sort((x, y) => x - y) : []; };
|
||
return {
|
||
key: fk,
|
||
addShop: (id) => add('s', id),
|
||
addEdge: (id) => add('e', id),
|
||
hasShop: (id) => hasIn('s', id),
|
||
hasEdge: (id) => hasIn('e', id),
|
||
shopIds: () => idsOf('s'),
|
||
edgeIds: () => idsOf('e'),
|
||
counts() { const b = bag(false); return { shops: b ? b.s.size : 0, edges: b ? b.e.size : 0, towns: known.size }; },
|
||
version: () => knownVersion,
|
||
};
|
||
},
|
||
|
||
// 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;
|
||
}
|