Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks, the cumulative reference (Module 11's plus combat) and the drop. The Damage action carries per-target hits with a priority; react stable-sorts its Queue by priority before order of play, so the triggers answering the defender's Damage Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness contract, catalog, research notes and earlier lessons' forward pointers follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
8.2 KiB
TypeScript
256 lines
8.2 KiB
TypeScript
// Module 12: Combat. Each test matches a heading in scenarios.md.
|
|
import { expect, test } from "vitest";
|
|
import { newGame, type CardDef, type GameEvent, type Id } from "../src/harness";
|
|
|
|
const fillers = (n: number): string[] => Array(n).fill("Test Filler");
|
|
const gameWithDecks = () => newGame({ decks: [fillers(5), fillers(5)] });
|
|
|
|
/** The `source` of each `Triggered` event, in log order. */
|
|
const triggeredSources = (log: GameEvent[]): Id[] => log.flatMap((e) => (e.type === "Triggered" ? [e.source] : []));
|
|
|
|
/** The id of the one `Summoned` event in a log. */
|
|
const summoned = (log: GameEvent[]): Id => {
|
|
const ids = log.flatMap((e) => (e.type === "Summoned" ? [e.id] : []));
|
|
expect(ids).toHaveLength(1);
|
|
return ids[0];
|
|
};
|
|
|
|
test("test_12_combat_is_simultaneous", () => {
|
|
const g = gameWithDecks();
|
|
const a = g.summon("Loot Hoarder", 0);
|
|
const d = g.summon("Loot Hoarder", 1);
|
|
g.clearLog();
|
|
|
|
g.orderAttack(a, d);
|
|
|
|
const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]];
|
|
expect(g.log()).toEqual([
|
|
{ type: "Damaged", target: a, amount: 2 },
|
|
{ type: "Damaged", target: d, amount: 2 },
|
|
{ type: "Destroyed", id: a },
|
|
{ type: "Destroyed", id: d },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: a, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 0, id: c0 },
|
|
{ type: "Triggered", source: d, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 1, id: c1 },
|
|
]);
|
|
expect(g.zoneOf(a)).toBe("graveyard");
|
|
expect(g.zoneOf(d)).toBe("graveyard");
|
|
});
|
|
|
|
test("test_12_defender_triggers_first", () => {
|
|
const g = gameWithDecks();
|
|
const a = g.summon("Acolyte of Pain", 0);
|
|
const d = g.summon("Acolyte of Pain", 1);
|
|
g.clearLog();
|
|
|
|
// The attacker is older: order of play alone would put its trigger first.
|
|
g.orderAttack(a, d);
|
|
|
|
const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]];
|
|
expect(g.log()).toEqual([
|
|
{ type: "Damaged", target: a, amount: 1 },
|
|
{ type: "Damaged", target: d, amount: 1 },
|
|
{ type: "Triggered", source: d, trigger: "damaged" },
|
|
{ type: "Drew", player: 1, id: c1 },
|
|
{ type: "Triggered", source: a, trigger: "damaged" },
|
|
{ type: "Drew", player: 0, id: c0 },
|
|
]);
|
|
|
|
// The other way round: the attacker is younger, and the defender still goes first.
|
|
g.clearLog();
|
|
g.orderAttack(d, a);
|
|
expect(triggeredSources(g.log())).toEqual([a, d]);
|
|
expect(g.health(a)).toBe(2);
|
|
expect(g.health(d)).toBe(2);
|
|
});
|
|
|
|
test("test_12_taunt_restricts_targets", () => {
|
|
const g = gameWithDecks();
|
|
const h = g.summon("Loot Hoarder", 1);
|
|
const t = g.summon("Unstable Ghoul", 1);
|
|
const a = g.summon("Harvest Golem", 0);
|
|
const enemyHero = g.hero(1);
|
|
g.clearLog();
|
|
|
|
expect(g.canAttack(a, h)).toContain("Taunt");
|
|
expect(g.canAttack(a, enemyHero)).toContain("Taunt");
|
|
expect(g.canAttack(a, t)).toBeNull();
|
|
|
|
expect(() => g.orderAttack(a, h)).toThrow(g.canAttack(a, h)!);
|
|
expect(g.log()).toEqual([]);
|
|
expect(g.health(h)).toBe(1);
|
|
|
|
g.orderAttack(a, t);
|
|
expect(g.log()).toEqual([
|
|
{ type: "Damaged", target: t, amount: 2 },
|
|
{ type: "Damaged", target: a, amount: 1 },
|
|
]);
|
|
expect(g.health(t)).toBe(1);
|
|
expect(g.health(a)).toBe(2);
|
|
});
|
|
|
|
test("test_12_zero_attack_cannot_attack", () => {
|
|
const g = gameWithDecks();
|
|
const bell = g.summon("Test Bell", 0);
|
|
const f0 = g.summon("Test Filler", 0);
|
|
const f1 = g.summon("Test Filler", 1);
|
|
const [hero0, hero1] = [g.hero(0), g.hero(1)];
|
|
g.clearLog();
|
|
|
|
expect(g.canAttack(bell, f1)).toContain("Attack");
|
|
expect(() => g.orderAttack(bell, f1)).toThrow();
|
|
expect(g.canAttack(f0, bell)).not.toBeNull();
|
|
expect(g.canAttack(hero0, f1)).not.toBeNull();
|
|
expect(g.canAttack(f0, f1)).toBeNull();
|
|
expect(g.canAttack(f0, hero1)).toBeNull();
|
|
expect(g.log()).toEqual([]);
|
|
});
|
|
|
|
test("test_12_attack_hero_through_armor", () => {
|
|
const g = gameWithDecks();
|
|
const s = g.summon("Armorsmith", 1);
|
|
g.dealDamage(s, 1);
|
|
g.dealDamage(s, 1);
|
|
g.destroy(s);
|
|
const b = g.summon("Bolf Ramshield", 0);
|
|
const enemyHero = g.hero(1);
|
|
expect(g.armor(1)).toBe(2);
|
|
g.clearLog();
|
|
|
|
g.orderAttack(b, enemyHero);
|
|
|
|
expect(g.log()).toEqual([
|
|
{ type: "ArmorLost", player: 1, amount: 2 },
|
|
{ type: "Damaged", target: enemyHero, amount: 3 },
|
|
]);
|
|
expect(g.armor(1)).toBe(0);
|
|
expect(g.health(enemyHero)).toBe(29);
|
|
expect(g.health(b)).toBe(9);
|
|
});
|
|
|
|
test("test_12_redirect_only_for_the_hero", () => {
|
|
const g = gameWithDecks();
|
|
const r = g.summon("Bolf Ramshield", 1);
|
|
const h = g.summon("Loot Hoarder", 1);
|
|
const a = g.summon("Harvest Golem", 0);
|
|
const enemyHero = g.hero(1);
|
|
g.clearLog();
|
|
|
|
g.orderAttack(a, h);
|
|
|
|
const c = g.hand(1)[0];
|
|
expect(g.log()).toEqual([
|
|
{ type: "Damaged", target: h, amount: 2 },
|
|
{ type: "Damaged", target: a, amount: 2 },
|
|
{ type: "Destroyed", id: h },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: h, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 1, id: c },
|
|
]);
|
|
expect(g.health(r)).toBe(9);
|
|
|
|
g.clearLog();
|
|
g.orderAttack(a, enemyHero);
|
|
|
|
expect(g.log()).toEqual([{ type: "Damaged", target: r, amount: 2 }]);
|
|
expect(g.health(enemyHero)).toBe(30);
|
|
expect(g.health(r)).toBe(7);
|
|
expect(g.health(a)).toBe(1);
|
|
});
|
|
|
|
test("test_12_end_of_turn_trigger", () => {
|
|
const g = gameWithDecks();
|
|
const b = g.summon("Test Bell", 0);
|
|
const [hero0, hero1] = [g.hero(0), g.hero(1)];
|
|
g.clearLog();
|
|
|
|
g.endTurn(1);
|
|
expect(g.log()).toEqual([]);
|
|
|
|
g.endTurn(0);
|
|
expect(g.log()).toEqual([
|
|
{ type: "Triggered", source: b, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
]);
|
|
expect(g.health(hero1)).toBe(29);
|
|
expect(g.health(hero0)).toBe(30);
|
|
});
|
|
|
|
test("test_12_drakkari_doubles_end_of_turn", () => {
|
|
const g = gameWithDecks();
|
|
const b0 = g.summon("Test Bell", 0);
|
|
g.summon("Drakkari Enchanter", 0);
|
|
const b1 = g.summon("Test Bell", 1);
|
|
const [hero0, hero1] = [g.hero(0), g.hero(1)];
|
|
g.clearLog();
|
|
|
|
g.endTurn(0);
|
|
expect(g.log()).toEqual([
|
|
{ type: "Triggered", source: b0, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
{ type: "Triggered", source: b0, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
]);
|
|
|
|
// Drakkari doubles its owner's end of turn effects only.
|
|
g.clearLog();
|
|
g.endTurn(1);
|
|
expect(g.log()).toEqual([
|
|
{ type: "Triggered", source: b1, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero0, amount: 1 },
|
|
]);
|
|
expect(g.health(hero1)).toBe(28);
|
|
expect(g.health(hero0)).toBe(29);
|
|
});
|
|
|
|
/** "At the end of your turn, summon a Drakkari Enchanter." A one-off card, written as data in the test. */
|
|
const TEST_CONJURER = { "name": "Test Conjurer", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "endOfTurn" }, { "kind": "effect", "effect": { "type": "summon", "card": "Drakkari Enchanter", "count": 1 } }] } as CardDef;
|
|
|
|
test("test_12_drakkari_summoned_at_end_of_turn", () => {
|
|
// Conjurer first: the Bell's entry starts after Drakkari's summon updated the auras.
|
|
{
|
|
const g = gameWithDecks();
|
|
g.registerCards([TEST_CONJURER]);
|
|
const c = g.summon("Test Conjurer", 0);
|
|
const b = g.summon("Test Bell", 0);
|
|
const hero1 = g.hero(1);
|
|
g.clearLog();
|
|
|
|
g.endTurn(0);
|
|
|
|
const k = summoned(g.log());
|
|
expect(g.nameOf(k)).toBe("Drakkari Enchanter");
|
|
expect(g.log()).toEqual([
|
|
{ type: "Triggered", source: c, trigger: "endOfTurn" },
|
|
{ type: "Summoned", id: k },
|
|
{ type: "Triggered", source: b, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
{ type: "Triggered", source: b, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
]);
|
|
}
|
|
|
|
// Bell first: its entry has started, and counted, before any Drakkari exists.
|
|
{
|
|
const g = gameWithDecks();
|
|
g.registerCards([TEST_CONJURER]);
|
|
const b = g.summon("Test Bell", 0);
|
|
const c = g.summon("Test Conjurer", 0);
|
|
const hero1 = g.hero(1);
|
|
g.clearLog();
|
|
|
|
g.endTurn(0);
|
|
|
|
const k = summoned(g.log());
|
|
expect(g.log()).toEqual([
|
|
{ type: "Triggered", source: b, trigger: "endOfTurn" },
|
|
{ type: "Damaged", target: hero1, amount: 1 },
|
|
{ type: "Triggered", source: c, trigger: "endOfTurn" },
|
|
{ type: "Summoned", id: k },
|
|
]);
|
|
expect(g.health(hero1)).toBe(29);
|
|
}
|
|
});
|