Files
systemic-game-design-course/course/12-combat/tests/ts/test_12.test.ts
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
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>
2026-11-11 13:34:20 +01:00

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