diff --git a/web/js/world/save.js b/web/js/world/save.js index 6a90e34..142004d 100644 --- a/web/js/world/save.js +++ b/web/js/world/save.js @@ -18,6 +18,33 @@ 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 @@ -50,6 +77,15 @@ export function validateSave(obj) { 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) { @@ -80,6 +116,9 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul // [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 @@ -94,8 +133,30 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul 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; } @@ -114,6 +175,8 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul // 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(); } @@ -188,6 +251,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul // 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(); @@ -293,6 +357,27 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul 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.