feat: slay-the-spire-like combat procedures

This commit is contained in:
2026-04-16 14:00:49 +08:00
parent 976ee43ed3
commit 94c7c91745
13 changed files with 2820 additions and 0 deletions
@@ -0,0 +1,344 @@
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);
});
});
});
@@ -0,0 +1,261 @@
import { describe, it, expect } from 'vitest';
import { createGameHost, GameHost } from '@/core/game-host';
import { createGameContext, createGameCommandRegistry } from '@/core/game';
import type { CombatState, CombatGameContext } from '@/samples/slay-the-spire-like/combat/types';
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
import { runCombat } from '@/samples/slay-the-spire-like/combat/procedure';
import { prompts } from '@/samples/slay-the-spire-like/combat/prompts';
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 } 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 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 waitForPrompt(host: GameHost<CombatState>): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (host.activePromptSchema.value !== null) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
describe('combat/procedure', () => {
describe('runCombat with GameHost', () => {
it('should start combat and prompt for player action', async () => {
const registry = createGameCommandRegistry<CombatState>();
const initialState = createTestCombatState();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
expect(host.activePromptSchema.value).not.toBeNull();
expect(host.activePromptSchema.value?.name).toBe('play-card');
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should accept play-card input', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
const cardId = state.player.deck.hand[0];
const error = host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
expect(error).toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should reject invalid card play', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const error = host.tryAnswerPrompt(prompts.playCard, 'nonexistent-card');
expect(error).not.toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should transition to end-turn after playing cards', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
const cardId = state.player.deck.hand[0];
host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
await waitForPrompt(host);
expect(host.activePromptSchema.value).not.toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should accept end-turn', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const error = host.tryAnswerPrompt(prompts.endTurn);
expect(error).toBeNull();
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
});
describe('combat outcome', () => {
it('should return victory when all enemies are dead', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => {
const state = createTestCombatState();
for (const enemyId of state.enemyOrder) {
state.enemies[enemyId].hp = 1;
}
return state;
},
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
let iterations = 0;
while (host.status.value === 'running' && iterations < 100) {
const state = host.state.value;
if (host.activePromptSchema.value?.name === 'play-card') {
const cardId = state.player.deck.hand[0];
if (cardId) {
const targetId = state.enemyOrder.find(id => state.enemies[id].isAlive);
host.tryAnswerPrompt(prompts.playCard, cardId, targetId);
}
} else if (host.activePromptSchema.value?.name === 'end-turn') {
host.tryAnswerPrompt(prompts.endTurn);
}
await new Promise(r => setTimeout(r, 10));
iterations++;
}
if (host.status.value === 'running') {
host._context._commands._cancel();
try { await combatPromise; } catch {}
}
});
});
describe('combat state transitions', () => {
it('should track turn number across turns', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
host.tryAnswerPrompt(prompts.endTurn);
await waitForPrompt(host);
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
it('should reset energy at start of player turn', async () => {
const registry = createGameCommandRegistry<CombatState>();
const host = new GameHost(
registry,
() => createTestCombatState(),
async (ctx) => {
return await runCombat(ctx);
},
);
const combatPromise = host.start(42);
await waitForPrompt(host);
const state = host.state.value;
expect(state.player.energy).toBe(state.player.maxEnergy);
host._context._commands._cancel();
try { await combatPromise; } catch {}
});
});
});
@@ -0,0 +1,303 @@
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');
expect(getEffectTiming('spike')).toBe('lingering');
expect(getEffectTiming('energyDrain')).toBe('permanent');
});
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('攻击');
});
});
});
@@ -0,0 +1,254 @@
import { describe, it, expect } from 'vitest';
import {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
} from '@/samples/slay-the-spire-like/combat/triggers';
import type { TriggerContext, CombatTriggerRegistry } from '@/samples/slay-the-spire-like/combat/triggers';
import type { CombatState } from '@/samples/slay-the-spire-like/combat/types';
import { createCombatState } 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 } 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 createTestTriggerCtx(state: CombatState): TriggerContext {
return { state, rng: new Mulberry32RNG(42) };
}
describe('combat/triggers', () => {
describe('createCombatTriggerRegistry', () => {
it('should create registry with desert zone triggers', () => {
const registry = createCombatTriggerRegistry();
expect(registry['spike']).toBeDefined();
expect(registry['aim']).toBeDefined();
expect(registry['charge']).toBeDefined();
expect(registry['roll']).toBeDefined();
expect(registry['tailSting']).toBeDefined();
expect(registry['energyDrain']).toBeDefined();
expect(registry['molt']).toBeDefined();
expect(registry['storm']).toBeDefined();
expect(registry['vultureEye']).toBeDefined();
expect(registry['venom']).toBeDefined();
expect(registry['static']).toBeDefined();
expect(registry['curse']).toBeDefined();
expect(registry['discard']).toBeDefined();
});
});
describe('spike trigger', () => {
it('should deal damage to attacker when enemy is attacked', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['spike'] = 2;
const initialPlayerHp = state.player.hp;
dispatchAttackedTrigger(ctx, 'player', enemyId, 5, registry);
expect(state.player.hp).toBeLessThan(initialPlayerHp);
});
});
describe('aim trigger', () => {
it('should double outgoing damage with aim stacks', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 3;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(10);
});
it('should not double damage with 0 aim stacks', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 0;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(5);
});
it('should lose aim stacks on damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['aim'] = 5;
dispatchDamageTrigger(ctx, enemyId, 3, registry);
expect(state.enemies[enemyId].buffs['aim']).toBe(2);
});
});
describe('charge trigger', () => {
it('should double outgoing and incoming damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['charge'] = 2;
const outDmg = dispatchOutgoingDamageTrigger(ctx, enemyId, 6, registry);
expect(outDmg).toBe(12);
const inDmg = dispatchIncomingDamageTrigger(ctx, enemyId, 6, registry);
expect(inDmg).toBe(12);
});
it('should lose charge stacks on damage', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['charge'] = 5;
dispatchDamageTrigger(ctx, enemyId, 3, registry);
expect(state.enemies[enemyId].buffs['charge']).toBe(2);
});
});
describe('roll trigger', () => {
it('should increase damage when roll >= 10', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['roll'] = 20;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(7);
expect(state.enemies[enemyId].buffs['roll']).toBe(0);
});
it('should not modify damage when roll < 10', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['roll'] = 5;
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
expect(modified).toBe(5);
});
});
describe('tailSting trigger', () => {
it('should deal damage to player at turn end', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['tailSting'] = 3;
const initialHp = state.player.hp;
dispatchTrigger(ctx, 'onTurnEnd', enemyId, registry);
expect(state.player.hp).toBeLessThan(initialHp);
});
});
describe('static trigger', () => {
it('should increase incoming damage to player', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
state.player.buffs['static'] = 2;
const modified = dispatchIncomingDamageTrigger(ctx, 'player', 5, registry);
expect(modified).toBe(7);
});
});
describe('molt trigger', () => {
it('should cause enemy to flee when molt stacks >= maxHp', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['molt'] = state.enemies[enemyId].maxHp;
dispatchDamageTrigger(ctx, enemyId, 1, registry);
expect(state.enemies[enemyId].isAlive).toBe(false);
expect(state.result).toBe('fled');
});
it('should not cause flee when molt stacks < maxHp', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['molt'] = 1;
dispatchDamageTrigger(ctx, enemyId, 1, registry);
expect(state.enemies[enemyId].isAlive).toBe(true);
});
});
describe('dispatchTrigger with missing handler', () => {
it('should be a no-op for unknown buff', () => {
const state = createTestCombatState();
const registry = createCombatTriggerRegistry();
const ctx = createTestTriggerCtx(state);
const enemyId = state.enemyOrder[0];
state.enemies[enemyId].buffs['nonexistentBuff'] = 5;
expect(() => {
dispatchTrigger(ctx, 'onTurnStart', enemyId, registry);
}).not.toThrow();
});
});
});