import { describe, expect, it, vi } from 'vitest'; vi.mock('../../services/ai', () => ({ generateNextStep: vi.fn(), })); import { generateNextStep } from '../../services/ai'; import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types'; import { createStoryChoiceActions } from './choiceActions'; 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'): StoryMoment { return { text, options: [], }; } const neverNpcEncounter = ( encounter: GameState['currentEncounter'], ): encounter is Encounter => false; describe('createStoryChoiceActions', () => { it('keeps the finishing action in history before npc victory follow-up generation', async () => { const state = createBaseState(); const option = createBattleOption(); const afterSequence = { ...state, inBattle: false, sceneHostileNpcs: [], currentNpcBattleOutcome: 'fight_victory' as const, }; const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写')); const setCurrentStory = vi.fn(); const setGameState = vi.fn(); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), isLoading: false, setGameState, setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(() => ({ optionKind: 'battle' as const, battlePlan: null, afterSequence, })), playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), buildStoryContextFromState: vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: false, playerX: 0, playerFacing: 'right', playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })), 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()), 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(() => ({ nextState: { ...afterSequence, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, inBattle: false, }, resultText: '山道客已经败下阵来。胜利奖励:无战利品。', })), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(generateStoryForState).toHaveBeenCalledTimes(1); const [{ history }] = generateStoryForState.mock.calls[0] as [ { history: StoryMoment[] }, ]; expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([ 'action:挥刀抢攻', 'result:山道客已经败下阵来。胜利奖励:无战利品。', ]); expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写')); }); it('injects an escape resolution into the immediate story context before ai continuation', async () => { const mockedGenerateNextStep = vi.mocked(generateNextStep); mockedGenerateNextStep.mockResolvedValue({ storyText: '你落到山道外侧,呼吸总算稳了下来。', options: [], }); const state = { ...createBaseState(), currentBattleNpcId: null, currentNpcBattleMode: null, 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 = createBattleOption('battle_escape_breakout'); const afterSequence = { ...state, inBattle: false, sceneHostileNpcs: [], playerX: -1.2, }; const setBattleReward = vi.fn(); const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState); const buildStoryContextFromState = vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: false, playerX: -1.2, playerFacing: 'right' as const, playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), isLoading: false, setGameState: vi.fn(), setCurrentStory: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward, buildResolvedChoiceState: vi.fn(() => ({ optionKind: 'escape' as const, battlePlan: null, afterSequence, })), playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), buildStoryContextFromState, 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, 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: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1); const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[]; expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([ 'action:挥刀抢攻', 'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。', ]); expect(buildStoryContextFromState).toHaveBeenCalledWith( expect.objectContaining({ inBattle: false, sceneHostileNpcs: [], }), expect.objectContaining({ lastFunctionId: 'battle_escape_breakout', recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。', }), ); expect(setBattleReward).toHaveBeenCalledTimes(1); expect(setBattleReward).toHaveBeenCalledWith(null); expect(incrementRuntimeStats).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ hostileNpcsDefeated: 0 }), ); }); });