This commit is contained in:
272
src/hooks/combat/battlePlan.test.ts
Normal file
272
src/hooks/combat/battlePlan.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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('does not turn recovery fallback into a random player 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,
|
||||
});
|
||||
|
||||
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
|
||||
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user