352 lines
9.8 KiB
TypeScript
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
|
|
>;
|