feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user