import { describe, expect, it } from 'vitest'; import { resolveActiveSceneActEncounterNpcIds } from '../services/customWorldSceneActRuntime'; import { AnimationState, type Character, type CustomWorldProfile, type Encounter, type GameState, type SceneNpc, WorldType, } from '../types'; import { getMonsterPresetsByWorld } from './hostileNpcPresets'; import { createSceneHostileNpc } from './hostileNpcs'; import { buildInitialNpcState } from './npcInteractions'; import { createSceneEncounterPreview, hasAutoBattleSceneEncounter, resolveSceneEncounterPreview, } from './sceneEncounterPreviews'; 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', initialAffinity: 12, hostile: false, }; } function createBaseState(): GameState { const encounter = createEncounter(); 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: false, currentScenePreset: { id: 'scene-1', name: 'Trail', description: 'A mountain trail.', imageSrc: '/trail.png', connectedSceneIds: [], npcs: [], treasureHints: [], }, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: { [encounter.id!]: { ...buildInitialNpcState(encounter, WorldType.WUXIA), affinity: -5, }, }, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } describe('sceneEncounterPreviews', () => { it('treats negative-affinity npc encounters as immediate battles', () => { const state = createBaseState(); expect(hasAutoBattleSceneEncounter(state)).toBe(true); const resolved = resolveSceneEncounterPreview(state); expect(resolved.inBattle).toBe(true); expect(resolved.currentEncounter).toBeNull(); expect(resolved.currentBattleNpcId).toBe('npc-trader'); expect(resolved.currentNpcBattleMode).toBe('fight'); expect(resolved.sceneHostileNpcs).toHaveLength(1); expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin'); }); it('attaches npc encounter metadata to regular monsters', () => { const monsterId = getMonsterPresetsByWorld(WorldType.WUXIA)[0]?.id; if (!monsterId) { throw new Error('Expected at least one monster preset'); } const monster = createSceneHostileNpc(WorldType.WUXIA, monsterId); expect(monster).not.toBeNull(); expect(monster?.encounter?.kind).toBe('npc'); expect(monster?.encounter?.monsterPresetId).toBe(monsterId); expect(monster?.encounter?.hostile).toBe(true); expect(monster?.encounter?.initialAffinity).toBe(-40); }); it('resolves active act npc ids when runtime scene id differs from landmark id', () => { const profile = { id: 'custom-profile', name: '测试世界', settingText: '', subtitle: '', summary: '', tone: '', playerGoal: '', templateWorldType: WorldType.WUXIA, majorFactions: [], coreConflicts: [], attributeSchema: { attributes: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [ { id: 'landmark-raw-1', name: '旧桥', description: '旧桥', sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], connections: [], }, ], sceneChapterBlueprints: [ { id: 'chapter-1', sceneId: 'landmark-raw-1', title: '旧桥章节', summary: '', sceneTaskDescription: '', linkedThreadIds: [], linkedLandmarkIds: ['landmark-raw-1'], acts: [ { id: 'act-1', sceneId: 'landmark-raw-1', title: '第一幕', summary: '', stageCoverage: ['opening'], encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], primaryNpcId: 'npc-front', oppositeNpcId: 'npc-front', eventDescription: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '', transitionHook: '', }, ], }, ], } as CustomWorldProfile; expect( resolveActiveSceneActEncounterNpcIds({ profile, sceneId: 'custom-scene-landmark-1', }), ).toEqual(['npc-front', 'npc-back-1', 'npc-back-2']); }); it('resolves active act npc ids from act scene id even when chapter scene id is abstract', () => { const profile = { id: 'custom-profile', name: '测试世界', settingText: '', subtitle: '', summary: '', tone: '', playerGoal: '', templateWorldType: WorldType.WUXIA, majorFactions: [], coreConflicts: [], attributeSchema: { attributes: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [ { id: 'landmark-raw-1', name: '旧桥', description: '旧桥', sceneNpcIds: [], connections: [], }, ], sceneChapterBlueprints: [ { id: 'chapter-1', sceneId: 'chapter-abstract-scene', title: '旧桥章节', summary: '', sceneTaskDescription: '', linkedThreadIds: [], linkedLandmarkIds: [], acts: [ { id: 'act-1', sceneId: 'landmark-raw-1', title: '第一幕', summary: '', stageCoverage: ['opening'], encounterNpcIds: [], primaryNpcId: '', oppositeNpcId: 'npc-front', eventDescription: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '', transitionHook: '', }, ], }, ], } as CustomWorldProfile; expect( resolveActiveSceneActEncounterNpcIds({ profile, sceneId: 'custom-scene-landmark-1', }), ).toEqual(['npc-front']); }); it('uses the active act opposite npc as the formal scene encounter', () => { const state = { ...createBaseState(), worldType: WorldType.CUSTOM, customWorldProfile: { id: 'custom-profile', name: '测试世界', settingText: '', subtitle: '', summary: '', tone: '', playerGoal: '', templateWorldType: WorldType.WUXIA, majorFactions: [], coreConflicts: [], attributeSchema: { attributes: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [ { id: 'landmark-raw-1', name: '旧桥', description: '旧桥', sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], connections: [], }, ], sceneChapterBlueprints: [ { id: 'chapter-1', sceneId: 'landmark-raw-1', title: '旧桥章节', summary: '', sceneTaskDescription: '', linkedThreadIds: [], linkedLandmarkIds: ['landmark-raw-1'], acts: [ { id: 'act-1', sceneId: 'landmark-raw-1', title: '第一幕', summary: '', stageCoverage: ['opening'], encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], primaryNpcId: 'npc-back-1', oppositeNpcId: 'npc-front', eventDescription: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '', transitionHook: '', }, ], }, ], } as CustomWorldProfile, currentEncounter: null, currentScenePreset: { id: 'custom-scene-landmark-1', name: '旧桥', description: '旧桥', imageSrc: '/bridge.png', connectedSceneIds: [], npcs: [ { id: 'hostile-side', name: '旁路敌人', description: '旁路敌人', avatar: '敌', role: '敌对角色', monsterPresetId: 'monster-01', initialAffinity: -40, hostile: true, }, { id: 'npc-back-1', name: '后排甲', description: '后排甲', avatar: '甲', role: '同幕角色', }, { id: 'npc-front', name: '主角色', description: '主角色', avatar: '主', role: '主角色', }, { id: 'npc-back-2', name: '后排乙', description: '后排乙', avatar: '乙', role: '同幕角色', }, ] satisfies SceneNpc[], treasureHints: [], }, } satisfies GameState; const preview = createSceneEncounterPreview(state); expect(preview.currentEncounter?.id).toBe('npc-front'); expect(preview.currentEncounter?.npcName).toBe('主角色'); }); it('uses active act opposite npc even when that npc is hostile', () => { const state = { ...createBaseState(), worldType: WorldType.CUSTOM, customWorldProfile: { id: 'custom-profile', name: '测试世界', settingText: '', subtitle: '', summary: '', tone: '', playerGoal: '', templateWorldType: WorldType.WUXIA, majorFactions: [], coreConflicts: [], attributeSchema: { attributes: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [], sceneChapterBlueprints: [ { id: 'chapter-1', sceneId: 'custom-scene-camp', title: '开局章节', summary: '', sceneTaskDescription: '', linkedThreadIds: [], linkedLandmarkIds: [], acts: [ { id: 'act-1', sceneId: 'custom-scene-camp', title: '第一幕', summary: '', stageCoverage: ['opening'], encounterNpcIds: ['npc-hostile-opposite', 'npc-back'], primaryNpcId: 'npc-back', oppositeNpcId: 'npc-hostile-opposite', eventDescription: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '', transitionHook: '', }, ], }, ], } as CustomWorldProfile, currentEncounter: null, currentScenePreset: { id: 'custom-scene-camp', name: '营地', description: '营地', imageSrc: '/camp.png', connectedSceneIds: [], npcs: [ { id: 'npc-hostile-opposite', name: '敌意对面角色', description: '第一幕先开口的敌意角色', avatar: '敌', role: '第一幕对面角色', initialAffinity: -20, hostile: true, }, { id: 'npc-back', name: '后排角色', description: '同幕后排角色', avatar: '后', role: '同幕角色', }, ] satisfies SceneNpc[], treasureHints: [], }, } satisfies GameState; const preview = createSceneEncounterPreview(state); const resolved = resolveSceneEncounterPreview({ ...state, ...preview, npcStates: { 'npc-hostile-opposite': { ...buildInitialNpcState( preview.currentEncounter!, WorldType.CUSTOM, state, ), affinity: -20, }, }, }); expect(preview.currentEncounter?.id).toBe('npc-hostile-opposite'); expect(preview.currentEncounter?.npcName).toBe('敌意对面角色'); expect(resolved.inBattle).toBe(false); expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite'); }); });