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

593 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game';
import { createRegion } from '@/core/region';
import { createStartWith, Triggers } from '@/samples/slay-the-spire-like/system/combat/triggers';
import { addTriggers } from '@/samples/slay-the-spire-like/data/desert/triggers';
import { CombatState, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
import { EffectData } from '@/samples/slay-the-spire-like/system/types';
import { GameCard, DeckRegions } from '@/samples/slay-the-spire-like/system/deck';
import { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
import { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
import { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
import { cards, effects, enemies, items } from '@/samples/slay-the-spire-like/data/desert';
function createEffect(id: string, lifecycle: EffectData['lifecycle'] = 'instant'): EffectData {
const found = effects.find(e => e.id === id);
if (found) return found;
return { id, name: id, description: '', lifecycle };
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
};
}
function createCard(id: string, itemId: string, costType: 'energy' | 'uses' | 'none' = 'energy', costCount = 0): GameCard {
const cardData = cards.find(c => c.id === itemId) ?? {
id: itemId, name: itemId, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [],
};
return {
id,
regionId: '',
position: [0],
itemId,
cardData,
};
}
function createEnemyEntity(enemyId: string, hp: number, maxHp: number, instanceIndex = 0): EnemyEntity {
const enemyData = enemies.find(e => e.id === enemyId);
if (!enemyData) throw new Error(`Enemy "${enemyId}" not found`);
const intent = enemyData.intents.find(i => i.initialIntent) ?? enemyData.intents[0];
const instanceId = `${enemyId}-${instanceIndex}`;
const intentMap: Record<string, typeof intent> = {};
for (const i of enemyData.intents) {
intentMap[i.id] = i;
}
return {
id: instanceId,
enemy: enemyData,
hp,
maxHp,
isAlive: true,
effects: {},
intents: intentMap,
currentIntent: intent,
};
}
function createInventory(itemsList: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
const map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>();
for (const item of itemsList) {
map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`);
}
return { width: 6, height: 4, items: map, occupiedCells: occupied };
}
function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
const regions = createDeckRegions();
return {
player: {
id: 'player',
effects: {},
hp: 30,
maxHp: 30,
isAlive: true,
energy: 3,
maxEnergy: 3,
deck: { cards: {}, regions },
itemEffects: {},
},
enemies: [],
inventory: createInventory([]),
phase: 'playerTurn',
turnNumber: 1,
result: null,
loot: [],
...overrides,
};
}
function createTestContext(state?: CombatState): IGameContext<CombatState> {
const registry = createGameCommandRegistry<CombatState>();
const ctx = createGameContext(registry, state ?? createCombatState());
ctx._rng.setSeed(42);
return ctx;
}
function getTriggers(): Triggers {
let capturedTriggers: Triggers;
createStartWith(triggers => {
capturedTriggers = triggers;
addTriggers(triggers);
});
return capturedTriggers!;
}
function addCardToHand(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'hand';
draft.player.deck.regions.hand.childIds.push(card.id);
});
}
function addCardToDrawPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'drawPile';
draft.player.deck.regions.drawPile.childIds.push(card.id);
});
}
function addCardToDiscardPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'discardPile';
draft.player.deck.regions.discardPile.childIds.push(card.id);
});
}
function makeDummyEnemy() {
return createEnemyEntity('仙人掌怪', 999, 999);
}
describe('desert triggers', () => {
describe('instant effects', () => {
it('should apply attack effect as damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [makeDummyEnemy()],
}));
const triggers = getTriggers();
const attackEffect = createEffect('attack');
await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect,
entityKey: 'player',
stacks: 5,
sourceEntityKey: 'enemy-0',
});
expect(ctx.value.player.hp).toBe(25);
});
it('should apply draw effect', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const drawEffect = createEffect('draw');
addCardToDrawPile(ctx, createCard('card-1', 'sword'));
addCardToDrawPile(ctx, createCard('card-2', 'sword'));
await triggers.onEffectApplied.execute(ctx, {
effect: drawEffect,
entityKey: 'player',
stacks: 2,
});
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0);
});
it('should apply gainEnergy effect', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const gainEnergyEffect = createEffect('gainEnergy');
const initialEnergy = ctx.value.player.energy;
await triggers.onEffectApplied.execute(ctx, {
effect: gainEnergyEffect,
entityKey: 'player',
stacks: 2,
});
expect(ctx.value.player.energy).toBe(initialEnergy + 2);
});
it('should remove wound cards from draw and discard piles', async () => {
const ctx = createTestContext(createCombatState({
enemies: [makeDummyEnemy()],
}));
const triggers = getTriggers();
const removeWoundEffect = createEffect('removeWound');
addCardToDrawPile(ctx, createCard('wound-1', 'wound', 'none', 0));
addCardToDiscardPile(ctx, createCard('wound-2', 'wound', 'none', 0));
addCardToDrawPile(ctx, createCard('sword-1', 'sword'));
await triggers.onEffectApplied.execute(ctx, {
effect: removeWoundEffect,
entityKey: 'player',
stacks: 2,
});
expect(ctx.value.player.deck.cards['wound-1']).toBeUndefined();
expect(ctx.value.player.deck.cards['wound-2']).toBeUndefined();
expect(ctx.value.player.deck.cards['sword-1']).toBeDefined();
});
});
describe('damage pipeline', () => {
it('should prevent damage with block', async () => {
const ctx = createTestContext(createCombatState({
enemies: [makeDummyEnemy()],
}));
const triggers = getTriggers();
const defendEffect = createEffect('defend', 'posture');
ctx._state.produce(draft => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 8,
sourceEntityKey: 'enemy-0',
});
expect(ctx.value.player.hp).toBe(27);
expect(ctx.value.player.effects.defend?.stacks).toBe(2);
});
it('should reduce damage with damageReduce', async () => {
const ctx = createTestContext(createCombatState({
enemies: [makeDummyEnemy()],
}));
const triggers = getTriggers();
const damageReduceEffect = createEffect('damageReduce', 'temporary');
ctx._state.produce(draft => {
draft.player.effects.damageReduce = { data: damageReduceEffect, stacks: 3 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 8,
sourceEntityKey: 'enemy-0',
});
expect(ctx.value.player.hp).toBe(25);
});
it('should increase damage with expose', async () => {
const ctx = createTestContext(createCombatState({
enemies: [makeDummyEnemy()],
}));
const triggers = getTriggers();
const exposeEffect = createEffect('expose', 'temporary');
ctx._state.produce(draft => {
draft.player.effects.expose = { data: exposeEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 5,
sourceEntityKey: 'enemy-0',
});
expect(ctx.value.player.hp).toBe(23);
});
});
describe('spike reflection', () => {
it('should damage attacker when entity has spike', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 12, 12)],
}));
const triggers = getTriggers();
const spikeEffect = createEffect('spike', 'permanent');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.spike = { data: spikeEffect, stacks: 3 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
amount: 5,
sourceEntityKey: 'player',
});
expect(ctx.value.player.hp).toBe(27);
});
});
describe('storm static card generation', () => {
it('should give player static cards when storm enemy executes intent', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('风暴之灵', 30, 30)],
}));
const triggers = getTriggers();
const stormEffect = createEffect('storm', 'permanent');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.storm = { data: stormEffect, stacks: 2 };
});
await triggers.onEnemyIntent.execute(ctx, { enemyId: '风暴之灵-0' });
const staticCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'static');
expect(staticCards.length).toBe(2);
});
});
describe('energyDrain', () => {
it('should drain player energy when energyDrain enemy takes damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('幼沙虫', 18, 18)],
}));
const triggers = getTriggers();
const energyDrainEffect = createEffect('energyDrain', 'lingering');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '幼沙虫-0',
amount: 5,
sourceEntityKey: 'player',
});
expect(ctx.value.player.energy).toBe(2);
});
});
describe('molt flee', () => {
it('should make enemy flee when molt >= maxHp after taking damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('蜥蜴', 14, 14)],
}));
const triggers = getTriggers();
const moltEffect = createEffect('molt', 'posture');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.molt = { data: moltEffect, stacks: 14 };
});
let threw = false;
try {
await triggers.onDamage.execute(ctx, {
entityKey: '蜥蜴-0',
amount: 1,
sourceEntityKey: 'player',
});
} catch (e) {
threw = true;
}
expect(threw).toBe(true);
expect(ctx.value.result).toBe('victory');
expect(ctx.value.enemies[0].isAlive).toBe(false);
});
});
describe('discard at turn start', () => {
it('should randomly discard a card when discard effect is active', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const discardEffect = createEffect('discard', 'lingering');
addCardToHand(ctx, createCard('card-1', 'sword'));
addCardToHand(ctx, createCard('card-2', 'shield'));
addCardToHand(ctx, createCard('card-3', 'dagger'));
ctx._state.produce(draft => {
draft.player.effects.discard = { data: discardEffect, stacks: 1 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1);
});
});
describe('next-turn effects', () => {
it('should gain block from defendNext at turn start', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const defendNextEffect = createEffect('defendNext', 'temporary');
ctx._state.produce(draft => {
draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
expect(ctx.value.player.effects.defend?.stacks).toBe(5);
expect(ctx.value.player.effects.defendNext).toBeUndefined();
});
it('should gain energy from energyNext at turn start', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const energyNextEffect = createEffect('energyNext', 'temporary');
ctx._state.produce(draft => {
draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
expect(ctx.value.player.energy).toBe(5);
expect(ctx.value.player.effects.energyNext).toBeUndefined();
});
it('should draw extra cards from drawNext at turn start', async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const drawNextEffect = createEffect('drawNext', 'temporary');
addCardToDrawPile(ctx, createCard('card-1', 'sword'));
addCardToDrawPile(ctx, createCard('card-2', 'sword'));
addCardToDrawPile(ctx, createCard('card-3', 'sword'));
ctx._state.produce(draft => {
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.effects.drawNext).toBeUndefined();
});
});
describe('posture damage effects', () => {
it('should double damage with aim', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 12, 12)],
}));
const triggers = getTriggers();
const aimEffect = createEffect('aim', 'posture');
ctx._state.produce(draft => {
draft.player.effects.aim = { data: aimEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
amount: 5,
sourceEntityKey: 'player',
});
expect(ctx.value.enemies[0].hp).toBe(2);
});
it('should add bonus damage with roll', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 99, 99)],
}));
const triggers = getTriggers();
const rollEffect = createEffect('roll', 'posture');
ctx._state.produce(draft => {
draft.player.effects.roll = { data: rollEffect, stacks: 20 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
amount: 5,
sourceEntityKey: 'player',
});
expect(ctx.value.enemies[0].hp).toBe(74);
expect(ctx.value.player.effects.roll).toBeUndefined();
});
it('should add bonus damage with tailSting', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('沙蝎', 10, 10)],
}));
const triggers = getTriggers();
const tailStingEffect = createEffect('tailSting', 'posture');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 5,
sourceEntityKey: '沙蝎-0',
});
expect(ctx.value.player.hp).toBe(23);
});
it('should double damage with charge on attacker', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('骑马枪手', 25, 25)],
}));
const triggers = getTriggers();
const chargeEffect = createEffect('charge', 'lingering');
ctx._state.produce(draft => {
const enemy = draft.enemies[0];
enemy.effects.charge = { data: chargeEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 5,
sourceEntityKey: '骑马枪手-0',
});
expect(ctx.value.player.hp).toBe(20);
});
});
describe('crossbow chain', () => {
it('should replay other crossbows on same target', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 20, 20)],
}));
const triggers = getTriggers();
const crossbowEffect = createEffect('crossbow');
addCardToHand(ctx, createCard('crossbow-1', 'crossbow'));
addCardToHand(ctx, createCard('crossbow-2', 'crossbow'));
await triggers.onEffectApplied.execute(ctx, {
effect: crossbowEffect,
entityKey: 'player',
stacks: 0,
cardId: 'crossbow-1',
sourceEntityKey: 'player',
targetId: '仙人掌怪-0',
});
expect(ctx.value.enemies[0].hp).toBe(8);
});
});
describe('sandwormKing fatigue heal', () => {
it('should heal sandworm king when player discards fatigue', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('沙虫王', 30, 40)],
}));
const triggers = getTriggers();
addCardToHand(ctx, createCard('fatigue-1', 'fatigue', 'none', 0));
await triggers.onCardDiscarded.execute(ctx, {
cardId: 'fatigue-1',
sourceEntityKey: 'player',
});
expect(ctx.value.enemies[0].hp).toBe(40);
});
});
describe('vulture on-damage', () => {
it('should give player vultureEye when vulture deals damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('秃鹫', 12, 12)],
}));
const triggers = getTriggers();
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
amount: 5,
sourceEntityKey: '秃鹫-0',
});
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'vultureEye');
expect(vultureEyeCards.length).toBe(1);
});
});
});