- 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]>
305 lines
7.9 KiB
TypeScript
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);
|
|
});
|