feat(mystery): save layer — round-trip, schema versioning, transcript cap

This commit is contained in:
2026-05-08 22:50:47 -05:00
parent bf9e210b88
commit bd6b421ce9
2 changed files with 147 additions and 0 deletions
+87
View File
@@ -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> = {}): 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<string, string>
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<string, string>
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()
})
})
+60
View File
@@ -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
}
}