2026-04-16 14:00:49 +08:00
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import {
|
|
|
|
|
createCombatState,
|
|
|
|
|
createEnemyInstance,
|
|
|
|
|
createPlayerCombatState,
|
|
|
|
|
drawCardsToHand,
|
|
|
|
|
addFatigueCards,
|
|
|
|
|
discardHand,
|
|
|
|
|
discardCard,
|
|
|
|
|
exhaustCard,
|
|
|
|
|
getEnemyCurrentIntent,
|
|
|
|
|
advanceEnemyIntent,
|
|
|
|
|
getEffectTiming,
|
|
|
|
|
getEffectData,
|
|
|
|
|
INITIAL_HAND_SIZE,
|
|
|
|
|
DEFAULT_MAX_ENERGY,
|
|
|
|
|
FATIGUE_CARDS_PER_SHUFFLE,
|
|
|
|
|
} 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 { encounterDesertData, enemyDesertData, effectDesertData } from '@/samples/slay-the-spire-like/data';
|
|
|
|
|
|
|
|
|
|
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 createTestPlayerState(): PlayerState {
|
|
|
|
|
return { maxHp: 50, currentHp: 50, gold: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('combat/state', () => {
|
|
|
|
|
describe('constants', () => {
|
|
|
|
|
it('should have correct default values', () => {
|
|
|
|
|
expect(INITIAL_HAND_SIZE).toBe(5);
|
|
|
|
|
expect(DEFAULT_MAX_ENERGY).toBe(3);
|
|
|
|
|
expect(FATIGUE_CARDS_PER_SHUFFLE).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createEnemyInstance', () => {
|
|
|
|
|
it('should create enemy from desert data', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
expect(enemy.templateId).toBe('仙人掌怪');
|
|
|
|
|
expect(enemy.hp).toBe(cactusData.initHp);
|
|
|
|
|
expect(enemy.maxHp).toBe(cactusData.initHp);
|
|
|
|
|
expect(enemy.isAlive).toBe(true);
|
|
|
|
|
expect(enemy.hadDefendBroken).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should apply bonus HP', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 5, { value: 0 });
|
|
|
|
|
|
|
|
|
|
expect(enemy.hp).toBe(cactusData.initHp + 5);
|
|
|
|
|
expect(enemy.maxHp).toBe(cactusData.initHp + 5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should initialize buffs from template', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
expect(enemy.buffs['spike']).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should set initial intent', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
expect(enemy.currentIntentId).toBe(cactusData.initialIntent);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should generate unique IDs', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const e1 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
const e2 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
expect(e1.id).not.toBe(e2.id);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createPlayerCombatState', () => {
|
|
|
|
|
it('should create player state from run state and inventory', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
|
|
|
|
|
expect(combatPlayer.hp).toBe(50);
|
|
|
|
|
expect(combatPlayer.maxHp).toBe(50);
|
|
|
|
|
expect(combatPlayer.energy).toBe(DEFAULT_MAX_ENERGY);
|
|
|
|
|
expect(combatPlayer.maxEnergy).toBe(DEFAULT_MAX_ENERGY);
|
|
|
|
|
expect(Object.keys(combatPlayer.buffs).length).toBe(0);
|
|
|
|
|
expect(combatPlayer.damagedThisTurn).toBe(false);
|
|
|
|
|
expect(combatPlayer.cardsDiscardedThisTurn).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should generate deck from inventory', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(combatPlayer.deck.cards).length).toBeGreaterThan(0);
|
|
|
|
|
expect(combatPlayer.deck.drawPile.length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createCombatState', () => {
|
|
|
|
|
it('should create full combat state from encounter', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
|
|
|
|
|
|
|
|
const combat = createCombatState(playerState, inv, encounter);
|
|
|
|
|
|
|
|
|
|
expect(combat.phase).toBe('playerTurn');
|
|
|
|
|
expect(combat.turnNumber).toBe(1);
|
|
|
|
|
expect(combat.result).toBeNull();
|
|
|
|
|
expect(combat.loot).toEqual([]);
|
|
|
|
|
expect(combat.fatigueAddedCount).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should create enemies from encounter data', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
|
|
|
|
|
|
|
|
const combat = createCombatState(playerState, inv, encounter);
|
|
|
|
|
|
|
|
|
|
expect(combat.enemyOrder.length).toBeGreaterThan(0);
|
|
|
|
|
for (const enemyId of combat.enemyOrder) {
|
|
|
|
|
expect(combat.enemies[enemyId].isAlive).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should draw initial hand', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
|
|
|
|
|
|
|
|
const combat = createCombatState(playerState, inv, encounter);
|
|
|
|
|
|
|
|
|
|
expect(combat.player.deck.hand.length).toBe(INITIAL_HAND_SIZE);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('drawCardsToHand', () => {
|
|
|
|
|
it('should draw cards from draw pile to hand', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
|
|
|
|
|
const initialDrawPile = combatPlayer.deck.drawPile.length;
|
|
|
|
|
const initialHand = combatPlayer.deck.hand.length;
|
|
|
|
|
drawCardsToHand(combatPlayer.deck, 3);
|
|
|
|
|
|
|
|
|
|
expect(combatPlayer.deck.hand.length).toBe(initialHand + 3);
|
|
|
|
|
expect(combatPlayer.deck.drawPile.length).toBe(initialDrawPile - 3);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('addFatigueCards', () => {
|
|
|
|
|
it('should add fatigue cards to draw pile', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
|
|
|
|
|
const initialCount = Object.keys(combatPlayer.deck.cards).length;
|
|
|
|
|
const fatigueCounter = { value: 0 };
|
|
|
|
|
addFatigueCards(combatPlayer.deck, FATIGUE_CARDS_PER_SHUFFLE, fatigueCounter);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(combatPlayer.deck.cards).length).toBe(initialCount + FATIGUE_CARDS_PER_SHUFFLE);
|
|
|
|
|
expect(fatigueCounter.value).toBe(FATIGUE_CARDS_PER_SHUFFLE);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should create fatigue cards with correct properties', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
|
|
|
|
|
addFatigueCards(combatPlayer.deck, 1, { value: 0 });
|
|
|
|
|
const fatigueCard = combatPlayer.deck.cards['fatigue-1'];
|
|
|
|
|
|
|
|
|
|
expect(fatigueCard).toBeDefined();
|
|
|
|
|
expect(fatigueCard.displayName).toBe('疲劳');
|
|
|
|
|
expect(fatigueCard.sourceItemId).toBeNull();
|
|
|
|
|
expect(fatigueCard.itemData).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('discardHand', () => {
|
|
|
|
|
it('should move all hand cards to discard pile', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
drawCardsToHand(combatPlayer.deck, 3);
|
|
|
|
|
|
|
|
|
|
const handCount = combatPlayer.deck.hand.length;
|
|
|
|
|
discardHand(combatPlayer.deck);
|
|
|
|
|
|
|
|
|
|
expect(combatPlayer.deck.hand).toEqual([]);
|
|
|
|
|
expect(combatPlayer.deck.discardPile.length).toBe(handCount);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('discardCard / exhaustCard', () => {
|
|
|
|
|
it('should move a card from hand to discard pile', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
drawCardsToHand(combatPlayer.deck, 3);
|
|
|
|
|
|
|
|
|
|
const cardId = combatPlayer.deck.hand[0];
|
|
|
|
|
discardCard(combatPlayer.deck, cardId);
|
|
|
|
|
|
|
|
|
|
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
|
|
|
|
expect(combatPlayer.deck.discardPile.includes(cardId)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should move a card from hand to exhaust pile', () => {
|
|
|
|
|
const inv = createTestInventory();
|
|
|
|
|
const playerState = createTestPlayerState();
|
|
|
|
|
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
|
|
|
drawCardsToHand(combatPlayer.deck, 3);
|
|
|
|
|
|
|
|
|
|
const cardId = combatPlayer.deck.hand[0];
|
|
|
|
|
exhaustCard(combatPlayer.deck, cardId);
|
|
|
|
|
|
|
|
|
|
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
|
|
|
|
expect(combatPlayer.deck.exhaustPile.includes(cardId)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('enemy intent', () => {
|
|
|
|
|
it('should get current intent', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
const intent = getEnemyCurrentIntent(enemy);
|
|
|
|
|
expect(intent).toBeDefined();
|
|
|
|
|
expect(intent!.id).toBe('boost');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should advance intent after action', () => {
|
|
|
|
|
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
|
|
|
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
|
|
|
|
|
|
|
|
const originalIntent = enemy.currentIntentId;
|
|
|
|
|
advanceEnemyIntent(enemy);
|
|
|
|
|
|
|
|
|
|
expect(enemy.currentIntentId).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getEffectTiming / getEffectData', () => {
|
|
|
|
|
it('should return timing for known effects', () => {
|
|
|
|
|
expect(getEffectTiming('attack')).toBe('instant');
|
|
|
|
|
expect(getEffectTiming('defend')).toBe('posture');
|
2026-04-16 18:33:13 +08:00
|
|
|
expect(getEffectTiming('spike')).toBe('permanent');
|
|
|
|
|
expect(getEffectTiming('energyDrain')).toBe('lingering');
|
2026-04-16 14:00:49 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return undefined for unknown effects', () => {
|
|
|
|
|
expect(getEffectTiming('nonexistent')).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return effect data for known effects', () => {
|
|
|
|
|
const data = getEffectData('attack');
|
|
|
|
|
expect(data).toBeDefined();
|
|
|
|
|
expect(data!.id).toBe('attack');
|
|
|
|
|
expect(data!.name).toBe('攻击');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|