1
This commit is contained in:
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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.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) {
|
||||
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
|
||||
>;
|
||||
Reference in New Issue
Block a user