feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -4,7 +4,7 @@ import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/ai';
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type {
Character,

View File

@@ -16,7 +16,7 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';

View File

@@ -39,7 +39,7 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {

View File

@@ -34,7 +34,7 @@ import {
removeInventoryItem,
syncNpcTradeInventory,
} from '../../data/npcInteractions';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
appendStoryEngineCarrierMemory,

View File

@@ -11,7 +11,7 @@ import {
} from '../../data/sceneEncounterPreviews';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { generateNextStep } from '../../services/ai';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {

View File

@@ -12,7 +12,12 @@ 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 type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
import {
deleteSaveSnapshot,
getSaveSnapshot,
putSaveSnapshot,
} from '../services/storageService';
import {
applyStoryEngineMigration,
buildSaveMigrationManifest,
@@ -169,9 +174,24 @@ export function useGamePersistence({
resetStoryState: () => void;
}) {
const [hasSavedGame, setHasSavedGame] = useState(false);
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
useEffect(() => {
setHasSavedGame(Boolean(readSavedSnapshot()));
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(() => {
@@ -180,21 +200,24 @@ export function useGamePersistence({
if (!canPersist) return;
const timeoutId = window.setTimeout(() => {
const didSave = writeSavedSnapshot({
void putSaveSnapshot({
gameState,
bottomTab,
currentStory,
});
if (didSave) {
setHasSavedGame(true);
}
})
.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((override?: {
const saveCurrentGame = useCallback(async (override?: {
gameState?: GameState;
bottomTab?: BottomTab;
currentStory?: StoryMoment | null;
@@ -207,28 +230,40 @@ export function useGamePersistence({
return false;
}
const didSave = writeSavedSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
});
if (didSave) {
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;
}
return didSave;
}, [bottomTab, currentStory, gameState]);
const clearSavedGame = useCallback(() => {
clearSavedSnapshot();
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(() => {
const snapshot = readSavedSnapshot();
const continueSavedGame = useCallback(async () => {
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
return null;
});
if (!snapshot) {
clearSavedGame();
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
@@ -236,9 +271,10 @@ export function useGamePersistence({
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
}, [clearSavedGame, hydrateStoryState, resetStoryState, setBottomTab, setGameState]);
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
return {
hasSavedGame,

View File

@@ -1,13 +1,64 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {clampVolume, readSavedSettings, writeSavedSettings} from '../persistence/gameSettingsStorage';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
} from '../persistence/gameSettingsStorage';
import { getSettings, putSettings } from '../services/storageService';
export function useGameSettings() {
const [musicVolume, setMusicVolumeState] = useState(() => readSavedSettings().musicVolume);
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
useEffect(() => {
writeSavedSettings({musicVolume});
}, [musicVolume]);
let isActive = true;
void getSettings()
.then((settings) => {
if (!isActive) return;
const nextVolume = clampVolume(settings.musicVolume);
lastSyncedVolumeRef.current = nextVolume;
setMusicVolumeState(nextVolume);
})
.catch((error) => {
console.warn('[useGameSettings] failed to load remote settings', error);
})
.finally(() => {
if (isActive) {
setHasHydratedSettings(true);
}
});
return () => {
isActive = false;
};
}, []);
useEffect(() => {
if (!hasHydratedSettings) {
return;
}
if (lastSyncedVolumeRef.current === musicVolume) {
return;
}
let isActive = true;
void putSettings({musicVolume})
.then((settings) => {
if (!isActive) return;
lastSyncedVolumeRef.current = clampVolume(settings.musicVolume);
})
.catch((error) => {
console.warn('[useGameSettings] failed to persist remote settings', error);
});
return () => {
isActive = false;
};
}, [hasHydratedSettings, musicVolume]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));

View File

@@ -42,7 +42,7 @@ import {
sortStoryOptionsByPriority,
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import { generateInitialStory, generateNextStep } from '../services/aiService';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,