import { describe, it, expect } from "vitest"; import { createGameContext, createGameCommandRegistry, IGameContext, } from "@/core/game"; import { createRegion } from "@/core/region"; import { createStartWith, createTriggers, 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 { CellKey, 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 { getCards, getEffects, getEncounters, getEnemies, getItems, } from "@/samples/slay-the-spire-like/data/desert"; const cards = getCards(); const effects = getEffects(); const encounters = getEncounters(); const items = getItems(); const enemies = getEnemies(); 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.offset.x},${item.transform.offset.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 { const triggers = createTriggers(); addTriggers(triggers); return triggers; } 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); }); }); });