This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -0,0 +1,12 @@
export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap';
export type { BottomTab } from './rpgSessionTypes';
export {
type RpgRuntimeSessionResult,
useRpgRuntimeSession,
} from './useRpgRuntimeSession';
export {
type RpgSessionPersistenceResult,
type UseRpgSessionPersistenceParams,
useRpgSessionPersistence,
} from './useRpgSessionPersistence';
export type { BottomTab as RpgBottomTab } from './rpgSessionTypes';

View File

@@ -0,0 +1,3 @@
import type { BottomTab } from '../../types/navigation';
export type { BottomTab };

View File

@@ -0,0 +1,191 @@
import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../../components/auth/AuthUiContext';
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell';
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { useBackgroundMusic } from '../useBackgroundMusic';
import { useCombatFlow } from '../useCombatFlow';
import { useNpcInteractionFlow } from '../useNpcInteractionFlow';
import { useRpgRuntimeStory } from '../rpg-runtime-story';
import { useRpgSessionBootstrap } from './useRpgSessionBootstrap';
import { useRpgSessionPersistence } from './useRpgSessionPersistence';
/**
* RPG 主运行态装配器真实实现。
* 工作包 C 起主链改为组合 `rpg-session` 下的 bootstrap / persistence 新入口。
*/
export function useRpgRuntimeSession(): RpgRuntimeShellProps {
const authUi = useAuthUi();
const {
gameState,
setGameState,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
resetGame,
handleCustomWorldSelect: selectCustomWorld,
handleBackToWorldSelect: backToWorldSelect,
handleCharacterSelect: selectCharacter,
} = useRpgSessionBootstrap();
const combatFlow = useCombatFlow({
setGameState,
});
const storyFlow = useRpgRuntimeStory({
gameState,
setGameState,
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
playResolvedChoice: combatFlow.playResolvedChoice,
});
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
const persistence = useRpgSessionPersistence({
authenticatedUserId: authUi?.user?.id ?? null,
gameState,
bottomTab,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
setGameState,
setBottomTab,
hydrateStoryState: storyFlow.hydrateStoryState,
resetStoryState: storyFlow.resetStoryState,
});
useBackgroundMusic({
active: Boolean(
gameState.playerCharacter && gameState.currentScene === 'Story',
),
volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
});
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
const intervalId = window.setInterval(() => {
setGameState((currentState) => {
if (
!currentState.playerCharacter ||
currentState.currentScene !== 'Story'
) {
return currentState;
}
return syncGameStatePlayTime(currentState);
});
}, 15000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
) => {
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile);
};
const handleCharacterSelect = (
character: Parameters<typeof selectCharacter>[0],
) => {
storyFlow.resetStoryState();
selectCharacter(character);
};
const handleBackToWorldSelect = () => {
storyFlow.resetStoryState();
backToWorldSelect();
};
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
void persistence.continueSavedGame(snapshot);
};
const handleStartNewGame = () => {
void persistence.clearSavedGame();
storyFlow.resetStoryState();
resetGame();
};
const handleSaveAndExit = () => {
const syncedGameState = syncGameStatePlayTime(gameState);
void persistence.saveCurrentGame({
gameState: syncedGameState,
bottomTab,
currentStory: storyFlow.currentStory,
});
storyFlow.resetStoryState();
resetGame();
};
const handleBenchCompanion = (npcId: string) => {
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
};
const handleActivateRosterCompanion = (
npcId: string,
swapNpcId?: string | null,
) => {
setGameState((currentState) =>
activateRosterCompanion(currentState, npcId, swapNpcId),
);
};
return {
session: {
gameState,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
aiError: storyFlow.aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
},
story: {
displayedOptions: storyFlow.displayedOptions,
canRefreshOptions: storyFlow.canRefreshOptions,
handleRefreshOptions: storyFlow.handleRefreshOptions,
handleChoice: storyFlow.handleChoice,
handleNpcChatInput: storyFlow.handleNpcChatInput,
exitNpcChat: storyFlow.exitNpcChat,
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
npcUi: storyFlow.npcUi,
characterChatUi: storyFlow.characterChatUi,
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
goalUi: storyFlow.goalUi,
},
entry: {
hasSavedGame: persistence.hasSavedGame,
savedSnapshot: persistence.savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
},
companions: {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion: handleBenchCompanion,
onActivateRosterCompanion: handleActivateRosterCompanion,
},
audio: {
musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
},
};
}
export type RpgRuntimeSessionResult = ReturnType<typeof useRpgRuntimeSession>;

View File

@@ -0,0 +1,416 @@
import { useEffect, useState } from 'react';
import {
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { getInitialPlayerCurrency } from '../../data/economy';
import {
applyEquipmentLoadoutToState,
buildInitialEquipmentLoadout,
createEmptyEquipmentLoadout,
} from '../../data/equipmentEffects';
import {
buildInitialNpcState,
buildInitialPlayerInventory,
} from '../../data/npcInteractions';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import {
ensureSceneEncounterPreview,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
Character,
CustomWorldProfile,
Encounter,
EquipmentLoadout,
GameState,
InventoryItem,
SceneNpc,
WorldType,
} from '../../types';
import type { BottomTab } from './rpgSessionTypes';
const PLAYER_BASE_MAX_HP = 180;
function mergeStarterInventoryItems<
T extends { category: string; name: string },
>(explicitItems: T[], fallbackItems: T[]) {
const merged = new Map<string, T>();
[...explicitItems, ...fallbackItems].forEach((item) => {
merged.set(`${item.category}:${item.name}`, item);
});
return [...merged.values()];
}
function normalizeExplicitStarterCategory(category: string) {
const normalized = category.trim();
return normalized === '专属物' ? '专属物品' : normalized;
}
function inferExplicitStarterSlot(category: string) {
const normalized = normalizeExplicitStarterCategory(category);
if (normalized === '武器') return 'weapon' as const;
if (normalized === '护甲') return 'armor' as const;
if (
normalized === '饰品' ||
normalized === '稀有品' ||
normalized === '专属物品'
) {
return 'relic' as const;
}
return null;
}
function buildExplicitCustomWorldRoleStarterState(
profile: CustomWorldProfile,
character: Character,
) {
const role =
profile.playableNpcs.find((entry) => entry.id === character.id) ??
profile.storyNpcs.find((entry) => entry.id === character.id) ??
profile.playableNpcs.find(
(entry) => entry.templateCharacterId === character.id,
) ??
profile.playableNpcs.find((entry) => entry.name === character.name) ??
profile.storyNpcs.find((entry) => entry.name === character.name) ??
null;
const inventory = role
? role.initialItems.map((item, index) => {
const category = normalizeExplicitStarterCategory(item.category);
return {
id: `custom-role-item:${role.id}:${index + 1}`,
category,
name: item.name,
quantity: Math.max(1, item.quantity),
rarity: item.rarity,
tags: [...item.tags],
description: item.description,
equipmentSlotId: inferExplicitStarterSlot(category),
runtimeMetadata: {
origin: 'ai_compiled' as const,
generationChannel: 'discovery' as const,
seedKey: `${role.id}:${index + 1}`,
relationAnchor: {
type: 'npc' as const,
npcId: role.id,
npcName: role.name,
roleText: role.role,
},
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
},
} satisfies InventoryItem;
})
: [];
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
inventory.forEach((item) => {
const slot = item.equipmentSlotId;
if (!slot || equipment[slot]) {
return;
}
equipment[slot] = item;
});
return {
inventory,
equipment,
};
}
function createInitialCampEncounter(
worldType: WorldType | null,
playerCharacter: Character,
): Encounter | null {
if (!worldType) return null;
const campScenePreset =
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
const npcCandidates = (campScenePreset?.npcs ?? [])
.filter((npc: SceneNpc) => Boolean(npc.characterId))
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
if (npcCandidates.length === 0) return null;
const npc =
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
if (!npc) return null;
return {
id: npc.id,
kind: 'npc',
characterId: npc.characterId,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.avatar,
context: npc.role,
gender: npc.gender,
xMeters: RESOLVED_ENTITY_X_METERS,
};
}
function createInitialGameState(): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: createInitialGameRuntimeStats(),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Selection',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: null,
activeCampaignPackId: null,
characterChats: {},
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: PLAYER_BASE_MAX_HP,
playerMaxHp: PLAYER_BASE_MAX_HP,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
/**
* RPG session bootstrap 主实现。
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
*/
export function useRpgSessionBootstrap() {
const [gameState, setGameState] = useState<GameState>(() =>
createInitialGameState(),
);
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
const [isMapOpen, setIsMapOpen] = useState(false);
useEffect(() => {
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
setRuntimeCharacterOverrides(
gameState.customWorldProfile
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
: null,
);
}, [gameState.customWorldProfile]);
const resetGame = () => {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState(createInitialGameState());
};
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const resolvedWorldType = WorldType.CUSTOM;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
buildCustomWorldRuntimeCharacters(customWorldProfile),
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
setGameState((prev) =>
ensureSceneEncounterPreview({
...prev,
worldType: resolvedWorldType,
customWorldProfile,
currentScenePreset: initialScenePreset,
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
playerProgression: createInitialPlayerProgressionState(),
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
playerActionMode: 'idle',
activeCombatEffects: [],
activeBuildBuffs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}),
);
};
const handleBackToWorldSelect = () => {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState(createInitialGameState());
};
const handleCharacterSelect = (character: Character) => {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState((prev) => {
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset = resolvedWorldType
? (getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: null;
const initialEncounter = createInitialCampEncounter(
resolvedWorldType,
character,
);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
const initialEquipment = buildInitialEquipmentLoadout(
character,
resolvedCustomWorldProfile,
);
const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!,
character,
)
: null;
const mergedStarterEquipment = {
weapon:
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
};
const playerMaxHp = getCharacterMaxHp(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId:
prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId:
prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
),
);
});
};
return {
gameState,
setGameState,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
resetGame,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};
}
export type RpgSessionBootstrapResult = ReturnType<
typeof useRpgSessionBootstrap
>;

View 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
>;