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

681 lines
20 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
createGameContext,
createGameCommandRegistry,
IGameContext,
} from "@/core/game";
import { createRegion } from "@/core/region";
import {
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,
IRunContext,
} 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/grid-inventory/types";
import {
CardEffect,
getCards,
getEffects,
getEnemies,
} from "@/samples/slay-the-spire-like/data/desert";
const cards = getCards();
const effects = getEffects();
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, emoji: "" };
}
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: "player" as const,
effects: [] as CardEffect[],
};
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<CellKey>();
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> = {}): CombatState {
const regions = createDeckRegions();
return {
player: {
id: "player",
effects: {},
hp: 30,
maxHp: 30,
isAlive: true,
energy: 3,
maxEnergy: 3,
deck: { cards: {}, regions },
itemEffects: {},
},
enemies: [],
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 {
const run: IRunContext = {
getConsumedUses() {
return 0;
},
getItemData() {
return null;
},
*getAdjacentItems() {},
async setConsumedUsesAsync() {},
};
const triggers = createTriggers(run);
addTriggers(triggers, run);
return triggers;
}
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,
entityId: "player",
stacks: 5,
sourceEntityId: "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,
entityId: "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,
entityId: "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,
entityId: "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", "temporary");
ctx._state.produce((draft) => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 };
});
await triggers.onDamage.execute(ctx, {
entityId: "player",
amount: 8,
sourceEntityId: "enemy-0",
});
expect(ctx.value.player.hp).toBe(27);
expect(ctx.value.player.effects.defend?.stacks).toBe(undefined);
});
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, {
entityId: "player",
amount: 8,
sourceEntityId: "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, {
entityId: "player",
amount: 5,
sourceEntityId: "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");
const attackEffect = createEffect("attack", "instant");
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.spike = { data: spikeEffect, stacks: 3 };
});
await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect,
entityId: "仙人掌怪-0",
stacks: 5,
sourceEntityId: "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();
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, {
entityId: "幼沙虫-0",
amount: 5,
sourceEntityId: "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, {
entityId: "蜥蜴-0",
amount: 1,
sourceEntityId: "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, { entityId: "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, { entityId: "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, { entityId: "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, { entityId: "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, {
entityId: "仙人掌怪-0",
amount: 5,
sourceEntityId: "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, {
entityId: "仙人掌怪-0",
amount: 5,
sourceEntityId: "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, {
entityId: "player",
amount: 5,
sourceEntityId: "沙蝎-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, {
entityId: "player",
amount: 5,
sourceEntityId: "骑马枪手-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,
entityId: "player",
stacks: 0,
cardId: "crossbow-1",
sourceEntityId: "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",
sourceEntityId: "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, {
entityId: "player",
amount: 5,
sourceEntityId: "秃鹫-0",
});
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter(
(c: GameCard) => c.itemId === "vultureEye",
);
expect(vultureEyeCards.length).toBe(1);
});
});
});