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