/**
* Сохранения в JSON-слотах. Хранилище инъекцией (localStorage или любой
* Storage-подобный объект) — чтобы работать и в тестах без браузера.
* Метаданные слота (заголовок, время) — в параллельном ключе `meta:<slot>`:
* формат самих сейвов не меняется, старые сейвы остаются читаемыми.
*/
/** Метаданные слота — читаются без полной загрузки сейва (задаёт игра). */
export interface SaveSlotMeta {
/** Человекочитаемый заголовок (движок не знает жанра). */
title?: string;
/** Момент записи (Date.now()). */
savedAt?: number;
/** Версия формата сейва. */
version?: number;
/** Жанровые поля игры (область, уровень, часы игры…). */
extras?: Record<string, string | number | boolean>;
}
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
key(index: number): string | null;
readonly length: number;
}
export class SaveManager {
constructor(
private storage: StorageLike,
private prefix = 'save:'
) {}
save(slot: string, data: unknown, meta?: SaveSlotMeta): void {
this.storage.setItem(this.prefix + slot, JSON.stringify(data));
if (meta) this.storage.setItem(this.metaKey(slot), JSON.stringify(meta));
}
load<T = unknown>(slot: string): T | null {
const raw = this.storage.getItem(this.prefix + slot);
if (raw === null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
/** Метаданные слота (null — мета не писалась, например у старых сейвов). */
slotMeta(slot: string): SaveSlotMeta | null {
const raw = this.storage.getItem(this.metaKey(slot));
if (raw === null) return null;
try {
return JSON.parse(raw) as SaveSlotMeta;
} catch {
return null;
}
}
/** Слоты с метой (мета может отсутствовать — у сейвов старых версий). */
list(): Array<{ slot: string; meta: SaveSlotMeta | null }> {
return this.listSlots().map((slot) => ({ slot, meta: this.slotMeta(slot) }));
}
delete(slot: string): void {
this.storage.removeItem(this.prefix + slot);
this.storage.removeItem(this.metaKey(slot));
}
/** Есть ли сохранение в слоте. */
has(slot: string): boolean {
return this.storage.getItem(this.prefix + slot) !== null;
}
/** Список занятых слотов (без префикса; мета-ключи не попадают). */
listSlots(): string[] {
const out: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const slot = key.slice(this.prefix.length);
if (!slot.startsWith('meta:')) out.push(slot);
}
}
return out;
}
private metaKey(slot: string): string {
return this.prefix + 'meta:' + slot;
}
}