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); }); }); });