Files
Genarrative/src/hooks/rpg-session/useRpgSessionPersistence.ts
2026-04-27 14:23:19 +08:00

352 lines
9.8 KiB
TypeScript

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<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: {
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
>;