import { useCallback, useEffect, useRef, useState } from 'react'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; import { rpgSnapshotClient } from '../../services/rpg-runtime'; import type { GameState, StoryMoment } from '../../types'; import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator'; import type { BottomTab } from './rpgSessionTypes'; const AUTO_SAVE_DELAY_MS = 400; function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) { return ( gameState.runtimePersistenceDisabled !== true && gameState.runtimeMode !== 'preview' && gameState.runtimeMode !== 'test' && gameState.currentScene === 'Story' && Boolean(gameState.worldType) && Boolean(gameState.playerCharacter) && story?.streaming !== true ); } function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab { if (bottomTab === 'character' || bottomTab === 'inventory') { return bottomTab; } return 'adventure'; } function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { return { gameState: snapshot.gameState, currentStory: snapshot.currentStory ?? null, bottomTab: normalizeBottomTab(snapshot.bottomTab), }; } export type UseRpgSessionPersistenceParams = { authenticatedUserId: string | null; gameState: GameState; bottomTab: BottomTab; currentStory: StoryMoment | null; isLoading: boolean; setGameState: (state: GameState) => void; setBottomTab: (tab: BottomTab) => void; hydrateStoryState: (story: StoryMoment | null) => void; resetStoryState: () => void; }; /** * RPG session persistence 主实现。 * 工作包 C 起由新域 hook 负责自动存档、继续游戏恢复与运行态 story 恢复刷新。 */ export function useRpgSessionPersistence({ authenticatedUserId, gameState, bottomTab, currentStory, isLoading, setGameState, setBottomTab, hydrateStoryState, resetStoryState, }: UseRpgSessionPersistenceParams) { const [hasSavedGame, setHasSavedGame] = useState(false); const [savedSnapshot, setSavedSnapshot] = useState(null); const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true); const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false); const [persistenceError, setPersistenceError] = useState(null); const hydrateControllerRef = useRef(null); const saveControllerRef = useRef(null); const saveRequestIdRef = useRef(0); const abortActiveSave = useCallback(() => { saveControllerRef.current?.abort(); saveControllerRef.current = null; setIsPersistingSnapshot(false); }, []); const persistSnapshot = useCallback( async (params: { payload: { gameState: GameState; bottomTab: BottomTab; currentStory: StoryMoment | null; }; logLabel: string; }) => { if (!authenticatedUserId) { return null; } abortActiveSave(); const requestId = saveRequestIdRef.current + 1; saveRequestIdRef.current = requestId; const controller = new AbortController(); saveControllerRef.current = controller; setIsPersistingSnapshot(true); setPersistenceError(null); try { const snapshot = await rpgSnapshotClient.putSnapshot( { gameState: params.payload.gameState, bottomTab: params.payload.bottomTab, currentStory: params.payload.currentStory, }, { signal: controller.signal }, ); if (saveRequestIdRef.current !== requestId) { return null; } setSavedSnapshot(snapshot); setHasSavedGame(true); return snapshot; } catch (error) { if (isAbortError(error)) { return null; } const message = error instanceof Error ? error.message : '远端存档同步失败'; if (saveRequestIdRef.current === requestId) { setPersistenceError(message); } console.warn(`[useRpgSessionPersistence] ${params.logLabel}`, error); return null; } finally { if (saveControllerRef.current === controller) { saveControllerRef.current = null; setIsPersistingSnapshot(false); } } }, [abortActiveSave, authenticatedUserId], ); useEffect(() => { hydrateControllerRef.current?.abort(); hydrateControllerRef.current = null; abortActiveSave(); if (!authenticatedUserId) { setSavedSnapshot(null); setHasSavedGame(false); setPersistenceError(null); setIsHydratingSnapshot(false); return; } const controller = new AbortController(); hydrateControllerRef.current = controller; setIsHydratingSnapshot(true); void rpgSnapshotClient .getSnapshot({ signal: controller.signal }) .then((snapshot) => { setSavedSnapshot(snapshot); setHasSavedGame(Boolean(snapshot)); setPersistenceError(null); }) .catch((error) => { if (isAbortError(error)) { return; } const message = error instanceof Error ? error.message : '读取远端存档失败'; setPersistenceError(message); console.warn( '[useRpgSessionPersistence] failed to load remote snapshot', error, ); }) .finally(() => { if (hydrateControllerRef.current === controller) { hydrateControllerRef.current = null; setIsHydratingSnapshot(false); } }); return () => { controller.abort(); if (hydrateControllerRef.current === controller) { hydrateControllerRef.current = null; } }; }, [abortActiveSave, authenticatedUserId]); useEffect( () => () => { hydrateControllerRef.current?.abort(); saveControllerRef.current?.abort(); saveControllerRef.current = null; }, [], ); useEffect(() => { const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory); if (!canPersist) return; const timeoutId = window.setTimeout(() => { void persistSnapshot({ payload: { gameState, bottomTab, currentStory, }, logLabel: 'failed to autosave remote snapshot', }); }, AUTO_SAVE_DELAY_MS); return () => window.clearTimeout(timeoutId); }, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]); 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; } const snapshot = await persistSnapshot({ payload: { gameState: nextGameState, bottomTab: nextBottomTab, currentStory: nextStory, }, logLabel: 'failed to save remote snapshot', }); return Boolean(snapshot); }, [bottomTab, currentStory, gameState, persistSnapshot], ); const clearSavedGame = useCallback(async () => { abortActiveSave(); if (!authenticatedUserId) { setSavedSnapshot(null); setHasSavedGame(false); setPersistenceError(null); return; } try { await rpgSnapshotClient.deleteSnapshot(); setPersistenceError(null); } catch (error) { console.warn( '[useRpgSessionPersistence] failed to delete remote snapshot', error, ); } setSavedSnapshot(null); setHasSavedGame(false); }, [abortActiveSave, authenticatedUserId]); const continueSavedGame = useCallback( async (snapshotOverride?: HydratedSavedGameSnapshot | null) => { if (!authenticatedUserId && !snapshotOverride) { return false; } const snapshot = snapshotOverride ?? savedSnapshot ?? (await rpgSnapshotClient.getSnapshot().catch((error) => { if (!isAbortError(error)) { console.warn( '[useRpgSessionPersistence] failed to refetch remote snapshot', error, ); } return null; })); if (!snapshot) { setSavedSnapshot(null); setHasSavedGame(false); return false; } resetStoryState(); const fallbackHydration = resolveRemoteSnapshotState(snapshot); const resumedState = await resumeServerRuntimeStory(snapshot).catch( (error) => { if (!isAbortError(error)) { console.warn( '[useRpgSessionPersistence] failed to refresh runtime story state from server', error, ); } return { hydratedSnapshot: fallbackHydration, nextStory: fallbackHydration.currentStory, }; }, ); setGameState(resumedState.hydratedSnapshot.gameState); setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab)); hydrateStoryState(resumedState.nextStory); setSavedSnapshot(snapshot); setHasSavedGame(true); setPersistenceError(null); return true; }, [ authenticatedUserId, hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState, ], ); return { hasSavedGame, savedSnapshot, isHydratingSnapshot, isPersistingSnapshot, persistenceError, saveCurrentGame, continueSavedGame, clearSavedGame, }; } export type RpgSessionPersistenceResult = ReturnType< typeof useRpgSessionPersistence >;