Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
203 lines
6.1 KiB
TypeScript
203 lines
6.1 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_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);
|
|
});
|