import {useCallback, useEffect, useState} from 'react'; import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets'; import { normalizeRoster } from '../data/companionRoster'; import { getInitialPlayerCurrency } from '../data/economy'; import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout, } from '../data/equipmentEffects'; import { normalizeNpcPersistentState } from '../data/npcInteractions'; import { normalizeQuestLogEntries } from '../data/questFlow'; import { normalizeGameRuntimeStats } from '../data/runtimeStats'; import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews'; import type { SavedGameSnapshot } from '../persistence/gameSaveStorage'; import { deleteSaveSnapshot, getSaveSnapshot, putSaveSnapshot, } from '../services/storageService'; import { applyStoryEngineMigration, buildSaveMigrationManifest, } from '../services/storyEngine/saveMigrationManifest'; import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; import { GameState, StoryMoment } from '../types'; import { BottomTab } from './useGameFlow'; const AUTO_SAVE_DELAY_MS = 400; function normalizeSavedStory(story: StoryMoment | null) { if (!story) return null; return { ...story, streaming: false, } satisfies StoryMoment; } function normalizeCharacterChats(gameState: GameState) { const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [ characterId, { history: Array.isArray(record?.history) ? record.history .filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character')) .map(turn => ({ speaker: turn.speaker, text: turn.text, })) : [], summary: typeof record?.summary === 'string' ? record.summary : '', updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null, }, ] as const); return Object.fromEntries(entries); } function normalizeSavedGameState(gameState: GameState) { const migrationManifest = buildSaveMigrationManifest({ version: 'story-engine-v5', }); const migratedState = applyStoryEngineMigration({ state: gameState, manifest: migrationManifest, }); const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []); const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure' ? ensureSceneEncounterPreview({ ...migratedState, currentEncounter: null, sceneHostileNpcs: [], inBattle: false, } as GameState) : migratedState; const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, { isActiveRun: Boolean( normalizedEncounterState.playerCharacter && normalizedEncounterState.currentScene === 'Story' ), }); const normalizedCommonState = { ...normalizedEncounterState, customWorldProfile: normalizedEncounterState.customWorldProfile ?? null, runtimeStats: normalizedRuntimeStats, storyEngineMemory: normalizedEncounterState.storyEngineMemory ?? createEmptyStoryEngineMemoryState(), chapterState: normalizedEncounterState.chapterState ?? normalizedEncounterState.storyEngineMemory?.currentChapter ?? null, campaignState: normalizedEncounterState.campaignState ?? normalizedEncounterState.storyEngineMemory?.campaignState ?? null, activeScenarioPackId: normalizedEncounterState.activeScenarioPackId ?? normalizedEncounterState.customWorldProfile?.scenarioPackId ?? null, activeCampaignPackId: normalizedEncounterState.activeCampaignPackId ?? normalizedEncounterState.customWorldProfile?.campaignPackId ?? null, npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false, playerCurrency: typeof gameState.playerCurrency === 'number' ? gameState.playerCurrency : getInitialPlayerCurrency(gameState.worldType), quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []), roster: normalizedRoster, npcStates: Object.fromEntries( Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [ npcId, normalizeNpcPersistentState(npcState), ]), ), characterChats: normalizeCharacterChats(normalizedEncounterState), activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [], } satisfies GameState; if (!normalizedEncounterState.playerCharacter) { return { ...normalizedCommonState, playerEquipment: createEmptyEquipmentLoadout(), } satisfies GameState; } const resolvedEquipment = normalizedEncounterState.playerEquipment ? normalizedEncounterState.playerEquipment : buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter); const playerMaxHp = getCharacterMaxHp( normalizedEncounterState.playerCharacter, normalizedEncounterState.worldType, normalizedEncounterState.customWorldProfile, ); return applyEquipmentLoadoutToState({ ...normalizedCommonState, playerMaxHp, playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp), playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter), playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter), playerEquipment: createEmptyEquipmentLoadout(), } as GameState, resolvedEquipment); } function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) { return ( gameState.currentScene === 'Story' && Boolean(gameState.worldType) && Boolean(gameState.playerCharacter) && story?.streaming !== true ); } export function useGamePersistence({ gameState, bottomTab, currentStory, isLoading, setGameState, setBottomTab, hydrateStoryState, resetStoryState, }: { gameState: GameState; bottomTab: BottomTab; currentStory: StoryMoment | null; isLoading: boolean; setGameState: (state: GameState) => void; setBottomTab: (tab: BottomTab) => void; hydrateStoryState: (story: StoryMoment | null) => void; resetStoryState: () => void; }) { const [hasSavedGame, setHasSavedGame] = useState(false); const [savedSnapshot, setSavedSnapshot] = useState(null); useEffect(() => { let isActive = true; void getSaveSnapshot() .then((snapshot) => { if (!isActive) return; setSavedSnapshot(snapshot); setHasSavedGame(Boolean(snapshot)); }) .catch((error) => { console.warn('[useGamePersistence] failed to load remote snapshot', error); }); return () => { isActive = false; }; }, []); useEffect(() => { const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory); if (!canPersist) return; const timeoutId = window.setTimeout(() => { void putSaveSnapshot({ gameState, bottomTab, currentStory, }) .then((snapshot) => { setSavedSnapshot(snapshot); setHasSavedGame(true); }) .catch((error) => { console.warn('[useGamePersistence] failed to autosave remote snapshot', error); }); }, AUTO_SAVE_DELAY_MS); return () => window.clearTimeout(timeoutId); }, [bottomTab, currentStory, gameState, isLoading]); const saveCurrentGame = useCallback(async (override?: { gameState?: GameState; bottomTab?: BottomTab; currentStory?: StoryMoment | null; }) => { const nextGameState = override?.gameState ?? gameState; const nextBottomTab = override?.bottomTab ?? bottomTab; const nextStory = override?.currentStory ?? currentStory; if (!canPersistSnapshot(nextGameState, nextStory)) { return false; } try { const snapshot = await putSaveSnapshot({ gameState: nextGameState, bottomTab: nextBottomTab, currentStory: nextStory, }); setSavedSnapshot(snapshot); setHasSavedGame(true); return true; } catch (error) { console.warn('[useGamePersistence] failed to save remote snapshot', error); return false; } }, [bottomTab, currentStory, gameState]); const clearSavedGame = useCallback(async () => { try { await deleteSaveSnapshot(); } catch (error) { console.warn('[useGamePersistence] failed to delete remote snapshot', error); } setSavedSnapshot(null); setHasSavedGame(false); }, []); const continueSavedGame = useCallback(async () => { const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => { console.warn('[useGamePersistence] failed to refetch remote snapshot', error); return null; }); if (!snapshot) { setSavedSnapshot(null); setHasSavedGame(false); return false; } resetStoryState(); setGameState(normalizeSavedGameState(snapshot.gameState)); setBottomTab(snapshot.bottomTab ?? 'adventure'); hydrateStoryState(normalizeSavedStory(snapshot.currentStory)); setSavedSnapshot(snapshot); setHasSavedGame(true); return true; }, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]); return { hasSavedGame, saveCurrentGame, continueSavedGame, clearSavedGame, }; }