Files
idlegame/src/engine/save.ts
T

111 lines
3.7 KiB
TypeScript

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<typeof saveSchema>;
/** 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;
}