This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,10 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../services/ai', () => ({
vi.mock('../../services/aiService', () => ({
generateNextStep: vi.fn(),
}));
import { generateNextStep } from '../../services/ai';
const {
isServerRuntimeFunctionIdMock,
resolveServerRuntimeChoiceMock,
} = vi.hoisted(() => ({
isServerRuntimeFunctionIdMock: vi.fn(() => false),
resolveServerRuntimeChoiceMock: vi.fn(),
}));
vi.mock('./runtimeStoryCoordinator', () => ({
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
vi.mock('../../services/runtimeStoryService', () => ({
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
}));
import { generateNextStep } from '../../services/aiService';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
@@ -133,6 +149,228 @@ const neverNpcEncounter = (
): encounter is Encounter => false;
describe('createStoryChoiceActions', () => {
beforeEach(() => {
isServerRuntimeFunctionIdMock.mockReset();
isServerRuntimeFunctionIdMock.mockReturnValue(false);
resolveServerRuntimeChoiceMock.mockReset();
});
it('routes task5 story choices through the server runtime action endpoint', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
isServerRuntimeFunctionIdMock.mockReturnValue(true);
resolveServerRuntimeChoiceMock.mockResolvedValue({
hydratedSnapshot: {
gameState: {
...state,
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 1,
npcStates: {
...state.npcStates,
'npc-opponent': {
...state.npcStates['npc-opponent'],
affinity: 6,
chattedCount: 1,
},
},
},
currentStory: {
text: '后端已结算关系变化',
options: [],
},
bottomTab: 'adventure',
},
nextStory: {
text: '后端已结算关系变化',
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
text: '请求援手',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
});
const { handleChoice } = createStoryChoiceActions({
gameState: {
...state,
currentEncounter: {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路的陌生人',
npcAvatar: '/npc.png',
context: '山道相遇',
},
npcInteractionActive: true,
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
},
currentStory: createFallbackStory('当前故事'),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
gameState: expect.objectContaining({
currentEncounter: expect.objectContaining({
id: 'npc-opponent',
}),
}),
currentStory: createFallbackStory('当前故事'),
option,
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
runtimeActionVersion: 1,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '后端已结算关系变化',
options: [
expect.objectContaining({
functionId: 'npc_help',
}),
],
}),
);
});
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
const state: GameState = {
...createBaseState(),
currentEncounter: {
id: 'npc-merchant',
kind: 'npc' as const,
npcName: '梁伯',
npcDescription: '沿街商贩',
npcAvatar: '/npc.png',
context: '沿街商贩',
},
npcInteractionActive: true,
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const option: StoryOption = {
functionId: 'npc_trade',
actionText: '交易',
text: '交易',
interaction: {
kind: 'npc' as const,
npcId: 'npc-merchant',
action: 'trade' as const,
},
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const handleNpcInteraction = vi.fn(() => true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory('当前故事'),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
const state = createBaseState();
const option = createBattleOption();

View File

@@ -3,25 +3,9 @@ import type {
SetStateAction,
} from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
import { addInventoryItems } from '../../data/npcInteractions';
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import { isServerRuntimeFunctionId } from '../../services/runtimeStoryService';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
@@ -34,6 +18,12 @@ import type {
CommitGeneratedStateWithEncounterEntry,
GenerateStoryForState,
} from './progressionActions';
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
import {
runCampTravelHomeChoice,
runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
@@ -78,112 +68,6 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
return options.filter(option => {
if (seenFunctionIds.has(option.functionId)) {
return false;
}
seenFunctionIds.add(option.functionId);
return true;
});
}
function buildCombatResolutionContextText(params: {
baseState: GameState;
afterSequence: GameState;
optionKind: ResolvedChoiceState['optionKind'];
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
}) {
const {
baseState,
afterSequence,
optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
} = params;
if (optionKind === 'escape') {
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
}
if (
!baseState.inBattle ||
afterSequence.inBattle ||
Boolean(baseState.currentBattleNpcId)
) {
return null;
}
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
const lootText =
projectedBattleReward?.items.length
? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}`
: '';
return hostileNames
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
async function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
optionKind: ResolvedChoiceState['optionKind'],
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): Promise<BattleRewardSummary | null> {
if (
optionKind === 'escape'
|| !state.worldType
|| state.currentBattleNpcId
|| !state.inBattle
|| afterSequence.inBattle
) {
return null;
}
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc =>
!nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id),
);
if (defeatedHostileNpcs.length === 0) {
return null;
}
const rolledItems = await rollHostileNpcLoot(
state,
defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
);
return {
id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
items: addInventoryItems([], rolledItems),
};
}
export function createStoryChoiceActions({
gameState,
currentStory,
@@ -250,8 +134,10 @@ export function createStoryChoiceActions({
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean;
handleTreasureInteraction: (option: StoryOption) => void | Promise<void> | boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
option: StoryOption,
) => void | Promise<void> | boolean | Promise<boolean>;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
finalizeNpcBattleResult: (
state: GameState,
@@ -268,91 +154,6 @@ export function createStoryChoiceActions({
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const handleCampTravelHome = async (option: StoryOption, character: Character) => {
const targetScene = getCampCompanionTravelScene(gameState, character);
if (!targetScene) {
return;
}
setBattleReward(null);
setAiError(null);
const companionName = isNpcEncounter(gameState.currentEncounter)
? gameState.currentEncounter.npcName
: fallbackCompanionName;
const travelRunState: GameState = {
...gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = incrementRuntimeStats({
...gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, {
scenesTraveled: 1,
});
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS);
setIsLoading(true);
setGameState(travelRunState);
await sleep(turnVisualMs);
await commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
character,
option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
option.functionId,
);
return;
};
const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return;
@@ -367,7 +168,43 @@ export function createStoryChoiceActions({
}
if (isCampTravelHomeOption(option)) {
await handleCampTravelHome(option, character);
await runCampTravelHomeChoice({
gameState,
option,
character,
setBattleReward,
setAiError,
setIsLoading,
setGameState,
incrementRuntimeStats,
getCampCompanionTravelScene,
commitGeneratedStateWithEncounterEntry,
isNpcEncounter,
fallbackCompanionName,
turnVisualMs,
});
return;
}
if (shouldOpenLocalRuntimeNpcModal(option)) {
setAiError(null);
await handleNpcInteraction(option);
return;
}
if (isServerRuntimeFunctionId(option.functionId)) {
await runServerRuntimeChoiceAction({
gameState,
currentStory,
option,
character,
setBattleReward,
setAiError,
setIsLoading,
setGameState,
setCurrentStory: (story) => setCurrentStory(story),
buildFallbackStoryForState,
});
return;
}
@@ -393,238 +230,41 @@ export function createStoryChoiceActions({
if (option.interaction?.kind === 'npc') {
setAiError(null);
handleNpcInteraction(option);
await handleNpcInteraction(option);
return;
}
if (option.interaction?.kind === 'treasure') {
setAiError(null);
handleTreasureInteraction(option);
await handleTreasureInteraction(option);
return;
}
setBattleReward(null);
setAiError(null);
setIsLoading(true);
const baseChoiceState = (
isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
&& !option.interaction
)
? {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
}
: gameState;
let fallbackState = baseChoiceState;
try {
const history = baseChoiceState.storyHistory;
const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character);
const projectedState = resolvedChoice.afterSequence;
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
(
projectedState.currentNpcBattleOutcome ||
(baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle)
),
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: await buildHostileNpcBattleReward(
baseChoiceState,
projectedState,
resolvedChoice.optionKind,
getResolvedSceneHostileNpcs,
);
const projectedStateWithBattleReward = projectedBattleReward
? appendStoryEngineCarrierMemory({
...projectedState,
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
} as GameState, projectedBattleReward.items)
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = getAvailableOptionsForState(
projectedStateWithBattleReward,
character,
);
const combatResolutionContextText = buildCombatResolutionContextText({
baseState: baseChoiceState,
afterSequence: projectedStateWithBattleReward,
optionKind: resolvedChoice.optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
});
const historyForStoryGeneration = combatResolutionContextText
? [
...history,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(combatResolutionContextText, 'result'),
]
: history;
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
: generateNextStep(
gameState.worldType,
character,
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
historyForStoryGeneration,
option.actionText,
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
observeSignsRequested: option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
}),
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
);
const responseSettledPromise = responsePromise.then(() => undefined, () => undefined);
const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape'
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = playResolvedChoice(
baseChoiceState,
option,
character,
resolvedChoice,
playbackSync,
);
const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]);
if (actionResult.status === 'rejected') {
throw actionResult.reason;
}
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
if (projectedBattleReward) {
afterSequence = appendStoryEngineCarrierMemory({
...afterSequence,
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
} as GameState, projectedBattleReward.items);
}
fallbackState = afterSequence;
if (shouldUseLocalNpcVictory) {
const victory = finalizeNpcBattleResult(
afterSequence,
character,
baseChoiceState.currentNpcBattleMode!,
afterSequence.currentNpcBattleOutcome,
);
if (victory) {
const historyBase = baseChoiceState.currentNpcBattleMode === 'spar'
? (afterSequence.sparStoryHistoryBefore ?? [])
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
...victory.nextState,
storyHistory: nextHistory,
};
const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter
? buildReasonedOptionCatalog(
buildNpcStory(
nextState,
character,
nextState.currentEncounter,
).options,
)
: null;
fallbackState = nextState;
setGameState(nextState);
try {
const nextStory = await generateStoryForState({
state: nextState,
character,
history: nextHistory,
choice: option.actionText,
lastFunctionId: option.functionId,
optionCatalog: postBattleOptionCatalog,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
}
return;
}
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}
const response = responseResult.value!;
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
? []
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
const nextHistory = combatResolutionContextText
? [
...historyForStoryGeneration,
createHistoryMoment(response.storyText, 'result', response.options),
]
: [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = incrementRuntimeStats({
...updateQuestLog(
afterSequence,
quests => applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
lastObserveSignsSceneId: option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
lastObserveSignsReport: option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
storyHistory: nextHistory,
}, {
hostileNpcsDefeated: defeatedHostileNpcIds.length,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
if (projectedBattleReward) {
setBattleReward(projectedBattleReward);
}
setCurrentStory(
buildStoryFromResponse(
recoveredState,
character,
{
text: response.storyText,
options: response.options,
},
projectedAvailableOptions,
),
);
} catch (error) {
console.error('Failed to get next step:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
} finally {
setIsLoading(false);
}
await runLocalStoryChoiceContinuation({
gameState,
currentStory,
option,
character,
setGameState,
setCurrentStory: (story) => setCurrentStory(story),
setAiError,
setIsLoading,
setBattleReward,
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState,
buildStoryFromResponse,
buildFallbackStoryForState,
generateStoryForState,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
updateQuestLog,
incrementRuntimeStats,
finalizeNpcBattleResult,
isRegularNpcEncounter,
});
};
return {

View File

@@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
} from '../../services/storyEngine/goalDirector';
import type { GameState } from '../../types';
import type { GoalFlowUi } from './uiTypes';
export function useStoryGoalFlow(gameState: GameState) {
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
const previousGoalPulseSnapshotRef =
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
const runtimeGoalStack = useMemo(
() =>
buildGoalStackState({
quests: gameState.quests,
worldType: gameState.worldType,
currentSceneId: gameState.currentScenePreset?.id ?? null,
chapterState:
gameState.chapterState ??
gameState.storyEngineMemory?.currentChapter ??
null,
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective:
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
currentCampEvent:
gameState.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
}),
[
gameState.chapterState,
gameState.currentScenePreset?.id,
gameState.currentScenePreset?.name,
gameState.quests,
gameState.storyEngineMemory?.currentCampEvent,
gameState.storyEngineMemory?.currentChapter,
gameState.storyEngineMemory?.currentJourneyBeat,
gameState.storyEngineMemory?.currentSetpieceDirective,
gameState.worldType,
],
);
useEffect(() => {
const currentSnapshot = createGoalPulseSnapshot(
gameState.quests,
runtimeGoalStack,
);
const previousSnapshot = previousGoalPulseSnapshotRef.current;
if (!previousSnapshot) {
previousGoalPulseSnapshotRef.current = currentSnapshot;
return;
}
const nextPulse = deriveGoalPulseEvent({
previous: previousSnapshot,
quests: gameState.quests,
goalStack: runtimeGoalStack,
});
if (nextPulse) {
setGoalPulse(nextPulse);
}
previousGoalPulseSnapshotRef.current = currentSnapshot;
}, [gameState.quests, runtimeGoalStack]);
const dismissGoalPulse = useCallback(() => {
setGoalPulse(null);
}, []);
const resetGoalPulseTracking = useCallback(() => {
previousGoalPulseSnapshotRef.current = null;
setGoalPulse(null);
}, []);
return {
runtimeGoalStack,
goalUi: {
goalStack: runtimeGoalStack,
pulse: goalPulse,
dismissPulse: dismissGoalPulse,
} satisfies GoalFlowUi,
resetGoalPulseTracking,
};
}

View File

@@ -1,44 +1,196 @@
import type { GameState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { useEquipmentFlow } from '../useEquipmentFlow';
import { useForgeFlow } from '../useForgeFlow';
import { useInventoryFlow } from '../useInventoryFlow';
import { useMemo, type Dispatch, type SetStateAction } from 'react';
import {
EQUIPMENT_EQUIP_FUNCTION,
EQUIPMENT_UNEQUIP_FUNCTION,
FORGE_CRAFT_FUNCTION,
FORGE_DISMANTLE_FUNCTION,
FORGE_REFORGE_FUNCTION,
INVENTORY_USE_FUNCTION,
} from '../../data/functionCatalog';
import { getForgeRecipeViews } from '../../data/forgeSystem';
import type { Character, GameState, StoryMoment } from '../../types';
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
import type { InventoryFlowUi } from './uiTypes';
type TickCooldowns = (cooldowns: Record<string, number>) => Record<string, number>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
export function useStoryInventoryActions({
gameState,
commitGeneratedState,
tickCooldowns,
runtime,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
tickCooldowns: TickCooldowns;
runtime: {
currentStory: StoryMoment | null;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildFallbackStoryForState: BuildFallbackStoryForState;
};
}) {
const inventoryFlow = useInventoryFlow({
gameState,
commitGeneratedState,
tickCooldowns,
});
const equipmentFlow = useEquipmentFlow({
gameState,
commitGeneratedState,
});
const forgeFlow = useForgeFlow({
gameState,
commitGeneratedState,
});
const {
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildFallbackStoryForState,
} = runtime;
const forgeRecipes = useMemo(
() =>
getForgeRecipeViews(
gameState.playerInventory,
gameState.playerCurrency,
gameState.worldType,
),
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
);
const resolveServerInventoryAction = async (params: {
functionId: string;
actionText: string;
payload: Record<string, unknown>;
}) => {
const character = gameState.playerCharacter;
if (
!character ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
setAiError(null);
setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
gameState,
currentStory,
option: {
functionId: params.functionId,
actionText: params.actionText,
},
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve inventory runtime action on the server:', error);
setAiError(error instanceof Error ? error.message : '背包动作执行失败');
if (!currentStory) {
setCurrentStory(buildFallbackStoryForState(gameState, character));
}
return false;
} finally {
setIsLoading(false);
}
};
const useInventoryItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: INVENTORY_USE_FUNCTION.id,
actionText: `使用${item.name}`,
payload: { itemId },
});
};
const equipInventoryItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
actionText: `装备${item.name}`,
payload: { itemId },
});
};
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
const equippedItem = gameState.playerEquipment[slot];
if (!equippedItem) {
return false;
}
return resolveServerInventoryAction({
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
actionText: `卸下${equippedItem.name}`,
payload: { slotId: slot },
});
};
const craftRecipe = async (recipeId: string) => {
const recipe = forgeRecipes.find(
(candidate) => candidate.id === recipeId,
);
if (!recipe) {
return false;
}
return resolveServerInventoryAction({
functionId: FORGE_CRAFT_FUNCTION.id,
actionText: `制作${recipe.resultLabel}`,
payload: { recipeId },
});
};
const dismantleItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: FORGE_DISMANTLE_FUNCTION.id,
actionText: `拆解${item.name}`,
payload: { itemId },
});
};
const reforgeItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: FORGE_REFORGE_FUNCTION.id,
actionText: `重铸${item.name}`,
payload: { itemId },
});
};
return {
inventoryUi: {
useInventoryItem: inventoryFlow.handleUseInventoryItem,
equipInventoryItem: equipmentFlow.handleEquipInventoryItem,
unequipItem: equipmentFlow.handleUnequipItem,
forgeRecipes: forgeFlow.forgeRecipes,
craftRecipe: forgeFlow.handleCraftRecipe,
dismantleItem: forgeFlow.handleDismantleItem,
reforgeItem: forgeFlow.handleReforgeItem,
useInventoryItem,
equipInventoryItem,
unequipItem,
forgeRecipes,
craftRecipe,
dismantleItem,
reforgeItem,
} satisfies InventoryFlowUi,
};
}

View File

@@ -60,6 +60,7 @@ import type {
} from '../../types';
import { AnimationState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
@@ -116,6 +117,7 @@ function isNpcEncounter(
export function createStoryNpcEncounterActions({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
@@ -142,6 +144,7 @@ export function createStoryNpcEncounterActions({
npcInteractionFlow,
}: {
gameState: GameState;
currentStory: StoryMoment | null;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
@@ -588,6 +591,46 @@ export function createStoryNpcEncounterActions({
return true;
};
const resolveServerNpcStoryAction = async (params: {
option: StoryOption;
encounter: Encounter;
payload?: Record<string, unknown>;
}) => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
setAiError(null);
setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
gameState,
currentStory,
option: params.option,
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc story action on the server:', error);
setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败');
if (!currentStory) {
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
}
return false;
} finally {
setIsLoading(false);
}
};
const handleNpcInteraction = (option: StoryOption) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
@@ -835,141 +878,23 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'quest_accept': {
const existingQuest = getQuestForIssuer(
gameState.quests,
getNpcEncounterKey(encounter),
);
if (existingQuest) return true;
setAiError(null);
setIsLoading(true);
void (async () => {
let committed = false;
try {
const quest =
(await generateQuestForNpcEncounter({
state: gameState,
encounter,
})) ??
buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) {
return;
}
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
committed = true;
} catch (error) {
console.error('Failed to accept npc quest:', error);
const fallbackQuest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!fallbackQuest) {
return;
}
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) =>
acceptQuest(quests, fallbackQuest),
),
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(fallbackQuest),
option.functionId,
);
committed = true;
} finally {
if (!committed) {
setIsLoading(false);
}
}
})();
void resolveServerNpcStoryAction({
option,
encounter,
});
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const quest = questId ? findQuestById(gameState.quests, questId) : null;
if (!quest || quest.status !== 'completed') return true;
const nextState = appendStoryEngineCarrierMemory({
...updateQuestLog(gameState, (quests) =>
markQuestTurnedIn(quests, quest.id),
),
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: {
...syncNpcNarrativeState({
encounter,
npcState: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
},
customWorldProfile: gameState.customWorldProfile,
storyEngineMemory: gameState.storyEngineMemory,
}),
},
},
playerCurrency: gameState.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(
gameState.playerInventory,
quest.reward.items,
),
} as GameState, quest.reward.items);
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
);
void resolveServerNpcStoryAction({
option,
encounter,
payload: questId
? {
questId,
}
: undefined,
});
return true;
}
case 'leave': {

View File

@@ -50,6 +50,7 @@ import type {
StoryOption,
} from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
import type {
GiftModalState,
RecruitModalState,
@@ -67,6 +68,7 @@ type GenerateStoryForState = (params: {
}) => Promise<StoryMoment>;
type StoryNpcInteractionRuntime = {
currentStory: StoryMoment | null;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
@@ -656,6 +658,60 @@ export function useStoryNpcInteractionFlow({
setRecruitModal(null);
};
const resolveServerNpcAction = async (params: {
encounter: Encounter;
actionText: string;
functionId: string;
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
payload?: Record<string, unknown>;
}) => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
runtime.setAiError(null);
runtime.setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
gameState,
currentStory: runtime.currentStory,
option: {
functionId: params.functionId,
actionText: params.actionText,
interaction: {
kind: 'npc',
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
action: params.action,
},
},
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc runtime action on the server:', error);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 交互执行失败',
);
if (!runtime.currentStory) {
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(gameState, playerCharacter),
);
}
return false;
} finally {
runtime.setIsLoading(false);
}
};
const confirmTrade = () => {
if (!tradeModal || !gameState.playerCharacter) return;
@@ -669,27 +725,8 @@ export function useStoryNpcInteractionFlow({
if (!npcItem || quantity <= 0) return;
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity),
}),
);
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerCurrency: nextState.playerCurrency - totalPrice,
playerInventory: addInventoryItems(
nextState.playerInventory,
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
),
} as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]);
setTradeModal(null);
void commitNpcReactionAndGenerate({
nextState,
void resolveServerNpcAction({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
@@ -697,16 +734,13 @@ export function useStoryNpcInteractionFlow({
item: npcItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
functionId: 'npc_trade',
action: 'trade',
payload: {
mode: 'buy',
item: npcItem,
itemId: npcItem.id,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
},
});
return;
}
@@ -715,27 +749,8 @@ export function useStoryNpcInteractionFlow({
if (!playerItem || quantity <= 0) return;
if (playerItem.quantity < quantity) return;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(playerItem, 'npc', quantity)],
),
}),
);
nextState = {
...nextState,
playerCurrency: nextState.playerCurrency + totalPrice,
playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity),
};
setTradeModal(null);
void commitNpcReactionAndGenerate({
nextState,
void resolveServerNpcAction({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
@@ -743,16 +758,13 @@ export function useStoryNpcInteractionFlow({
item: playerItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
functionId: 'npc_trade',
action: 'trade',
payload: {
mode: 'sell',
item: playerItem,
itemId: playerItem.id,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
},
});
};
@@ -763,57 +775,15 @@ export function useStoryNpcInteractionFlow({
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
if (!giftItem) return;
const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
.find(candidate => candidate.item.id === giftItem.id);
const affinityGain = giftCandidate?.affinityGain ?? 0;
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null;
let nextAffinity = 0;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: currentNpcState.giftsGiven + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_gift',
{ affinityGain },
),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(giftItem, 'npc')],
),
};
},
);
nextState = {
...nextState,
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
};
setGiftModal(null);
void commitNpcReactionAndGenerate({
nextState,
void resolveServerNpcAction({
encounter,
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
resultText: buildNpcGiftResultText(
encounter,
giftItem,
affinityGain,
nextAffinity,
attributeSummary ?? undefined,
),
lastFunctionId: 'npc_gift',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
functionId: 'npc_gift',
action: 'gift',
payload: {
itemId: giftItem.id,
},
});
};

View File

@@ -0,0 +1,366 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
putSaveSnapshotMock,
getRuntimeStoryStateMock,
resolveRuntimeStoryActionMock,
getRuntimeSessionIdMock,
getRuntimeClientVersionMock,
} = vi.hoisted(() => ({
putSaveSnapshotMock: vi.fn(),
getRuntimeStoryStateMock: vi.fn(),
resolveRuntimeStoryActionMock: vi.fn(),
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
vi.mock('../../services/storageService', () => ({
putSaveSnapshot: putSaveSnapshotMock,
}));
vi.mock('../../services/runtimeStoryService', async () => {
const actual =
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
'../../services/runtimeStoryService',
);
return {
...actual,
getRuntimeStoryState: getRuntimeStoryStateMock,
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRuntimeSessionId: getRuntimeSessionIdMock,
getRuntimeClientVersion: getRuntimeClientVersionMock,
};
});
import type { GameState, StoryMoment, StoryOption } from '../../types';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
loadServerRuntimeOptionCatalog,
resumeServerRuntimeStory,
resolveServerRuntimeChoice,
} from './runtimeStoryCoordinator';
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 7,
} as GameState;
}
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
getRuntimeStoryStateMock.mockReset();
resolveRuntimeStoryActionMock.mockReset();
getRuntimeSessionIdMock.mockReset();
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
getRuntimeClientVersionMock.mockReset();
getRuntimeClientVersionMock.mockReturnValue(7);
});
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 3,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
const options = await loadServerRuntimeOptionCatalog({
gameState,
currentStory,
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
});
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '继续交谈',
}),
]);
});
it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
interaction: {
kind: 'npc',
npcId: 'npc-opponent',
action: 'chat',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
const hydratedSnapshot = {
version: 8,
savedAt: '2026-04-08T00:00:00.000Z',
gameState: {
...gameState,
runtimeActionVersion: 8,
runtimeSessionId: 'runtime-main',
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
} as GameState,
currentStory: createStory('快照中的故事'),
bottomTab: 'adventure',
} as HydratedSavedGameSnapshot;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 96,
maxHp: 100,
mana: 18,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '继续交谈',
resultText: '关系已有变化',
storyText: '',
options: [],
},
patches: [],
snapshot: hydratedSnapshot,
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
payload: {
note: 'server-runtime-test',
},
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
});
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
option,
targetId: 'npc-opponent',
payload: {
note: 'server-runtime-test',
},
});
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
expect(result.nextStory).toEqual(
expect.objectContaining({
text: '快照中的故事',
options: [
expect.objectContaining({
functionId: 'npc_help',
actionText: '请求援手',
}),
],
}),
);
});
it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => {
const localHydratedSnapshot = {
version: 7,
savedAt: '2026-04-08T00:00:00.000Z',
gameState: {
currentScene: 'Story',
worldType: 'wuxia',
playerCharacter: {
id: 'hero',
},
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeActionVersion: 7,
runtimeSessionId: 'runtime-main',
} as unknown as GameState,
currentStory: createStory('本地快照故事'),
bottomTab: 'inventory' as const,
} as HydratedSavedGameSnapshot;
const serverHydratedSnapshot = {
version: 8,
savedAt: '2026-04-08T00:00:00.000Z',
gameState: {
currentScene: 'Story',
worldType: 'wuxia',
playerCharacter: {
id: 'hero',
},
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeActionVersion: 8,
runtimeSessionId: 'runtime-main',
} as unknown as GameState,
currentStory: createStory('服务端快照故事'),
bottomTab: 'character' as const,
} as HydratedSavedGameSnapshot;
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 90,
maxHp: 100,
mana: 16,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端恢复后的故事',
options: [],
},
patches: [],
snapshot: serverHydratedSnapshot,
});
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
expect(result.nextStory).toEqual(
expect.objectContaining({
text: '服务端恢复后的故事',
options: [
expect.objectContaining({
functionId: 'npc_help',
actionText: '请求援手',
}),
],
}),
);
});
it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => {
const localHydratedSnapshot = {
version: 7,
savedAt: '2026-04-08T00:00:00.000Z',
gameState: {
currentScene: 'Home',
worldType: null,
playerCharacter: null,
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeActionVersion: 7,
runtimeSessionId: 'runtime-main',
} as unknown as GameState,
currentStory: createStory('本地快照故事'),
bottomTab: 'adventure' as const,
} as HydratedSavedGameSnapshot;
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
});

View File

@@ -0,0 +1,122 @@
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStoryState,
resolveRuntimeStoryAction,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/runtimeStoryService';
import { putSaveSnapshot } from '../../services/storageService';
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
? response.viewModel.availableOptions
: response.presentation.options;
}
async function syncRuntimeSnapshot(
gameState: GameState,
currentStory: StoryMoment | null,
) {
await putSaveSnapshot({
gameState,
bottomTab: 'adventure',
currentStory,
});
}
export async function loadServerRuntimeOptionCatalog(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await getRuntimeStoryState(
getRuntimeSessionId(params.gameState),
);
const options = buildStoryMomentFromRuntimeOptions({
storyText: response.presentation.storyText,
options: getRuntimeResponseOptions(response),
gameState: params.gameState,
}).options;
return options.length > 0 ? options : null;
}
export async function resumeServerRuntimeStory(
snapshot: HydratedSavedGameSnapshot,
) {
const hydratedSnapshot = snapshot;
const shouldRefreshFromServer =
hydratedSnapshot.gameState.currentScene === 'Story' &&
Boolean(hydratedSnapshot.gameState.worldType) &&
Boolean(hydratedSnapshot.gameState.playerCharacter);
if (!shouldRefreshFromServer) {
return {
hydratedSnapshot,
nextStory: hydratedSnapshot.currentStory,
};
}
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
const resumedSnapshot = response.snapshot;
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
? buildStoryMomentFromRuntimeOptions({
storyText:
response.presentation.storyText ||
resumedSnapshot.currentStory?.text ||
hydratedSnapshot.currentStory?.text ||
'',
options: runtimeOptions,
gameState: resumedSnapshot.gameState,
})
: resumedSnapshot.currentStory;
return {
hydratedSnapshot: resumedSnapshot,
nextStory,
};
}
export async function resolveServerRuntimeChoice(params: {
gameState: GameState;
currentStory: StoryMoment | null;
option: Pick<StoryOption, 'functionId' | 'actionText'> &
Partial<Pick<StoryOption, 'interaction'>>;
payload?: RuntimeStoryChoicePayload;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await resolveRuntimeStoryAction({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
option: params.option,
targetId:
params.option.interaction?.kind === 'npc'
? params.option.interaction.npcId
: undefined,
payload: params.payload,
});
const hydratedSnapshot = response.snapshot;
return {
response,
hydratedSnapshot,
nextStory: buildStoryMomentFromRuntimeOptions({
storyText:
response.presentation.storyText ||
hydratedSnapshot.currentStory?.text ||
params.option.actionText,
options: getRuntimeResponseOptions(response),
gameState: hydratedSnapshot.gameState,
}),
};
}

View File

@@ -0,0 +1,249 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types';
import {
playOpeningAdventureSequence,
type PreparedOpeningAdventure,
} from './openingAdventure';
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type BuildDialogueStoryMoment = (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
export function useStoryBootstrap(params: {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
prepareOpeningAdventure: (
state: GameState,
character: Character,
) => PreparedOpeningAdventure | null;
getNpcEncounterKey: (encounter: Encounter) => string;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
buildDialogueStoryMoment: BuildDialogueStoryMoment;
buildStoryContextFromState: (
state: GameState,
extras?: {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
}) {
const {
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
prepareOpeningAdventure,
getNpcEncounterKey,
buildFallbackStoryForState,
generateStoryForState,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
isNpcEncounter,
isInitialCompanionEncounter,
} = params;
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
useState<PreparedOpeningAdventure | null>(null);
useEffect(() => {
if (
gameState.currentScene !== 'Story' ||
!gameState.playerCharacter ||
gameState.storyHistory.length > 0 ||
currentStory ||
!isNpcEncounter(gameState.currentEncounter) ||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
) {
setPreparedOpeningAdventure(null);
return;
}
setPreparedOpeningAdventure(
prepareOpeningAdventure(gameState, gameState.playerCharacter),
);
}, [
currentStory,
gameState,
isNpcEncounter,
prepareOpeningAdventure,
]);
const startOpeningAdventure = useCallback(async () => {
if (
!gameState.playerCharacter ||
!isNpcEncounter(gameState.currentEncounter)
) {
return;
}
const encounter = gameState.currentEncounter;
if (encounter.specialBehavior !== 'initial_companion') {
return;
}
const preparedStory =
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
? preparedOpeningAdventure
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
if (!preparedStory) {
return;
}
await playOpeningAdventureSequence({
gameState,
character: gameState.playerCharacter,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
});
}, [
buildDialogueStoryMoment,
buildStoryContextFromState,
gameState,
getNpcEncounterKey,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
isNpcEncounter,
prepareOpeningAdventure,
preparedOpeningAdventure,
setAiError,
setCurrentStory,
setGameState,
setIsLoading,
]);
useEffect(() => {
const startStory = async () => {
if (
gameState.currentScene !== 'Story' ||
!gameState.worldType ||
!gameState.playerCharacter ||
currentStory ||
isLoading
) {
return;
}
if (
gameState.storyHistory.length === 0 &&
isInitialCompanionEncounter(gameState.currentEncounter) &&
!gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
setIsLoading(true);
try {
setAiError(null);
const nextStory = await generateStoryForState({
state: gameState,
character: gameState.playerCharacter,
history: [],
});
setGameState(applyStoryReasoningRecovery(gameState));
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to start story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildFallbackStoryForState(gameState, gameState.playerCharacter),
);
} finally {
setIsLoading(false);
}
};
void startStory();
}, [
buildFallbackStoryForState,
currentStory,
gameState,
generateStoryForState,
isInitialCompanionEncounter,
isLoading,
setAiError,
setCurrentStory,
setGameState,
setIsLoading,
startOpeningAdventure,
]);
const resetPreparedOpeningAdventure = useCallback(() => {
setPreparedOpeningAdventure(null);
}, []);
return {
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
};
}

View File

@@ -0,0 +1,337 @@
import { describe, expect, it, vi } from 'vitest';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import {
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
createCampCompanionStoryHelpers,
} from './storyCampCompanion';
function createCharacter(): Character {
return {
id: 'sword-princess',
name: '测试同伴',
title: '试剑公主',
description: '在营地观察局势的试炼者。',
backstory: '她在旅途中始终保留自己的真正目标。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 12,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: '谨慎冷静',
skills: [],
adventureOpenings: {
[WorldType.WUXIA]: {
reason: '调查旧路异动',
goal: '查清前方局势',
monologue: '风声里还藏着未说破的话。',
surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。',
immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。',
guardedMotive: '我真正要找的东西,还不能让更多人知道。',
},
},
};
}
function createOption(
functionId: string,
actionText = functionId,
): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'camp-companion',
kind: 'npc',
npcName: '沈砺',
npcDescription: '正靠在营地灯火旁观察风向。',
npcAvatar: '/npc.png',
context: '营地夜谈',
specialBehavior: 'camp_companion',
...overrides,
};
}
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
return {
text,
options,
};
}
function createGameState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA),
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
describe('storyCampCompanion', () => {
it('builds opening dialogue from the character adventure opening', () => {
const text = buildInitialCompanionDialogueText(
createCharacter(),
createEncounter(),
WorldType.WUXIA,
);
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
expect(text).toContain('沈砺:那就不要说得太快太多。');
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
});
it('summarizes the camp opening result with the current concern', () => {
const text = buildCampCompanionOpeningResultText(
createCharacter(),
createEncounter(),
WorldType.WUXIA,
);
expect(text).toContain('沈砺 在');
expect(text).toContain('眼下的风向不对');
});
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
const buildNpcStory = vi.fn(() =>
createStory('营地开场', [
createOption('npc_chat', '继续交谈'),
createOption('npc_recruit', '邀请同行'),
createOption('npc_trade', '查看货物'),
]),
);
const helpers = createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState: vi.fn(),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep: vi.fn(),
});
const options = helpers.buildCampCompanionOpeningOptions(
createGameState(),
createCharacter(),
createEncounter(),
);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_recruit',
'camp_travel_home_scene',
]);
});
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
const baseOptions = [createOption('npc_chat', '继续交谈')];
const generateNextStep = vi
.fn()
.mockResolvedValueOnce({
storyText: '继续营地交谈',
options: [
createOption('npc_trade', '先看对方带来的东西'),
createOption('npc_chat', '继续交谈'),
],
})
.mockRejectedValueOnce(new Error('llm failed'));
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
inBattle: false,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'camp',
sceneName: '营地',
sceneDescription: '营火微亮。',
pendingSceneEncounter: false,
}));
const helpers = createCampCompanionStoryHelpers({
buildNpcStory: vi.fn(),
buildStoryContextFromState,
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep,
});
const state = createGameState();
const character = createCharacter();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const resolvedOptions = await helpers.inferOpeningCampFollowupOptions(
state,
character,
baseOptions,
'营地里风声微沉。',
'你们刚交换完第一轮判断。',
);
const fallbackOptions = await helpers.inferOpeningCampFollowupOptions(
state,
character,
baseOptions,
'营地里风声微沉。',
'你们刚交换完第一轮判断。',
);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
state,
expect.objectContaining({
openingCampBackground: '营地里风声微沉。',
openingCampDialogue: '你们刚交换完第一轮判断。',
}),
);
expect(resolvedOptions.map((option) => option.functionId)).toEqual([
'npc_trade',
'npc_chat',
]);
expect(fallbackOptions).toBe(baseOptions);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('reconstructs the opening camp chat context from story history and filters idle camp options', () => {
const encounter = createEncounter();
const buildNpcStory = vi.fn(() =>
createStory('营地常态', [
createOption('npc_chat', '继续交谈'),
createOption('npc_leave', '结束对话'),
createOption('npc_fight', '直接切磋'),
createOption('npc_trade', '查看货物'),
]),
);
const helpers = createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState: vi.fn(),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep: vi.fn(),
});
const state = createGameState({
currentEncounter: encounter,
npcStates: {
'camp-companion': {
affinity: 16,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
storyHistory: [
{
text: `在营地与 ${encounter.npcName} 交换开场判断`,
options: [],
historyRole: 'action',
},
{
text: '你们先对了一遍眼前局势。',
options: [],
historyRole: 'result',
},
],
});
const chatContext = helpers.buildOpeningCampChatContext(
state,
createCharacter(),
encounter,
);
const idleStory = helpers.buildCampCompanionIdleStory(
state,
createCharacter(),
encounter,
);
expect(chatContext).toEqual(
expect.objectContaining({
openingCampBackground: expect.stringContaining('沈砺 在'),
openingCampDialogue: '你们先对了一遍眼前局势。',
}),
);
expect(idleStory.options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_trade',
'camp_travel_home_scene',
]);
});
});

View File

@@ -0,0 +1,300 @@
import {
getCharacterAdventureOpening,
getCharacterHomeSceneId,
} from '../../data/characterPresets';
import {
buildCampTravelHomeOption,
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_RECRUIT_FUNCTION,
} from '../../data/functionCatalog';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {
getForwardScenePreset,
getScenePresetById,
getTravelScenePreset,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type { StoryGenerationContext } from '../../services/aiService';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
type BuildNpcStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
},
) => StoryGenerationContext;
type GetStoryGenerationHostileNpcs = (
state: GameState,
) => GameState['sceneHostileNpcs'];
type GetNpcEncounterKey = (encounter: Encounter) => string;
type GenerateNextStep =
(typeof import('../../services/aiService'))['generateNextStep'];
export function buildInitialCompanionDialogueText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const surfaceHook =
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
const immediateConcern =
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return [
`你:${surfaceHook}`,
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
`你:${immediateConcern}`,
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
].join('\n');
}
export function buildCampCompanionOpeningResultText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const campSceneName = worldType
? (getWorldCampScenePreset(worldType)?.name ?? '归处')
: '归处';
if (!opening) {
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
}
return `${encounter.npcName}${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
}
function getCampCompanionHomeScene(state: GameState, character: Character) {
if (!state.worldType) return null;
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
return getScenePresetById(state.worldType, sceneId);
}
export function createCampCompanionStoryHelpers(params: {
buildNpcStory: BuildNpcStory;
buildStoryContextFromState: BuildStoryContextFromState;
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
getNpcEncounterKey: GetNpcEncounterKey;
generateNextStep: GenerateNextStep;
}) {
const getCampCompanionTravelScene = (
state: GameState,
character: Character,
) => {
if (!state.worldType) return null;
const campScene = getWorldCampScenePreset(state.worldType);
const homeScene = getCampCompanionHomeScene(state, character);
if (
homeScene &&
homeScene.id !== campScene?.id &&
homeScene.id !== state.currentScenePreset?.id
) {
return homeScene;
}
const fallbackSceneId =
campScene?.id ?? state.currentScenePreset?.id ?? null;
return (
getForwardScenePreset(state.worldType, fallbackSceneId) ??
getTravelScenePreset(state.worldType, fallbackSceneId) ??
homeScene
);
};
const buildCampCompanionOpeningOptions = (
state: GameState,
character: Character,
encounter: Encounter,
) => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseOptions = params.buildNpcStory(
state,
character,
encounter,
).options;
const chatOptions = baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 1);
const recruitOption =
baseOptions.find(
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
) ?? null;
const openingOptions = recruitOption
? [...chatOptions, recruitOption]
: chatOptions;
if (!targetScene) {
return openingOptions;
}
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
};
const inferOpeningCampFollowupOptions = async (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => {
if (!state.worldType || baseOptions.length === 0) {
return baseOptions;
}
try {
const response = await params.generateNextStep(
state.worldType,
character,
params.getStoryGenerationHostileNpcs(state),
state.storyHistory,
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
params.buildStoryContextFromState(state, {
openingCampBackground: openingBackground,
openingCampDialogue: openingDialogue,
}),
{
availableOptions: baseOptions,
},
);
return sortStoryOptionsByPriority(response.options);
} catch (error) {
console.error('Failed to infer opening camp follow-up options:', error);
return baseOptions;
}
};
const buildOpeningCampChatContext = (
state: GameState,
character: Character,
encounter: Encounter,
) => {
if (encounter.specialBehavior !== 'camp_companion') {
return {};
}
const npcState =
state.npcStates[params.getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType, state);
if (npcState.chattedCount > 2) {
return {};
}
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
let openingDialogue: string | null = null;
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
const entry = state.storyHistory[index];
if (!entry) {
continue;
}
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
continue;
}
for (
let nextIndex = index + 1;
nextIndex < state.storyHistory.length;
nextIndex += 1
) {
const nextEntry = state.storyHistory[nextIndex];
if (!nextEntry) {
continue;
}
if (nextEntry.historyRole === 'action') {
break;
}
if (nextEntry.text.trim()) {
openingDialogue = nextEntry.text;
break;
}
}
if (openingDialogue) {
break;
}
}
if (!openingDialogue) {
return {};
}
return {
openingCampBackground: buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
),
openingCampDialogue: openingDialogue,
};
};
const buildCampCompanionIdleStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseStory = params.buildNpcStory(
state,
character,
encounter,
overrideText,
);
const filteredOptions = baseStory.options.filter(
(option) =>
option.functionId !== NPC_LEAVE_FUNCTION.id &&
option.functionId !== NPC_FIGHT_FUNCTION.id,
);
if (!targetScene) {
return {
...baseStory,
options: filteredOptions,
};
}
return {
...baseStory,
options: [
...filteredOptions.slice(0, 2),
buildCampTravelHomeOption(targetScene.name),
...filteredOptions.slice(2),
],
};
};
return {
getCampCompanionTravelScene,
buildCampCompanionOpeningOptions,
inferOpeningCampFollowupOptions,
buildOpeningCampChatContext,
buildCampCompanionIdleStory,
};
}

View File

@@ -0,0 +1,407 @@
import { addInventoryItems } from '../../data/npcInteractions';
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { EscapePlaybackSync } from '../combat/escapeFlow';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type BuildStoryFromResponse = (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) => StoryMoment;
type BuildNpcStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
},
) => StoryGenerationContext;
type UpdateQuestLog = (
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) => GameState;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
export async function runLocalStoryChoiceContinuation(params: {
gameState: GameState;
currentStory: StoryMoment | null;
option: StoryOption;
character: Character;
setGameState: (state: GameState) => void;
setCurrentStory: (story: StoryMoment) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
setBattleReward: (reward: BattleRewardSummary | null) => void;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: EscapePlaybackSync,
) => Promise<GameState>;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
finalizeNpcBattleResult: (
state: GameState,
character: Character,
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
}) {
params.setBattleReward(null);
params.setAiError(null);
params.setIsLoading(true);
const baseChoiceState =
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
!params.gameState.npcInteractionActive &&
!params.option.interaction
? {
...params.gameState,
currentEncounter: null,
npcInteractionActive: false,
}
: params.gameState;
let fallbackState = baseChoiceState;
try {
const history = baseChoiceState.storyHistory;
const resolvedChoice = params.buildResolvedChoiceState(
baseChoiceState,
params.option,
params.character,
);
const projectedState = resolvedChoice.afterSequence;
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
(projectedState.currentNpcBattleOutcome ||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
!projectedState.inBattle)),
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: await buildHostileNpcBattleReward(
baseChoiceState,
projectedState,
resolvedChoice.optionKind,
params.getResolvedSceneHostileNpcs,
);
const projectedStateWithBattleReward = projectedBattleReward
? appendStoryEngineCarrierMemory(
{
...projectedState,
playerInventory: addInventoryItems(
projectedState.playerInventory,
projectedBattleReward.items,
),
} as GameState,
projectedBattleReward.items,
)
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = params.getAvailableOptionsForState(
projectedStateWithBattleReward,
params.character,
);
const combatResolutionContextText = buildCombatResolutionContextText({
baseState: baseChoiceState,
afterSequence: projectedStateWithBattleReward,
optionKind: resolvedChoice.optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
});
const historyForStoryGeneration = combatResolutionContextText
? [
...history,
createHistoryMoment(params.option.actionText, 'action'),
createHistoryMoment(combatResolutionContextText, 'result'),
]
: history;
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
: generateNextStep(
params.gameState.worldType!,
params.character,
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
historyForStoryGeneration,
params.option.actionText,
params.buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: params.option.functionId,
observeSignsRequested:
params.option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
}),
projectedAvailableOptions
? { availableOptions: projectedAvailableOptions }
: undefined,
);
const responseSettledPromise = responsePromise.then(
() => undefined,
() => undefined,
);
const playbackSync: EscapePlaybackSync | undefined =
resolvedChoice.optionKind === 'escape'
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = params.playResolvedChoice(
baseChoiceState,
params.option,
params.character,
resolvedChoice,
playbackSync,
);
const [actionResult, responseResult] = await Promise.allSettled([
actionPromise,
responsePromise,
]);
if (actionResult.status === 'rejected') {
throw actionResult.reason;
}
let afterSequence = shouldUseLocalNpcVictory
? resolvedChoice.afterSequence
: actionResult.value;
if (projectedBattleReward) {
afterSequence = appendStoryEngineCarrierMemory(
{
...afterSequence,
playerInventory: addInventoryItems(
afterSequence.playerInventory,
projectedBattleReward.items,
),
} as GameState,
projectedBattleReward.items,
);
}
fallbackState = afterSequence;
if (shouldUseLocalNpcVictory) {
const victory = params.finalizeNpcBattleResult(
afterSequence,
params.character,
baseChoiceState.currentNpcBattleMode!,
afterSequence.currentNpcBattleOutcome,
);
if (victory) {
const historyBase =
baseChoiceState.currentNpcBattleMode === 'spar'
? (afterSequence.sparStoryHistoryBefore ?? [])
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(params.option.actionText, 'action'),
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
...victory.nextState,
storyHistory: nextHistory,
};
const postBattleOptionCatalog =
baseChoiceState.currentNpcBattleMode === 'spar' &&
nextState.currentEncounter
? buildReasonedOptionCatalog(
params.buildNpcStory(
nextState,
params.character,
nextState.currentEncounter,
).options,
)
: null;
fallbackState = nextState;
params.setGameState(nextState);
try {
const nextStory = await params.generateStoryForState({
state: nextState,
character: params.character,
history: nextHistory,
choice: params.option.actionText,
lastFunctionId: params.option.functionId,
optionCatalog: postBattleOptionCatalog,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
params.setGameState(recoveredState);
params.setCurrentStory(nextStory);
} catch (storyError) {
console.error(
'Failed to continue npc battle resolution story:',
storyError,
);
params.setAiError(
storyError instanceof Error
? storyError.message
: '未知智能生成错误',
);
params.setCurrentStory(
params.buildFallbackStoryForState(
nextState,
params.character,
victory.resultText,
),
);
}
return;
}
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}
const response = responseResult.value!;
const defeatedHostileNpcIds =
baseChoiceState.currentBattleNpcId ||
resolvedChoice.optionKind === 'escape'
? []
: params
.getResolvedSceneHostileNpcs(baseChoiceState)
.map((hostileNpc) => hostileNpc.id)
.filter(
(hostileNpcId) =>
!params
.getResolvedSceneHostileNpcs(afterSequence)
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
);
const nextHistory = combatResolutionContextText
? [
...historyForStoryGeneration,
createHistoryMoment(response.storyText, 'result', response.options),
]
: [
...baseChoiceState.storyHistory,
createHistoryMoment(params.option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = params.incrementRuntimeStats(
{
...params.updateQuestLog(afterSequence, (quests) =>
applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
lastObserveSignsSceneId:
params.option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
lastObserveSignsReport:
params.option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
storyHistory: nextHistory,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
const recoveredState = applyStoryReasoningRecovery(nextState);
params.setGameState(recoveredState);
if (projectedBattleReward) {
params.setBattleReward(projectedBattleReward);
}
params.setCurrentStory(
params.buildStoryFromResponse(
recoveredState,
params.character,
{
text: response.storyText,
options: response.options,
},
projectedAvailableOptions,
),
);
} catch (error) {
console.error('Failed to get next step:', error);
params.setAiError(
error instanceof Error ? error.message : '未知智能生成错误',
);
params.setCurrentStory(
params.buildFallbackStoryForState(fallbackState, params.character),
);
} finally {
params.setIsLoading(false);
}
}

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: '谨慎',
skills: [],
adventureOpenings: {},
} as unknown as Character;
}
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createState(): GameState {
return {
worldType: 'WUXIA',
currentScene: 'Story',
} as GameState;
}
const neverNpcEncounter = (
_encounter: GameState['currentEncounter'],
): _encounter is Encounter => false;
describe('storyChoiceCoordinator', () => {
it('builds one config object for createStoryChoiceActions from runtime controller and support', () => {
const runtimeController = {
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn(),
buildFallbackStoryForState: vi.fn(),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(),
getCampCompanionTravelScene: vi.fn(),
startOpeningAdventure: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
};
const runtimeSupport = {
buildNpcStory: vi.fn(),
updateQuestLog: vi.fn(),
updateRuntimeStats: vi.fn(),
};
const config = createStoryChoiceCoordinatorConfig({
gameState: createState(),
currentStory: createStory('当前故事'),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(() => []),
runtimeController: runtimeController as never,
runtimeSupport: runtimeSupport as never,
enterNpcInteraction: vi.fn(),
handleNpcInteraction: vi.fn(),
handleTreasureInteraction: vi.fn(),
finalizeNpcBattleResult: vi.fn(),
sortOptions: vi.fn((options: StoryOption[]) => options),
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
expect(config).toEqual(
expect.objectContaining({
buildStoryContextFromState: runtimeController.buildStoryContextFromState,
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
buildFallbackStoryForState: runtimeController.buildFallbackStoryForState,
generateStoryForState: runtimeController.generateStoryForState,
getAvailableOptionsForState: runtimeController.getAvailableOptionsForState,
buildNpcStory: runtimeSupport.buildNpcStory,
updateQuestLog: runtimeSupport.updateQuestLog,
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
commitGeneratedStateWithEncounterEntry:
runtimeController.commitGeneratedStateWithEncounterEntry,
}),
);
void createCharacter();
});
});

View File

@@ -0,0 +1,175 @@
import type { Dispatch, SetStateAction } from 'react';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import type { BattleRewardSummary } from './uiTypes';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type { StoryGenerationContext } from '../../services/aiTypes';
export type ChoiceRuntimeController = {
buildStoryContextFromState: (
state: GameState,
extras?: {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
buildStoryFromResponse: (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) => StoryMoment;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
generateStoryForState: (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
getCampCompanionTravelScene: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void>;
};
export type ChoiceRuntimeSupport = Pick<
StoryRuntimeSupport,
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
>;
export type StoryChoiceCoordinatorParams = {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: ResolvedChoicePlaybackSync,
) => Promise<GameState>;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
runtimeController: ChoiceRuntimeController;
runtimeSupport: ChoiceRuntimeSupport;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
option: StoryOption,
) => void | Promise<void> | boolean | Promise<boolean>;
finalizeNpcBattleResult: (
state: GameState,
character: Character,
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null;
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
};
export function createStoryChoiceCoordinatorConfig(
params: StoryChoiceCoordinatorParams,
) {
return {
gameState: params.gameState,
currentStory: params.currentStory,
isLoading: params.isLoading,
setGameState: params.setGameState,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
setBattleReward: params.setBattleReward,
buildResolvedChoiceState: params.buildResolvedChoiceState,
playResolvedChoice: params.playResolvedChoice,
buildStoryContextFromState:
params.runtimeController.buildStoryContextFromState,
buildStoryFromResponse: params.runtimeController.buildStoryFromResponse,
buildFallbackStoryForState:
params.runtimeController.buildFallbackStoryForState,
generateStoryForState: params.runtimeController.generateStoryForState,
getAvailableOptionsForState:
params.runtimeController.getAvailableOptionsForState,
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
buildNpcStory: params.runtimeSupport.buildNpcStory,
updateQuestLog: params.runtimeSupport.updateQuestLog,
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene:
params.runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
enterNpcInteraction: params.enterNpcInteraction,
handleNpcInteraction: params.handleNpcInteraction,
handleTreasureInteraction: params.handleTreasureInteraction,
commitGeneratedStateWithEncounterEntry:
params.runtimeController.commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
fallbackCompanionName: params.fallbackCompanionName,
turnVisualMs: params.turnVisualMs,
};
}

View File

@@ -0,0 +1,338 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
rollHostileNpcLootMock,
resolveServerRuntimeChoiceMock,
} = vi.hoisted(() => ({
rollHostileNpcLootMock: vi.fn(),
resolveServerRuntimeChoiceMock: vi.fn(),
}));
vi.mock('../../data/hostileNpcPresets', async () => {
const actual =
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
'../../data/hostileNpcPresets',
);
return {
...actual,
rollHostileNpcLoot: rollHostileNpcLootMock,
};
});
vi.mock('./runtimeStoryCoordinator', () => ({
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as unknown as Character;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createOption(
functionId: string,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText: functionId,
text: functionId,
interaction,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
function createState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: 'WUXIA',
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
describe('storyChoiceRuntime', () => {
beforeEach(() => {
rollHostileNpcLootMock.mockReset();
resolveServerRuntimeChoiceMock.mockReset();
});
it('deduplicates option catalogs by function id for post-battle recovery', () => {
const options = buildReasonedOptionCatalog([
createOption('npc_chat'),
createOption('npc_chat'),
createOption('npc_help'),
]);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_help',
]);
});
it('identifies npc trade and gift as local runtime modal actions', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_trade', {
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_gift', {
kind: 'npc',
npcId: 'npc-friend',
action: 'gift',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
).toBe(false);
});
it('builds escape and victory context text for local battle resolution', () => {
const baseState = createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
});
expect(
buildCombatResolutionContextText({
baseState,
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'escape',
projectedBattleReward: null,
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('你已成功逃脱');
expect(
buildCombatResolutionContextText({
baseState: {
...baseState,
currentBattleNpcId: null,
},
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'battle',
projectedBattleReward: {
id: 'reward-1',
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
items: [
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
],
},
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('战利品:狼牙。');
});
it('builds defeated hostile rewards from locally resolved battle states', async () => {
rollHostileNpcLootMock.mockResolvedValue([
{
id: 'loot-1',
category: '材料',
name: '狼牙',
quantity: 1,
rarity: 'common',
tags: [],
},
]);
const reward = await buildHostileNpcBattleReward(
createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
currentBattleNpcId: null,
}),
createState({
inBattle: false,
sceneHostileNpcs: [],
}),
'battle',
(state) => state.sceneHostileNpcs,
);
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
expect(reward?.items[0]).toEqual(
expect.objectContaining({
name: '狼牙',
}),
);
});
it('applies server runtime responses and falls back locally when the request fails', async () => {
const gameState = createState();
const currentStory = createStory('当前故事');
const setBattleReward = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: {
...gameState,
runtimeActionVersion: 3,
},
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory,
option: createOption('npc_chat'),
character: createCharacter(),
setBattleReward,
setAiError,
setIsLoading,
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
runtimeActionVersion: 3,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '服务端故事',
}),
);
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
await runServerRuntimeChoiceAction({
gameState,
currentStory: null,
option: createOption('npc_chat'),
character: createCharacter(),
setBattleReward,
setAiError,
setIsLoading,
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
} finally {
consoleErrorSpy.mockRestore();
}
expect(setAiError).toHaveBeenCalledWith('boom');
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: 'fallback',
}),
);
});
});

View File

@@ -0,0 +1,322 @@
import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
import { addInventoryItems } from '../../data/npcInteractions';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import {
AnimationState,
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
export function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
return options.filter((option) => {
if (seenFunctionIds.has(option.functionId)) {
return false;
}
seenFunctionIds.add(option.functionId);
return true;
});
}
export function buildCombatResolutionContextText(params: {
baseState: GameState;
afterSequence: GameState;
optionKind: 'battle' | 'escape' | 'idle';
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
}) {
const {
baseState,
afterSequence,
optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
} = params;
if (optionKind === 'escape') {
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
}
if (
!baseState.inBattle ||
afterSequence.inBattle ||
Boolean(baseState.currentBattleNpcId)
) {
return null;
}
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
const lootText =
projectedBattleReward?.items.length
? `战利品:${projectedBattleReward.items
.map((item) => item.name)
.join('、')}`
: '';
return hostileNames
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
return (
option.interaction?.kind === 'npc' &&
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
);
}
export async function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
optionKind: 'battle' | 'escape' | 'idle',
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): Promise<BattleRewardSummary | null> {
if (
optionKind === 'escape' ||
!state.worldType ||
state.currentBattleNpcId ||
!state.inBattle ||
afterSequence.inBattle
) {
return null;
}
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
const defeatedHostileNpcs = activeHostileNpcs.filter(
(hostileNpc) =>
!nextHostileNpcs.some(
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
),
);
if (defeatedHostileNpcs.length === 0) {
return null;
}
const rolledItems = await rollHostileNpcLoot(
state,
defeatedHostileNpcs.map((hostileNpc) => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
);
return {
id: `battle-reward-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
items: addInventoryItems([], rolledItems),
};
}
export async function runCampTravelHomeChoice(params: {
gameState: GameState;
option: StoryOption;
character: Character;
setBattleReward: (reward: BattleRewardSummary | null) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
setGameState: (state: GameState) => void;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const targetScene = params.getCampCompanionTravelScene(
params.gameState,
params.character,
);
if (!targetScene) {
return false;
}
params.setBattleReward(null);
params.setAiError(null);
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
? params.gameState.currentEncounter.npcName
: params.fallbackCompanionName;
const travelRunState: GameState = {
...params.gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = params.incrementRuntimeStats(
{
...params.gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
scenesTraveled: 1,
},
);
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(
resolvedState,
CALL_OUT_ENTRY_X_METERS,
);
params.setIsLoading(true);
params.setGameState(travelRunState);
await sleep(params.turnVisualMs);
await params.commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
params.character,
params.option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
params.option.functionId,
);
return true;
}
export async function runServerRuntimeChoiceAction(params: {
gameState: GameState;
currentStory: StoryMoment | null;
option: StoryOption;
character: Character;
setBattleReward: (reward: BattleRewardSummary | null) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
setGameState: (state: GameState) => void;
setCurrentStory: (story: StoryMoment) => void;
buildFallbackStoryForState: BuildFallbackStoryForState;
}) {
params.setBattleReward(null);
params.setAiError(null);
params.setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
gameState: params.gameState,
currentStory: params.currentStory,
option: params.option,
});
params.setGameState(hydratedSnapshot.gameState);
params.setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to resolve runtime action on the server:', error);
params.setAiError(
error instanceof Error ? error.message : '运行时动作执行失败',
);
if (!params.currentStory) {
params.setCurrentStory(
params.buildFallbackStoryForState(
params.gameState,
params.character,
),
);
}
} finally {
params.setIsLoading(false);
}
}

View File

@@ -0,0 +1,625 @@
import { getCharacterById } from '../../data/characterPresets';
import {
NPC_CHAT_FUNCTION,
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
describeNpcAffinityInWords,
getNpcConversationDirective,
isNpcFirstMeaningfulContact,
} from '../../data/npcInteractions';
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../../services/storyEngine/actorNarrativeProfile';
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
import {
buildCampEvent,
evaluateCampEventOpportunity,
} from '../../services/storyEngine/campEventDirector';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import {
advanceCompanionArc,
buildCompanionArcStates,
} from '../../services/storyEngine/companionArcDirector';
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
import {
buildSetpieceDirective,
evaluateSetpieceOpportunity,
} from '../../services/storyEngine/setpieceDirector';
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from '../../services/storyEngine/visibilityEngine';
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
import type { GameState } from '../../types';
import { getCharacterChatRecord } from './characterChat';
import { getNpcEncounterKey } from './storyGenerationState';
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
export type StoryContextBuilderExtras = {
pendingSceneEncounter?: boolean;
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
function buildPartyRelationshipNotes(state: GameState) {
const lines: string[] = [];
const seenCharacterIds = new Set<string>();
const appendNote = (characterId: string, roleLabel: string) => {
if (seenCharacterIds.has(characterId)) return;
const character = getCharacterById(characterId);
const summary = getCharacterChatRecord(state, characterId).summary.trim();
if (hasMixedNarrativeLanguage(summary)) return;
if (!character || !summary) return;
seenCharacterIds.add(characterId);
lines.push(
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
);
};
state.companions.forEach((companion) =>
appendNote(companion.characterId, '当前同行'),
);
state.roster.forEach((companion) =>
appendNote(companion.characterId, '营地待命'),
);
return lines.length > 0 ? lines.join('\n') : null;
}
function describeScenePressureLevel(
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
) {
switch (pressureLevel) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
case 'extreme':
return '极高';
default:
return null;
}
}
function buildRecentConversationEventText(state: GameState) {
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
}
if (/|||/u.test(recentText)) {
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
}
return null;
}
function inferConversationSituation(
state: GameState,
extras: Pick<
StoryContextBuilderExtras,
'lastFunctionId' | 'openingCampDialogue'
>,
) {
if (state.inBattle) return 'shared_danger_coordination' as const;
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
return 'camp_first_contact' as const;
if (
state.currentEncounter?.specialBehavior === 'camp_companion' &&
extras.openingCampDialogue?.trim()
) {
return 'camp_followup' as const;
}
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return 'post_battle_breath' as const;
}
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
return 'private_followup' as const;
return 'first_contact_cautious' as const;
}
function inferConversationPressure(
state: GameState,
situation: ReturnType<typeof inferConversationSituation>,
) {
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
if (
situation === 'post_battle_breath' ||
situation === 'shared_danger_coordination'
)
return 'medium' as const;
if (situation === 'camp_first_contact' || situation === 'camp_followup')
return 'low' as const;
return 'medium' as const;
}
function describeConversationSituation(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
case 'camp_followup':
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
case 'post_battle_breath':
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
case 'shared_danger_coordination':
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
case 'private_followup':
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
default:
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
}
}
function describeConversationTalkPriority(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
case 'camp_followup':
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
case 'post_battle_breath':
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
case 'shared_danger_coordination':
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
case 'private_followup':
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
default:
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
}
}
function resolveEncounterNarrativeProfile(state: GameState) {
const encounter = state.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return null;
}
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!state.customWorldProfile) {
return null;
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function resolveActiveThreadIds(
state: GameState,
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
) {
if (state.storyEngineMemory?.activeThreadIds?.length) {
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
}
if (encounterNarrativeProfile?.relatedThreadIds.length) {
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
}
if (!state.customWorldProfile) {
return [];
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
}
export function buildStoryContextFromState(
state: GameState,
extras: StoryContextBuilderExtras = {},
): StoryGenerationContext {
const conversationSituation = inferConversationSituation(state, extras);
const conversationPressure = inferConversationPressure(
state,
conversationSituation,
);
const recentSharedEvent = buildRecentConversationEventText(state);
const encounterNpcState =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return extras.encounterNpcStateOverride
?? state.npcStates[getNpcEncounterKey(encounter)]
?? buildInitialNpcState(encounter, state.worldType, state);
})()
: null;
const encounterDirective =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? getNpcConversationDirective(encounter, encounterNpcState)
: null;
})()
: null;
const isFirstMeaningfulContact =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
: false;
})()
: false;
const firstContactRelationStance = (() => {
if (
!isFirstMeaningfulContact ||
!state.currentEncounter ||
state.currentEncounter.kind !== 'npc'
) {
return null;
}
const stance = encounterNpcState?.relationState?.stance ?? null;
if (
stance === 'guarded' ||
stance === 'neutral' ||
stance === 'cooperative' ||
stance === 'bonded'
) {
return stance;
}
return null;
})();
const encounterAffinityText =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
recruited: encounterNpcState.recruited,
})
: null;
})()
: null;
const baseSceneDescription = state.currentScenePreset?.description ?? null;
const sceneMutationDescription = [
state.currentScenePreset?.mutationStateText
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
: null,
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
: null,
]
.filter(Boolean)
.join('\n');
const observeSignsSceneDescription =
extras.observeSignsRequested && state.worldType
? [
baseSceneDescription,
sceneMutationDescription,
'当前可观察实体池:',
buildSceneEntityCatalogText(
state.worldType,
state.currentScenePreset?.id ?? null,
),
]
.filter(Boolean)
.join('\n')
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const knowledgeFacts =
state.customWorldProfile?.knowledgeFacts
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
const activeThreadIds = resolveActiveThreadIds(
{
...state,
storyEngineMemory,
} as GameState,
encounterNarrativeProfile,
);
const visibilitySlice =
state.currentEncounter?.kind === 'npc'
? (() => {
const relevantFacts = knowledgeFacts.filter((fact) =>
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
);
return relevantFacts.length > 0
? buildVisibilitySliceFromFacts({
facts: relevantFacts,
discoveredFactIds: [
...storyEngineMemory.discoveredFactIds,
...(encounterNpcState?.revealedFacts ?? []),
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
(chapterId) =>
relevantFacts.find((fact) =>
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
)?.id ?? '',
),
],
activeThreadIds,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
})
: buildEncounterVisibilitySlice({
narrativeProfile: encounterNarrativeProfile,
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
})()
: null;
const sceneNarrativeDirective = buildSceneNarrativeDirective({
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
activeThreadIds,
visibilitySlice,
encounterNarrativeProfile,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
affinity: encounterNpcState?.affinity ?? null,
});
const chapterState = advanceChapterState({
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
nextChapter: resolveCurrentChapterState({
state: {
...state,
storyEngineMemory,
},
}),
});
const journeyBeat = resolveCurrentJourneyBeat({
state: {
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
} as GameState,
chapterState,
});
const companionArcStates = advanceCompanionArc({
previous: storyEngineMemory.companionArcStates,
next: buildCompanionArcStates({
state,
reactions: storyEngineMemory.recentCompanionReactions,
}),
});
const currentCampEvent = evaluateCampEventOpportunity({
state,
chapterState,
journeyBeat,
companionArcStates,
})
? buildCampEvent({
state,
chapterState,
journeyBeat,
companionArcStates,
})
: null;
const setpieceDirective = evaluateSetpieceOpportunity({
state,
chapterState,
journeyBeat,
})
? buildSetpieceDirective({
state,
chapterState,
journeyBeat,
})
: null;
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
const recentChronicleSummary = buildChronicleSummary({
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
companionArcStates,
},
} as GameState);
const compiledPacks = state.customWorldProfile
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
: null;
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const activeScenarioPack =
resolveScenarioPack(state.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const fallbackChapterRecap = buildChapterRecap({
state: { ...state, chapterState } as GameState,
});
const safeEncounterRelationshipSummary =
state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary
.trim()
: '';
return applyAdaptiveTuningToPromptContext({
context: {
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
inBattle: state.inBattle,
playerX: state.playerX,
playerFacing: state.playerFacing,
playerAnimation: state.animationState,
skillCooldowns: state.playerSkillCooldowns,
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
sceneDescription: observeSignsSceneDescription,
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
lastFunctionId: extras.lastFunctionId ?? null,
observeSignsRequested: extras.observeSignsRequested ?? false,
recentActionResult: extras.recentActionResult ?? null,
lastObserveSignsReport:
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
? (state.lastObserveSignsReport ?? null)
: null,
encounterKind: state.currentEncounter?.kind ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
encounterDescription: state.currentEncounter?.npcDescription ?? null,
encounterContext: state.currentEncounter?.context ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterCharacterId: state.currentEncounter?.characterId ?? null,
encounterGender: state.currentEncounter?.gender ?? null,
encounterCustomProfile: state.currentEncounter
? {
title: state.currentEncounter.title ?? '',
description: state.currentEncounter.npcDescription ?? '',
backstory: state.currentEncounter.backstory ?? '',
personality: state.currentEncounter.personality ?? '',
motivation: state.currentEncounter.motivation ?? '',
combatStyle: state.currentEncounter.combatStyle ?? '',
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
tags: [...(state.currentEncounter.tags ?? [])],
backstoryReveal: state.currentEncounter.backstoryReveal,
skills: [...(state.currentEncounter.skills ?? [])],
initialItems: [...(state.currentEncounter.initialItems ?? [])],
imageSrc: state.currentEncounter.imageSrc,
visual: state.currentEncounter.visual,
narrativeProfile: state.currentEncounter.narrativeProfile,
}
: null,
encounterAffinity: encounterDirective?.affinity ?? null,
encounterAffinityText,
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
encounterConversationStyle: encounterDirective?.style ?? null,
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
encounterAnswerMode: encounterDirective?.answerMode ?? null,
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
isFirstMeaningfulContact,
firstContactRelationStance,
conversationSituation,
conversationPressure,
recentSharedEvent:
recentSharedEvent ?? describeConversationSituation(conversationSituation),
talkPriority: describeConversationTalkPriority(conversationSituation),
visibilitySlice,
sceneNarrativeDirective,
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
actState: storyEngineMemory.actState ?? null,
chapterState,
journeyBeat,
goalStack,
currentCampEvent,
setpieceDirective,
activeScenarioPack,
activeCampaignPack,
encounterNarrativeProfile,
knowledgeFacts,
activeThreadIds,
companionArcStates,
companionResolutions: storyEngineMemory.companionResolutions ?? [],
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
recentCarrierEchoes: buildRecentCarrierEchoes(state),
recentWorldMutations,
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
recentChronicleSummary:
recentChronicleSummary.trim() &&
!hasMixedNarrativeLanguage(recentChronicleSummary)
? recentChronicleSummary
: fallbackChapterRecap,
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
encounterRelationshipSummary: state.currentEncounter?.characterId
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
? safeEncounterRelationshipSummary || null
: null
: null,
partyRelationshipNotes: buildPartyRelationshipNotes(state),
customWorldProfile: state.customWorldProfile ?? null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
},
profile: storyEngineMemory.playerStyleProfile ?? null,
});
}

View File

@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type StoryMoment,
} from '../../types';
import { createStoryStateResolvers } from './storyEncounterState';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
personality: 'calm',
skills: [],
} as unknown as Character;
}
function createGameState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createNpcEncounter(
overrides: Partial<Encounter> = {},
): Encounter {
return {
id: 'npc-guard',
kind: 'npc',
npcName: '山道客',
npcDescription: '守在路口的陌生人',
npcAvatar: '/npc.png',
context: '山道相遇',
...overrides,
} as Encounter;
}
describe('storyEncounterState', () => {
it('delegates camp companion option pools to the dedicated builder', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'camp_companion',
}),
});
const campStory: StoryMoment = {
text: '营地同伴剧情',
options: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
};
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
const buildNpcStory = vi.fn();
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions,
buildNpcStory,
});
expect(getAvailableOptionsForState(state, character)).toEqual(
campStory.options,
);
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
state,
character,
state.currentEncounter,
undefined,
);
expect(buildNpcStory).not.toHaveBeenCalled();
});
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'initial_companion',
}),
});
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});
const options = getAvailableOptionsForState(state, character);
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_preview_talk',
}),
]);
});
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
const state = createGameState();
const character = createCharacter();
const { buildFallbackStoryForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});
const story = buildFallbackStoryForState(state, character, '手动兜底文本');
expect(story.text).toBe('手动兜底文本');
expect(story.options.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,238 @@
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
import {
getDefaultFunctionIdsForContext,
resolveFunctionOption,
} from '../../data/stateFunctions';
import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { buildFallbackStoryMoment } from '../combatStoryUtils';
type CampCompanionEncounter = Encounter & {
specialBehavior: 'camp_companion';
};
type EncounterStoryBuilder = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
export function buildNpcPreviewStory(
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment {
if (!state.worldType) {
return {
text:
overrideText ??
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
options: [buildNpcPreviewTalkOption(encounter)],
};
}
const functionContext = {
worldType: state.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
monsters: [],
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
};
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
.filter((functionId) => functionId !== 'idle_call_out')
.map((functionId) => resolveFunctionOption(functionId, functionContext))
.filter((option): option is StoryOption => Boolean(option));
return {
text:
overrideText ??
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
};
}
export function getResolvedSceneHostileNpcs(state: GameState) {
return state.sceneHostileNpcs;
}
export function getStoryGenerationHostileNpcs(state: GameState) {
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
}
export function isCampCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is CampCompanionEncounter {
return Boolean(
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
);
}
export function isInitialCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(
encounter?.kind === 'npc' &&
encounter.specialBehavior === 'initial_companion',
);
}
export function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
export function isRegularNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
}
export function isTreasureEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'treasure');
}
export function buildTreasureStory(
state: GameState,
_character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment {
return buildTreasureEncounterStoryMoment({
state,
encounter,
overrideText,
});
}
function resolveEncounterStory(params: {
state: GameState;
character: Character;
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
fallbackText?: string;
}) {
const { state, character, fallbackText } = params;
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
return params.buildCampCompanionIdleOptions(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (
isInitialCompanionEncounter(state.currentEncounter) &&
!state.inBattle &&
!state.npcInteractionActive
) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
if (!state.npcInteractionActive) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
return params.buildNpcStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
return params.buildNpcStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
return buildTreasureStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
return null;
}
export function createStoryStateResolvers(params: {
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
}) {
const getAvailableOptionsForState = (
state: GameState,
character: Character,
) =>
resolveEncounterStory({
state,
character,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
})?.options ?? null;
const buildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => {
const resolvedStory = resolveEncounterStory({
state,
character,
fallbackText,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
});
if (resolvedStory) {
return resolvedStory;
}
const fallback = buildFallbackStoryMoment(state, character);
return fallbackText
? {
...fallback,
text: fallbackText,
}
: fallback;
};
return {
getAvailableOptionsForState,
buildFallbackStoryForState,
};
}

View File

@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
function createOption(
functionId = 'npc_chat',
actionText = '继续交谈',
): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createState(): GameState {
return {
worldType: 'WUXIA',
currentScene: 'Story',
} as GameState;
}
describe('storyInteractionCoordinator', () => {
it('builds shared interaction configs for treasure, inventory and npc flows', () => {
const gameState = createState();
const currentStory = createStory('当前故事');
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const buildFallbackStoryForState = vi.fn();
const buildStoryContextFromState = vi.fn();
const buildDialogueStoryMoment = vi.fn();
const generateStoryForState = vi.fn();
const getStoryGenerationHostileNpcs = vi.fn(() => []);
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
const getTypewriterDelay = (_char: string) => 90 as const;
const commitGeneratedState = vi.fn();
const commitGeneratedStateWithEncounterEntry = vi.fn();
const appendHistory = vi.fn();
const buildOpeningCampChatContext = vi.fn();
const sortOptions = vi.fn((options: StoryOption[]) => options);
const buildContinueAdventureOption = vi.fn(() => createOption('continue'));
const sanitizeOptions = vi.fn((options: StoryOption[]) => options);
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
const runtimeSupport = {
buildNpcStory: vi.fn(),
cloneInventoryItemForOwner: vi.fn(),
getNpcEncounterKey: vi.fn(),
getResolvedNpcState: vi.fn(),
updateNpcState: vi.fn(),
updateQuestLog: vi.fn(),
updateRuntimeStats: vi.fn(),
};
const config = createStoryInteractionCoordinatorConfig({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
currentStory,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
runtimeSupport,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
sortOptions,
buildContinueAdventureOption,
sanitizeOptions,
resolveNpcInteractionDecision,
});
expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime);
expect(config.treasureFlow).toEqual({
gameState,
runtime: config.inventoryFlow.runtime,
});
expect(config.npcInteractionFlow).toEqual(
expect.objectContaining({
gameState,
setGameState,
commitGeneratedState,
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
updateNpcState: runtimeSupport.updateNpcState,
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
runtime: expect.objectContaining({
currentStory,
setCurrentStory,
setAiError,
setIsLoading,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
}),
}),
);
expect(config.npcEncounterActions).toEqual(
expect.objectContaining({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
sanitizeOptions,
sortOptions,
buildContinueAdventureOption,
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
updateNpcState: runtimeSupport.updateNpcState,
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
resolveNpcInteractionDecision,
}),
);
});
});

View File

@@ -0,0 +1,137 @@
import type { Dispatch, SetStateAction } from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
type StoryInteractionCoordinatorParams = {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
currentStory: StoryMoment | null;
buildStoryContextFromState: (
state: GameState,
extras?: {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
buildDialogueStoryMoment: StoryRuntimeControllerResult['buildDialogueStoryMoment'];
generateStoryForState: StoryRuntimeControllerResult['generateStoryForState'];
getAvailableOptionsForState: StoryRuntimeControllerResult['getAvailableOptionsForState'];
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getTypewriterDelay: StoryRuntimeControllerResult['getTypewriterDelay'];
runtimeSupport: StoryRuntimeSupport;
commitGeneratedState: StoryRuntimeControllerResult['commitGeneratedState'];
commitGeneratedStateWithEncounterEntry: StoryRuntimeControllerResult['commitGeneratedStateWithEncounterEntry'];
appendHistory: StoryRuntimeControllerResult['appendHistory'];
buildOpeningCampChatContext: StoryRuntimeControllerResult['buildOpeningCampChatContext'];
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
sanitizeOptions: (
options: StoryOption[],
character: Character,
state: GameState,
) => StoryOption[];
resolveNpcInteractionDecision: (
state: GameState,
option: StoryOption,
) => { kind: string };
};
export function createStoryInteractionCoordinatorConfig(
params: StoryInteractionCoordinatorParams,
) {
const sharedRuntime = {
currentStory: params.currentStory,
setGameState: params.setGameState,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
buildFallbackStoryForState: params.buildFallbackStoryForState,
};
return {
treasureFlow: {
gameState: params.gameState,
runtime: sharedRuntime,
},
inventoryFlow: {
gameState: params.gameState,
runtime: sharedRuntime,
},
npcInteractionFlow: {
gameState: params.gameState,
setGameState: params.setGameState,
commitGeneratedState: params.commitGeneratedState,
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
updateNpcState: params.runtimeSupport.updateNpcState,
cloneInventoryItemForOwner:
params.runtimeSupport.cloneInventoryItemForOwner,
runtime: {
currentStory: params.currentStory,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
buildStoryContextFromState: params.buildStoryContextFromState,
buildFallbackStoryForState: params.buildFallbackStoryForState,
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
generateStoryForState: params.generateStoryForState,
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getTypewriterDelay: params.getTypewriterDelay,
},
},
npcEncounterActions: {
gameState: params.gameState,
currentStory: params.currentStory,
setGameState: params.setGameState,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
commitGeneratedState: params.commitGeneratedState,
commitGeneratedStateWithEncounterEntry:
params.commitGeneratedStateWithEncounterEntry,
appendHistory: params.appendHistory,
buildOpeningCampChatContext: params.buildOpeningCampChatContext,
buildStoryContextFromState: params.buildStoryContextFromState,
buildFallbackStoryForState: params.buildFallbackStoryForState,
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
generateStoryForState: params.generateStoryForState,
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getTypewriterDelay: params.getTypewriterDelay,
getAvailableOptionsForState: params.getAvailableOptionsForState,
sanitizeOptions: params.sanitizeOptions,
sortOptions: params.sortOptions,
buildContinueAdventureOption: params.buildContinueAdventureOption,
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
updateNpcState: params.runtimeSupport.updateNpcState,
cloneInventoryItemForOwner:
params.runtimeSupport.cloneInventoryItemForOwner,
resolveNpcInteractionDecision: params.resolveNpcInteractionDecision,
},
};
}
export type StoryInteractionCoordinatorConfig = ReturnType<
typeof createStoryInteractionCoordinatorConfig
>;

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type GameState,
type StoryMoment,
type StoryOption,
} from '../../types';
import { buildStoryFromResponse } from './storyPresentation';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
personality: 'calm',
skills: [],
} as unknown as Character;
}
function createGameState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createOption(
functionId = 'npc_chat',
actionText = '继续交谈',
): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
function createStory(
text: string,
options: StoryOption[] = [],
): StoryMoment {
return {
text,
options,
};
}
describe('storyPresentation', () => {
it('keeps provided available options when the AI response omits them', () => {
const availableOptions = [createOption('npc_help', '请求援手')];
const story = buildStoryFromResponse({
state: createGameState(),
character: createCharacter(),
response: createStory('服务端返回正文'),
availableOptions,
});
expect(story.text).toBe('服务端返回正文');
expect(story.options).toEqual([
expect.objectContaining({
functionId: 'npc_help',
actionText: '请求援手',
}),
]);
});
it('deduplicates repeated response options before padding local fallbacks', () => {
const duplicatedOptions = [
createOption('npc_chat', '继续交谈'),
createOption('npc_chat', '继续交谈'),
];
const story = buildStoryFromResponse({
state: createGameState(),
character: createCharacter(),
response: createStory('需要本地归一化', duplicatedOptions),
availableOptions: null,
});
expect(
story.options.filter(
(option) =>
option.functionId === 'npc_chat' &&
option.actionText === '继续交谈',
),
).toHaveLength(1);
expect(story.options.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,245 @@
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type {
Character,
GameState,
StoryDialogueTurn,
StoryMoment,
StoryOption,
} from '../../types';
import {
buildFallbackStoryMoment,
normalizeSkillProbabilities,
} from '../combatStoryUtils';
import { resolveStoryResponseOptions } from './storyResponseOptions';
const MIN_OPTION_POOL_SIZE = 6;
function dedupeStoryOptions(options: StoryOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
if (seen.has(identity)) {
return false;
}
seen.add(identity);
return true;
});
}
function escapeRegExp(value: string) {
const specialChars = [
'\\',
'^',
'$',
'*',
'+',
'?',
'.',
'(',
')',
'|',
'[',
']',
'{',
'}',
];
return specialChars.reduce(
(escaped, char) => escaped.split(char).join('\\' + char),
value,
);
}
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
return rawSpeakerName
.trim()
.replace(
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
'',
)
.replace(
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
'',
)
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
.trim();
}
export function sanitizeStoryOptions(
options: StoryOption[],
character: Character,
state: GameState,
) {
const normalizedOptions = dedupeStoryOptions(
options.map((option) => normalizeSkillProbabilities(option, character)),
);
if (normalizedOptions.length === 0) {
return buildFallbackStoryMoment(state, character).options;
}
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
return normalizedOptions;
}
return sortStoryOptionsByPriority(
dedupeStoryOptions([
...normalizedOptions,
...buildFallbackStoryMoment(state, character).options,
]).slice(0, MIN_OPTION_POOL_SIZE),
);
}
export function buildStoryFromResponse(params: {
state: GameState;
character: Character;
response: StoryMoment;
availableOptions: StoryOption[] | null;
optionCatalog?: StoryOption[] | null;
}) {
return {
text: params.response.text,
options: resolveStoryResponseOptions({
responseOptions: params.response.options,
availableOptions: params.availableOptions,
optionCatalog: params.optionCatalog ?? null,
getSanitizedOptions: () =>
sanitizeStoryOptions(
params.response.options,
params.character,
params.state,
),
}),
} satisfies StoryMoment;
}
export function parseDialogueTurns(
text: string,
npcName: string,
): StoryDialogueTurn[] {
const turns: StoryDialogueTurn[] = [];
const dialogueColonPattern = '(?:\\uFF1A|:)';
const playerPrefixPattern = new RegExp(
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const npcPrefixPattern = new RegExp(
'^' +
escapeRegExp(npcName) +
'\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const namedSpeakerPattern = new RegExp(
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
'u',
);
const lines = text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const playerMatch = line.match(playerPrefixPattern);
const playerText = playerMatch?.[1]?.trim();
if (playerText) {
turns.push({ speaker: 'player', text: playerText });
continue;
}
const npcMatch = line.match(npcPrefixPattern);
const npcText = npcMatch?.[1]?.trim();
if (npcText) {
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
continue;
}
const namedSpeakerMatch = line.match(namedSpeakerPattern);
if (namedSpeakerMatch) {
const rawSpeakerName = namedSpeakerMatch[1];
const rawSpeakerText = namedSpeakerMatch[2];
if (!rawSpeakerName || !rawSpeakerText) {
continue;
}
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
const speakerText = rawSpeakerText.trim();
if (speakerName && speakerText) {
turns.push({
speaker: speakerName === npcName ? 'npc' : 'companion',
speakerName,
text: speakerText,
});
continue;
}
}
if (line.startsWith('你:') || line.startsWith('你:')) {
turns.push({ speaker: 'player', text: line.slice(2).trim() });
continue;
}
if (line.startsWith(npcName + ':') || line.startsWith(npcName + '')) {
turns.push({
speaker: 'npc',
text: line.slice(npcName.length + 1).trim(),
});
continue;
}
if (line.startsWith('主角:') || line.startsWith('主角:')) {
turns.push({ speaker: 'player', text: line.slice(3).trim() });
continue;
}
if (turns.length > 0) {
const lastTurnIndex = turns.length - 1;
const lastTurn = turns[lastTurnIndex];
if (lastTurn) {
turns[lastTurnIndex] = {
...lastTurn,
text: lastTurn.text + line,
};
}
}
}
return turns.filter((turn) => turn.text.length > 0);
}
export function buildDialogueStoryMoment(
npcName: string,
text: string,
options: StoryOption[],
streaming = false,
): StoryMoment {
return {
text,
options,
displayMode: 'dialogue',
dialogue: parseDialogueTurns(text, npcName),
streaming,
};
}
export function hasRenderableDialogueTurns(text: string, npcName: string) {
return parseDialogueTurns(text, npcName).length >= 2;
}
export function getTypewriterDelay(char: string) {
if (/[!?]/u.test(char)) {
return 240;
}
if (/[;:]/u.test(char)) {
return 150;
}
if (/\s/u.test(char)) {
return 45;
}
return 90;
}

View File

@@ -0,0 +1,241 @@
import type {
Character,
GameState,
StoryDialogueTurn,
StoryMoment,
StoryOption,
} from '../../types';
import {
buildFallbackStoryMoment,
normalizeSkillProbabilities,
} from '../combatStoryUtils';
const MIN_OPTION_POOL_SIZE = 6;
export function dedupeStoryOptions(options: StoryOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
export function buildLocalCharacterChatSummary(
character: Character,
history: Array<{ speaker: 'player' | 'character'; text: string }>,
previousSummary: string,
) {
const latestTurns = history
.slice(-4)
.map(
(turn) =>
`${turn.speaker === 'player' ? '玩家' : character.name}${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}`
: `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
return `${previousSummary} ${currentSummary}`.slice(0, 118);
}
export function buildLocalCharacterChatSuggestions(character: Character) {
return [
'我想听你把这件事再说得更明白一点。',
`${character.name},你现在真正担心的是什么?`,
'先把外面的局势放一放,我想更了解你一些。',
];
}
export function sanitizeOptions(
options: StoryOption[],
character: Character,
state: GameState,
) {
const normalizedOptions = dedupeStoryOptions(
options.map((option) => normalizeSkillProbabilities(option, character)),
);
if (normalizedOptions.length === 0) {
return buildFallbackStoryMoment(state, character).options;
}
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
return normalizedOptions;
}
return dedupeStoryOptions([
...normalizedOptions,
...buildFallbackStoryMoment(state, character).options,
]).slice(0, MIN_OPTION_POOL_SIZE);
}
function escapeRegExp(value: string) {
const specialChars = [
'\\',
'^',
'$',
'*',
'+',
'?',
'.',
'(',
')',
'|',
'[',
']',
'{',
'}',
];
return specialChars.reduce(
(escaped, char) => escaped.split(char).join('\\' + char),
value,
);
}
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
return rawSpeakerName
.trim()
.replace(
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
'',
)
.replace(
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
'',
)
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
.trim();
}
function parseDialogueTurns(
text: string,
npcName: string,
): StoryDialogueTurn[] {
const turns: StoryDialogueTurn[] = [];
const dialogueColonPattern = '(?:\\uFF1A|:)';
const playerPrefixPattern = new RegExp(
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const npcPrefixPattern = new RegExp(
'^' +
escapeRegExp(npcName) +
'\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const namedSpeakerPattern = new RegExp(
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
'u',
);
const lines = text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const playerMatch = line.match(playerPrefixPattern);
const playerText = playerMatch?.[1]?.trim();
if (playerText) {
turns.push({ speaker: 'player', text: playerText });
continue;
}
const npcMatch = line.match(npcPrefixPattern);
const npcText = npcMatch?.[1]?.trim();
if (npcText) {
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
continue;
}
const namedSpeakerMatch = line.match(namedSpeakerPattern);
if (namedSpeakerMatch) {
const rawSpeakerName = namedSpeakerMatch[1];
const rawSpeakerText = namedSpeakerMatch[2];
if (!rawSpeakerName || !rawSpeakerText) {
continue;
}
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
const speakerText = rawSpeakerText.trim();
if (speakerName && speakerText) {
turns.push({
speaker: speakerName === npcName ? 'npc' : 'companion',
speakerName,
text: speakerText,
});
continue;
}
}
if (line.startsWith('你:') || line.startsWith('你:')) {
turns.push({ speaker: 'player', text: line.slice(2).trim() });
continue;
}
if (line.startsWith(npcName + ':') || line.startsWith(npcName + '')) {
turns.push({
speaker: 'npc',
text: line.slice(npcName.length + 1).trim(),
});
continue;
}
if (line.startsWith('主角:') || line.startsWith('主角:')) {
turns.push({ speaker: 'player', text: line.slice(3).trim() });
continue;
}
if (turns.length > 0) {
const lastTurnIndex = turns.length - 1;
const lastTurn = turns[lastTurnIndex];
if (lastTurn) {
turns[lastTurnIndex] = {
...lastTurn,
text: lastTurn.text + line,
};
}
}
}
return turns.filter((turn) => turn.text.length > 0);
}
export function buildDialogueStoryMoment(
npcName: string,
text: string,
options: StoryOption[],
streaming = false,
): StoryMoment {
return {
text,
options,
displayMode: 'dialogue',
dialogue: parseDialogueTurns(text, npcName),
streaming,
};
}
export function hasRenderableDialogueTurns(text: string, npcName: string) {
return parseDialogueTurns(text, npcName).length >= 2;
}
export function getTypewriterDelay(char: string) {
if (/[!?]/u.test(char)) return 240;
if (/[;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { StoryGenerationContext } from '../../services/aiService';
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import {
generateStoryForStateWithCoordinator,
resolveStoryRequestOptions,
} from './storyRequestCoordinator';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '在风声里辨认危险的旅人。',
personality: '谨慎而果断',
skills: [],
} as unknown as Character;
}
function createGameState(): GameState {
return {
worldType: 'WUXIA',
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
} as GameState;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
describe('storyRequestCoordinator', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => {
const state = createGameState();
const character = createCharacter();
const currentStory = createStory('当前故事');
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
const loadRuntimeOptionCatalog = vi
.fn()
.mockResolvedValue([createOption('npc_help', '请求援手')]);
const result = await resolveStoryRequestOptions({
state,
character,
currentStory,
getAvailableOptionsForState,
loadRuntimeOptionCatalog,
});
expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({
gameState: state,
currentStory,
});
expect(result.availableOptions).toBeNull();
expect(result.optionCatalog).toEqual([
expect.objectContaining({
functionId: 'npc_help',
actionText: '请求援手',
}),
]);
});
it('keeps explicit option catalogs without reloading server runtime options', async () => {
const state = createGameState();
const character = createCharacter();
const currentStory = createStory('当前故事');
const optionCatalog = [createOption('npc_help', '请求援手')];
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
const loadRuntimeOptionCatalog = vi.fn();
const result = await resolveStoryRequestOptions({
state,
character,
currentStory,
optionCatalog,
getAvailableOptionsForState,
loadRuntimeOptionCatalog,
});
expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled();
expect(getAvailableOptionsForState).not.toHaveBeenCalled();
expect(result.optionCatalog).toBe(optionCatalog);
expect(result.availableOptions).toBeNull();
});
it('falls back to local available options when server runtime catalog refresh fails during续写', async () => {
const state = createGameState();
const character = createCharacter();
const currentStory = createStory('当前故事');
const history = [createStory('上一轮剧情')];
const localOptions = [createOption('npc_chat', '继续交谈')];
const getAvailableOptionsForState = vi.fn(() => localOptions);
const getStoryGenerationHostileNpcs = vi.fn(() => []);
const buildStoryContextFromState = vi.fn(
(_state, extras) =>
({
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: 'idle',
skillCooldowns: {},
sceneId: 'inn_room',
sceneName: '客栈内室',
sceneDescription: '屋里安静得只剩风声。',
pendingSceneEncounter: false,
lastFunctionId: extras?.lastFunctionId ?? null,
}) as StoryGenerationContext,
);
const buildStoryFromResponse = vi.fn(
(
_state: GameState,
_character: Character,
response: StoryMoment,
) => response,
);
const requestInitialStory = vi.fn();
const requestNextStep = vi.fn().mockResolvedValue({
storyText: '服务端续写完成',
options: [createOption('npc_help', '顺势追问')],
});
const loadRuntimeOptionCatalog = vi
.fn()
.mockRejectedValue(new Error('server option catalog failed'));
const onServerOptionCatalogLoadError = vi.fn();
const result = await generateStoryForStateWithCoordinator({
state,
character,
history,
currentStory,
choice: '继续交谈',
lastFunctionId: 'npc_chat',
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
buildStoryContextFromState,
buildStoryFromResponse,
requestInitialStory,
requestNextStep,
loadRuntimeOptionCatalog,
onServerOptionCatalogLoadError,
});
expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1);
expect(requestInitialStory).not.toHaveBeenCalled();
expect(requestNextStep).toHaveBeenCalledWith(
'WUXIA',
character,
[],
history,
'继续交谈',
expect.objectContaining({
sceneId: 'inn_room',
lastFunctionId: 'npc_chat',
}),
{
availableOptions: localOptions,
},
);
expect(result).toEqual({
text: '服务端续写完成',
options: [createOption('npc_help', '顺势追问')],
});
});
});

View File

@@ -0,0 +1,196 @@
import type {
StoryGenerationContext,
StoryRequestOptions,
} from '../../services/aiService';
import { shouldUseServerRuntimeOptions } from '../../services/runtimeStoryService';
import type {
AIResponse,
Character,
GameState,
SceneHostileNpc,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
import { loadServerRuntimeOptionCatalog } from './runtimeStoryCoordinator';
type BuildStoryContextFromState = (
state: GameState,
extras?: {
lastFunctionId?: string | null;
},
) => StoryGenerationContext;
type BuildStoryFromResponse = (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) => StoryMoment;
type GetAvailableOptionsForState = (
state: GameState,
character: Character,
) => StoryOption[] | null;
type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[];
type RequestInitialStory = (
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions?: StoryRequestOptions,
) => Promise<AIResponse>;
type RequestNextStep = (
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions?: StoryRequestOptions,
) => Promise<AIResponse>;
type LoadRuntimeOptionCatalog = typeof loadServerRuntimeOptionCatalog;
export type ResolvedStoryRequestOptions = {
availableOptions: StoryOption[] | null;
optionCatalog: StoryOption[] | null;
};
export async function resolveStoryRequestOptions(params: {
state: GameState;
character: Character;
currentStory: StoryMoment | null;
optionCatalog?: StoryOption[] | null;
getAvailableOptionsForState: GetAvailableOptionsForState;
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
onServerOptionCatalogLoadError?: (error: unknown) => void;
}) {
let optionCatalog =
params.optionCatalog && params.optionCatalog.length > 0
? params.optionCatalog
: null;
let availableOptions = optionCatalog
? null
: params.getAvailableOptionsForState(params.state, params.character);
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
return {
availableOptions,
optionCatalog,
} satisfies ResolvedStoryRequestOptions;
}
try {
const serverOptionCatalog = await (
params.loadRuntimeOptionCatalog ?? loadServerRuntimeOptionCatalog
)({
gameState: params.state,
currentStory: params.currentStory,
});
if (serverOptionCatalog && serverOptionCatalog.length > 0) {
optionCatalog = serverOptionCatalog;
availableOptions = null;
}
} catch (error) {
params.onServerOptionCatalogLoadError?.(error);
}
return {
availableOptions,
optionCatalog,
} satisfies ResolvedStoryRequestOptions;
}
export function buildAiStoryRequestOptions(
options: ResolvedStoryRequestOptions,
) {
if (options.availableOptions) {
return {
availableOptions: options.availableOptions,
};
}
if (options.optionCatalog) {
return {
optionCatalog: options.optionCatalog,
};
}
return undefined;
}
export async function generateStoryForStateWithCoordinator(params: {
state: GameState;
character: Character;
history: StoryMoment[];
currentStory: StoryMoment | null;
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
getAvailableOptionsForState: GetAvailableOptionsForState;
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
requestInitialStory: RequestInitialStory;
requestNextStep: RequestNextStep;
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
onServerOptionCatalogLoadError?: (error: unknown) => void;
}) {
if (!params.state.worldType) {
throw new Error(
'The current world is not initialized, so story generation cannot continue.',
);
}
const worldType = params.state.worldType;
const resolvedOptions = await resolveStoryRequestOptions({
state: params.state,
character: params.character,
currentStory: params.currentStory,
optionCatalog: params.optionCatalog,
getAvailableOptionsForState: params.getAvailableOptionsForState,
loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog,
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
});
const requestOptions = buildAiStoryRequestOptions(resolvedOptions);
const monsters = params.getStoryGenerationHostileNpcs(params.state);
const context = params.choice
? params.buildStoryContextFromState(params.state, {
lastFunctionId: params.lastFunctionId,
})
: params.buildStoryContextFromState(params.state);
const response = params.choice
? await params.requestNextStep(
worldType,
params.character,
monsters,
params.history,
params.choice,
context,
requestOptions,
)
: await params.requestInitialStory(
worldType,
params.character,
monsters,
context,
requestOptions,
);
return params.buildStoryFromResponse(
params.state,
params.character,
{
text: response.storyText,
options: response.options,
},
resolvedOptions.availableOptions,
resolvedOptions.optionCatalog,
);
}

View File

@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest';
import type {
Character,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { createGenerateStoryForState } from './storyRequestRuntime';
const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({
generateStoryForStateWithCoordinatorMock: vi.fn(),
}));
vi.mock('./storyRequestCoordinator', () => ({
generateStoryForStateWithCoordinator:
generateStoryForStateWithCoordinatorMock,
}));
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '在风声里辨认危险的旅人。',
backstory: '长年行走江湖。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: '谨慎而果断',
skills: [],
adventureOpenings: {},
} as unknown as Character;
}
function createGameState(): GameState {
return {
worldType: 'WUXIA',
currentScene: 'Story',
} as GameState;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createOption(
functionId = 'npc_chat',
actionText = '继续交谈',
): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
describe('storyRequestRuntime', () => {
it('forwards runtime request dependencies and currentStory into the coordinator', async () => {
const currentStory = createStory('当前故事');
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
const getStoryGenerationHostileNpcs = vi.fn(() => []);
const buildStoryContextFromState = vi.fn();
const buildStoryFromResponse = vi.fn();
const requestInitialStory = vi.fn();
const requestNextStep = vi.fn();
const onServerOptionCatalogLoadError = vi.fn();
const state = createGameState();
const character = createCharacter();
const history = [createStory('上一轮剧情')];
generateStoryForStateWithCoordinatorMock.mockResolvedValue({
text: '生成完成',
options: [],
});
const generateStoryForState = createGenerateStoryForState({
currentStory,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
buildStoryContextFromState,
buildStoryFromResponse,
requestInitialStory,
requestNextStep,
onServerOptionCatalogLoadError,
});
const result = await generateStoryForState({
state,
character,
history,
choice: '继续交谈',
lastFunctionId: 'npc_chat',
optionCatalog: [createOption('npc_help', '请求援手')],
});
expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({
state,
character,
history,
currentStory,
choice: '继续交谈',
lastFunctionId: 'npc_chat',
optionCatalog: [createOption('npc_help', '请求援手')],
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
buildStoryContextFromState,
buildStoryFromResponse,
requestInitialStory,
requestNextStep,
onServerOptionCatalogLoadError,
});
expect(result).toEqual({
text: '生成完成',
options: [],
});
});
});

View File

@@ -0,0 +1,69 @@
import type {
Character,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { GenerateStoryForState } from './progressionActions';
import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator';
type GetAvailableOptionsForState = (
state: GameState,
character: Character,
) => StoryOption[] | null;
type BuildStoryContextFromState = Parameters<
typeof generateStoryForStateWithCoordinator
>[0]['buildStoryContextFromState'];
type BuildStoryFromResponse = Parameters<
typeof generateStoryForStateWithCoordinator
>[0]['buildStoryFromResponse'];
type GetStoryGenerationHostileNpcs = Parameters<
typeof generateStoryForStateWithCoordinator
>[0]['getStoryGenerationHostileNpcs'];
type RequestInitialStory = Parameters<
typeof generateStoryForStateWithCoordinator
>[0]['requestInitialStory'];
type RequestNextStep = Parameters<
typeof generateStoryForStateWithCoordinator
>[0]['requestNextStep'];
export function createGenerateStoryForState(params: {
currentStory: StoryMoment | null;
getAvailableOptionsForState: GetAvailableOptionsForState;
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
requestInitialStory: RequestInitialStory;
requestNextStep: RequestNextStep;
onServerOptionCatalogLoadError?: (error: unknown) => void;
}): GenerateStoryForState {
return async ({
state,
character,
history,
choice,
lastFunctionId,
optionCatalog,
}) =>
generateStoryForStateWithCoordinator({
state,
character,
history,
currentStory: params.currentStory,
choice,
lastFunctionId,
optionCatalog,
getAvailableOptionsForState: params.getAvailableOptionsForState,
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
buildStoryContextFromState: params.buildStoryContextFromState,
buildStoryFromResponse: params.buildStoryFromResponse,
requestInitialStory: params.requestInitialStory,
requestNextStep: params.requestNextStep,
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
});
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import type { GameState, InventoryItem } from '../../types';
import {
cloneInventoryItemForOwner,
updateQuestLog,
updateRuntimeStats,
} from './storyRuntimeSupport';
function createGameState(): GameState {
return {
worldType: 'WUXIA',
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 1,
playerMaxHp: 1,
playerMana: 1,
playerMaxMana: 1,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} as GameState;
}
describe('storyRuntimeSupport', () => {
it('preserves identity-sensitive inventory items when cloning for another owner', () => {
const item = {
id: 'artifact-1',
category: '饰品',
name: '旧日秘匣',
quantity: 1,
rarity: 'epic',
tags: ['relic'],
runtimeMetadata: {
seedKey: 'artifact-seed',
},
} as InventoryItem;
expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual(
expect.objectContaining({
id: 'npc:artifact-1:2',
quantity: 2,
runtimeMetadata: expect.objectContaining({
seedKey: 'artifact-seed:npc',
}),
}),
);
});
it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => {
const item = {
id: 'potion-1',
category: '消耗品',
name: '回气散',
quantity: 3,
rarity: 'common',
tags: ['healing'],
} as InventoryItem;
expect(cloneInventoryItemForOwner(item, 'player')).toEqual(
expect.objectContaining({
id: `player:${encodeURIComponent('消耗品-回气散')}`,
quantity: 1,
}),
);
});
it('updates quest logs and runtime stats without keeping that logic in the main hook', () => {
const initialState = createGameState();
const withQuest = updateQuestLog(initialState, () => [
{
id: 'quest-1',
},
] as GameState['quests']);
const withStats = updateRuntimeStats(withQuest, {
itemsUsed: 2,
scenesTraveled: 1,
});
expect(withStats.quests).toEqual([{ id: 'quest-1' }]);
expect(withStats.runtimeStats.itemsUsed).toBe(2);
expect(withStats.runtimeStats.scenesTraveled).toBe(1);
});
});

View File

@@ -0,0 +1,127 @@
import {
buildInitialNpcState,
buildNpcEncounterStoryMoment,
normalizeNpcPersistentState,
} from '../../data/npcInteractions';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory';
import type {
Character,
Encounter,
GameState,
InventoryItem,
} from '../../types';
import { getNpcEncounterKey } from './storyGenerationState';
export function cloneInventoryItemForOwner(
item: InventoryItem,
owner: 'player' | 'npc',
quantity = 1,
) {
const preserveIdentity = Boolean(
item.runtimeMetadata ||
item.buildProfile ||
item.equipmentSlotId ||
item.statProfile ||
item.attributeResonance,
);
return {
...item,
id: preserveIdentity
? `${owner}:${item.id}:${quantity}`
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
quantity,
runtimeMetadata: item.runtimeMetadata
? {
...item.runtimeMetadata,
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
}
: item.runtimeMetadata,
};
}
export function getResolvedNpcState(
state: GameState,
encounter: Encounter,
) {
return (
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType, state)
);
}
export function buildNpcStory(
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) {
return buildNpcEncounterStoryMoment({
state,
encounter,
npcState: getResolvedNpcState(state, encounter),
playerCharacter: character,
playerInventory: state.playerInventory,
activeQuests: state.quests,
scene: state.currentScenePreset,
partySize: state.companions.length,
overrideText,
worldType: state.worldType,
});
}
export function updateNpcState(
state: GameState,
encounter: Encounter,
updater: (
npcState: ReturnType<typeof getResolvedNpcState>,
) => ReturnType<typeof getResolvedNpcState>,
) {
return {
...state,
npcStates: {
...state.npcStates,
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
syncNpcNarrativeState({
encounter,
npcState: updater(getResolvedNpcState(state, encounter)),
customWorldProfile: state.customWorldProfile,
storyEngineMemory: state.storyEngineMemory,
}),
),
},
};
}
export function updateQuestLog(
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) {
return {
...state,
quests: updater(state.quests),
};
}
export function updateRuntimeStats(
state: GameState,
increments: Parameters<typeof incrementGameRuntimeStats>[1],
) {
return {
...state,
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
};
}
export const storyRuntimeSupport = {
cloneInventoryItemForOwner,
getNpcEncounterKey,
getResolvedNpcState,
buildNpcStory,
updateNpcState,
updateQuestLog,
updateRuntimeStats,
};
export type StoryRuntimeSupport = typeof storyRuntimeSupport;

View File

@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
describe('useStoryChoiceCoordinator helpers', () => {
it('clears choice ui by dismissing battle reward', () => {
const calls: string[] = [];
const clearStoryChoiceUi = createClearStoryChoiceUi({
clearBattleReward: vi.fn(() => calls.push('battle')),
});
clearStoryChoiceUi();
expect(calls).toEqual(['battle']);
});
});

View File

@@ -0,0 +1,140 @@
import { useCallback, useState } from 'react';
import { createStoryChoiceActions } from './choiceActions';
import {
createStoryChoiceCoordinatorConfig,
type ChoiceRuntimeController,
type ChoiceRuntimeSupport,
} from './storyChoiceCoordinator';
import type { BattleRewardSummary } from './uiTypes';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
type StoryChoiceCoordinatorParams = {
gameState: GameState;
isLoading: boolean;
setGameState: Parameters<typeof createStoryChoiceActions>[0]['setGameState'];
setCurrentStory: Parameters<
typeof createStoryChoiceActions
>[0]['setCurrentStory'];
setAiError: Parameters<typeof createStoryChoiceActions>[0]['setAiError'];
setIsLoading: Parameters<typeof createStoryChoiceActions>[0]['setIsLoading'];
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: ResolvedChoicePlaybackSync,
) => Promise<GameState>;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
runtimeController: ChoiceRuntimeController & {
currentStory: StoryMoment | null;
};
runtimeSupport: ChoiceRuntimeSupport;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
option: StoryOption,
) => void | Promise<void> | boolean | Promise<boolean>;
finalizeNpcBattleResult: Parameters<
typeof createStoryChoiceCoordinatorConfig
>[0]['finalizeNpcBattleResult'];
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
};
export function createClearStoryChoiceUi(params: {
clearBattleReward: () => void;
}) {
return () => {
params.clearBattleReward();
};
}
export function useStoryChoiceCoordinator(
params: StoryChoiceCoordinatorParams,
) {
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
null,
);
const { handleChoice } = createStoryChoiceActions(
createStoryChoiceCoordinatorConfig({
gameState: params.gameState,
currentStory: params.runtimeController.currentStory,
isLoading: params.isLoading,
setGameState: params.setGameState,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
setBattleReward,
buildResolvedChoiceState: params.buildResolvedChoiceState,
playResolvedChoice: params.playResolvedChoice,
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
runtimeController: params.runtimeController,
runtimeSupport: params.runtimeSupport,
enterNpcInteraction: params.enterNpcInteraction,
handleNpcInteraction: params.handleNpcInteraction,
handleTreasureInteraction: params.handleTreasureInteraction,
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
sortOptions: params.sortOptions,
buildContinueAdventureOption: params.buildContinueAdventureOption,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
fallbackCompanionName: params.fallbackCompanionName,
turnVisualMs: params.turnVisualMs,
}),
);
const clearStoryChoiceUi = useCallback(
createClearStoryChoiceUi({
clearBattleReward: () => setBattleReward(null),
}),
[],
);
return {
handleChoice,
battleRewardUi: {
reward: battleReward,
dismiss: () => setBattleReward(null),
},
clearStoryChoiceUi,
};
}

View File

@@ -0,0 +1,190 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Character, Encounter, GameState, StoryOption } from '../../types';
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
import { sanitizeStoryOptions } from './storyPresentation';
import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import { useStoryGoalSessionCoordinator } from './useStoryGoalSessionCoordinator';
import { useStoryInteractionCoordinator } from './useStoryInteractionCoordinator';
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
type StoryFlowCoordinatorParams = {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: ResolvedChoicePlaybackSync,
) => Promise<GameState>;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
runtimeController: StoryRuntimeControllerResult;
runtimeSupport: StoryRuntimeSupport;
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
resolveNpcInteractionDecision: (
state: GameState,
option: StoryOption,
) => { kind: string };
clearCharacterChatModal: () => void;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
};
export function useStoryFlowCoordinator({
gameState,
setGameState,
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
runtimeController,
runtimeSupport,
sortOptions,
buildContinueAdventureOption,
resolveNpcInteractionDecision,
clearCharacterChatModal,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
}: StoryFlowCoordinatorParams) {
const {
currentStory,
setCurrentStory,
setAiError,
setIsLoading,
isLoading,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getAvailableOptionsForState,
getTypewriterDelay,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
resetPreparedOpeningAdventure,
} = runtimeController;
const interactionConfig = createStoryInteractionCoordinatorConfig({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
currentStory,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
runtimeSupport,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
sortOptions,
buildContinueAdventureOption,
sanitizeOptions: sanitizeStoryOptions,
resolveNpcInteractionDecision,
});
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
goalUi,
clearStoryGoalOptionUi,
} = useStoryGoalOptionCoordinator({
gameState,
currentStory,
});
const {
handleChoice,
battleRewardUi,
npcUi,
inventoryUi,
clearStoryInteractionUi,
} = useStoryInteractionCoordinator({
gameState,
isLoading,
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,
playResolvedChoice,
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
});
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
useStoryGoalSessionCoordinator({
gameState,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState,
buildFallbackStoryForState,
resetPreparedOpeningAdventure,
clearStoryGoalOptionUi,
clearStoryInteractionUi,
clearCharacterChatModal,
});
return {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleChoice,
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
battleRewardUi,
questUi,
goalUi,
npcUi,
inventoryUi,
};
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
describe('useStoryGoalOptionCoordinator helpers', () => {
it('clears story goal and option ui together', () => {
const calls: string[] = [];
const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({
resetStoryOptions: vi.fn(() => calls.push('options')),
resetGoalPulseTracking: vi.fn(() => calls.push('goal')),
});
clearStoryGoalOptionUi();
expect(calls).toEqual(['options', 'goal']);
});
});

View File

@@ -0,0 +1,50 @@
import { useCallback } from 'react';
import type { GameState, StoryMoment } from '../../types';
import { useStoryOptions } from '../useStoryOptions';
import { useStoryGoalFlow } from './goalFlow';
export function createClearStoryGoalOptionUi(params: {
resetStoryOptions: () => void;
resetGoalPulseTracking: () => void;
}) {
return () => {
params.resetStoryOptions();
params.resetGoalPulseTracking();
};
}
export function useStoryGoalOptionCoordinator(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow(
params.gameState,
);
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
resetStoryOptions,
} = useStoryOptions(params.currentStory, runtimeGoalStack);
const clearStoryGoalOptionUi = useCallback(
createClearStoryGoalOptionUi({
resetStoryOptions,
resetGoalPulseTracking,
}),
[resetGoalPulseTracking, resetStoryOptions],
);
return {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
goalUi,
clearStoryGoalOptionUi,
};
}
export type StoryGoalOptionCoordinatorResult = ReturnType<
typeof useStoryGoalOptionCoordinator
>;

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryRuntimeUi } from './useStoryGoalSessionCoordinator';
describe('useStoryGoalSessionCoordinator helpers', () => {
it('clears story runtime ui in the expected order', () => {
const calls: string[] = [];
const clearStoryRuntimeUi = createClearStoryRuntimeUi({
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)),
resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')),
clearCharacterChatModal: vi.fn(() => calls.push('chat')),
});
clearStoryRuntimeUi();
expect(calls).toEqual([
'goal-option',
'interaction',
'ai:null',
'loading:false',
'opening',
'chat',
]);
});
});

View File

@@ -0,0 +1,94 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react';
import type { StoryMoment, GameState, Character } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { createStorySessionActions } from './sessionActions';
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
export function createClearStoryRuntimeUi(params: {
clearStoryGoalOptionUi: () => void;
clearStoryInteractionUi: () => void;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
resetPreparedOpeningAdventure: () => void;
clearCharacterChatModal: () => void;
}) {
return () => {
params.clearStoryGoalOptionUi();
params.clearStoryInteractionUi();
params.setAiError(null);
params.setIsLoading(false);
params.resetPreparedOpeningAdventure();
params.clearCharacterChatModal();
};
}
export function useStoryGoalSessionCoordinator(params: {
gameState: GameState;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
commitGeneratedState: CommitGeneratedState;
buildFallbackStoryForState: BuildFallbackStoryForState;
resetPreparedOpeningAdventure: () => void;
clearStoryGoalOptionUi: () => void;
clearStoryInteractionUi: () => void;
clearCharacterChatModal: () => void;
}) {
const clearStoryRuntimeUi = useCallback(
createClearStoryRuntimeUi({
clearStoryGoalOptionUi: params.clearStoryGoalOptionUi,
clearStoryInteractionUi: params.clearStoryInteractionUi,
setAiError: params.setAiError,
setIsLoading: params.setIsLoading,
resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure,
clearCharacterChatModal: params.clearCharacterChatModal,
}),
[
params.clearCharacterChatModal,
params.clearStoryGoalOptionUi,
params.clearStoryInteractionUi,
params.resetPreparedOpeningAdventure,
params.setAiError,
params.setIsLoading,
],
);
const {
acknowledgeQuestCompletion,
claimQuestReward,
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
} = createStorySessionActions({
gameState: params.gameState,
isLoading: params.isLoading,
setGameState: params.setGameState,
setCurrentStory: params.setCurrentStory,
clearStoryRuntimeUi,
commitGeneratedState: params.commitGeneratedState,
buildFallbackStoryForState: params.buildFallbackStoryForState,
});
return {
questUi: {
acknowledgeQuestCompletion,
claimQuestReward,
},
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
clearStoryRuntimeUi,
};
}
export type StoryGoalSessionCoordinatorResult = ReturnType<
typeof useStoryGoalSessionCoordinator
>;

View File

@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryInteractionUi } from './useStoryInteractionCoordinator';
describe('useStoryInteractionCoordinator helpers', () => {
it('clears interaction ui in the expected order', () => {
const calls: string[] = [];
const clearStoryInteractionUi = createClearStoryInteractionUi({
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
});
clearStoryInteractionUi();
expect(calls).toEqual(['choice', 'npc']);
});
});

View File

@@ -0,0 +1,203 @@
import { useCallback } from 'react';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
import { useTreasureFlow } from '../useTreasureFlow';
import { useStoryInventoryActions } from './inventoryActions';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
import { useStoryNpcInteractionFlow } from './npcInteraction';
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type {
ChoiceRuntimeController,
StoryChoiceCoordinatorParams,
} from './storyChoiceCoordinator';
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
type StoryInteractionCoordinatorParams = {
gameState: GameState;
isLoading: boolean;
interactionConfig: StoryInteractionCoordinatorConfig;
runtimeSupport: StoryRuntimeSupport;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: ResolvedChoicePlaybackSync,
) => Promise<GameState>;
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
};
export function createClearStoryInteractionUi(params: {
clearStoryChoiceUi: () => void;
clearNpcInteractionUi: () => void;
}) {
return () => {
params.clearStoryChoiceUi();
params.clearNpcInteractionUi();
};
}
export function useStoryInteractionCoordinator({
gameState,
isLoading,
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,
playResolvedChoice,
buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene,
startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
}: StoryInteractionCoordinatorParams) {
const { buildNpcStory } = runtimeSupport;
const { handleTreasureInteraction } = useTreasureFlow(
interactionConfig.treasureFlow,
);
const { inventoryUi } = useStoryInventoryActions(
interactionConfig.inventoryFlow,
);
const npcInteractionFlow = useStoryNpcInteractionFlow(
interactionConfig.npcInteractionFlow,
);
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
});
const choiceRuntimeController: Parameters<
typeof useStoryChoiceCoordinator
>[0]['runtimeController'] = {
currentStory: interactionConfig.npcEncounterActions.currentStory,
buildStoryContextFromState:
interactionConfig.npcEncounterActions.buildStoryContextFromState,
buildStoryFromResponse: (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) =>
buildStoryFromResponse(
state,
character,
response,
availableOptions,
optionCatalog,
),
buildFallbackStoryForState:
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
generateStoryForState: async (params) =>
interactionConfig.npcEncounterActions.generateStoryForState(params),
getAvailableOptionsForState:
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
getCampCompanionTravelScene: (state, character) =>
getCampCompanionTravelScene(state, character),
startOpeningAdventure: () => startOpeningAdventure(),
commitGeneratedStateWithEncounterEntry: async (
entryState,
resolvedState,
character,
actionText,
resultText,
lastFunctionId,
) => {
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
character,
actionText,
resultText,
lastFunctionId,
);
},
};
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
useStoryChoiceCoordinator({
gameState,
isLoading,
setGameState: interactionConfig.npcEncounterActions.setGameState,
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
setAiError: interactionConfig.npcEncounterActions.setAiError,
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs:
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
runtimeController: choiceRuntimeController,
runtimeSupport,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
finalizeNpcBattleResult,
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
buildContinueAdventureOption:
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
});
const clearStoryInteractionUi = useCallback(
createClearStoryInteractionUi({
clearStoryChoiceUi,
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
}),
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
);
return {
handleChoice,
battleRewardUi,
npcUi: npcInteractionFlow.npcUi,
inventoryUi,
clearStoryInteractionUi,
};
}

View File

@@ -0,0 +1,200 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { generateInitialStory, generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
import {
appendStoryHistory,
createStoryProgressionActions,
} from './progressionActions';
import {
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
createCampCompanionStoryHelpers,
} from './storyCampCompanion';
import { useStoryBootstrap } from './storyBootstrap';
import {
createStoryStateResolvers,
getStoryGenerationHostileNpcs,
isInitialCompanionEncounter,
isNpcEncounter,
} from './storyEncounterState';
import { getNpcEncounterKey } from './storyGenerationState';
import {
buildDialogueStoryMoment,
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
getTypewriterDelay,
hasRenderableDialogueTurns,
} from './storyPresentation';
import { buildNpcStory } from './storyRuntimeSupport';
import { createGenerateStoryForState } from './storyRequestRuntime';
import type { StoryContextBuilderExtras } from './storyContextBuilder';
type BuildStoryContextFromState = (
state: GameState,
extras?: StoryContextBuilderExtras,
) => StoryGenerationContext;
export function useStoryRuntimeController(params: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildStoryContextFromState: BuildStoryContextFromState;
}) {
const { gameState, setGameState, buildStoryContextFromState } = params;
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
const [aiError, setAiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const {
getCampCompanionTravelScene,
buildCampCompanionOpeningOptions,
inferOpeningCampFollowupOptions,
buildOpeningCampChatContext,
buildCampCompanionIdleStory,
} = useMemo(
() =>
createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
getNpcEncounterKey,
generateNextStep,
}),
[buildStoryContextFromState],
);
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
() =>
createStoryStateResolvers({
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
buildNpcStory,
}),
[buildCampCompanionIdleStory],
);
const buildStoryFromResponse = useCallback(
(
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog: StoryOption[] | null = null,
) =>
buildStoryFromResponseFromPresentation({
state,
character,
response,
availableOptions,
optionCatalog,
}),
[],
);
const generateStoryForState = useMemo(
() =>
createGenerateStoryForState({
currentStory,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
buildStoryContextFromState,
buildStoryFromResponse,
requestInitialStory: generateInitialStory,
requestNextStep: generateNextStep,
onServerOptionCatalogLoadError: (error) => {
console.warn(
'[useStoryGeneration] failed to load server runtime option catalog',
error,
);
},
}),
[
buildStoryContextFromState,
buildStoryFromResponse,
currentStory,
getAvailableOptionsForState,
],
);
const appendHistory = useCallback(appendStoryHistory, []);
const prepareOpeningAdventure = useCallback(
(state: GameState, character: Character) =>
buildPreparedOpeningAdventureState({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}),
[appendHistory, buildCampCompanionOpeningOptions],
);
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
createStoryProgressionActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
generateStoryForState,
buildFallbackStoryForState,
});
const {
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
} = useStoryBootstrap({
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
prepareOpeningAdventure,
getNpcEncounterKey,
buildFallbackStoryForState,
generateStoryForState,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
isNpcEncounter,
isInitialCompanionEncounter,
});
return {
currentStory,
setCurrentStory,
aiError,
setAiError,
isLoading,
setIsLoading,
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
buildStoryContextFromState,
buildDialogueStoryMoment,
getTypewriterDelay,
getCampCompanionTravelScene,
buildOpeningCampChatContext,
getAvailableOptionsForState,
buildFallbackStoryForState,
buildStoryFromResponse,
generateStoryForState,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
};
}
export type StoryRuntimeControllerResult = ReturnType<
typeof useStoryRuntimeController
>;

View File

@@ -1,150 +1,18 @@
import {useCallback, useEffect, useState} from 'react';
import { useCallback, useEffect, useRef, 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 type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import { isAbortError } from '../services/apiClient';
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';
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from './useGameFlow';
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
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' &&
@@ -154,6 +22,22 @@ function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
);
}
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
if (bottomTab === 'character' || bottomTab === 'inventory') {
return bottomTab;
}
return 'adventure';
}
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
return {
gameState: snapshot.gameState,
currentStory: snapshot.currentStory ?? null,
bottomTab: normalizeBottomTab(snapshot.bottomTab),
};
}
export function useGamePersistence({
gameState,
bottomTab,
@@ -174,93 +58,202 @@ export function useGamePersistence({
resetStoryState: () => void;
}) {
const [hasSavedGame, setHasSavedGame] = useState(false);
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
const [savedSnapshot, setSavedSnapshot] =
useState<HydratedSavedGameSnapshot | null>(null);
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
const [persistenceError, setPersistenceError] = useState<string | null>(null);
const hydrateControllerRef = useRef<AbortController | null>(null);
const saveControllerRef = useRef<AbortController | null>(null);
const saveRequestIdRef = useRef(0);
const abortActiveSave = useCallback(() => {
saveControllerRef.current?.abort();
saveControllerRef.current = null;
setIsPersistingSnapshot(false);
}, []);
const persistSnapshot = useCallback(
async (params: {
payload: {
gameState: GameState;
bottomTab: BottomTab;
currentStory: StoryMoment | null;
};
logLabel: string;
}) => {
abortActiveSave();
const requestId = saveRequestIdRef.current + 1;
saveRequestIdRef.current = requestId;
const controller = new AbortController();
saveControllerRef.current = controller;
setIsPersistingSnapshot(true);
setPersistenceError(null);
try {
const snapshot = await putSaveSnapshot(
{
gameState: params.payload.gameState,
bottomTab: params.payload.bottomTab,
currentStory: params.payload.currentStory,
},
{ signal: controller.signal },
);
if (saveRequestIdRef.current !== requestId) {
return null;
}
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return snapshot;
} catch (error) {
if (isAbortError(error)) {
return null;
}
const message =
error instanceof Error ? error.message : '远端存档同步失败';
if (saveRequestIdRef.current === requestId) {
setPersistenceError(message);
}
console.warn(`[useGamePersistence] ${params.logLabel}`, error);
return null;
} finally {
if (saveControllerRef.current === controller) {
saveControllerRef.current = null;
setIsPersistingSnapshot(false);
}
}
},
[abortActiveSave],
);
useEffect(() => {
let isActive = true;
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsHydratingSnapshot(true);
void getSaveSnapshot()
void getSaveSnapshot({ signal: controller.signal })
.then((snapshot) => {
if (!isActive) return;
setSavedSnapshot(snapshot);
setHasSavedGame(Boolean(snapshot));
setPersistenceError(null);
})
.catch((error) => {
console.warn('[useGamePersistence] failed to load remote snapshot', error);
if (isAbortError(error)) {
return;
}
const message =
error instanceof Error ? error.message : '读取远端存档失败';
setPersistenceError(message);
console.warn(
'[useGamePersistence] failed to load remote snapshot',
error,
);
})
.finally(() => {
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
setIsHydratingSnapshot(false);
}
});
return () => {
isActive = false;
controller.abort();
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
}
};
}, []);
useEffect(
() => () => {
hydrateControllerRef.current?.abort();
saveControllerRef.current?.abort();
saveControllerRef.current = null;
},
[],
);
useEffect(() => {
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
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);
});
void persistSnapshot({
payload: {
gameState,
bottomTab,
currentStory,
},
logLabel: 'failed to autosave remote snapshot',
});
}, AUTO_SAVE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [bottomTab, currentStory, gameState, isLoading]);
}, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]);
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;
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;
}
if (!canPersistSnapshot(nextGameState, nextStory)) {
return false;
}
try {
const snapshot = await putSaveSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
const snapshot = await persistSnapshot({
payload: {
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
},
logLabel: 'failed to save remote snapshot',
});
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
} catch (error) {
console.warn('[useGamePersistence] failed to save remote snapshot', error);
return false;
}
}, [bottomTab, currentStory, gameState]);
return Boolean(snapshot);
},
[bottomTab, currentStory, gameState, persistSnapshot],
);
const clearSavedGame = useCallback(async () => {
abortActiveSave();
try {
await deleteSaveSnapshot();
setPersistenceError(null);
} catch (error) {
console.warn('[useGamePersistence] failed to delete remote snapshot', error);
console.warn(
'[useGamePersistence] failed to delete remote snapshot',
error,
);
}
setSavedSnapshot(null);
setHasSavedGame(false);
}, []);
}, [abortActiveSave]);
const continueSavedGame = useCallback(async () => {
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
return null;
});
const snapshot =
savedSnapshot ??
(await getSaveSnapshot().catch((error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refetch remote snapshot',
error,
);
}
return null;
}));
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
@@ -268,16 +261,44 @@ export function useGamePersistence({
}
resetStoryState();
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refresh runtime story state from server',
error,
);
}
return {
hydratedSnapshot: fallbackHydration,
nextStory: fallbackHydration.currentStory,
};
},
);
setGameState(resumedState.hydratedSnapshot.gameState);
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
hydrateStoryState(resumedState.nextStory);
setSavedSnapshot(snapshot);
setHasSavedGame(true);
setPersistenceError(null);
return true;
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
}, [
hydrateStoryState,
resetStoryState,
savedSnapshot,
setBottomTab,
setGameState,
]);
return {
hasSavedGame,
isHydratingSnapshot,
isPersistingSnapshot,
persistenceError,
saveCurrentGame,
continueSavedGame,
clearSavedGame,

View File

@@ -4,37 +4,71 @@ import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
} from '../persistence/gameSettingsStorage';
import { isAbortError } from '../services/apiClient';
import { getSettings, putSettings } from '../services/storageService';
const SETTINGS_SYNC_DELAY_MS = 180;
export function useGameSettings() {
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
const hydrateControllerRef = useRef<AbortController | null>(null);
const persistControllerRef = useRef<AbortController | null>(null);
const persistRequestIdRef = useRef(0);
const abortActivePersist = useCallback(() => {
persistControllerRef.current?.abort();
persistControllerRef.current = null;
setIsPersistingSettings(false);
}, []);
useEffect(() => {
let isActive = true;
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsHydratingSettings(true);
void getSettings()
void getSettings({ signal: controller.signal })
.then((settings) => {
if (!isActive) return;
const nextVolume = clampVolume(settings.musicVolume);
lastSyncedVolumeRef.current = nextVolume;
setMusicVolumeState(nextVolume);
setSettingsError(null);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
const message =
error instanceof Error ? error.message : '读取远端设置失败';
setSettingsError(message);
console.warn('[useGameSettings] failed to load remote settings', error);
})
.finally(() => {
if (isActive) {
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
setIsHydratingSettings(false);
setHasHydratedSettings(true);
}
});
return () => {
isActive = false;
controller.abort();
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
}
};
}, []);
useEffect(() => () => {
hydrateControllerRef.current?.abort();
persistControllerRef.current?.abort();
persistControllerRef.current = null;
}, []);
useEffect(() => {
if (!hasHydratedSettings) {
return;
@@ -44,21 +78,49 @@ export function useGameSettings() {
return;
}
let isActive = true;
const timeoutId = window.setTimeout(() => {
abortActivePersist();
void putSettings({musicVolume})
.then((settings) => {
if (!isActive) return;
lastSyncedVolumeRef.current = clampVolume(settings.musicVolume);
})
.catch((error) => {
console.warn('[useGameSettings] failed to persist remote settings', error);
});
const requestId = persistRequestIdRef.current + 1;
persistRequestIdRef.current = requestId;
const controller = new AbortController();
persistControllerRef.current = controller;
setIsPersistingSettings(true);
setSettingsError(null);
return () => {
isActive = false;
};
}, [hasHydratedSettings, musicVolume]);
void putSettings({ musicVolume }, { signal: controller.signal })
.then((settings) => {
if (persistRequestIdRef.current !== requestId) {
return;
}
const nextVolume = clampVolume(settings.musicVolume);
lastSyncedVolumeRef.current = nextVolume;
setMusicVolumeState((currentValue) =>
currentValue === nextVolume ? currentValue : nextVolume,
);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
const message =
error instanceof Error ? error.message : '保存远端设置失败';
if (persistRequestIdRef.current === requestId) {
setSettingsError(message);
}
console.warn('[useGameSettings] failed to persist remote settings', error);
})
.finally(() => {
if (persistControllerRef.current === controller) {
persistControllerRef.current = null;
setIsPersistingSettings(false);
}
});
}, SETTINGS_SYNC_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [abortActivePersist, hasHydratedSettings, musicVolume]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));
@@ -67,5 +129,9 @@ export function useGameSettings() {
return {
musicVolume,
setMusicVolume,
hasHydratedSettings,
isHydratingSettings,
isPersistingSettings,
settingsError,
};
}

View File

@@ -0,0 +1,179 @@
import { useEffect } from 'react';
import type { GameShellProps } from '../components/game-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
import { syncGameStatePlayTime } from '../data/runtimeStats';
import { useBackgroundMusic } from './useBackgroundMusic';
import { useCombatFlow } from './useCombatFlow';
import { useGameFlow } from './useGameFlow';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
import { useStoryGeneration } from './useStoryGeneration';
export function useGameShellRuntime(): GameShellProps {
const {
gameState,
setGameState,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
resetGame,
handleCustomWorldSelect: selectCustomWorld,
handleBackToWorldSelect: backToWorldSelect,
handleCharacterSelect: selectCharacter,
} = useGameFlow();
const combatFlow = useCombatFlow({
setGameState,
});
const storyFlow = useStoryGeneration({
gameState,
setGameState,
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
playResolvedChoice: combatFlow.playResolvedChoice,
});
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
const settings = useGameSettings();
const persistence = useGamePersistence({
gameState,
bottomTab,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
setGameState,
setBottomTab,
hydrateStoryState: storyFlow.hydrateStoryState,
resetStoryState: storyFlow.resetStoryState,
});
useBackgroundMusic({
active: Boolean(
gameState.playerCharacter && gameState.currentScene === 'Story',
),
volume: settings.musicVolume,
});
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
const intervalId = window.setInterval(() => {
setGameState((currentState) => {
if (
!currentState.playerCharacter ||
currentState.currentScene !== 'Story'
) {
return currentState;
}
return syncGameStatePlayTime(currentState);
});
}, 15000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
) => {
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile);
};
const handleCharacterSelect = (
character: Parameters<typeof selectCharacter>[0],
) => {
storyFlow.resetStoryState();
selectCharacter(character);
};
const handleBackToWorldSelect = () => {
storyFlow.resetStoryState();
backToWorldSelect();
};
const handleContinueGame = () => {
void persistence.continueSavedGame();
};
const handleStartNewGame = () => {
void persistence.clearSavedGame();
storyFlow.resetStoryState();
resetGame();
};
const handleSaveAndExit = () => {
const syncedGameState = syncGameStatePlayTime(gameState);
void persistence.saveCurrentGame({
gameState: syncedGameState,
bottomTab,
currentStory: storyFlow.currentStory,
});
storyFlow.resetStoryState();
resetGame();
};
const handleBenchCompanion = (npcId: string) => {
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
};
const handleActivateRosterCompanion = (
npcId: string,
swapNpcId?: string | null,
) => {
setGameState((currentState) =>
activateRosterCompanion(currentState, npcId, swapNpcId),
);
};
return {
session: {
gameState,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
aiError: storyFlow.aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
},
story: {
displayedOptions: storyFlow.displayedOptions,
canRefreshOptions: storyFlow.canRefreshOptions,
handleRefreshOptions: storyFlow.handleRefreshOptions,
handleChoice: storyFlow.handleChoice,
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
npcUi: storyFlow.npcUi,
characterChatUi: storyFlow.characterChatUi,
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
goalUi: storyFlow.goalUi,
},
entry: {
hasSavedGame: persistence.hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
},
companions: {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion: handleBenchCompanion,
onActivateRosterCompanion: handleActivateRosterCompanion,
},
audio: {
musicVolume: settings.musicVolume,
onMusicVolumeChange: settings.setMusicVolume,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +1,73 @@
import { useCallback } from 'react';
import { addInventoryItems } from '../data/npcInteractions';
import {
buildTreasureEncounterStoryMoment,
buildTreasureResultText,
resolveTreasureReward,
} from '../data/treasureInteractions';
import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory';
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
import type {CommitGeneratedState} from './generatedState';
type ProgressTreasureQuest = (state: GameState, sceneId: string | null) => GameState;
export function isTreasureEncounter(encounter: GameState['currentEncounter']): encounter is Encounter {
return Boolean(encounter?.kind === 'treasure');
}
export function buildTreasureStory(
state: GameState,
_character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment {
return buildTreasureEncounterStoryMoment({
state,
encounter,
overrideText,
});
}
import { resolveServerRuntimeChoice } from './story/runtimeStoryCoordinator';
import { Character, GameState, StoryMoment, StoryOption } from '../types';
export function useTreasureFlow({
gameState,
commitGeneratedState,
progressTreasureQuest,
runtime,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
progressTreasureQuest: ProgressTreasureQuest;
runtime: {
currentStory: StoryMoment | null;
setGameState: (state: GameState) => void;
setCurrentStory: (story: StoryMoment) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
};
}) {
const handleTreasureInteraction = useCallback((option: StoryOption) => {
if (!gameState.playerCharacter || option.interaction?.kind !== 'treasure' || gameState.currentEncounter?.kind !== 'treasure') {
return false;
}
const handleTreasureInteraction = useCallback(
async (option: StoryOption) => {
if (
!gameState.playerCharacter ||
option.interaction?.kind !== 'treasure' ||
gameState.currentEncounter?.kind !== 'treasure'
) {
return false;
}
const encounter = gameState.currentEncounter;
const action = option.interaction.action;
const reward = action === 'leave'
? null
: resolveTreasureReward(gameState, encounter, action);
const progressedState = action === 'leave'
? gameState
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
runtime.setAiError(null);
runtime.setIsLoading(true);
const nextState: GameState = appendStoryEngineCarrierMemory({
...progressedState,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: progressedState.animationState,
scrollWorld: false,
inBattle: false,
playerHp: reward
? Math.min(progressedState.playerMaxHp, progressedState.playerHp + reward.hp)
: progressedState.playerHp,
playerMana: reward
? Math.min(progressedState.playerMaxMana, progressedState.playerMana + reward.mana)
: progressedState.playerMana,
playerCurrency: reward
? progressedState.playerCurrency + reward.currency
: progressedState.playerCurrency,
playerInventory: reward
? addInventoryItems(progressedState.playerInventory, reward.items)
: progressedState.playerInventory,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, reward?.items ?? []);
try {
const { hydratedSnapshot, nextStory } =
await resolveServerRuntimeChoice({
gameState,
currentStory: runtime.currentStory,
option,
});
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildTreasureResultText(encounter, action, reward ?? undefined),
option.functionId,
);
return true;
}, [commitGeneratedState, gameState, progressTreasureQuest]);
runtime.setGameState(hydratedSnapshot.gameState);
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error(
'Failed to resolve treasure runtime action on the server:',
error,
);
runtime.setAiError(
error instanceof Error ? error.message : '宝藏动作执行失败',
);
if (!runtime.currentStory) {
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(
gameState,
gameState.playerCharacter,
),
);
}
return false;
} finally {
runtime.setIsLoading(false);
}
},
[gameState, runtime],
);
return {
handleTreasureInteraction,