feat(mystery): save layer — round-trip, schema versioning, transcript cap
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user