import { describe, expect, it, vi } from 'vitest'; const { scenes } = vi.hoisted(() => ({ scenes: [ { id: 'scene-1', name: 'Camp', description: 'A quiet camp.', imageSrc: '/camp.png', connectedSceneIds: ['scene-2'], monsterIds: [], npcs: [], treasureHints: [], }, { id: 'scene-2', name: 'Trail', description: 'A mountain trail.', imageSrc: '/trail.png', connectedSceneIds: ['scene-1'], monsterIds: [], npcs: [], treasureHints: [], }, ], })); vi.mock('../../data/scenePresets', () => ({ getScenePresetById: (_worldType: unknown, sceneId: string) => scenes.find(scene => scene.id === sceneId) ?? null, getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [], getSceneHostileNpcs: () => [], getScenePresetsByWorld: () => scenes, getWorldCampScenePreset: () => scenes[0] ?? null, })); import { buildInitialNpcState, MAX_COMPANIONS, } from '../../data/npcInteractions'; import { getScenePresetsByWorld } from '../../data/scenePresets'; import { AnimationState, type Character, type CompanionState, type Encounter, type GameState, type InventoryItem, type StoryOption, WorldType, } from '../../types'; import { buildMapTravelResolution, resolveNpcInteractionDecision, } from './storyGenerationState'; 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 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 createEncounter(): Encounter { return { id: 'npc-trader', kind: 'npc', npcName: 'Trader Lin', npcDescription: 'A traveling merchant.', npcAvatar: 'T', context: 'merchant', }; } function createCompanion(npcId: string): CompanionState { return { npcId, characterId: `character-${npcId}`, joinedAtAffinity: 10, hp: 10, maxHp: 10, mana: 5, maxMana: 5, skillCooldowns: {}, }; } function createBaseState(): GameState { const scenes = getScenePresetsByWorld(WorldType.WUXIA); 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: createEncounter(), npcInteractionActive: false, currentScenePreset: scenes[0] ?? null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 10, playerInventory: [createInventoryItem('player-potion', 'Potion')], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: { 'npc-trader': { ...buildInitialNpcState(createEncounter(), WorldType.WUXIA), inventory: [createInventoryItem('npc-herb', 'Herb')], }, }, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function createInteractionOption(action: Extract, { kind: 'npc' }>['action']): StoryOption { return { functionId: `npc_${action}`, actionText: action, text: action, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc', npcId: 'npc-trader', action, }, }; } describe('storyGenerationState', () => { it('opens the trade modal with the first npc and player inventory items selected', () => { const decision = resolveNpcInteractionDecision( createBaseState(), createInteractionOption('trade'), ); expect(decision.kind).toBe('trade_modal'); if (decision.kind !== 'trade_modal') { throw new Error('Expected trade modal decision'); } expect(decision.modal.selectedNpcItemId).toBe('npc-herb'); expect(decision.modal.selectedPlayerItemId).toBe('player-potion'); expect(decision.modal.selectedQuantity).toBe(1); }); it('skips zero-quantity player items when opening the trade modal', () => { const decision = resolveNpcInteractionDecision( { ...createBaseState(), playerInventory: [ createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }), createInventoryItem('player-herb', 'Herb'), ], }, createInteractionOption('trade'), ); expect(decision.kind).toBe('trade_modal'); if (decision.kind !== 'trade_modal') { throw new Error('Expected trade modal decision'); } expect(decision.modal.selectedPlayerItemId).toBe('player-herb'); }); it('forces a recruit replacement modal when the active party is full', () => { const state = { ...createBaseState(), companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)), }; const decision = resolveNpcInteractionDecision( state, createInteractionOption('recruit'), ); expect(decision.kind).toBe('recruit_modal'); if (decision.kind !== 'recruit_modal') { throw new Error('Expected recruit modal decision'); } expect(decision.modal.selectedReleaseNpcId).toBe('npc-1'); }); it('opens the gift modal with the preferred gift candidate selected', () => { const state = { ...createBaseState(), playerInventory: [ createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }), createInventoryItem('jade-token', 'Jade Token', { rarity: 'rare', category: '专属', tags: ['merchant'], }), ], }; const decision = resolveNpcInteractionDecision( state, createInteractionOption('gift'), ); expect(decision.kind).toBe('gift_modal'); if (decision.kind !== 'gift_modal') { throw new Error('Expected gift modal decision'); } expect(decision.modal.selectedItemId).toBe('jade-token'); }); it('does not open the gift modal when there are no gift candidates', () => { const state = { ...createBaseState(), playerInventory: [], }; const decision = resolveNpcInteractionDecision( state, createInteractionOption('gift'), ); expect(decision.kind).toBe('none'); }); it('builds a map travel transition that increments runtime stats and clears battle state', () => { const scenes = getScenePresetsByWorld(WorldType.WUXIA); const sourceScene = scenes[0]; const targetScene = scenes[1]!; const state = { ...createBaseState(), currentScenePreset: sourceScene ?? null, inBattle: true, currentBattleNpcId: 'battle-npc', currentNpcBattleMode: 'fight' as const, currentNpcBattleOutcome: 'fight_victory' as const, sparReturnEncounter: createEncounter(), }; const resolution = buildMapTravelResolution(state, targetScene.id); expect(resolution).not.toBeNull(); if (!resolution) { throw new Error('Expected map travel resolution'); } expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id); expect(resolution.nextState.npcInteractionActive).toBe(false); expect(resolution.nextState.inBattle).toBe(false); expect(resolution.nextState.currentBattleNpcId).toBeNull(); expect(resolution.nextState.currentNpcBattleMode).toBeNull(); expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1); }); });