import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { run, toolsDir } from "./helpers.mjs"; import { mergeSeams, mergeWords, scenarioProblems } from "../run-scenario.mjs"; const course = join(toolsDir, "..", "course"); const example = join(course, "10-spellgrid", "designer-example"); /** Write `scenario` to a temp file and run the tool on it. */ function scenario(s) { const file = join(mkdtempSync(join(tmpdir(), "sdc-scn-")), "scenario.json"); writeFileSync(file, typeof s === "string" ? s : JSON.stringify(s)); return run("run-scenario.mjs", [file]); } const text = (word, x, y) => ({ kind: `text:${word}`, x, y }); // BABA IS YOU on row 0, WOUND IS DOUBLE on row 1, Baba at (0,3). const LEVEL = { width: 5, height: 4, objects: [text("BABA", 0, 0), text("IS", 1, 0), text("YOU", 2, 0), text("WOUND", 0, 1), text("IS", 1, 1), text("DOUBLE", 2, 1), { kind: "baba", x: 0, y: 3 }], cards: [], hand: [], }; const TOTEM = { name: "Test Totem", type: "minion", cost: 1, attack: 0, health: 3, noun: "ROCK", split: 0, text: [{ kind: "trigger", on: "selfDamaged" }, { kind: "effect", effect: { type: "dealDamage", target: "enemyHero", amount: 1 } }], }; test("run_scenario_reports_schema_errors_readably", () => { const bad = { ...TOTEM, text: [TOTEM.text[0], { kind: "effect", effect: { type: "dealDamage", target: "everyone", amount: 1 } }] }; const r = scenario({ cards: [bad], level: LEVEL, inputs: [] }); assert.equal(r.code, 1, r.out); assert.match(r.out, /is not a valid scenario:\n/); assert.match(r.out, /cards\[0\] \("Test Totem"\)\.text\[1\]\.effect\.target: not in enum \["self",/); assert.doesNotMatch(r.out, /oneOf/); const shape = scenario({ level: { width: 3 }, inputs: "d", seams: { triggerWords: { HURT: "whenever" } } }); assert.equal(shape.code, 1); assert.match(shape.out, /level: missing "height"/); assert.match(shape.out, /inputs: expected a list of command lines/); assert.match(shape.out, /seams\.triggerWords\.HURT: "whenever" is not a trigger/); const json = scenario("{ not json"); assert.equal(json.code, 1); assert.match(json.out, /is not JSON: /); }); test("run_scenario_merges_words_into_the_rules", () => { const r = scenario({ words: [{ name: "WOUND", role: "noun" }], seams: { triggerWords: { WOUND: "selfDamaged" } }, level: LEVEL, inputs: [], }); assert.equal(r.code, 0, r.out); assert.match(r.out, /^Rules: BABA IS YOU, TEXT IS PUSH, WOUND IS DOUBLE$/m); // Without the word, the text spells nothing. const without = scenario({ level: LEVEL, inputs: [] }); assert.match(without.out, /^Rules: BABA IS YOU, TEXT IS PUSH$/m); }); test("run_scenario_prints_the_log_as_a_tree", () => { const level = { ...LEVEL, cards: [{ card: "Test Totem", x: 3, y: 3 }], hand: ["Whirlwind"] }; const r = scenario({ cards: [TOTEM], words: [{ name: "WOUND", role: "noun" }], seams: { triggerWords: { WOUND: "selfDamaged" } }, level, inputs: ["c Whirlwind", "w"] }); assert.equal(r.code, 0, r.out); // WOUND IS DOUBLE: the totem's damage trigger activates twice, each with its Damage one level down. assert.match(r.out, /\n> c Whirlwind\nLog:\nSpellCast\{card: "Whirlwind"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged\{target: \d+, amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged/); assert.match(r.out, /Minions: Test Totem \(3,3\) 2 Health\nHand: empty\nHeroes: 30 Health \(yours\), 28 Health \(enemy\)\n/); assert.match(r.out, /\n> w\n/); assert.match(r.out, /\(end of inputs\)\n$/); }); test("run_scenario_validates_words_and_seams", () => { const base = [{ name: "BABA", role: "noun" }, { name: "DOUBLE", role: "property" }]; assert.deepEqual(mergeWords(base, [{ name: "WOUND", role: "noun" }, { name: "BABA", role: "noun" }]).map((w) => w.name), ["BABA", "DOUBLE", "WOUND"]); assert.throws(() => mergeWords(base, [{ name: "BABA", role: "property" }]), /BABA is already a noun/); const seams = { triggerWords: { DEATHRATTLE: "deathrattle" }, multiplierWords: { DOUBLE: { times: 2, stacking: "max" } } }; assert.deepEqual(mergeSeams(seams, { triggerWords: { WOUND: "selfDamaged" } }).triggerWords, { DEATHRATTLE: "deathrattle", WOUND: "selfDamaged" }); assert.throws(() => mergeSeams(seams, { triggerWords: { DEATHRATTLE: "battlecry" } }), /DEATHRATTLE already names "deathrattle"/); const ctx = { words: base, cardNames: ["Whirlwind"] }; const problems = (s) => scenarioProblems({ level: LEVEL, inputs: [], ...s }, ctx); assert.deepEqual(problems({}), []); assert.match(problems({ words: [{ name: "wound", role: "noun" }] }).join("\n"), /words\[0\]\.name: "wound" must be capital letters/); assert.match(problems({ words: [{ name: "AND", role: "operator" }] }).join("\n"), /words\[0\]\.role: must be "noun" or "property"/); assert.match(problems({ seams: { triggerWords: { HURT: "selfDamaged" } } }).join("\n"), /seams\.triggerWords\.HURT: HURT is not a noun/); assert.match(problems({ seams: { multiplierWords: { DOUBLE: { times: 1, stacking: "max" } } } }).join("\n"), /seams\.multiplierWords\.DOUBLE\.times: below minimum 2/); assert.match(problems({ cards: [{ ...TOTEM, name: "Whirlwind" }] }).join("\n"), /cards\[0\] \("Whirlwind"\): the course already has a card named "Whirlwind"/); assert.match(problems({ extra: 1 }).join("\n"), /unknown key "extra"/); }); test("run_scenario_designer_example_output_is_current", () => { // The capstone's worked example shows the tool's real output; this keeps it from going stale. const r = run("run-scenario.mjs", [join(example, "scenario.json")]); assert.equal(r.code, 0, r.out); assert.equal(r.out, readFileSync(join(example, "output.txt"), "utf8")); // The scenario carries the example's data files: the three cards, the word and its seam. const json = (f) => JSON.parse(readFileSync(join(example, f), "utf8")); const s = json("scenario.json"); assert.deepEqual(s.cards, ["ash-totem", "thorn-idol", "scorch"].map((c) => json(`cards/${c}.json`))); assert.deepEqual(s.words, json("words.json")); assert.deepEqual(s.seams, json("seams.json")); });