import {describe, expect, it} from 'vitest'; import {AnimationState, type Character, type GameState, type StoryOption, WorldType} from '../../types'; import {buildBattlePlan} from './battlePlan'; function createTestCharacter(): Character { return { id: 'test-hero', name: 'Test Hero', title: 'Hero', description: 'A test character', backstory: 'A test backstory', avatar: '/hero.png', portrait: '/hero-portrait.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 10, intelligence: 10, spirit: 10, }, personality: 'calm', skills: [ { id: 'skill-basic', name: 'Basic Strike', animation: AnimationState.ATTACK, damage: 10, manaCost: 0, cooldownTurns: 1, range: 1, style: 'steady', }, { id: 'skill-heavy', name: 'Heavy Strike', animation: AnimationState.SKILL1, damage: 18, manaCost: 4, cooldownTurns: 2, range: 1, style: 'burst', }, ], adventureOpenings: {}, }; } function createBaseState(): GameState { return { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: createTestCharacter(), 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: null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: true, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, 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, }; } function createBattleOption(): StoryOption { return { functionId: 'battle_all_in_crush', actionText: 'Attack', visuals: { playerAnimation: AnimationState.ATTACK, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }; } describe('buildBattlePlan', () => { it('short-circuits when there are no monsters', () => { const state = createBaseState(); const plan = buildBattlePlan({ state, option: createBattleOption(), character: createTestCharacter(), totalSequenceMs: 6000, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 6, }); expect(plan.turns).toEqual([]); expect(plan.finalState.inBattle).toBe(false); expect(plan.finalState.sceneHostileNpcs).toEqual([]); expect(plan.finalState.animationState).toBe(AnimationState.IDLE); }); it('builds a battle plan when npc battle entry already provides sceneHostileNpcs', () => { const state = { ...createBaseState(), currentBattleNpcId: 'npc-opponent', currentNpcBattleMode: 'fight' as const, sceneHostileNpcs: [ { id: 'npc-opponent', name: '山道客', action: '摆开架势,随时准备出手', description: '拦路的江湖客', animation: 'idle' as const, xMeters: 3.2, yOffset: 0, facing: 'left' as const, attackRange: 1.8, speed: 7, hp: 12, maxHp: 12, renderKind: 'npc' as const, encounter: { id: 'npc-opponent', kind: 'npc' as const, npcName: '山道客', npcDescription: '拦路的江湖客', npcAvatar: '/npc.png', context: '山道客', xMeters: 3.2, }, }, ], }; const plan = buildBattlePlan({ state, option: createBattleOption(), character: createTestCharacter(), totalSequenceMs: 6000, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 6, }); expect(plan.turns.length).toBeGreaterThan(0); expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1); }); it('uses runtimePayload skillId for local battle fallback skill resolution', () => { const state = { ...createBaseState(), playerMana: 20, sceneHostileNpcs: [ { id: 'monster-1', name: '山狼', action: '压低身体', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 1, hp: 80, maxHp: 80, }, ], }; const option = { ...createBattleOption(), functionId: 'battle_use_skill', runtimePayload: { skillId: 'skill-heavy' }, }; const plan = buildBattlePlan({ state, option, character: createTestCharacter(), totalSequenceMs: 900, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 1, }); const playerTurn = plan.turns.find((turn) => turn.actor === 'player'); expect(playerTurn).toEqual( expect.objectContaining({ selectedSkillId: 'skill-heavy', appliedCooldowns: expect.objectContaining({ 'skill-heavy': 2 }), }), ); }); it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => { const state = { ...createBaseState(), playerMana: 20, sceneHostileNpcs: [ { id: 'monster-1', name: '山狼', action: '压低身体', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 1, hp: 80, maxHp: 80, }, ], }; const plan = buildBattlePlan({ state, option: { ...createBattleOption(), functionId: 'battle_attack_basic', }, character: createTestCharacter(), totalSequenceMs: 900, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 1, }); const playerTurns = plan.turns.filter((turn) => turn.actor === 'player'); expect(playerTurns).toHaveLength(1); expect(playerTurns[0]).toEqual( expect.objectContaining({ selectedSkillId: 'battle-basic-attack', }), ); expect(plan.finalState.playerMana).toBe(state.playerMana); }); it('resolves one full speed-ordered round when combat continues', () => { const state = { ...createBaseState(), sceneHostileNpcs: [ { id: 'monster-1', name: '山狼', action: '压低身体', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 1, hp: 120, maxHp: 120, }, ], }; const plan = buildBattlePlan({ state, option: { ...createBattleOption(), functionId: 'battle_attack_basic', }, character: createTestCharacter(), totalSequenceMs: 6000, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 6, }); expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']); expect(plan.finalState.inBattle).toBe(true); expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0); }); it('keeps recovery as a player turn without converting it into an attack', () => { const state = { ...createBaseState(), playerHp: 40, playerMana: 3, sceneHostileNpcs: [ { id: 'monster-1', name: '山狼', action: '压低身体', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 1, hp: 80, maxHp: 80, }, ], }; const plan = buildBattlePlan({ state, option: { ...createBattleOption(), functionId: 'battle_recover_breath', }, character: createTestCharacter(), totalSequenceMs: 900, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 1, }); const playerTurn = plan.turns.find((turn) => turn.actor === 'player'); expect(playerTurn).toEqual( expect.objectContaining({ actor: 'player', actionKind: 'recover', selectedSkillId: null, damage: 0, }), ); expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp); expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana); }); it('includes companion turns in fight mode and orders the round by speed', () => { const state = { ...createBaseState(), currentNpcBattleMode: 'fight' as const, companions: [ { npcId: 'companion-1', characterId: 'archer-hero', joinedAtAffinity: 10, hp: 60, maxHp: 60, mana: 20, maxMana: 20, skillCooldowns: {}, }, ], sceneHostileNpcs: [ { id: 'monster-1', name: '山狼', action: '压低身体', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 0.5, hp: 120, maxHp: 120, }, ], }; const plan = buildBattlePlan({ state, option: { ...createBattleOption(), functionId: 'battle_attack_basic', }, character: createTestCharacter(), totalSequenceMs: 6000, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 6, }); expect(plan.turns.map((turn) => turn.actor)).toEqual([ 'companion', 'player', 'monster', ]); expect(plan.turns[0]).toEqual( expect.objectContaining({ actor: 'companion', companionNpcId: 'companion-1', }), ); }); it('prefers fight_defeat over fight_victory when the round ends with player death after local battle settlement', () => { const state = { ...createBaseState(), currentBattleNpcId: 'npc-opponent', currentNpcBattleMode: 'fight' as const, playerHp: 6, playerMaxHp: 30, sceneHostileNpcs: [ { id: 'npc-opponent', name: '山道客', action: '提刀逼近', description: '测试敌人', animation: 'idle' as const, xMeters: 3, yOffset: 0, facing: 'left' as const, attackRange: 1, speed: 1, hp: 8, maxHp: 8, }, ], }; const plan = buildBattlePlan({ state, option: { ...createBattleOption(), functionId: 'battle_all_in_crush', }, character: createTestCharacter(), totalSequenceMs: 900, turnVisualMs: 820, resetStageMs: 260, minTurnCount: 1, }); expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']); expect(plan.finalState.playerHp).toBe(0); expect(plan.finalState.inBattle).toBe(false); expect(plan.finalState.currentNpcBattleOutcome).toBe('fight_defeat'); expect(plan.finalState.sceneHostileNpcs).toEqual([]); }); });