CI / Tool tests and sync-check (push) Successful in 16s
CI / Verify references (all modules, Rust and TS) (push) Successful in 2m4s
CI / Verify Expansion Drop solutions (all modules, Rust and TS) (push) Successful in 2m1s
CI / Coupling audit on references (push) Successful in 5s
CI / Differential fuzzing of the Rust and TS references (300 seeds) (push) Successful in 53s
CI / Mutation check (tools/mutants.json against the highest TS reference) (push) Successful in 34s
CI / Verify the SpellGrid level proofs (solver) (push) Successful in 5m25s
CI / Site build (push) Successful in 15s
CI / Naive engine tests (Module 0) (push) Successful in 20s
I1: run-scenario and both SpellGrid CLIs check moves first and treat any other engine error as fatal (same text in both tracks); trigger blocks close on error (try/finally, catch_unwind in Rust); unknown summons rejected. I2, I3, M1-M7, T3, T9a-c, T10a-b. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
// 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$/);
|
|
});
|