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