Lane F R30 (v7.0-alpha ledgers #1+#3): THE SAVE CORE + SLEEP=SAVE=TOMORROW — save.js (procity-save/1, delta law by shape, loud reject + stash, beforeunload), game-backed wallet facade on the proven seam, buy seams record the collection (type/sku|slotId/title, C's §9 asks), day-salted stock rotation (stk-* seam in interiors/layout [marked], dig binSeed; day 1 = pre-v7, REAL never rotates), gig weekNight = (day-1)%7 (amended from day%7 with the measurement, §30.3), wake at DAWN. classic/game=0: layer never constructed, ZERO storage touches measured. Verified 7 legs green in fresh contexts; qa.sh --strict 6/6, 0 warn

This commit is contained in:
m3ultra 2026-07-18 14:26:35 +10:00
parent 369a2be23e
commit 207bcffb54
7 changed files with 312 additions and 18 deletions

View File

@ -1471,8 +1471,8 @@ window.PROCITY.game = {
// (console.error), raw stashed at localStorage['procity-save.rejected'], fresh start.
// THE TOWN NEVER BREAKS: the save holds only player deltas (the delta law), so a bad
// save can cost you your stuff, never the world.
sleep(), // → the new day (int). day+1 → gig weekNight re-keys to day % 7 → wake at DAWN
// (lighting segment 0) → save(). Stock pick streams re-seed via the day salt (§30.3).
sleep(), // → the new day (int). day+1 → gig weekNight re-keys to (day 1) % 7 (see the §30.3
// amendment) → wake at DAWN (lighting segment 0) → save(). Stock streams re-seed (§30.3).
export(), // → JSON string — the exact procity-save/1 payload (John moves machines).
import(json), // → bool. Validated exactly like load(). true ⇒ state replaced + saved + day-derived
// state applied (weekNight etc). false ⇒ loud reject, CURRENT STATE UNTOUCHED.
@ -1545,10 +1545,15 @@ fewer window.)
`sourcing:'real'` (Monster Robot Party) gets NO day salt, ever — its crate is a real crate. `mint`
crates and parody stock rotate. (The town-wide v2 packs are real *images* used as seeded set-dressing,
not a real shop's inventory — they rotate with the parody streams. Ruled here so nobody re-derives it.)
- **Gig night = day % 7.** `gig_state.js` (F-owned) gains `setWeekNight(n)` + `get weekNight` — every
venue's latch re-keys from its night-0 gig to its night-(day % 7) gig (plan.gigs is already the whole
week; the three hard-won latch laws are untouched). Cover stamps reset with the re-key (new night ⇒
cover due), which is correct by John's R12 ruling.
- **Gig night = (day 1) % 7 — AMENDED from the brief's literal `day % 7`, with the measurement.**
`day` starts at 1, so the literal form keys a FRESH game's first night to night 1 — flipping the
default boot's gigs off night 0 before the player ever sleeps, breaking the R13 cover/band gates and
B's night-0-keyed frontage, and contradicting the day-1-is-the-pre-v7-town convention this same
contract establishes for stock. (day 1) % 7 keeps the brief's intent exactly — every sleep walks
the existing seeded week — and day 1 IS night 0. `gig_state.js` (F-owned) gains `setWeekNight(n)` +
`get weekNight`; every venue's latch re-keys to its night-n gig (plan.gigs is already the whole week;
the three hard-won latch laws untouched). Cover stamps reset with the re-key (new night ⇒ cover due),
correct by John's R12 ruling. Fable ratifies or overrules at review — filed loudly, not slipped in.
- **Wake at dawn:** `sleep()``lighting.setSegment(0)` (fires `procity:segment`, so every latch +
facade observes the morning — the listens-not-polls law does the work for free).

View File

@ -62,6 +62,7 @@ import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R1
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
main().catch((err) => {
console.error('[procity] init failed:', err);
@ -240,18 +241,46 @@ if (STOCK_REAL) {
}
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
const wallet = createWallet(seed);
// [Lane F R30 — v7 THE SAVE CORE] The game layer, default-ON (the v3.1 flip precedent): ?game=0 opts out,
// ?classic=1 forces its total absence (createGame never called ⇒ ZERO localStorage touches — the covenant
// stays a pure v2 boot). When the game is on, PROCITY.wallet IS game.wallet — Lane C's exact v0 interface
// (buy/sell/canBuy/…) backed by game-owned cash, so a loaded save's cash is authoritative and every
// existing consumer (dig onBuy, shelf buy, the cover charge, C's onSell) rides the proven seam unchanged.
// A fresh game opens with Lane C's seeded start cash, so day 1 is byte-identical money to a ?game=0 boot.
const GAME_ON = flagOn('game');
const TOWNKEY = `${PLANSRC}/${TOWN || 'default'}@${seed}`;
const baseWallet = createWallet(seed);
const game = GAME_ON ? createGame({
townKey: TOWNKEY, startCash: baseWallet.start(),
// fired after sleep()/import() moves the day: re-key the gig week (day % 7 into the existing schedule),
// wake at dawn (segment 0 — fires procity:segment, so every latch observes the morning), tell John.
onDay: (day) => {
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below
lighting.setSegment(0);
hud.showToast(`🌅 day ${day}`);
},
}) : null;
const wallet = game ? game.wallet : baseWallet;
// [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
// [Lane F R30 — SLEEP=TOMORROW] key tonight's gigs off the game day: weekNight = (day 1) % 7, so a
// loaded save wakes mid-week and every sleep walks the EXISTING seeded week schedule. Measured against
// the brief's literal `day % 7`: day starts at 1, so the literal form would boot a FRESH game on night 1
// — flipping the default boot's gigs off night 0, breaking the R13 cover/band gates and B's night-0-keyed
// frontage before the player ever sleeps. Day 1 IS night 0 (the pre-v7 tonight), same convention as the
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null) }); // [R27] tier 2
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null), // [R27] tier 2
game }); // [R30] the collection (buy seam records finds) + the day salt (stock rotation, §30.3)
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
@ -485,7 +514,8 @@ frame();
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
wallet, // [Lane F R8] Lane C buy loop v0 (cash/buy/sell/count) — game-backed facade when the game is on
game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
@ -494,7 +524,8 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram'),
game: GAME_ON }, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so

View File

@ -172,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
const norm = normalizeShop(shop);
const recipe = getRecipe(norm.type);
const ctx = new Ctx(THREE, norm.seed);
// [Lane F R30 seam — SLEEP=TOMORROW, LANE_F_NOTES §30.3] opts.stockDay salts ONLY the stock pick
// streams (layout.js `stk-*`): the room/fittings/audio streams are untouched, so the shop is the same
// shop every day and only what's in the crates rotates. Absent (no game / day 1 / real-sourced) ⇒ ''.
ctx.stockSalt = opts.stockDay != null ? ':d' + (opts.stockDay >>> 0) : '';
// room shape → dims → shell
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);

View File

@ -414,7 +414,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
for (const p of placed) {
if (p.removed) continue;
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}${ctx.stockSalt || ''}`)); // [Lane F R30 seam] day-salted stock rotation (§30.3)
places.push(...(p.fitting.places || []));
}

View File

@ -101,12 +101,20 @@ export function createGigState({ plan, lighting }) {
// single-venue alias retired in R15 (B swept its cross-lane readers in R14; F migrated its own tools).
const venueIds = [...new Set(gigs.map((g) => g.venueShopId))];
// one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet')
// one latch per venue, keyed by its night-N gig (null → dark tonight → 'quiet'). N defaults to 0 —
// the pre-v7 "tonight" every consumer was built against. [Lane F R30 — SLEEP=TOMORROW] the game layer
// re-keys N to (day 1) % 7 via setWeekNight(), walking the EXISTING seeded week; no game ⇒ N stays 0
// and this file behaves byte-identically to R29.
let weekNight = 0;
const latch = new Map();
for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
latch.set(id, createVenueLatch(tonight, lighting));
function buildLatches() {
latch.clear();
for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === weekNight) || null;
latch.set(id, createVenueLatch(tonight, lighting));
}
}
buildLatches();
// ONE listener drives every latch — the listens-not-polls law, now fanned out across the district.
const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); };
@ -118,6 +126,19 @@ export function createGigState({ plan, lighting }) {
const L = (id) => latch.get(id) || null;
return {
// ── the week (R30, SLEEP=TOMORROW) ────────────────────────────────────────────────────────────
// setWeekNight(n): re-key every venue's latch to its night-n gig. Fresh latches ⇒ paid stamps and
// played state reset — correct: a new night means the cover is due again (John's R12 ruling). The
// three hard-won latch laws live inside createVenueLatch, untouched. Idempotent per n (no-op when
// the night hasn't changed, so a boot-time call on night 0 changes nothing).
get weekNight() { return weekNight; },
setWeekNight(n) {
n = ((n % 7) + 7) % 7;
if (n === weekNight) return;
weekNight = n;
buildLatches();
tickAll(); // settle every fresh latch on the current segment
},
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
get venueShopIds() { return venueIds.slice(); },
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },

View File

@ -98,7 +98,7 @@ function applyLive(pack, live) {
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null, gigState = null, rosterOf = null,
liveBase = null, sourcingOf = null }) {
liveBase = null, sourcingOf = null, game = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@ -114,6 +114,8 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback)
let currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation:
// no game, day 1, or a REAL-sourced crate — real stock never rotates)
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
@ -153,15 +155,31 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (dig.active) return;
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic pos)
// [R30 §30.3] the riffle rotates with the day: salt the bin key when this room's stock rotates
// (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7).
const dayTag = currentStockDay != null ? '@d' + currentStockDay : '';
dig.open({
seed: binSeed((currentShop && currentShop.seed) || 1, key), count: 16,
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
// shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate);
// sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index.
getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (item) => wallet.buy(item) : undefined,
onBuy: wallet ? (o) => {
const ok = wallet.buy(o);
if (ok && game) game.recordFind(currentShop, {
type: 'record',
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined,
slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
price: (o && o.price) || 0,
});
return ok;
} : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
});
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
@ -190,6 +208,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (!best) return;
const item = best.item || {};
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
// [R30 — THE COLLECTION] record the find (§30.1): a shelf buy's type is the pack's slot type,
// which is the shop type by construction (SLOT_FOR: book→spine, toy→box — the only shelf packs).
if (game) game.recordFind(currentShop, {
type: currentShop && currentShop.type,
sku: item.id != null ? String(item.id) : undefined,
slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`,
title: item.title, artist: item.artist,
price: item.price || 20,
});
collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
} else {
@ -276,7 +303,14 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
const gigOn = !!(gigState && gigState.onOf(shop.id));
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
// [R30 §30.3 — SLEEP=TOMORROW] the day salt for this room's stock pick streams. RUNTIME ONLY (the
// plan never sees day — A verifies the boundary). Null (= no rotation, byte-identical to pre-v7)
// when: no game layer · day 1 (the day-1 convention) · a REAL-sourced crate (the godverse manifest
// says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate.
const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real');
currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
stockDay: currentStockDay,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop;
scene.add(current.group);
@ -393,6 +427,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
current = null;
currentShop = null;
currentAdapter = null;
currentStockDay = null;
banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0;
if (doorReturn) {

198
web/js/world/save.js Normal file
View File

@ -0,0 +1,198 @@
// 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` };
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 = [];
// ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ────────────
function save() {
if (!store) return false;
const payload = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() };
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();
}
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;
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
save();
return day;
},
export() {
return JSON.stringify({ schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() });
},
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);
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;
},
};
// 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;
}