Files
systemic-game-design-course/course/05-assemble/tests/ts/test_05.test.ts
T
Lucas WintherandClaude Opus 5.5 21fb40c6f5 feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)
A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:07:54 +01:00

374 lines
14 KiB
TypeScript

/// <reference types="vite/client" />
// Module 5: Behavior as data and Assemble. Each test matches a heading in scenarios.md.
import { expect, test } from "vitest";
import { AssembleError, newGame, type CardDef } from "../src/harness";
/** Every file in the workspace's `cards/` directory, keyed by path (sorted by file name below). */
const files = import.meta.glob<CardDef>("../cards/*.json", { eager: true, import: "default" });
const cardFiles = (): CardDef[] =>
Object.keys(files)
.sort()
.map((path) => files[path]);
/** Names of the minion files with a trigger clause listening for `on`, sorted. */
const minionsWithTrigger = (on: string): string[] =>
cardFiles()
.filter((c) => c.type === "minion" && c.text.some((clause) => clause.kind === "trigger" && clause.on === on))
.map((c) => c.name)
.sort();
const fillers = (n: number): string[] => Array(n).fill("Test Filler");
test("test_05_regression_data_cards", () => {
const defs = cardFiles();
const names = defs.map((c) => c.name);
for (const used of [
"Armorsmith",
"Acolyte of Pain",
"Loot Hoarder",
"Harvest Golem",
"Damaged Golem",
"Unstable Ghoul",
"Baron Rivendare",
"Novice Engineer",
"Whirlwind",
"Twisting Nether",
"Test Echo",
"Test Reactor",
"Test Filler",
]) {
expect(names, `cards/ has no file for ${used}`).toContain(used);
}
const g = newGame();
g.registerCards(defs);
for (const def of defs.filter((c) => c.type === "minion")) {
const id = g.summon(def.name, 0);
expect(g.nameOf(id)).toBe(def.name);
expect(g.attack(id), def.name).toBe(def.attack);
expect(g.health(id), def.name).toBe(def.health);
expect(g.cost(id), def.name).toBe(def.cost);
}
});
test("test_05_assemble_bottom_half", () => {
const g = newGame();
const name = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
expect(name).toBe("Harvest Golem (Assembled)");
const b = g.summon("Baron Rivendare", 0);
const f = g.summon("Test Filler", 0);
const s = g.summon("Test Sponge", 0);
const a = g.summon(name, 0);
const [h0, h1] = [g.hero(0), g.hero(1)];
g.clearLog();
g.destroy(a);
const activation = [
{ type: "Triggered", source: a, trigger: "deathrattle" },
{ type: "Damaged", target: h0, amount: 1 },
{ type: "Damaged", target: h1, amount: 1 },
{ type: "Damaged", target: b, amount: 1 },
{ type: "Damaged", target: f, amount: 1 },
{ type: "Damaged", target: s, amount: 1 },
];
expect(g.log()).toEqual([
{ type: "Destroyed", id: a },
{ type: "DeathPhase", round: 1 },
...activation,
...activation,
{ type: "Destroyed", id: f },
{ type: "DeathPhase", round: 2 },
]);
expect(g.health(h0)).toBe(28);
expect(g.health(h1)).toBe(28);
expect(g.health(b)).toBe(5);
expect(g.health(s)).toBe(1);
expect(g.zoneOf(f)).toBe("graveyard");
expect(g.board(0)).toEqual([b, s]);
});
test("test_05_assembled_keeps_base_stats", () => {
const g = newGame();
const name = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
const a = g.addToHand(name, 0);
expect(g.nameOf(a)).toBe(name);
expect(name).toContain("Harvest Golem");
expect(g.cost(a)).toBe(3);
expect(g.attack(a)).toBe(2);
expect(g.health(a)).toBe(3);
});
test("test_05_assemble_must_change_text", () => {
const g = newGame();
expect(() => g.assemble("Loot Hoarder", "Loot Hoarder", "bottom")).toThrow(AssembleError);
expect(() => g.assemble("Loot Hoarder", "Loot Hoarder", "top")).toThrow(AssembleError);
});
test("test_05_assemble_never_rewrites_an_earlier_card", () => {
const g = newGame();
const first = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
const a = g.addToHand(first, 0);
// The base changes (a new cost, the same text), then the same Assemble is made again.
const golem = cardFiles().find((c) => c.name === "Harvest Golem")!;
g.registerCards([{ ...golem, cost: 5 }]);
const second = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
const b = g.addToHand(second, 0);
// A different card, so a different name: the first card in hand is still the one it was.
expect(second).not.toBe(first);
expect(g.cost(a)).toBe(3);
expect(g.cost(b)).toBe(5);
});
test("test_05_top_half_swap", () => {
const g = newGame({ decks: [fillers(5), []] });
const name = g.assemble("Loot Hoarder", "Novice Engineer", "top");
const e = g.addToHand(name, 0);
g.clearLog();
g.play(e);
const hand = g.hand(0);
expect(hand).toHaveLength(1);
const [c] = hand;
expect(g.log()).toEqual([
{ type: "Summoned", id: e },
{ type: "Triggered", source: e, trigger: "battlecry" },
{ type: "Drew", player: 0, id: c },
]);
expect(g.attack(e)).toBe(2);
expect(g.health(e)).toBe(1);
g.clearLog();
g.destroy(e);
expect(g.log()).toEqual([
{ type: "Destroyed", id: e },
{ type: "DeathPhase", round: 1 },
]);
expect(g.hand(0)).toHaveLength(1);
});
test("test_05_assemble_effect_with_chooser", () => {
const g = newGame();
const t = g.addToHand("Test Filler", 0);
const calls: [string, string[]][] = [];
g.setChooser((kind, options) => {
calls.push([kind, [...options]]);
return kind === "base" ? "Novice Engineer" : kind === "donor" ? "Harvest Golem" : "bottom";
});
g.clearLog();
g.cast("Test Workshop", 0);
expect(calls.map(([kind]) => kind)).toEqual(["base", "donor", "half"]);
expect(calls[0][1]).toEqual(minionsWithTrigger("battlecry"));
expect(calls[1][1]).toContain("Harvest Golem");
expect(calls[1][1]).not.toContain("Novice Engineer");
expect(calls[2][1]).toEqual(["top", "bottom"]);
const hand = g.hand(0);
expect(hand).toHaveLength(2);
expect(hand[0]).toBe(t);
const a = hand[1];
expect(g.nameOf(a)).toBe("Novice Engineer (Assembled)");
expect(g.cost(a)).toBe(2);
expect(g.attack(a)).toBe(2);
expect(g.health(a)).toBe(2);
expect(g.log()).toEqual([
{ type: "SpellCast", card: "Test Workshop" },
{ type: "StatsChanged", id: t, attack: 2, health: 2 },
{ type: "StatsChanged", id: a, attack: 2, health: 2 },
]);
g.clearLog();
g.play(a);
const board = g.board(0);
expect(board).toHaveLength(2);
expect(board[0]).toBe(a);
const d = board[1];
expect(g.nameOf(d)).toBe("Damaged Golem");
expect(g.log()).toEqual([
{ type: "Summoned", id: a },
{ type: "Triggered", source: a, trigger: "battlecry" },
{ type: "Summoned", id: d },
]);
});
test("test_05_additive_multipliers_stack", () => {
const g = newGame({ decks: [fillers(5), []] });
g.summon("Test Chorus", 0);
g.summon("Test Chorus", 0);
const h = g.summon("Loot Hoarder", 0);
g.clearLog();
g.dealDamage(h, 1);
expect(g.hand(0)).toHaveLength(3);
const activations = g
.log()
.filter((e) => e.type === "Triggered" && e.source === h && e.trigger === "deathrattle");
expect(activations).toHaveLength(3);
});
/** "Deathrattle: Summon a Baron Rivendare." A one-off card, written as data in the test. */
const TEST_HERALD = { "name": "Test Herald", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Baron Rivendare", "count": 1 } }] } as CardDef;
test("test_05_summoned_multiplier_counts_for_later_entries", () => {
// Herald is older: its Deathrattle resolves first and summons Baron before the Hoarder's starts.
const a = newGame({ decks: [fillers(5), []] });
a.registerCards([TEST_HERALD]);
a.summon("Test Herald", 0);
const hoarderA = a.summon("Loot Hoarder", 0);
a.clearLog();
a.cast("Twisting Nether", 0);
expect(a.log().filter((e) => e.type === "Triggered" && e.source === hoarderA)).toHaveLength(2);
expect(a.hand(0)).toHaveLength(2);
expect(a.board(0).map((id) => a.nameOf(id))).toEqual(["Baron Rivendare"]);
// The Hoarder is older: its Deathrattle has started, and finished, before any Baron exists.
const b = newGame({ decks: [fillers(5), []] });
b.registerCards([TEST_HERALD]);
const hoarderB = b.summon("Loot Hoarder", 0);
b.summon("Test Herald", 0);
b.clearLog();
b.cast("Twisting Nether", 0);
expect(b.log().filter((e) => e.type === "Triggered" && e.source === hoarderB)).toHaveLength(1);
expect(b.hand(0)).toHaveLength(1);
expect(b.board(0).map((id) => b.nameOf(id))).toEqual(["Baron Rivendare"]);
});
/** "Battlecry: Draw a card. Your Battlecries trigger twice." A one-off card, written as data in the test. */
const TEST_BRAGGART = { "name": "Test Braggart", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "draw", "count": 1 } }, { "kind": "aura", "modifier": { "type": "triggerMultiplier", "trigger": "battlecry", "times": 2, "stacking": "max" } }] } as CardDef;
test("test_05_own_aura_misses_own_battlecry", () => {
const g = newGame({ decks: [fillers(5), []] });
g.registerCards([TEST_BRAGGART]);
const first = g.addToHand("Test Braggart", 0);
const second = g.addToHand("Test Braggart", 0);
g.clearLog();
g.play(first);
// Playing is not summoning: the Braggart's own aura joins at the end of the Phase.
expect(g.log().filter((e) => e.type === "Triggered")).toEqual([
{ type: "Triggered", source: first, trigger: "battlecry" },
]);
expect(g.hand(0)).toHaveLength(2);
g.clearLog();
g.play(second);
// The first Braggart was already in play: the second one's Battlecry triggers twice.
expect(g.log().filter((e) => e.type === "Triggered")).toEqual([
{ type: "Triggered", source: second, trigger: "battlecry" },
{ type: "Triggered", source: second, trigger: "battlecry" },
]);
expect(g.hand(0)).toHaveLength(3);
});
/** "Whenever a minion takes damage, deal 1 damage to all minions." A one-off card, written as data in the test. */
const TEST_BOMB = { "name": "Test Bomb", "type": "minion", "cost": 1, "attack": 1, "health": 50, "split": 1, "text": [{ "kind": "trigger", "on": "anyMinionDamaged" }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] } as CardDef;
test("test_05_trigger_does_not_nest_in_itself", () => {
const g = newGame();
g.registerCards([TEST_BOMB]);
const b1 = g.summon("Test Bomb", 0);
const b2 = g.summon("Test Bomb", 0);
g.clearLog();
g.dealDamage(b1, 1);
// A trigger is never queued while that same minion's same trigger is resolving.
expect(g.log().filter((e) => e.type === "Triggered")).toEqual(
[b1, b2, b2, b2, b1, b1].map((source) => ({ type: "Triggered", source, trigger: "damaged" })),
);
expect(g.log().filter((e) => e.type === "Damaged")).toHaveLength(13);
expect(g.health(b1)).toBe(43);
expect(g.health(b2)).toBe(44);
});
/** "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." A one-off card, written as data in the test. */
const TEST_EMBER = { "name": "Test Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Ember", "count": 1 } }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] } as CardDef;
test("test_05_death_phase_limit", () => {
const g = newGame();
g.registerCards([TEST_EMBER]);
const e = g.summon("Test Ember", 0);
g.clearLog();
expect(() => g.dealDamage(e, 1)).toThrow("Death Phase limit");
// Death Phases 1 to 64 ran; the 65th was refused before it started.
const phases = g.log().filter((event) => event.type === "DeathPhase");
expect(phases).toHaveLength(64);
expect(phases[63]).toEqual({ type: "DeathPhase", round: 64 });
// The error is raised between Death Phases, where no trigger is resolving: the next event is at depth 0.
g.clearLog();
const f = g.summon("Test Filler", 0);
expect(g.logTree()).toBe(`Summoned{id: ${f}}\n`);
});
test("test_05_assemble_rejects_ill_formed_text", () => {
const g = newGame();
// Top: "Draw a card." with no trigger. Bottom: a Deathrattle with no effect, then Baron's aura.
expect(() => g.assemble("Loot Hoarder", "Baron Rivendare", "top")).toThrow(AssembleError);
expect(() => g.assemble("Loot Hoarder", "Baron Rivendare", "bottom")).toThrow(AssembleError);
const calls: [string, string[]][] = [];
g.setChooser((kind, options) => {
calls.push([kind, [...options]]);
return kind === "base" ? "Novice Engineer" : kind === "donor" ? "Loot Hoarder" : "top";
});
g.cast("Test Workshop", 0);
// A minion whose whole text is one aura has no half that fits around a trigger and its effect.
const auraOnly = cardFiles()
.filter((c) => c.type === "minion" && c.text.length > 0 && c.text.every((clause) => clause.kind === "aura"))
.map((c) => c.name);
expect(auraOnly).toContain("Baron Rivendare");
expect(auraOnly).toContain("Bolf Ramshield");
const [, donors] = calls[1];
expect(donors).toContain("Loot Hoarder");
for (const name of auraOnly) expect(donors).not.toContain(name);
});
/** "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers." A one-off card, written as data in the test. */
const TEST_BROOD = { "name": "Test Brood", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }, { "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }] } as CardDef;
test("test_05_multi_summon_order", () => {
const g = newGame();
g.registerCards([TEST_BROOD]);
const f1 = g.summon("Test Filler", 0);
const b = g.addToHand("Test Brood", 0);
g.clearLog();
g.play(b);
// Ids are issued in summon order: the first token is the smaller id, and it stands further left.
const summoned = () => g.log().flatMap((e) => (e.type === "Summoned" && e.id !== b ? [e.id] : []));
const [t1, t2] = summoned();
expect(t1).toBeLessThan(t2);
expect(g.board(0)).toEqual([f1, b, t1, t2]);
const f2 = g.summon("Test Filler", 0);
g.clearLog();
g.destroy(b);
const [t3, t4] = summoned();
expect(t3).toBeLessThan(t4);
expect(g.board(0)).toEqual([f1, t3, t4, t1, t2, f2]);
});