import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; import { z } from 'zod'; import type { Content } from '../content/schema'; import type { GameState } from './game'; import { tickGame } from './game'; import { TICK_MS } from './tickLoop'; /** * Save format, serialization, and offline crediting. * * Pure TS — no IndexedDB, no DOM. The IO adapter (idb-keyval + localStorage * fallback) lives in src/state/persistence.ts; this module only turns game state * into a validated, versioned, compressed string and back. Saves carry a * `version` so future migrations have a hook; for now a version mismatch is * rejected cleanly rather than silently coerced. * * Offline credit: `applyOfflineProgress` floors elapsed ms to whole ticks * (same `TICK_MS` as the live loop) and runs `tickGame` that many times. * Elapsed beyond `DEFAULT_MAX_OFFLINE_MS` is not credited. */ export const SAVE_VERSION = 1; /** Default cap on credited offline time: 24 hours. */ export const DEFAULT_MAX_OFFLINE_MS = 24 * 60 * 60 * 1000; export const gameStateSchema = z.object({ resources: z.record(z.string(), z.number()), activeActionId: z.string().nullable(), actionElapsedMs: z.number().nonnegative(), actionQueue: z.array(z.string()).default([]), storyFlags: z.record(z.string(), z.boolean()).default({}), currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).default([]), }); export const saveSchema = z.object({ version: z.number().int().nonnegative(), savedAt: z.number().nonnegative(), state: gameStateSchema, }); export type SaveData = z.infer; /** Snapshot the current state into a versioned, timestamped save. */ export function createSave(state: GameState, now: number): SaveData { return { version: SAVE_VERSION, savedAt: now, state: { resources: { ...state.resources }, activeActionId: state.activeActionId, actionElapsedMs: state.actionElapsedMs, actionQueue: [...state.actionQueue], storyFlags: { ...state.storyFlags }, currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], }, }; } export function serializeSave(save: SaveData): string { return JSON.stringify(save); } /** Parse + validate a save from JSON. Throws on malformed or unsupported saves. */ export function deserializeSave(json: string): SaveData { const parsed: unknown = JSON.parse(json); const save = saveSchema.parse(parsed); if (save.version !== SAVE_VERSION) { throw new Error( `Unsupported save version ${save.version} (this build expects ${SAVE_VERSION})`, ); } return save; } export function toExportString(save: SaveData): string { return compressToEncodedURIComponent(serializeSave(save)); } /** Decompress + validate an export string. Throws cleanly on tampered input. */ export function fromExportString(compressed: string): SaveData { const json = decompressFromEncodedURIComponent(compressed); if (json === null || json === '') { throw new Error('Save string is corrupt or empty'); } return deserializeSave(json); } /** * Credit elapsed wall-clock time to the running simulation on load, using the * same per-tick path the live loop uses. Elapsed is clamped to `maxOfflineMs` * and floored to whole ticks. Returns the milliseconds actually credited. */ export function applyOfflineProgress( state: GameState, content: Content, savedAt: number, now: number, maxOfflineMs: number = DEFAULT_MAX_OFFLINE_MS, ): number { const elapsed = Math.max(0, now - savedAt); const credited = Math.min(elapsed, maxOfflineMs); const ticks = Math.floor(credited / TICK_MS); for (let i = 0; i < ticks; i += 1) { tickGame(state, content, TICK_MS); } return credited; }