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 { buildNpcBattleFormationFromEncounter, 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.sparReturnEncounter).toEqual(state.currentEncounter); 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'); }); it('builds active act npc battle formations with stable back-row slots', () => { 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: [ { id: 'npc-front', name: '正面对手', title: '刀客', description: '正面对手', initialAffinity: -30, imageSrc: '', role: '敌对角色', backstory: '', personality: '', motivation: '', combatStyle: '', relationshipHooks: [], tags: [], skills: [], initialItems: [], }, { id: 'npc-back-1', name: '后排甲', title: '弓手', description: '后排甲', initialAffinity: -25, imageSrc: '', role: '敌对角色', backstory: '', personality: '', motivation: '', combatStyle: '', relationshipHooks: [], tags: [], skills: [], initialItems: [], }, { id: 'npc-back-2', name: '后排乙', title: '术士', description: '后排乙', initialAffinity: -20, imageSrc: '', role: '敌对角色', backstory: '', personality: '', motivation: '', combatStyle: '', relationshipHooks: [], tags: [], skills: [], initialItems: [], }, ], 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, currentScenePreset: { id: 'landmark-raw-1', name: '旧桥', description: '旧桥', imageSrc: '/bridge.png', connectedSceneIds: [], treasureHints: [], npcs: [ { id: 'npc-front', name: '正面对手', description: '正面对手', avatar: '正', role: '敌对角色', initialAffinity: -30, hostile: true, attributeProfile: { attributes: {}, combat: { maxHp: 96, attack: 12, defense: 8, speed: 10, }, } as SceneNpc['attributeProfile'], }, { id: 'npc-back-1', name: '后排甲', description: '后排甲', avatar: '甲', role: '敌对角色', initialAffinity: -25, hostile: true, attributeProfile: { attributes: {}, combat: { maxHp: 82, attack: 10, defense: 6, speed: 9, }, } as SceneNpc['attributeProfile'], }, { id: 'npc-back-2', name: '后排乙', description: '后排乙', avatar: '乙', role: '敌对角色', initialAffinity: -20, hostile: true, attributeProfile: { attributes: {}, combat: { maxHp: 78, attack: 9, defense: 5, speed: 11, }, } as SceneNpc['attributeProfile'], }, ] satisfies SceneNpc[], }, currentEncounter: { id: 'npc-front', kind: 'npc', npcName: '正面对手', npcDescription: '正面对手', npcAvatar: '正', context: '敌对角色', hostile: true, initialAffinity: -30, xMeters: 3.2, }, npcStates: { 'npc-front': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -30, }, 'npc-back-1': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -25, }, 'npc-back-2': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -20, }, }, } satisfies GameState; const formation = buildNpcBattleFormationFromEncounter({ state, encounter: state.currentEncounter!, }); expect(formation).toHaveLength(3); expect(formation.map((monster) => monster.encounter?.id)).toEqual([ 'npc-front', 'npc-back-1', 'npc-back-2', ]); expect( formation.map((monster) => ({ id: monster.encounter?.id, xMeters: monster.xMeters, yOffset: monster.yOffset, })), ).toEqual([ { id: 'npc-front', xMeters: 3.2, yOffset: 0 }, { id: 'npc-back-1', xMeters: 4.28, yOffset: 62 }, { id: 'npc-back-2', xMeters: 4.28, yOffset: -46 }, ]); }); it('keeps scene-act formation order even when the clicked encounter comes from the back row', () => { 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-front', oppositeNpcId: 'npc-front', eventDescription: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '', transitionHook: '', }, ], }, ], } as CustomWorldProfile, currentScenePreset: { id: 'landmark-raw-1', name: '海底遗址', description: '海底遗址', imageSrc: '/underwater.png', connectedSceneIds: [], treasureHints: [], npcs: [ { id: 'npc-front', name: '珊瑚祭司', description: '前排祭司', avatar: '祭', role: '敌对角色', initialAffinity: -20, hostile: true, }, { id: 'npc-back-1', name: '赤发护卫', description: '后排护卫', avatar: '卫', role: '敌对角色', initialAffinity: -20, hostile: true, }, { id: 'npc-back-2', name: '潮歌侍从', description: '后排侍从', avatar: '侍', role: '敌对角色', initialAffinity: -20, hostile: true, }, ] satisfies SceneNpc[], }, currentEncounter: { id: 'npc-back-1', kind: 'npc', npcName: '赤发护卫', npcDescription: '后排护卫', npcAvatar: '卫', context: '敌对角色', hostile: true, initialAffinity: -20, xMeters: 4.28, }, npcStates: { 'npc-front': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -20, }, 'npc-back-1': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -20, }, 'npc-back-2': { ...buildInitialNpcState(createEncounter(), WorldType.CUSTOM), affinity: -20, }, }, } satisfies GameState; const formation = buildNpcBattleFormationFromEncounter({ state, encounter: state.currentEncounter!, }); expect( formation.map((monster) => ({ id: monster.encounter?.id, xMeters: monster.xMeters, yOffset: monster.yOffset, })), ).toEqual([ { id: 'npc-front', xMeters: 3.2, yOffset: 0 }, { id: 'npc-back-1', xMeters: 4.28, yOffset: 62 }, { id: 'npc-back-2', xMeters: 4.28, yOffset: -46 }, ]); }); });