diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 33f00f5..bb8110e 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -256,6 +256,21 @@ Что класть в сейв: позицию/прогресс игры + `gameState.serialize()`. Настройки — отдельно через `Settings` (слот `settings`), в сейв они не попадают. +### Метаданные слота + +Заголовок/время слота — в параллельном ключе `meta:` (формат самих +сейвов не меняется; у сейвов, записанных без меты, `slotMeta` вернёт `null`): + +```ts +saves.save('slot1', data, { title: 'Луга', savedAt: Date.now(), version: 3, + extras: { area: 'meadows' } }); +const meta = saves.slotMeta('slot1'); // без полной загрузки сейва +const rows = saves.list(); // [{ slot, meta: SaveSlotMeta | null }] +saves.delete('slot1'); // чистит и сейв, и мету +``` + +`listSlots()` возвращает только сейвы — мета-ключи в список не попадают. + ### Рекомендуемая схема сейва игры ```ts @@ -271,4 +286,6 @@ ``` Версионирование контента — через `GameState.dataVersion` + `addMigration`: -при изменении формата флагов старые сейвы чинятся автоматически при загрузке. \ No newline at end of file +при изменении формата флагов старые сейвы чинятся автоматически при загрузке. +Версия самого сейва (`version` в SaveData и в мете) — отдельная ось от +`GameStateData.version`. \ No newline at end of file diff --git a/packages/engine/src/save/SaveManager.ts b/packages/engine/src/save/SaveManager.ts index a8ac3bc..25ec53d 100644 --- a/packages/engine/src/save/SaveManager.ts +++ b/packages/engine/src/save/SaveManager.ts @@ -1,7 +1,22 @@ /** * Сохранения в JSON-слотах. Хранилище инъекцией (localStorage или любой * Storage-подобный объект) — чтобы работать и в тестах без браузера. + * Метаданные слота (заголовок, время) — в параллельном ключе `meta:`: + * формат самих сейвов не меняется, старые сейвы остаются читаемыми. */ + +/** Метаданные слота — читаются без полной загрузки сейва (задаёт игра). */ +export interface SaveSlotMeta { + /** Человекочитаемый заголовок (движок не знает жанра). */ + title?: string; + /** Момент записи (Date.now()). */ + savedAt?: number; + /** Версия формата сейва. */ + version?: number; + /** Жанровые поля игры (область, уровень, часы игры…). */ + extras?: Record; +} + export interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -16,8 +31,9 @@ private prefix = 'save:' ) {} - save(slot: string, data: unknown): void { + 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(slot: string): T | null { @@ -30,8 +46,25 @@ } } + /** Метаданные слота (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)); } /** Есть ли сохранение в слоте. */ @@ -39,15 +72,20 @@ 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)) { - out.push(key.slice(this.prefix.length)); + 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; + } } \ No newline at end of file diff --git a/packages/engine/src/save/__tests__/SaveManager.test.ts b/packages/engine/src/save/__tests__/SaveManager.test.ts index 0eb41c9..9d4bae9 100644 --- a/packages/engine/src/save/__tests__/SaveManager.test.ts +++ b/packages/engine/src/save/__tests__/SaveManager.test.ts @@ -37,4 +37,31 @@ sm.delete('a'); expect(sm.listSlots()).toEqual(['b']); }); + + it('мета пишется в параллельный ключ и читается без сейва', () => { + const storage = memoryStorage(); + const sm = new SaveManager(storage); + sm.save('slot1', { hp: 3 }, { title: 'Луга', savedAt: 42, version: 3 }); + expect(storage.getItem('save:slot1')).not.toContain('Луга'); + expect(sm.slotMeta('slot1')).toEqual({ title: 'Луга', savedAt: 42, version: 3 }); + expect(sm.list()).toEqual([{ slot: 'slot1', meta: { title: 'Луга', savedAt: 42, version: 3 } }]); + }); + + it('сейв без меты и битая мета -> slotMeta null, сейв читается', () => { + const storage = memoryStorage(); + const sm = new SaveManager(storage); + sm.save('old', { hp: 1 }); // без меты — как старые сейвы + expect(sm.slotMeta('old')).toBeNull(); + storage.setItem('save:meta:broken', '{oops'); + expect(sm.slotMeta('broken')).toBeNull(); + }); + + it('delete чистит и сейв, и мету; listSlots без meta:*', () => { + const sm = new SaveManager(memoryStorage()); + sm.save('a', 1, { title: 'A' }); + expect(sm.listSlots()).toEqual(['a']); // meta-ключ не в списке + sm.delete('a'); + expect(sm.has('a')).toBe(false); + expect(sm.slotMeta('a')).toBeNull(); + }); }); \ No newline at end of file