Files
Genarrative/src/hooks/useGamePersistence.ts
2026-04-08 16:41:29 +08:00

286 lines
9.3 KiB
TypeScript

import {useCallback, useEffect, useState} from 'react';
import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets';
import { normalizeRoster } from '../data/companionRoster';
import { getInitialPlayerCurrency } from '../data/economy';
import {
applyEquipmentLoadoutToState,
buildInitialEquipmentLoadout,
createEmptyEquipmentLoadout,
} from '../data/equipmentEffects';
import { normalizeNpcPersistentState } from '../data/npcInteractions';
import { normalizeQuestLogEntries } from '../data/questFlow';
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
import {
deleteSaveSnapshot,
getSaveSnapshot,
putSaveSnapshot,
} from '../services/storageService';
import {
applyStoryEngineMigration,
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
function normalizeSavedStory(story: StoryMoment | null) {
if (!story) return null;
return {
...story,
streaming: false,
} satisfies StoryMoment;
}
function normalizeCharacterChats(gameState: GameState) {
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
characterId,
{
history: Array.isArray(record?.history)
? record.history
.filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character'))
.map(turn => ({
speaker: turn.speaker,
text: turn.text,
}))
: [],
summary: typeof record?.summary === 'string' ? record.summary : '',
updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null,
},
] as const);
return Object.fromEntries(entries);
}
function normalizeSavedGameState(gameState: GameState) {
const migrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const migratedState = applyStoryEngineMigration({
state: gameState,
manifest: migrationManifest,
});
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure'
? ensureSceneEncounterPreview({
...migratedState,
currentEncounter: null,
sceneHostileNpcs: [],
inBattle: false,
} as GameState)
: migratedState;
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
isActiveRun: Boolean(
normalizedEncounterState.playerCharacter &&
normalizedEncounterState.currentScene === 'Story'
),
});
const normalizedCommonState = {
...normalizedEncounterState,
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
runtimeStats: normalizedRuntimeStats,
storyEngineMemory:
normalizedEncounterState.storyEngineMemory ??
createEmptyStoryEngineMemoryState(),
chapterState:
normalizedEncounterState.chapterState
?? normalizedEncounterState.storyEngineMemory?.currentChapter
?? null,
campaignState:
normalizedEncounterState.campaignState
?? normalizedEncounterState.storyEngineMemory?.campaignState
?? null,
activeScenarioPackId:
normalizedEncounterState.activeScenarioPackId
?? normalizedEncounterState.customWorldProfile?.scenarioPackId
?? null,
activeCampaignPackId:
normalizedEncounterState.activeCampaignPackId
?? normalizedEncounterState.customWorldProfile?.campaignPackId
?? null,
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency
: getInitialPlayerCurrency(gameState.worldType),
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
roster: normalizedRoster,
npcStates: Object.fromEntries(
Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [
npcId,
normalizeNpcPersistentState(npcState),
]),
),
characterChats: normalizeCharacterChats(normalizedEncounterState),
activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [],
} satisfies GameState;
if (!normalizedEncounterState.playerCharacter) {
return {
...normalizedCommonState,
playerEquipment: createEmptyEquipmentLoadout(),
} satisfies GameState;
}
const resolvedEquipment = normalizedEncounterState.playerEquipment
? normalizedEncounterState.playerEquipment
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
const playerMaxHp = getCharacterMaxHp(
normalizedEncounterState.playerCharacter,
normalizedEncounterState.worldType,
normalizedEncounterState.customWorldProfile,
);
return applyEquipmentLoadoutToState({
...normalizedCommonState,
playerMaxHp,
playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp),
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
playerEquipment: createEmptyEquipmentLoadout(),
} as GameState, resolvedEquipment);
}
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
return (
gameState.currentScene === 'Story' &&
Boolean(gameState.worldType) &&
Boolean(gameState.playerCharacter) &&
story?.streaming !== true
);
}
export function useGamePersistence({
gameState,
bottomTab,
currentStory,
isLoading,
setGameState,
setBottomTab,
hydrateStoryState,
resetStoryState,
}: {
gameState: GameState;
bottomTab: BottomTab;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: (state: GameState) => void;
setBottomTab: (tab: BottomTab) => void;
hydrateStoryState: (story: StoryMoment | null) => void;
resetStoryState: () => void;
}) {
const [hasSavedGame, setHasSavedGame] = useState(false);
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
useEffect(() => {
let isActive = true;
void getSaveSnapshot()
.then((snapshot) => {
if (!isActive) return;
setSavedSnapshot(snapshot);
setHasSavedGame(Boolean(snapshot));
})
.catch((error) => {
console.warn('[useGamePersistence] failed to load remote snapshot', error);
});
return () => {
isActive = false;
};
}, []);
useEffect(() => {
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
if (!canPersist) return;
const timeoutId = window.setTimeout(() => {
void putSaveSnapshot({
gameState,
bottomTab,
currentStory,
})
.then((snapshot) => {
setSavedSnapshot(snapshot);
setHasSavedGame(true);
})
.catch((error) => {
console.warn('[useGamePersistence] failed to autosave remote snapshot', error);
});
}, AUTO_SAVE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [bottomTab, currentStory, gameState, isLoading]);
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;
}
try {
const snapshot = await putSaveSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
});
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
} catch (error) {
console.warn('[useGamePersistence] failed to save remote snapshot', error);
return false;
}
}, [bottomTab, currentStory, gameState]);
const clearSavedGame = useCallback(async () => {
try {
await deleteSaveSnapshot();
} catch (error) {
console.warn('[useGamePersistence] failed to delete remote snapshot', error);
}
setSavedSnapshot(null);
setHasSavedGame(false);
}, []);
const continueSavedGame = useCallback(async () => {
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
return null;
});
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
resetStoryState();
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
return {
hasSavedGame,
saveCurrentGame,
continueSavedGame,
clearSavedGame,
};
}