boardgame-core/tests/samples/slay-the-spire-like/combat/effects.test.ts

345 lines
12 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
getModifiedAttackDamage,
getModifiedDefendAmount,
} from '@/samples/slay-the-spire-like/combat/effects';
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
import {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
} from '@/samples/slay-the-spire-like/combat/state';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
import { enemyDesertData, encounterDesertData } from '@/samples/slay-the-spire-like/data';
import { Mulberry32RNG } from '@/utils/rng';
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc: '测试',
},
shape,
};
}
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
const meta1 = createTestMeta('短刀', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'item-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
};
placeItem(inv, item1);
return inv;
}
function createTestCombatState(): CombatState {
const inv = createTestInventory();
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
return createCombatState(playerState, inv, encounter);
}
function createSimpleRng() {
return new Mulberry32RNG(42);
}
describe('combat/effects', () => {
describe('applyDamage', () => {
it('should deal damage to player', () => {
const state = createTestCombatState();
applyDamage(state, 'player', 10);
expect(state.player.hp).toBe(40);
expect(state.player.damageTakenThisTurn).toBe(10);
expect(state.player.damagedThisTurn).toBe(true);
});
it('should deal damage to enemy', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
const enemy = state.enemies[enemyId];
const initialHp = enemy.hp;
applyDamage(state, enemyId, 5);
expect(enemy.hp).toBe(initialHp - 5);
});
it('should be absorbed by defend buff on player', () => {
const state = createTestCombatState();
state.player.buffs['defend'] = 3;
const result = applyDamage(state, 'player', 5);
expect(result.blockedByDefend).toBe(3);
expect(result.damageDealt).toBe(2);
expect(state.player.hp).toBe(48);
});
it('should be fully absorbed by defend buff', () => {
const state = createTestCombatState();
state.player.buffs['defend'] = 10;
applyDamage(state, 'player', 5);
expect(state.player.hp).toBe(50);
expect(state.player.buffs['defend']).toBe(5);
});
it('should be absorbed by defend buff on enemy', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['defend'] = 4;
const result = applyDamage(state, enemyId, 6);
expect(result.blockedByDefend).toBe(4);
expect(result.damageDealt).toBe(2);
expect(state.enemies[enemyId].hp).toBe(state.enemies[enemyId].maxHp - 2);
});
it('should mark defend broken when defend fully consumed', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['defend'] = 3;
applyDamage(state, enemyId, 5);
expect(state.enemies[enemyId].hadDefendBroken).toBe(true);
});
it('should kill enemy when HP reaches 0', () => {
const state = createTestCombatState();
const enemyId = state.enemyOrder[0];
applyDamage(state, enemyId, state.enemies[enemyId].maxHp);
expect(state.enemies[enemyId].isAlive).toBe(false);
expect(state.enemies[enemyId].hp).toBe(0);
});
it('should not deal negative damage', () => {
const state = createTestCombatState();
const result = applyDamage(state, 'player', -5);
expect(result.damageDealt).toBe(0);
expect(state.player.hp).toBe(50);
});
it('should apply damageReduce buff', () => {
const state = createTestCombatState();
state.player.buffs['damageReduce'] = 3;
applyDamage(state, 'player', 5);
expect(state.player.hp).toBe(48);
});
});
describe('applyDefend', () => {
it('should add defend stacks', () => {
const buffs: Record<string, number> = {};
applyDefend(buffs, 5);
expect(buffs['defend']).toBe(5);
});
it('should stack with existing defend', () => {
const buffs: Record<string, number> = { defend: 3 };
applyDefend(buffs, 4);
expect(buffs['defend']).toBe(7);
});
});
describe('applyBuff / removeBuff', () => {
it('should apply buff stacks', () => {
const buffs: Record<string, number> = {};
applyBuff(buffs, 'aim', 'lingering', 3);
expect(buffs['aim']).toBe(3);
});
it('should stack existing buffs', () => {
const buffs: Record<string, number> = { aim: 2 };
applyBuff(buffs, 'aim', 'lingering', 3);
expect(buffs['aim']).toBe(5);
});
it('should remove buff partially', () => {
const buffs: Record<string, number> = { aim: 5 };
const removed = removeBuff(buffs, 'aim', 3);
expect(removed).toBe(3);
expect(buffs['aim']).toBe(2);
});
it('should remove buff fully when stacks exceed current', () => {
const buffs: Record<string, number> = { aim: 2 };
const removed = removeBuff(buffs, 'aim', 10);
expect(removed).toBe(2);
expect(buffs['aim']).toBeUndefined();
});
});
describe('updateBuffs', () => {
it('should clear temporary buffs', () => {
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
updateBuffs(buffs);
expect(buffs['damageReduce']).toBeUndefined();
expect(buffs['defendNext']).toBeUndefined();
});
it('should decrement lingering buffs', () => {
const buffs: Record<string, number> = { spike: 3, aim: 1 };
updateBuffs(buffs);
expect(buffs['spike']).toBe(2);
expect(buffs['aim']).toBeUndefined();
});
it('should not affect permanent or posture buffs', () => {
const buffs: Record<string, number> = { defend: 5, energyDrain: 1 };
updateBuffs(buffs);
expect(buffs['defend']).toBe(5);
expect(buffs['energyDrain']).toBe(1);
});
});
describe('canPlayCard', () => {
it('should allow playing card with enough energy', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
const result = canPlayCard(state, cardId);
expect(result.canPlay).toBe(true);
});
it('should reject card not in hand', () => {
const state = createTestCombatState();
const result = canPlayCard(state, 'nonexistent-card');
expect(result.canPlay).toBe(false);
});
it('should reject card with insufficient energy', () => {
const state = createTestCombatState();
state.player.energy = 0;
const cardId = state.player.deck.hand[0];
const card = state.player.deck.cards[cardId];
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
const result = canPlayCard(state, cardId);
expect(result.canPlay).toBe(false);
expect(result.reason).toBe('能量不足');
}
});
});
describe('playCard', () => {
it('should deduct energy cost when playing card', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
const card = state.player.deck.cards[cardId];
const initialEnergy = state.player.energy;
if (card?.itemData?.costType === 'energy') {
const ctx = { state, rng: createSimpleRng() };
const result = playCard(ctx, cardId);
if (result.success) {
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
}
}
});
it('should move card to discard pile after playing', () => {
const state = createTestCombatState();
const cardId = state.player.deck.hand[0];
const ctx = { state, rng: createSimpleRng() };
const result = playCard(ctx, cardId);
if (result.success) {
expect(state.player.deck.hand.includes(cardId)).toBe(false);
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
}
});
});
describe('areAllEnemiesDead / isPlayerDead', () => {
it('should detect all enemies dead', () => {
const state = createTestCombatState();
expect(areAllEnemiesDead(state)).toBe(false);
for (const enemyId of state.enemyOrder) {
state.enemies[enemyId].isAlive = false;
}
expect(areAllEnemiesDead(state)).toBe(true);
});
it('should detect player death', () => {
const state = createTestCombatState();
expect(isPlayerDead(state)).toBe(false);
state.player.hp = 0;
expect(isPlayerDead(state)).toBe(true);
});
});
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
it('should return base damage with no item buffs', () => {
const state = createTestCombatState();
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
});
it('should return base defend with no item buffs', () => {
const state = createTestCombatState();
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
});
it('should add item buff attack damage', () => {
const state = createTestCombatState();
state.itemBuffs.push({
effectId: 'attackBuff',
stacks: 3,
timing: 'itemUntilPlayed',
sourceItemId: 'item-1',
targetItemId: 'item-1',
});
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
});
});
});