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, interaction?: StoryOption['interaction'], ): StoryOption { return { functionId, actionText, text: actionText, interaction, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }; } function createEncounter(overrides: Partial = {}): Encounter { return { id: 'camp-companion', kind: 'npc', characterId: 'sword-princess', 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 { 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('眼下的风向不对,我们不能直接把底牌亮出来。'); expect(text).toContain('我真正要找的东西,还不能让更多人知道。'); expect(text).not.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 the opening camp options focused on继续交谈', () => { 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']); }); it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => { const baseOptions = [ createOption('npc_chat', '继续交谈', { kind: 'npc', npcId: 'camp-companion', action: 'chat', }), createOption('camp_travel_home_scene', '前往旧地点'), ]; const generateNextStep = vi .fn() .mockResolvedValueOnce({ storyText: '继续营地交谈', options: [ createOption('npc_chat', '顺着刚才的话继续问下去'), createOption('camp_travel_home_scene', '先回云河渡'), ], }) .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).toEqual([ expect.objectContaining({ functionId: 'npc_chat', actionText: '顺着刚才的话继续问下去', interaction: { kind: 'npc', npcId: 'camp-companion', action: 'chat', }, }), expect.objectContaining({ functionId: 'camp_travel_home_scene', actionText: '先回云河渡', }), ]); 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', ]); }); });