286 lines
9.3 KiB
TypeScript
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,
|
|
};
|
|
}
|