/// // 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("../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_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); });