213
src/hooks/useGamePersistence.ts
Normal file
213
src/hooks/useGamePersistence.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import { 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 {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage';
|
||||
import { GameState, StoryMoment } from '../types';
|
||||
import { BottomTab } from './useGameFlow';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
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 normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
|
||||
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && gameState.currentEncounter?.kind === 'treasure'
|
||||
? ensureSceneEncounterPreview({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
sceneMonsters: [],
|
||||
inBattle: false,
|
||||
} as GameState)
|
||||
: gameState;
|
||||
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
normalizedEncounterState.playerCharacter &&
|
||||
normalizedEncounterState.currentScene === 'Story'
|
||||
),
|
||||
});
|
||||
const normalizedCommonState = {
|
||||
...normalizedEncounterState,
|
||||
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
|
||||
runtimeStats: normalizedRuntimeStats,
|
||||
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);
|
||||
|
||||
return applyEquipmentLoadoutToState({
|
||||
...normalizedCommonState,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, PLAYER_BASE_MAX_HP),
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setHasSavedGame(Boolean(readSavedSnapshot()));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const didSave = writeSavedSnapshot({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
setHasSavedGame(true);
|
||||
}
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading]);
|
||||
|
||||
const saveCurrentGame = useCallback((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 didSave = writeSavedSnapshot({
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
setHasSavedGame(true);
|
||||
}
|
||||
|
||||
return didSave;
|
||||
}, [bottomTab, currentStory, gameState]);
|
||||
|
||||
const clearSavedGame = useCallback(() => {
|
||||
clearSavedSnapshot();
|
||||
setHasSavedGame(false);
|
||||
}, []);
|
||||
|
||||
const continueSavedGame = useCallback(() => {
|
||||
const snapshot = readSavedSnapshot();
|
||||
if (!snapshot) {
|
||||
clearSavedGame();
|
||||
return false;
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
setGameState(normalizeSavedGameState(snapshot.gameState));
|
||||
setBottomTab(snapshot.bottomTab ?? 'adventure');
|
||||
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
|
||||
setHasSavedGame(true);
|
||||
return true;
|
||||
}, [clearSavedGame, hydrateStoryState, resetStoryState, setBottomTab, setGameState]);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user