Files
systemic-game-design-course/course/10-spellgrid/tests/ts/test_10.test.ts
T
Lucas WintherandClaude Opus 5.5 570e5d6826 feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool
tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each
level's shortest solution, and that it exhausts without its idea (neverRule)
or on the naive engine. Levels 4-10, one course idea each, with
test_10_level_N_solution in both tracks, scenarios and Lesson 10's list;
l1 no longer matches l10, and proofs.json is not listed as a level.

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

548 lines
15 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";
import l4 from "../levels/l4-crossed-out.json";
import l5 from "../levels/l5-sealed-keg.json";
import l6 from "../levels/l6-melt-before-defeat.json";
import l7 from "../levels/l7-clay-key.json";
import l8 from "../levels/l8-nothing-dies.json";
import l9 from "../levels/l9-skull-river.json";
import l10 from "../levels/l10-friendly-fire.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;
/** Play one turn per direction, in order. */
function steps(g: SpellGrid, ...dirs: ("up" | "down" | "left" | "right")[]): void {
for (const dir of dirs) g.step(dir);
}
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([]);
});
/** The events of a Spark that kills `minion`, whose Deathrattle summons `summoned`. */
const sparkKillsSummoner = (minion: Id, summoned: Id) => [
{ type: "SpellCast", card: "Spark" },
{ type: "Damaged", target: minion, amount: 1 },
{ type: "Destroyed", id: minion },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: minion, trigger: "deathrattle" },
{ type: "Summoned", id: summoned },
];
test("test_10_summoned_minion_enters_grid", () => {
const g = newSpellGrid(
level(5, 3, {
words: [
["SHARD", 0, 0],
["IS", 1, 0],
["PUSH", 2, 0],
],
cards: [["Clay Golem", 2, 2]],
hand: ["Spark", "Spark"],
}),
[],
);
const golem = minion(g, 2, 2);
g.cast("Spark", golem);
g.clearLog();
g.cast("Spark", golem);
const shard = minion(g, 2, 2);
expect(g.log()).toEqual(sparkKillsSummoner(golem, shard));
expect(g.kindsAt(2, 2)).toEqual(["shard"]);
expect(g.posOf(golem)).toBeNull();
expect(g.nameOf(shard)).toBe("Clay Shard");
expect(g.health(shard)).toBe(1);
expect(g.activeRules()).toContain("SHARD IS PUSH");
expect(g.hasProperty(shard, "PUSH")).toBe(true);
for (const older of [golem, g.hero(0), g.hero(1)]) expect(shard).toBeGreaterThan(older);
expect(g.place("rock", 4, 2)).toBe(shard + 1);
});
test("test_10_summon_without_noun_stays_off_grid", () => {
const g = newSpellGrid(level(3, 1, { hand: ["Graft", "Spark", "Spark"] }), []);
g.setChooser((kind) => (kind === "base" ? "Clay Golem" : kind === "donor" ? "Harvest Golem" : "bottom"));
g.cast("Graft");
expect(g.hand()).toEqual(["Spark", "Spark", "Clay Golem (Assembled)"]);
const golem = g.play("Clay Golem (Assembled)", 1, 0);
g.cast("Spark", golem);
g.clearLog();
g.cast("Spark", golem);
const last = g.log().at(-1);
if (last?.type !== "Summoned") throw new Error(`the last event is not a Summoned: ${JSON.stringify(g.log())}`);
const token = last.id;
expect(g.log()).toEqual(sparkKillsSummoner(golem, token));
expect(g.nameOf(token)).toBe("Damaged Golem");
expect(g.health(token)).toBe(1);
expect(g.posOf(token)).toBeNull();
expect(g.kindsAt(1, 0)).toEqual([]);
expect(g.minionAt(1, 0)).toBeNull();
});
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);
});
test("test_10_level_4_solution", () => {
const g = newSpellGrid(l4 as LevelDef, []);
const keg = minion(g, 1, 6);
const hedge = minion(g, 6, 4);
expect(g.rules()).toContain("DEATHRATTLE IS DOUBLE");
expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE");
steps(g, "right", "down");
expect(g.rules()).not.toContain("DEATHRATTLE IS NOT DOUBLE");
expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE");
steps(g, "right", "right");
g.cast("Spark", keg);
expect(deathrattles(g, keg)).toBe(2);
expect(g.posOf(hedge)).toBeNull();
steps(g, "right", "up");
expect(g.won()).toBe(true);
});
test("test_10_level_5_solution", () => {
const g = newSpellGrid(l5 as LevelDef, []);
const keg = minion(g, 1, 5);
steps(g, "left", "left", "down", "left");
expect(g.kindsAt(1, 5)).toEqual(["keg"]);
g.step("up");
expect(g.activeRules()).toContain("KEG IS NOT KEG");
expect(g.activeRules()).not.toContain("KEG IS KEG");
expect(deathrattles(g, keg)).toBe(1);
expect(g.posOf(keg)).toBeNull();
expect(g.kindsAt(1, 5)).toEqual(["flag"]);
g.step("left");
expect(g.won()).toBe(true);
});
test("test_10_level_6_solution", () => {
const g = newSpellGrid(l6 as LevelDef, []);
const knight = minion(g, 0, 6);
const keg = minion(g, 3, 6);
expect(g.stackAt(3, 6)).toEqual(["keg", "shard"]);
steps(g, "right", "right", "up", "down");
expect(g.activeRules()).toContain("LAVA IS HOT");
g.step("right");
// The knight stepped onto the DEFEAT shard as the keg went into the lava. HOT/MELT runs
// first: the keg's Deathrattle kills the shard, and DEFEAT finds nothing on the knight's tile.
expect(deathrattles(g, keg)).toBe(1);
expect(g.posOf(keg)).toBeNull();
expect(g.posOf(knight)).toEqual([3, 6]);
expect(g.kindsAt(3, 6)).toEqual(["knight"]);
expect(g.health(knight)).toBe(2);
expect(g.lost()).toBe(false);
steps(g, "right", "right");
expect(g.won()).toBe(true);
});
test("test_10_level_7_solution", () => {
const g = newSpellGrid(l7 as LevelDef, []);
const golem = minion(g, 4, 5);
steps(g, "right", "up", "right");
expect(g.activeRules()).toContain("SHARD IS OPEN");
g.cast("Spark", golem);
g.cast("Spark", golem);
expect(g.posOf(golem)).toBeNull();
expect(g.kindsAt(4, 5)).toEqual(["shard"]);
expect(g.nameOf(minion(g, 4, 5))).toBe("Clay Shard");
steps(g, "right", "right");
expect(g.kindsAt(6, 5)).toEqual([]);
steps(g, "right", "up");
expect(g.won()).toBe(true);
});
test("test_10_level_8_solution", () => {
const g = newSpellGrid(l8 as LevelDef, []);
g.setChooser((kind) => (kind === "base" ? "Clay Golem" : kind === "donor" ? "Novice Engineer" : "top"));
g.step("left");
expect(g.activeRules()).toContain("SHARD IS WIN");
g.cast("Graft");
g.clearLog();
const golem = g.play("Clay Golem (Assembled)", 4, 3);
// Battlecry, not Deathrattle: the golem is alive and its shard already stands on its tile.
expect(g.log()).toContainEqual({ type: "Triggered", source: golem, trigger: "battlecry" });
expect(g.posOf(golem)).toEqual([4, 3]);
expect(g.stackAt(4, 3)).toEqual(["golem", "shard"]);
g.step("right");
expect(g.won()).toBe(true);
});
test("test_10_level_9_solution", () => {
const g = newSpellGrid(l9 as LevelDef, []);
g.step("right");
expect(g.kindsAt(4, 5)).toEqual(["rock", "skull"]);
expect(g.kindsAt(3, 5)).toEqual(["rock"]);
steps(g, "down", "left");
// SKULL IS SINK: at the end of the turn the skull under the rock takes the rock with it.
expect(g.activeRules()).toContain("SKULL IS SINK");
expect(g.kindsAt(4, 5)).toEqual([]);
steps(g, "up", "right", "right", "right");
expect(g.kindsAt(5, 5)).toEqual([]);
steps(g, "right", "right");
expect(g.won()).toBe(true);
});
test("test_10_level_10_solution", () => {
const g = newSpellGrid(l10 as LevelDef, []);
const shard = minion(g, 1, 6);
const knight = minion(g, 5, 5);
const hedge = minion(g, 6, 4);
steps(g, "left", "up");
expect(g.activeRules()).toContain("KNIGHT IS YOU");
expect(g.activeRules()).not.toContain("SHARD IS YOU");
g.step("up");
g.cast("Whirlwind");
// Whirlwind and the keg's Deathrattle: 2 damage to everything. The shard you were dies;
// the knight you are now is left on 1.
expect(g.posOf(shard)).toBeNull();
expect(g.posOf(hedge)).toBeNull();
expect(g.health(knight)).toBe(1);
expect(g.lost()).toBe(false);
steps(g, "right", "up");
expect(g.won()).toBe(true);
});