Files
Genarrative/src/hooks/combat/battlePlan.test.ts
2026-04-28 02:05:12 +08:00

472 lines
12 KiB
TypeScript

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([]);
});
});