593 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|