472 lines
12 KiB
TypeScript
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([]);
|
|
});
|
|
});
|