367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
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<HydratedSavedGameSnapshot | null>(null);
|
||
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
|
||
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
|
||
const [persistenceError, setPersistenceError] = useState<string | null>(null);
|
||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||
const saveControllerRef = useRef<AbortController | null>(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
|
||
>;
|