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:
parent
369a2be23e
commit
207bcffb54
@ -1471,8 +1471,8 @@ window.PROCITY.game = {
|
|||||||
// (console.error), raw stashed at localStorage['procity-save.rejected'], fresh start.
|
// (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
|
// 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.
|
// 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
|
sleep(), // → the new day (int). day+1 → gig weekNight re-keys to (day − 1) % 7 (see the §30.3
|
||||||
// (lighting segment 0) → save(). Stock pick streams re-seed via the day salt (§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).
|
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
|
import(json), // → bool. Validated exactly like load(). true ⇒ state replaced + saved + day-derived
|
||||||
// state applied (weekNight etc). false ⇒ loud reject, CURRENT STATE UNTOUCHED.
|
// 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`
|
`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,
|
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.)
|
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
|
- **Gig night = (day − 1) % 7 — AMENDED from the brief's literal `day % 7`, with the measurement.**
|
||||||
venue's latch re-keys from its night-0 gig to its night-(day % 7) gig (plan.gigs is already the whole
|
`day` starts at 1, so the literal form keys a FRESH game's first night to night 1 — flipping the
|
||||||
week; the three hard-won latch laws are untouched). Cover stamps reset with the re-key (new night ⇒
|
default boot's gigs off night 0 before the player ever sleeps, breaking the R13 cover/band gates and
|
||||||
cover due), which is correct by John's R12 ruling.
|
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 +
|
- **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).
|
facade observes the morning — the listens-not-polls law does the work for free).
|
||||||
|
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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) => {
|
main().catch((err) => {
|
||||||
console.error('[procity] init failed:', 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.
|
// [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.
|
// 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)
|
// [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
|
// → 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
|
// 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).
|
// 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;
|
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()
|
// [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.
|
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
||||||
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
||||||
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
|
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
|
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
|
// [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
|
// 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".
|
// doesn't read as "walked out of the shop".
|
||||||
@ -485,7 +514,8 @@ frame();
|
|||||||
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
||||||
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
|
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)
|
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
|
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
|
// 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)
|
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.
|
// ({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
|
// [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.
|
// 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
|
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
|
// [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
|
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
|
||||||
|
|||||||
@ -172,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
|
|||||||
const norm = normalizeShop(shop);
|
const norm = normalizeShop(shop);
|
||||||
const recipe = getRecipe(norm.type);
|
const recipe = getRecipe(norm.type);
|
||||||
const ctx = new Ctx(THREE, norm.seed);
|
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
|
// room shape → dims → shell
|
||||||
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
||||||
|
|||||||
@ -414,7 +414,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
|||||||
for (const p of placed) {
|
for (const p of placed) {
|
||||||
if (p.removed) continue;
|
if (p.removed) continue;
|
||||||
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
|
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 || []));
|
places.push(...(p.fitting.places || []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
// 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))];
|
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();
|
const latch = new Map();
|
||||||
for (const id of venueIds) {
|
function buildLatches() {
|
||||||
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
|
latch.clear();
|
||||||
latch.set(id, createVenueLatch(tonight, lighting));
|
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.
|
// 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); };
|
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;
|
const L = (id) => latch.get(id) || null;
|
||||||
|
|
||||||
return {
|
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) ──────────────────────────────────────────────────────────────
|
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
|
||||||
get venueShopIds() { return venueIds.slice(); },
|
get venueShopIds() { return venueIds.slice(); },
|
||||||
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },
|
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },
|
||||||
|
|||||||
@ -98,7 +98,7 @@ function applyLive(pack, live) {
|
|||||||
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
||||||
dig: digEnabled = false, stockReal = false, wallet = null,
|
dig: digEnabled = false, stockReal = false, wallet = null,
|
||||||
occupancyOf = null, gigState = null, rosterOf = null,
|
occupancyOf = null, gigState = null, rosterOf = null,
|
||||||
liveBase = null, sourcingOf = null }) {
|
liveBase = null, sourcingOf = null, game = null }) {
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color('#0e0b07');
|
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 current = null; // active Lane C interior handle
|
||||||
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
|
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 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 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 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)
|
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;
|
if (dig.active) return;
|
||||||
const p = new THREE.Vector3(); bin.getWorldPosition(p);
|
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)
|
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({
|
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),
|
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||||||
shop: currentShop,
|
shop: currentShop,
|
||||||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
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.
|
// [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).
|
// 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,
|
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')),
|
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
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;
|
if (!best) return;
|
||||||
const item = best.item || {};
|
const item = best.item || {};
|
||||||
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
|
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
|
collapseBuyItem(mesh, best); // zero-area the bought quad in place
|
||||||
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
||||||
} else {
|
} 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.
|
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
|
||||||
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
||||||
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
|
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,
|
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||||||
|
stockDay: currentStockDay,
|
||||||
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
||||||
currentShop = shop;
|
currentShop = shop;
|
||||||
scene.add(current.group);
|
scene.add(current.group);
|
||||||
@ -393,6 +427,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
current = null;
|
current = null;
|
||||||
currentShop = null;
|
currentShop = null;
|
||||||
currentAdapter = null;
|
currentAdapter = null;
|
||||||
|
currentStockDay = null;
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
toast.style.display = 'none'; _toastT = 0;
|
toast.style.display = 'none'; _toastT = 0;
|
||||||
if (doorReturn) {
|
if (doorReturn) {
|
||||||
|
|||||||
198
web/js/world/save.js
Normal file
198
web/js/world/save.js
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user