Files
systemic-game-design-course/course/10-spellgrid/tests/ts/test_10.test.ts
T
Lucas WintherandClaude Opus 5.5 6f576940b7 fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:11:09 +01:00

305 lines
7.9 KiB
TypeScript

// Module 10: SpellGrid. Each test matches a heading in scenarios.md.
import { expect, test } from "vitest";
import { newSpellGrid, type Id, type LevelDef, type SpellGrid } from "../src/harness";
import l1 from "../levels/l1-double-tap.json";
import l2 from "../levels/l2-spliced-corpse.json";
import l3 from "../levels/l3-simultaneous-paradox.json";
type Placed = [string, number, number];
/** A level `width` wide and `height` high: text written as `[word, x, y]`, other objects, minion cards, a hand. */
function level(
width: number,
height: number,
opts: { words?: Placed[]; objects?: Placed[]; cards?: Placed[]; hand?: string[] },
): LevelDef {
return {
width,
height,
objects: [
...(opts.words ?? []).map(([word, x, y]) => ({ kind: `text:${word}`, x, y })),
...(opts.objects ?? []).map(([kind, x, y]) => ({ kind, x, y })),
],
cards: (opts.cards ?? []).map(([card, x, y]) => ({ card, x, y })),
hand: opts.hand ?? [],
};
}
/** The minion on tile (x, y); fails the test if there is none. */
function minion(g: SpellGrid, x: number, y: number): Id {
const id = g.minionAt(x, y);
expect(id).not.toBeNull();
return id!;
}
/** How many times `source`'s Deathrattle triggered in the log. */
const deathrattles = (g: SpellGrid, source: Id): number =>
g.log().filter((e) => e.type === "Triggered" && e.source === source && e.trigger === "deathrattle").length;
test("test_10_spatial_rule_feeds_interceptor", () => {
const g = newSpellGrid(
level(6, 3, {
words: [
["DEATHRATTLE", 0, 0],
["IS", 1, 0],
],
cards: [
["Powder Keg", 0, 2],
["Bramble Wall", 2, 2],
],
hand: ["Spark"],
}),
[],
);
const keg = minion(g, 0, 2);
const bramble = minion(g, 2, 2);
expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE");
g.place("text:DOUBLE", 2, 0);
expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE");
g.clearLog();
g.cast("Spark", keg);
expect(deathrattles(g, keg)).toBe(2);
expect(g.health(bramble)).toBe(1);
expect(g.posOf(keg)).toBeNull();
expect(g.posOf(bramble)).toEqual([2, 2]);
expect(g.hand()).toEqual([]);
});
test("test_10_not_cancels_spatial_modifier", () => {
const g = newSpellGrid(
level(6, 4, {
words: [
["DEATHRATTLE", 0, 0],
["IS", 1, 0],
["DOUBLE", 2, 0],
["DEATHRATTLE", 0, 1],
["IS", 1, 1],
["NOT", 2, 1],
["DOUBLE", 3, 1],
],
cards: [
["Grave Warden", 0, 3],
["Powder Keg", 1, 3],
["Bramble Wall", 2, 3],
],
hand: ["Spark"],
}),
[],
);
const warden = minion(g, 0, 3);
const keg = minion(g, 1, 3);
const bramble = minion(g, 2, 3);
expect(g.rules()).toContain("DEATHRATTLE IS DOUBLE");
expect(g.rules()).toContain("DEATHRATTLE IS NOT DOUBLE");
expect(g.activeRules()).toContain("DEATHRATTLE IS NOT DOUBLE");
expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE");
g.clearLog();
g.cast("Spark", keg);
expect(deathrattles(g, keg)).toBe(1);
expect(g.health(bramble)).toBe(2);
expect(g.posOf(warden)).toBeNull();
});
test("test_10_grid_and_card_doublers_take_max", () => {
const g = newSpellGrid(
level(6, 3, {
words: [
["DEATHRATTLE", 0, 0],
["IS", 1, 0],
["DOUBLE", 2, 0],
],
cards: [
["Grave Warden", 0, 2],
["Powder Keg", 1, 2],
["Bramble Wall", 2, 2],
],
hand: ["Spark"],
}),
[],
);
const keg = minion(g, 1, 2);
const bramble = minion(g, 2, 2);
g.clearLog();
g.cast("Spark", keg);
expect(deathrattles(g, keg)).toBe(2);
expect(g.health(bramble)).toBe(1);
});
test("test_10_hazard_feeds_death_step", () => {
const g = newSpellGrid(
level(5, 4, {
words: [
["KEG", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
["AND", 3, 0],
["MELT", 4, 0],
["LAVA", 0, 1],
["IS", 1, 1],
["HOT", 2, 1],
],
objects: [["lava", 2, 3]],
cards: [
["Powder Keg", 1, 3],
["Bramble Wall", 4, 3],
],
}),
[],
);
const keg = minion(g, 1, 3);
const bramble = minion(g, 4, 3);
g.clearLog();
g.step("right");
expect(g.log()).toEqual([
{ type: "Destroyed", id: keg },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: keg, trigger: "deathrattle" },
{ type: "Damaged", target: bramble, amount: 1 },
]);
expect(g.posOf(keg)).toBeNull();
expect(g.kindsAt(2, 3)).toEqual(["lava"]);
expect(g.health(bramble)).toBe(2);
expect(g.lost()).toBe(true);
});
test("test_10_spawned_text_triggers_reparse", () => {
const g = newSpellGrid(
level(5, 3, {
words: [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
["BABA", 0, 1],
["IS", 1, 1],
],
cards: [["Rune Scribe", 2, 1]],
hand: ["Spark"],
}),
[],
);
const scribe = minion(g, 2, 1);
const baba = g.place("baba", 4, 2);
expect(g.rules()).not.toContain("BABA IS WIN");
g.cast("Spark", scribe);
expect(g.kindsAt(2, 1)).toEqual(["text:WIN"]);
expect(g.rules()).toContain("BABA IS WIN");
expect(g.hasProperty(baba, "WIN")).toBe(true);
expect(g.won()).toBe(false);
g.step("left");
expect(g.won()).toBe(true);
});
test("test_10_targeted_spell_needs_target", () => {
const g = newSpellGrid(level(3, 1, { cards: [["Bramble Wall", 0, 0]], hand: ["Spark"] }), []);
const bramble = minion(g, 0, 0);
expect(() => g.cast("Spark")).toThrow();
expect(g.hand()).toEqual(["Spark"]);
expect(g.health(bramble)).toBe(3);
g.cast("Spark", bramble);
expect(g.health(bramble)).toBe(2);
expect(g.hand()).toEqual([]);
});
test("test_10_level_1_solution", () => {
const g = newSpellGrid(l1 as LevelDef, []);
const keg = minion(g, 0, 4);
const bramble = minion(g, 5, 6);
g.step("up");
g.step("up");
expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE");
g.cast("Whirlwind");
expect(deathrattles(g, keg)).toBe(2);
expect(g.posOf(bramble)).toBeNull();
for (const dir of ["down", "down", "left", "down", "down", "right"] as const) g.step(dir);
expect(g.won()).toBe(false);
expect(g.kindsAt(5, 6)).toEqual(["baba"]);
g.step("right");
expect(g.won()).toBe(true);
});
test("test_10_level_2_solution", () => {
const g = newSpellGrid(l2 as LevelDef, []);
g.setChooser((kind) => (kind === "base" ? "Ember Moth" : kind === "donor" ? "Rune Scribe" : "bottom"));
g.cast("Graft");
expect(g.hand()).toEqual(["Ember Moth (Assembled)"]);
const moth = g.play("Ember Moth (Assembled)", 4, 3);
expect(g.kindsAt(4, 3)).toEqual(["moth"]);
expect(g.nameOf(moth)).toBe("Ember Moth (Assembled)");
g.step("left");
g.step("left");
expect(g.posOf(moth)).toBeNull();
expect(g.kindsAt(2, 3)).toEqual(["lava", "text:WIN"]);
expect(g.rules()).toContain("BABA IS WIN");
expect(g.won()).toBe(true);
});
/** Level 3's first input, one Whirlwind, after clearing the log. Returns the knight, keg, warden and hedge. */
function castLevel3Whirlwind(g: SpellGrid): { knight: Id; keg: Id; warden: Id; hedge: Id } {
const ids = { knight: minion(g, 0, 4), keg: minion(g, 1, 5), warden: minion(g, 2, 5), hedge: minion(g, 4, 4) };
g.clearLog();
g.cast("Whirlwind");
return ids;
}
test("test_10_level_3_solution", () => {
const g = newSpellGrid(l3 as LevelDef, []);
const { knight, keg, warden, hedge } = castLevel3Whirlwind(g);
expect(deathrattles(g, keg)).toBe(1);
expect(g.health(knight)).toBe(1);
expect(g.posOf(hedge)).toBeNull();
expect(g.posOf(keg)).toBeNull();
expect(g.posOf(warden)).toBeNull();
for (let i = 0; i < 5; i++) g.step("right");
expect(g.won()).toBe(true);
});
test("test_10_level_3_naive_would_differ", () => {
const g = newSpellGrid(l3 as LevelDef, []);
g.withNaiveAuraTiming();
const { knight, keg } = castLevel3Whirlwind(g);
for (let i = 0; i < 5; i++) g.step("right");
expect(deathrattles(g, keg)).toBe(2);
expect(g.posOf(knight)).toBeNull();
expect(g.lost()).toBe(true);
expect(g.won()).toBe(false);
});