import { useCallback, useEffect, useRef, useState } from 'react'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; import { getRpgRuntimeSessionId, 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) { // 中文注释:preview / test 模式、非 Story 场景、未选角、以及流式输出中的故事都不应入正式存档, // 否则容易把临时态或半成品叙事写进继续游戏链路。 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) { // 中文注释:远端快照允许缺少局部 UI 状态; // 这里统一补底部 tab 的兜底值,避免恢复后落到非法面板名。 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: { sessionId: string; bottomTab: BottomTab; }; 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 { // 中文注释:这里不再上传整份本地快照; // 前端只告诉后端“当前 session 需要 checkpoint”,真实 GameState 由服务端快照表读取。 const snapshot = await rpgSnapshotClient.putSnapshot( { sessionId: params.payload.sessionId, bottomTab: params.payload.bottomTab, }, { 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: { sessionId: getRpgRuntimeSessionId(gameState), bottomTab, }, 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; } // 中文注释:手动存档和自动存档走同一套底层 persist 逻辑, // 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。 const snapshot = await persistSnapshot({ payload: { sessionId: getRpgRuntimeSessionId(nextGameState), bottomTab: nextBottomTab, }, 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); // 中文注释:继续游戏不是简单把旧 currentStory 塞回去, // 还要向服务端刷新一遍 runtime story,拿到当前服务端判定的可选动作与视图模型。 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 >;