111 lines
3.7 KiB
TypeScript
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;
|
|
}
|