Files
systemic-game-design-course/course/05-assemble/tests/ts/test_05.test.ts
T
Lucas WintherandClaude Opus 5.5 e04dc1a11f feat: module 05 assemble
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]>
2026-10-29 03:17:21 +01:00

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