// The SpellGrid command line, played from a script (the reference's own check; not a course test). import { readFileSync } from "node:fs"; import { expect, test } from "vitest"; import { run } from "../src/cli"; import { newSpellGrid, type CardDef, type LevelDef } from "../src/harness"; function play(level: string, input: string[]): { outcome: string; out: string } { let out = ""; const outcome = run(readFileSync(`levels/${level}.json`, "utf8"), () => input.shift(), (s) => (out += s)); return { outcome, out }; } test("cli_wins_level_1", () => { const { outcome, out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"]); expect(outcome).toBe("won"); expect(out).toContain("DEA IS DOU"); expect(out).toContain("You win!"); }); test("cli_wins_level_2_with_choices_and_play", () => { const input = ["c Graft", "Ember Moth", "Rune Scribe", "bottom", "c Ember Moth (Assembled) 4 3", "a", "a"]; const { outcome, out } = play("l2-spliced-corpse", input); expect(outcome).toBe("won"); expect(out).toContain("Choose the base"); expect(out).toContain("Hand: Ember Moth (Assembled)"); }); test("cli_prints_the_log_tree_of_each_command", () => { const { out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "q"]); // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. expect(out.split("Log:\n")).toHaveLength(2); expect(out).toContain( [ "Log:", 'SpellCast{card: "Whirlwind"}', "Damaged{target: 32, amount: 1}", "Damaged{target: 33, amount: 1}", "Destroyed{id: 32}", "DeathPhase{round: 1}", 'Triggered{source: 32, trigger: "deathrattle"}', " Damaged{target: 33, amount: 1}", 'Triggered{source: 32, trigger: "deathrattle"}', " Damaged{target: 33, amount: 1}", "Destroyed{id: 33}", "DeathPhase{round: 2}", "", ].join("\n"), ); }); test("cli_reports_errors_and_quits", () => { const { outcome, out } = play("l3-simultaneous-paradox", ["c Spark", "x", "q"]); expect(outcome).toBe("quit"); expect(out).toContain('"Spark" is not in the hand'); expect(out).toContain("Commands:"); }); // The two reference CLIs print the same text for the same input (tools/diff-tracks.mjs checks it). test("cli_prints_card_names_as_typed", () => { const { out } = play("l3-simultaneous-paradox", ['c a"b', "q"]); expect(out).toContain('"a"b" is not in the hand'); }); test("cli_numbers_are_plain_digits", () => { // "-1" is not a coordinate, so it is part of the card's name. const cast = play("l1-double-tap", ["c Whirlwind -1 0", "q"]); expect(cast.out).toContain('"Whirlwind -1 0" is not in the hand'); // "+2" is not a number, so the chooser takes the first option. const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]); expect(graft.out).toContain("Hand: Clay Golem (Assembled)"); }); // Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). const ZAP: CardDef = { name: "Zap", type: "spell", cost: 1, split: 0, text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }], }; // Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. const LOOP_EMBER: CardDef = { name: "Loop Ember", type: "minion", cost: 1, attack: 1, health: 1, noun: "ROCK", split: 1, text: [ { kind: "trigger", on: "deathrattle" }, { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }, ], }; // Its trigger names a card that does not exist, so it fails part-way through. const BAD_GOLEM: CardDef = { name: "Bad Golem", type: "minion", cost: 1, attack: 1, health: 3, noun: "KEY", split: 1, text: [ { kind: "trigger", on: "selfDamaged" }, { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }, ], }; const ROW: LevelDef = { width: 6, height: 3, objects: [ { kind: "baba", x: 0, y: 0 }, { kind: "text:BABA", x: 0, y: 2 }, { kind: "text:IS", x: 1, y: 2 }, { kind: "text:YOU", x: 2, y: 2 }, ], cards: [], hand: ["Zap", "Zap", "Zap"], }; test("cli_stops_when_a_sequence_does_not_finish", () => { const level = { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }; const input = ["c Zap", "d", "q"]; let out = ""; const outcome = run(JSON.stringify(level), () => input.shift(), (s) => (out += s), [ZAP, LOOP_EMBER]); expect(outcome).toBe("failed"); // The error, then the end: no log tree of a half-done Sequence, no further commands read. expect(out).toMatch(/> Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/); expect(out).not.toContain("Log:"); expect(input).toEqual(["d", "q"]); }); test("an_error_mid_sequence_leaves_no_trigger_resolving", () => { const g = newSpellGrid({ ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, [ZAP, BAD_GOLEM]); expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); // The failed trigger closed its block: the next Sequence's log starts at depth 0, and // the golem's trigger is queued again (the nesting guard does not think it is still resolving). g.clearLog(); expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); expect(g.logTree()).toMatch(/^SpellCast\{card: "Zap"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n$/); });