PROCITY/web/js/world/save.js
m3ultra 0ee7a84710 Lane B R39 39.2: THE FOG — the map shows what you walked past, and is finally readable
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>
2026-08-03 21:04:34 +10:00

500 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.52.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;
}