import { describe, expect, it } from 'vitest'; import type { Character, Encounter, GameState, InventoryItem } from '../types'; import { AnimationState, WorldType } from '../types'; import { buildGiftCandidateSummary, buildInitialNpcState, buildNpcEncounterStoryMoment, buildNpcHelpReward, buildNpcTradeTransactionActionText, syncNpcTradeInventory, } from './npcInteractions'; function createCharacter(): Character { return { id: 'hero', name: 'Hero', title: 'Wanderer', description: 'A reliable test hero.', backstory: 'Travels the land.', avatar: '/hero.png', portrait: '/hero-portrait.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 9, intelligence: 8, spirit: 7, }, personality: 'steady', skills: [], adventureOpenings: {}, }; } function createEncounter(): Encounter { return { id: 'npc-trader', kind: 'npc', npcName: 'Trader Lin', npcDescription: 'A traveling merchant.', npcAvatar: 'T', context: 'merchant', }; } function createInventoryItem( id: string, name: string, overrides: Partial = {}, ): InventoryItem { return { id, name, description: `${name} description`, quantity: 1, category: 'misc', rarity: 'common', tags: [], value: 1, ...overrides, }; } function createGameState( encounter: Encounter, 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: encounter, npcInteractionActive: true, currentScenePreset: { id: 'scene-camp', name: 'Camp', description: 'A temporary camp.', imageSrc: '/camp.png', npcs: [], treasureHints: [], }, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 80, playerMaxHp: 100, playerMana: 40, playerMaxMana: 60, playerSkillCooldowns: {}, activeBuildBuffs: [], 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, }; } describe('npcInteractions', () => { it('builds a readable fallback summary for empty gift candidates', () => { expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物'); }); it('includes gift candidate context in the npc gift option detail text', () => { const encounter = createEncounter(); const story = buildNpcEncounterStoryMoment({ encounter, npcState: buildInitialNpcState(encounter, WorldType.WUXIA), playerCharacter: createCharacter(), playerInventory: [ createInventoryItem('jade-token', 'Jade Token', { rarity: 'rare', category: '专属', tags: ['merchant'], }), createInventoryItem('tea-brick', 'Tea Brick'), ], activeQuests: [], scene: { id: 'scene-1', name: 'Camp', npcs: [], treasureHints: [], }, worldType: WorldType.WUXIA, partySize: 0, }); const giftOption = story.options.find((option) => option.functionId === 'npc_gift'); expect(giftOption).toBeTruthy(); expect(giftOption?.detailText).toContain('Jade Token'); expect(giftOption?.detailText).toContain('Tea Brick'); }); it('omits the npc gift option when the player has no gift candidates', () => { const encounter = createEncounter(); const story = buildNpcEncounterStoryMoment({ encounter, npcState: buildInitialNpcState(encounter, WorldType.WUXIA), playerCharacter: createCharacter(), playerInventory: [], activeQuests: [], scene: { id: 'scene-1', name: 'Camp', npcs: [], treasureHints: [], }, worldType: WorldType.WUXIA, partySize: 0, }); expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false); }); it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => { const encounter = createEncounter(); const story = buildNpcEncounterStoryMoment({ encounter, npcState: buildInitialNpcState(encounter, WorldType.WUXIA), playerCharacter: createCharacter(), playerInventory: [], activeQuests: [], scene: { id: 'scene-ruins', name: '遗迹外缘', npcs: [], treasureHints: ['半截封泥'], }, worldType: WorldType.WUXIA, partySize: 0, }); const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept'); expect(questOption).toBeTruthy(); expect(questOption?.detailText).toContain('AI 剧情引擎'); expect(questOption?.detailText).not.toContain('完成后可获得'); }); it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => { const encounter = createEncounter(); const hostileState = { ...buildInitialNpcState(encounter, WorldType.WUXIA), affinity: -12, }; const story = buildNpcEncounterStoryMoment({ encounter, npcState: hostileState, playerCharacter: createCharacter(), playerInventory: [], activeQuests: [], scene: { id: 'scene-pass', name: '断桥口', npcs: [], treasureHints: [], }, worldType: WorldType.WUXIA, partySize: 0, }); expect(story.displayMode).toBe('dialogue'); expect(story.dialogue).toEqual([ expect.objectContaining({ speaker: 'npc', speakerName: 'Trader Lin', }), ]); expect(story.options.map((option) => option.functionId)).toEqual([ 'battle_escape_breakout', 'npc_fight', ]); expect(story.options.map((option) => option.actionText)).toEqual([ '逃跑', '与他对战', ]); }); it('builds concrete trade action text for story continuation', () => { const encounter = createEncounter(); expect( buildNpcTradeTransactionActionText({ encounter, mode: 'buy', item: createInventoryItem('jade-token', 'Jade Token'), quantity: 2, }), ).toBe('从Trader Lin手里买下Jade Token x2'); expect( buildNpcTradeTransactionActionText({ encounter, mode: 'sell', item: createInventoryItem('tea-brick', 'Tea Brick'), quantity: 1, }), ).toBe('把Tea Brick卖给Trader Lin'); }); it('syncs generic trade stock to the current build while preserving sold-in items', () => { const encounter: Encounter = { ...createEncounter(), context: '商贩', }; const state = createGameState(encounter); const syncedState = syncNpcTradeInventory(state, encounter, { ...buildInitialNpcState(encounter, WorldType.WUXIA), inventory: [createInventoryItem('sold-tea', 'Tea Brick')], tradeStockSignature: 'stale-build', }); expect(syncedState.tradeStockSignature).not.toBe('stale-build'); expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true); expect( syncedState.inventory.some( item => item.runtimeMetadata?.generationChannel === 'npc_trade', ), ).toBe(true); }); it('builds npc help rewards from the runtime director', () => { const encounter: Encounter = { ...createEncounter(), context: '商贩', }; const reward = buildNpcHelpReward(encounter, createGameState(encounter)); expect(reward.items.length).toBeGreaterThan(0); expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward'); expect(reward.storyHint).toBeTruthy(); }); });