815 lines
26 KiB
TypeScript
815 lines
26 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../services/aiService', () => ({
|
|
generateNextStep: vi.fn(),
|
|
}));
|
|
|
|
const {
|
|
isRpgRuntimeServerFunctionIdMock,
|
|
runServerRuntimeChoiceActionMock,
|
|
} = vi.hoisted(() => ({
|
|
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
|
runServerRuntimeChoiceActionMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../services/rpg-runtime', () => ({
|
|
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
|
}));
|
|
|
|
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
|
import { createStoryChoiceActions } from './choiceActions';
|
|
|
|
vi.mock('./storyChoiceRuntime', async () => {
|
|
return {
|
|
runCampTravelHomeChoice: vi.fn(),
|
|
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
|
|
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
|
|
(
|
|
option.interaction?.kind === 'npc' ||
|
|
!option.interaction
|
|
) &&
|
|
(
|
|
option.functionId === 'npc_chat' ||
|
|
option.functionId === 'npc_trade' ||
|
|
option.functionId === 'npc_gift'
|
|
),
|
|
};
|
|
});
|
|
|
|
function createTestCharacter(): Character {
|
|
return {
|
|
id: 'test-hero',
|
|
name: '测试主角',
|
|
title: '游侠',
|
|
description: '一名测试用主角',
|
|
backstory: '测试背景',
|
|
avatar: '/hero.png',
|
|
portrait: '/hero-portrait.png',
|
|
assetFolder: 'hero',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 10,
|
|
intelligence: 10,
|
|
spirit: 10,
|
|
},
|
|
personality: 'calm',
|
|
skills: [
|
|
{
|
|
id: 'skill-basic',
|
|
name: '试探一击',
|
|
animation: AnimationState.ATTACK,
|
|
damage: 10,
|
|
manaCost: 0,
|
|
cooldownTurns: 1,
|
|
range: 1,
|
|
style: 'steady',
|
|
},
|
|
],
|
|
adventureOpenings: {},
|
|
};
|
|
}
|
|
|
|
function createBaseState(): GameState {
|
|
return {
|
|
worldType: WorldType.WUXIA,
|
|
customWorldProfile: null,
|
|
playerCharacter: createTestCharacter(),
|
|
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: true,
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
playerSkillCooldowns: {},
|
|
activeCombatEffects: [],
|
|
playerCurrency: 0,
|
|
playerInventory: [],
|
|
playerEquipment: {
|
|
weapon: null,
|
|
armor: null,
|
|
relic: null,
|
|
},
|
|
npcStates: {
|
|
'npc-opponent': {
|
|
affinity: 0,
|
|
helpUsed: false,
|
|
chattedCount: 0,
|
|
giftsGiven: 0,
|
|
inventory: [],
|
|
recruited: false,
|
|
},
|
|
},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: 'npc-opponent',
|
|
currentNpcBattleMode: 'fight',
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
}
|
|
|
|
function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
|
|
return {
|
|
functionId,
|
|
actionText: '挥刀抢攻',
|
|
text: '挥刀抢攻',
|
|
visuals: {
|
|
playerAnimation: AnimationState.ATTACK,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFallbackStory(
|
|
text = 'fallback',
|
|
options: StoryOption[] = [],
|
|
): StoryMoment {
|
|
return {
|
|
text,
|
|
options,
|
|
};
|
|
}
|
|
|
|
const neverNpcEncounter = (
|
|
encounter: GameState['currentEncounter'],
|
|
): encounter is Encounter => false;
|
|
|
|
describe('createStoryChoiceActions', () => {
|
|
beforeEach(() => {
|
|
isRpgRuntimeServerFunctionIdMock.mockReset();
|
|
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
|
runServerRuntimeChoiceActionMock.mockReset();
|
|
});
|
|
|
|
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
inBattle: false,
|
|
sceneHostileNpcs: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
};
|
|
const deferredOptions = [
|
|
{
|
|
functionId: 'idle_explore_forward',
|
|
actionText: '继续向前探索',
|
|
text: '继续向前探索',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right' as const,
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
] satisfies StoryOption[];
|
|
const continueOption: StoryOption = {
|
|
functionId: 'story_continue_adventure',
|
|
actionText: '查看后续',
|
|
text: '查看后续',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right' as const,
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
};
|
|
const currentStory: StoryMoment = {
|
|
text: '对话已经完成',
|
|
options: [continueOption],
|
|
deferredOptions,
|
|
};
|
|
const setCurrentStory = vi.fn();
|
|
const generateStoryForState = vi.fn();
|
|
const handleNpcInteraction = 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,
|
|
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,
|
|
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({
|
|
...currentStory,
|
|
options: deferredOptions,
|
|
deferredOptions: undefined,
|
|
});
|
|
expect(generateStoryForState).not.toHaveBeenCalled();
|
|
expect(handleNpcInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies deferred runtime state when story_continue_adventure reveals the next act', async () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
inBattle: false,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
};
|
|
const deferredOptions = [
|
|
{
|
|
functionId: 'idle_observe_signs',
|
|
actionText: '观察下一幕的线索',
|
|
text: '观察下一幕的线索',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right' as const,
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
] satisfies StoryOption[];
|
|
const continueOption: StoryOption = {
|
|
functionId: 'story_continue_adventure',
|
|
actionText: '继续冒险',
|
|
text: '继续冒险',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right' as const,
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
};
|
|
const currentStory: StoryMoment = {
|
|
text: '对话已经完成',
|
|
options: [continueOption],
|
|
deferredOptions,
|
|
deferredRuntimeState: {
|
|
currentScenePreset: {
|
|
id: 'scene-bridge',
|
|
name: '断桥',
|
|
description: '桥上雾气很重。',
|
|
imageSrc: '/scene-bridge.png',
|
|
treasureHints: [],
|
|
npcs: [],
|
|
},
|
|
},
|
|
};
|
|
const setCurrentStory = vi.fn();
|
|
const setGameState = vi.fn();
|
|
|
|
const { handleChoice } = createStoryChoiceActions({
|
|
gameState: state,
|
|
currentStory,
|
|
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()),
|
|
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(setGameState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
currentScenePreset: expect.objectContaining({
|
|
id: 'scene-bridge',
|
|
}),
|
|
}),
|
|
);
|
|
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
|
|
'storyEngineMemory',
|
|
);
|
|
expect(setCurrentStory).toHaveBeenCalledWith({
|
|
...currentStory,
|
|
options: deferredOptions,
|
|
deferredOptions: undefined,
|
|
deferredRuntimeState: undefined,
|
|
});
|
|
});
|
|
|
|
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');
|
|
const setGameState = vi.fn();
|
|
const setCurrentStory = vi.fn();
|
|
const handleNpcInteraction = vi.fn(() => true);
|
|
|
|
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
|
|
|
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()),
|
|
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,
|
|
handleTreasureInteraction: vi.fn(() => false),
|
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
|
finalizeNpcBattleResult: vi.fn(() => null),
|
|
isContinueAdventureOption: vi.fn(() => false),
|
|
isCampTravelHomeOption: vi.fn(() => false),
|
|
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(
|
|
expect.objectContaining({
|
|
functionId: 'npc_chat',
|
|
}),
|
|
);
|
|
expect(setGameState).not.toHaveBeenCalled();
|
|
expect(setCurrentStory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
|
|
isRpgRuntimeServerFunctionIdMock.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()),
|
|
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,
|
|
handleTreasureInteraction: vi.fn(() => false),
|
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
|
finalizeNpcBattleResult: vi.fn(() => null),
|
|
isContinueAdventureOption: vi.fn(() => false),
|
|
isCampTravelHomeOption: vi.fn(() => false),
|
|
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);
|
|
});
|
|
|
|
it('routes battle attack and skill choices to the backend resolver even while in battle', async () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'wolf-1',
|
|
name: '山狼',
|
|
action: '低伏逼近',
|
|
description: '一头山狼',
|
|
animation: 'idle' as const,
|
|
xMeters: 3.2,
|
|
yOffset: 0,
|
|
facing: 'left' as const,
|
|
attackRange: 1.4,
|
|
speed: 7,
|
|
hp: 10,
|
|
maxHp: 10,
|
|
renderKind: 'npc' as const,
|
|
},
|
|
],
|
|
};
|
|
const option: StoryOption = {
|
|
...createBattleOption('battle_use_skill'),
|
|
runtimePayload: {
|
|
skillId: 'skill-basic',
|
|
},
|
|
};
|
|
const setGameState = vi.fn();
|
|
const setCurrentStory = vi.fn();
|
|
const buildResolvedChoiceState = vi.fn(() => ({
|
|
optionKind: 'battle' as const,
|
|
battlePlan: null,
|
|
afterSequence: state,
|
|
}));
|
|
const playResolvedChoice = vi.fn().mockResolvedValue(state);
|
|
|
|
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
|
|
|
const { handleChoice } = createStoryChoiceActions({
|
|
gameState: state,
|
|
currentStory: createFallbackStory(),
|
|
isLoading: false,
|
|
setGameState,
|
|
setCurrentStory,
|
|
setAiError: vi.fn(),
|
|
setIsLoading: vi.fn(),
|
|
setBattleReward: vi.fn(),
|
|
buildResolvedChoiceState,
|
|
playResolvedChoice,
|
|
buildStoryContextFromState: vi.fn(() => ({
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
inBattle: true,
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
playerAnimation: AnimationState.IDLE,
|
|
skillCooldowns: {},
|
|
})),
|
|
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
|
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
|
generateStoryForState: vi.fn(),
|
|
getAvailableOptionsForState: vi.fn(() => [option]),
|
|
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
|
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(() => false),
|
|
handleTreasureInteraction: vi.fn(() => false),
|
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
|
finalizeNpcBattleResult: vi.fn(() => null),
|
|
isContinueAdventureOption: vi.fn(() => false),
|
|
isCampTravelHomeOption: vi.fn(() => false),
|
|
isRegularNpcEncounter: neverNpcEncounter,
|
|
isNpcEncounter: neverNpcEncounter,
|
|
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
|
fallbackCompanionName: '同伴',
|
|
turnVisualMs: 820,
|
|
});
|
|
|
|
await handleChoice(option);
|
|
|
|
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
gameState: state,
|
|
option,
|
|
character: state.playerCharacter,
|
|
}),
|
|
);
|
|
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
|
expect(playResolvedChoice).not.toHaveBeenCalled();
|
|
expect(setGameState).not.toHaveBeenCalled();
|
|
expect(setCurrentStory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => {
|
|
const battleOption = createBattleOption('battle_attack_basic');
|
|
const state = {
|
|
...createBaseState(),
|
|
inBattle: false,
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'wolf-1',
|
|
name: '山狼',
|
|
action: '低伏逼近',
|
|
description: '一头山狼',
|
|
animation: 'idle' as const,
|
|
xMeters: 3.2,
|
|
yOffset: 0,
|
|
facing: 'left' as const,
|
|
attackRange: 1.4,
|
|
speed: 7,
|
|
hp: 10,
|
|
maxHp: 10,
|
|
renderKind: 'npc' as const,
|
|
},
|
|
],
|
|
};
|
|
const currentStory: StoryMoment = {
|
|
text: '山狼还在你面前压低身位,战斗并未真正结束。',
|
|
options: [battleOption],
|
|
};
|
|
const buildResolvedChoiceState = vi.fn(() => ({
|
|
optionKind: 'battle' as const,
|
|
battlePlan: null,
|
|
afterSequence: {
|
|
...state,
|
|
inBattle: true,
|
|
},
|
|
}));
|
|
const playResolvedChoice = vi.fn().mockResolvedValue({
|
|
...state,
|
|
inBattle: true,
|
|
});
|
|
|
|
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
|
|
|
const { handleChoice } = createStoryChoiceActions({
|
|
gameState: state,
|
|
currentStory,
|
|
isLoading: false,
|
|
setGameState: vi.fn(),
|
|
setCurrentStory: vi.fn(),
|
|
setAiError: vi.fn(),
|
|
setIsLoading: vi.fn(),
|
|
setBattleReward: vi.fn(),
|
|
buildResolvedChoiceState,
|
|
playResolvedChoice,
|
|
buildStoryContextFromState: vi.fn(() => ({
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
inBattle: true,
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
playerAnimation: AnimationState.IDLE,
|
|
skillCooldowns: {},
|
|
})),
|
|
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
|
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
|
generateStoryForState: vi.fn(),
|
|
getAvailableOptionsForState: vi.fn(() => [battleOption]),
|
|
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
|
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(() => false),
|
|
handleTreasureInteraction: vi.fn(() => false),
|
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
|
finalizeNpcBattleResult: vi.fn(() => null),
|
|
isContinueAdventureOption: vi.fn(() => false),
|
|
isCampTravelHomeOption: vi.fn(() => false),
|
|
isRegularNpcEncounter: neverNpcEncounter,
|
|
isNpcEncounter: neverNpcEncounter,
|
|
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
|
fallbackCompanionName: '同伴',
|
|
turnVisualMs: 820,
|
|
});
|
|
|
|
await handleChoice(battleOption);
|
|
|
|
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
gameState: state,
|
|
currentStory,
|
|
option: battleOption,
|
|
character: state.playerCharacter,
|
|
}),
|
|
);
|
|
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
|
expect(playResolvedChoice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes inventory_use combat choices to the backend resolver', async () => {
|
|
const state = createBaseState();
|
|
const option: StoryOption = {
|
|
...createBattleOption('inventory_use'),
|
|
runtimePayload: {
|
|
itemId: 'focus-tonic',
|
|
},
|
|
};
|
|
const buildResolvedChoiceState = vi.fn();
|
|
const playResolvedChoice = vi.fn();
|
|
|
|
isRpgRuntimeServerFunctionIdMock.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,
|
|
playResolvedChoice,
|
|
buildStoryContextFromState: vi.fn(),
|
|
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
|
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
|
generateStoryForState: vi.fn(),
|
|
getAvailableOptionsForState: vi.fn(() => [option]),
|
|
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
|
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(() => false),
|
|
handleTreasureInteraction: vi.fn(() => false),
|
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
|
finalizeNpcBattleResult: vi.fn(() => null),
|
|
isContinueAdventureOption: vi.fn(() => false),
|
|
isCampTravelHomeOption: vi.fn(() => false),
|
|
isRegularNpcEncounter: neverNpcEncounter,
|
|
isNpcEncounter: neverNpcEncounter,
|
|
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
|
fallbackCompanionName: '同伴',
|
|
turnVisualMs: 820,
|
|
});
|
|
|
|
await handleChoice(option);
|
|
|
|
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
gameState: state,
|
|
option,
|
|
character: state.playerCharacter,
|
|
}),
|
|
);
|
|
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
|
expect(playResolvedChoice).not.toHaveBeenCalled();
|
|
});
|
|
});
|