初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

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