This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

@@ -398,6 +398,88 @@ describe('createStoryChoiceActions', () => {
});
});
it('keeps the deferred auto choice for the scene transition model to trigger after entry', async () => {
const state = {
...createBaseState(),
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const autoChoice = createBattleOption('npc_preview_talk');
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
text: '继续冒险',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions: [autoChoice],
deferredAutoChoice: autoChoice,
};
const setCurrentStory = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
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()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
options: [autoChoice],
deferredOptions: undefined,
deferredAutoChoice: autoChoice,
}),
);
});
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');

View File

@@ -1,5 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
import {
@@ -185,8 +186,13 @@ export function createStoryChoiceActions({
currentStory?.deferredOptions?.length &&
isContinueAdventureOption(option)
) {
const deferredAutoChoice =
currentStory.deferredAutoChoice &&
currentStory.deferredOptions.includes(currentStory.deferredAutoChoice)
? currentStory.deferredAutoChoice
: undefined;
if (currentStory.deferredRuntimeState) {
setGameState({
const restoredState = ensureSceneEncounterPreview({
...gameState,
currentEncounter: null,
npcInteractionActive: false,
@@ -202,12 +208,15 @@ export function createStoryChoiceActions({
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
setGameState(restoredState);
}
setCurrentStory({
...currentStory,
options: currentStory.deferredOptions,
deferredOptions: undefined,
deferredRuntimeState: undefined,
deferredAutoChoice,
});
return;
}

View File

@@ -25,6 +25,7 @@ import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
type RpgRuntimeInteractionFlowParams = {
gameState: GameState;
isLoading: boolean;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
interactionConfig: StoryInteractionCoordinatorConfig;
runtimeSupport: StoryRuntimeSupport;
buildResolvedChoiceState: (
@@ -76,6 +77,7 @@ export function createClearStoryInteractionUi(params: {
export function useRpgRuntimeInteractionFlow({
gameState,
isLoading,
sceneTransitionPhase = 'idle',
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,
@@ -117,7 +119,15 @@ export function useRpgRuntimeInteractionFlow({
});
useEffect(() => {
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
const pendingAutoChoice =
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice;
if (
isLoading ||
sceneTransitionPhase !== 'idle' ||
pendingAutoChoice ||
gameState.inBattle ||
gameState.npcInteractionActive
) {
return;
}
@@ -134,8 +144,10 @@ export function useRpgRuntimeInteractionFlow({
gameState.currentEncounter,
gameState.inBattle,
gameState.npcInteractionActive,
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice,
isLoading,
isNpcEncounter,
sceneTransitionPhase,
]);
const choiceRuntimeController: Parameters<

View File

@@ -25,8 +25,8 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
resolveLimitedPrimaryNpcChatState,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
@@ -1730,6 +1730,14 @@ export function createStoryNpcEncounterActions({
deferredOptions: progressionResult?.options,
deferredRuntimeState:
progressionResult?.deferredRuntimeState ?? undefined,
deferredAutoChoice:
progressionResult?.options.find(
(option) => option.functionId === 'npc_preview_talk',
) ??
progressionResult?.options.find(
(option) => option.functionId === 'npc_chat',
) ??
undefined,
});
return true;
}

View File

@@ -56,11 +56,13 @@ export type {
export function useRpgRuntimeStory({
gameState,
setGameState,
sceneTransitionPhase = 'idle',
buildResolvedChoiceState,
playResolvedChoice,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
@@ -108,6 +110,7 @@ export function useRpgRuntimeStory({
} = useRpgRuntimeStoryFlow({
gameState,
setGameState,
sceneTransitionPhase,
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs,

View File

@@ -14,6 +14,7 @@ import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
type RpgRuntimeStoryFlowParams = {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
@@ -61,6 +62,7 @@ type RpgRuntimeStoryFlowParams = {
export function useRpgRuntimeStoryFlow({
gameState,
setGameState,
sceneTransitionPhase = 'idle',
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs,
@@ -148,6 +150,7 @@ export function useRpgRuntimeStoryFlow({
} = useRpgRuntimeInteractionFlow({
gameState,
isLoading,
sceneTransitionPhase,
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,