diff --git a/src/samples/slay-the-spire-like/data/desert/triggers/effect.ts b/src/samples/slay-the-spire-like/data/desert/triggers/effect.ts index b81d286..da96790 100644 --- a/src/samples/slay-the-spire-like/data/desert/triggers/effect.ts +++ b/src/samples/slay-the-spire-like/data/desert/triggers/effect.ts @@ -228,22 +228,21 @@ export function addEffectTriggers(triggers: Triggers) { } }); - // ========== storm: give static card to player on attack ========== - triggers.onDamage.use(async (ctx, next) => { + // ========== storm: give static card to player when storm enemy attacks ========== + triggers.onEnemyIntent.use(async (ctx, next) => { await next(); - if (ctx.amount - (ctx.prevented ?? 0) <= 0) return; + const enemy = getCombatEntity(ctx.game.value, ctx.enemyId); + if (!enemy || !enemy.isAlive) return; - const entity = getCombatEntity(ctx.game.value, ctx.entityKey); - if (!entity || !entity.isAlive) return; - - const storm = entity.effects.storm?.stacks ?? 0; - if (storm > 0 && ctx.entityKey !== "player") { + const storm = enemy.effects.storm?.stacks ?? 0; + if (storm > 0) { for (let i = 0; i < storm; i++) { await triggers.onEffectApplied.execute(ctx.game, { effect: findEffect(ctx.game, "static"), entityKey: "player", stacks: 1, + sourceEntityKey: ctx.enemyId, }); } } @@ -269,10 +268,11 @@ export function addEffectTriggers(triggers: Triggers) { // ========== molt: enemy flees if molt >= maxHp ========== triggers.onDamage.use(async (ctx, next) => { - await next(); - const entity = getCombatEntity(ctx.game.value, ctx.entityKey); - if (!entity || !entity.isAlive) return; + if (!entity || !entity.isAlive) { + await next(); + return; + } const molt = entity.effects.molt?.stacks ?? 0; if (molt >= entity.maxHp) { @@ -285,69 +285,84 @@ export function addEffectTriggers(triggers: Triggers) { draft.result = draft.enemies.every(en => !en.isAlive) ? "victory" : null; }); if (ctx.game.value.result) throw ctx.game.value; + return; } + + await next(); }); // ========== discard: random discard at turn start ========== triggers.onTurnStart.use(async (ctx, next) => { - await next(); - - if (ctx.entityKey !== "player") return; + if (ctx.entityKey !== "player") { + await next(); + return; + } const discard = ctx.game.value.player.effects.discard; - if (!discard || discard.stacks <= 0) return; + if (discard && discard.stacks > 0) { + const handIds = [...ctx.game.value.player.deck.regions.hand.childIds]; + if (handIds.length > 0) { + const randomIndex = ctx.game.rng.nextInt(handIds.length); + const randomCardId = handIds[randomIndex]; + await triggers.onCardDiscarded.execute(ctx.game, { cardId: randomCardId }); + } + } - const handIds = [...ctx.game.value.player.deck.regions.hand.childIds]; - if (handIds.length === 0) return; - - const randomIndex = ctx.game.rng.nextInt(handIds.length); - const randomCardId = handIds[randomIndex]; - await triggers.onCardDiscarded.execute(ctx.game, { cardId: randomCardId }); + await next(); }); // ========== defendNext: gain block next turn ========== triggers.onTurnStart.use(async (ctx, next) => { - await next(); - - if (ctx.entityKey !== "player") return; + if (ctx.entityKey !== "player") { + await next(); + return; + } const defendNext = ctx.game.value.player.effects.defendNext; - if (!defendNext || defendNext.stacks <= 0) return; + if (defendNext && defendNext.stacks > 0) { + await ctx.game.produceAsync(draft => { + addEntityEffect(draft.player, findEffect(ctx.game, "defend"), defendNext.stacks); + addEntityEffect(draft.player, defendNext.data, -defendNext.stacks); + }); + } - await ctx.game.produceAsync(draft => { - addEntityEffect(draft.player, findEffect(ctx.game, "defend"), defendNext.stacks); - addEntityEffect(draft.player, defendNext.data, -defendNext.stacks); - }); + await next(); }); // ========== energyNext: gain energy next turn ========== triggers.onTurnStart.use(async (ctx, next) => { - await next(); - - if (ctx.entityKey !== "player") return; + if (ctx.entityKey !== "player") { + await next(); + return; + } const energyNext = ctx.game.value.player.effects.energyNext; - if (!energyNext || energyNext.stacks <= 0) return; + if (energyNext && energyNext.stacks > 0) { + await ctx.game.produceAsync(draft => { + draft.player.energy += energyNext.stacks; + addEntityEffect(draft.player, energyNext.data, -energyNext.stacks); + }); + } - await ctx.game.produceAsync(draft => { - draft.player.energy += energyNext.stacks; - addEntityEffect(draft.player, energyNext.data, -energyNext.stacks); - }); + await next(); }); // ========== drawNext: draw extra cards next turn ========== triggers.onTurnStart.use(async (ctx, next) => { - await next(); - - if (ctx.entityKey !== "player") return; + if (ctx.entityKey !== "player") { + await next(); + return; + } const drawNext = ctx.game.value.player.effects.drawNext; - if (!drawNext || drawNext.stacks <= 0) return; + if (drawNext && drawNext.stacks > 0) { + await ctx.game.produceAsync(draft => { + addEntityEffect(draft.player, drawNext.data, -drawNext.stacks); + }); + await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks }); + } - await ctx.game.produceAsync(draft => { - addEntityEffect(draft.player, drawNext.data, -drawNext.stacks); - }); - await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks }); + await next(); }); // ========== aim: double damage, lose aim on damage ========== diff --git a/tests/samples/slay-the-spire-like/combat/triggers.test.ts b/tests/samples/slay-the-spire-like/combat/triggers.test.ts new file mode 100644 index 0000000..c1aa1bf --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/triggers.test.ts @@ -0,0 +1,592 @@ +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 = {}; + 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[]): GridInventory { + const map = new Map>(); + const occupied = new Set(); + 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 { + 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 { + const registry = createGameCommandRegistry(); + 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, 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, 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, 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); + }); + }); +});