diff --git a/src/engine/save.test.ts b/src/engine/save.test.ts new file mode 100644 index 0000000..43a8aae --- /dev/null +++ b/src/engine/save.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { saveState, loadState, clearSave, SAVE_KEY } from './save' +import type { GameState, TranscriptLine } from './types' +import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types' + +const baseState = (overrides: Partial = {}): GameState => ({ + schemaVersion: SCHEMA_VERSION, + location: 'foyer', + inventory: [], + roomState: {}, + flags: {}, + resolveLevel: 'steady', + encounterState: {}, + lastNoun: null, + pendingDisambiguation: null, + transcript: [], + theme: 'amber', + endedWith: null, + ...overrides, +}) + +describe('save — round trip', () => { + let store: Record + + beforeEach(() => { + store = {} + vi.stubGlobal('localStorage', { + getItem: (k: string) => (k in store ? store[k]! : null), + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + }) + }) + + it('round-trips an identical state', () => { + const s = baseState({ location: 'cellar', flags: { gateOpened: true } }) + saveState(s) + expect(loadState()).toEqual(s) + }) + + it('returns null when nothing is saved', () => { + expect(loadState()).toBeNull() + }) + + it('returns null and clears the slot on schema mismatch', () => { + store[SAVE_KEY] = JSON.stringify({ ...baseState(), schemaVersion: SCHEMA_VERSION + 99 }) + expect(loadState()).toBeNull() + expect(store[SAVE_KEY]).toBeUndefined() + }) + + it('returns null and clears the slot on malformed JSON', () => { + store[SAVE_KEY] = 'not-json' + expect(loadState()).toBeNull() + expect(store[SAVE_KEY]).toBeUndefined() + }) + + it('truncates transcript to TRANSCRIPT_CAP on save', () => { + const long: TranscriptLine[] = Array.from({ length: TRANSCRIPT_CAP + 50 }, (_, i) => ({ + kind: 'narration', + text: `line ${i}`, + })) + saveState(baseState({ transcript: long })) + const loaded = loadState() + expect(loaded?.transcript).toHaveLength(TRANSCRIPT_CAP) + // Keeps the most recent lines (the tail). + expect(loaded?.transcript[0]?.text).toBe(`line ${50}`) + expect(loaded?.transcript[TRANSCRIPT_CAP - 1]?.text).toBe(`line ${TRANSCRIPT_CAP + 49}`) + }) +}) + +describe('save — clear', () => { + let store: Record + + beforeEach(() => { + store = { [SAVE_KEY]: JSON.stringify(baseState()) } + vi.stubGlobal('localStorage', { + getItem: (k: string) => (k in store ? store[k]! : null), + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + }) + }) + + it('removes the save slot', () => { + clearSave() + expect(store[SAVE_KEY]).toBeUndefined() + expect(loadState()).toBeNull() + }) +}) diff --git a/src/engine/save.ts b/src/engine/save.ts new file mode 100644 index 0000000..3c48523 --- /dev/null +++ b/src/engine/save.ts @@ -0,0 +1,60 @@ +import type { GameState } from './types' +import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types' + +export const SAVE_KEY = 'halfstreet:save:v1' + +/** Save the state to localStorage, truncating the transcript to TRANSCRIPT_CAP. */ +export function saveState(state: GameState): void { + const trimmed: GameState = { + ...state, + transcript: + state.transcript.length > TRANSCRIPT_CAP + ? state.transcript.slice(-TRANSCRIPT_CAP) + : state.transcript, + } + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(trimmed)) + } catch (err) { + // Quota exceeded or storage disabled — silently fail. The game still runs; + // the player just won't have persistence. + if (typeof console !== 'undefined') console.warn('halfstreet save failed', err) + } +} + +/** Load the state, or return null if nothing is saved or the save is unusable. */ +export function loadState(): GameState | null { + let raw: string | null + try { + raw = localStorage.getItem(SAVE_KEY) + } catch { + return null + } + if (!raw) return null + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + clearSave() + return null + } + + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as { schemaVersion?: unknown }).schemaVersion !== SCHEMA_VERSION + ) { + clearSave() + return null + } + + return parsed as GameState +} + +export function clearSave(): void { + try { + localStorage.removeItem(SAVE_KEY) + } catch { + // ignore + } +}