Merge remote-tracking branch 'origin/server_node'
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 19:16:55 +08:00
69 changed files with 8262 additions and 81 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

@@ -40,7 +40,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,13 +12,18 @@ 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,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment, WorldType } from '../types';
import { GameState, StoryMoment } from '../types';
import { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
@@ -31,14 +36,6 @@ function normalizeSavedStory(story: StoryMoment | null) {
} satisfies StoryMoment;
}
function isPlayableSavedGameState(gameState: GameState | null | undefined) {
return Boolean(
gameState
&& gameState.worldType === WorldType.CUSTOM
&& gameState.customWorldProfile,
);
}
function normalizeCharacterChats(gameState: GameState) {
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
characterId,
@@ -108,10 +105,7 @@ function normalizeSavedGameState(gameState: GameState) {
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency
: getInitialPlayerCurrency(
gameState.worldType,
normalizedEncounterState.customWorldProfile,
),
: getInitialPlayerCurrency(gameState.worldType),
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
roster: normalizedRoster,
npcStates: Object.fromEntries(
@@ -180,10 +174,24 @@ export function useGamePersistence({
resetStoryState: () => void;
}) {
const [hasSavedGame, setHasSavedGame] = useState(false);
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
useEffect(() => {
const snapshot = readSavedSnapshot();
setHasSavedGame(isPlayableSavedGameState(snapshot?.gameState ?? null));
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(() => {
@@ -192,21 +200,24 @@ export function useGamePersistence({
if (!canPersist) return;
const timeoutId = window.setTimeout(() => {
const didSave = writeSavedSnapshot({
void putSaveSnapshot({
gameState,
bottomTab,
currentStory,
});
if (didSave) {
setHasSavedGame(isPlayableSavedGameState(gameState));
}
})
.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;
@@ -219,33 +230,40 @@ export function useGamePersistence({
return false;
}
const didSave = writeSavedSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
});
if (didSave) {
setHasSavedGame(isPlayableSavedGameState(nextGameState));
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();
return false;
}
if (!isPlayableSavedGameState(snapshot.gameState)) {
clearSavedGame();
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
@@ -253,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

@@ -44,6 +44,7 @@ import {
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import { hasMixedNarrativeLanguage } from '../services/narrativeLanguage';
import { generateInitialStory, generateNextStep } from '../services/aiService';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,