Files
systemic-game-design-course/tools/test/schema.test.mjs
T
Lucas WintherandClaude Opus 5.5 34c655c1a4 feat: Module 11, Conditions (NOUN ON NOUN IS ..., checked per object at use; NEAR as the Expansion Drop)
Adds course/11-conditions: lesson, scenarios, nine test_11 tests in both
tracks, the cumulative reference (Module 10's plus conditions) and the NEAR
drop. The engine learns a condition role, rules carry conditions, and
Level.hasProperty and conversion evaluate content's condition checks against
the board whenever a rule is used. Resolution pairs only rules with the same
conditions. Catalog, harness contract, research notes and the diff-tracks
generator follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:00:26 +01:00

169 lines
8.6 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { validate } from "../schema.mjs";
import { loadCatalog } from "../lib.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const json = (p) => JSON.parse(readFileSync(p, "utf8"));
const cardSchema = json(join(here, "../../course/schema/card.schema.json"));
const levelSchema = json(join(here, "../../course/schema/level.schema.json"));
const entry = {
type: "object",
required: ["name", "kind", "text", "source"],
properties: { name: { type: "string" }, kind: { enum: ["card", "word"] }, text: { type: "string" } },
};
const cardEntry = {
type: "object",
required: ["type", "cost"],
properties: { type: { enum: ["minion", "spell"] }, cost: { type: "integer", minimum: 0 }, attack: { type: "integer" }, health: { type: "integer" } },
};
const wordEntry = { type: "object", required: ["role"], properties: { role: { enum: ["noun", "operator", "property", "meta", "condition"] } } };
test("catalog_valid", () => {
const cat = loadCatalog();
assert.ok(Array.isArray(cat) && cat.length > 0);
const names = new Set();
for (const e of cat) {
// Test cards and SpellGrid's own cards and words (Module 10) have no outside source.
const sourced = !e.fictional && e.game !== "spellgrid";
assert.deepEqual(validate({ ...entry, required: sourced ? entry.required : ["name", "kind", "text"] }, e), [], e.name);
assert.deepEqual(validate(e.kind === "card" ? cardEntry : wordEntry, e), [], e.name);
assert.ok(!names.has(e.name), `duplicate ${e.name}`);
names.add(e.name);
if (e.kind === "card" && sourced) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name);
if (e.game === "spellgrid") assert.ok(!e.fictional && !e.name.startsWith("Test "), `${e.name}: a SpellGrid card is not a test card`);
if (e.kind === "card" && e.name.startsWith("Test ")) assert.equal(e.fictional, true, e.name);
if (e.fictional) assert.ok(e.name.startsWith("Test "), e.name);
}
for (const n of ["Baron Rivendare", "Fleshshape", "Test Echo", "BABA", "IS", "DEATHRATTLE"]) assert.ok(names.has(n), n);
});
test("card_schema_accepts_fixtures", () => {
const dir = join(here, "fixtures/cards");
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
assert.ok(files.length >= 5);
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
for (const f of files) {
const c = json(join(dir, f));
assert.deepEqual(validate(cardSchema, c), [], f);
const m = cat.get(c.name);
assert.ok(m, `${c.name} in catalog`);
assert.deepEqual([c.cost, c.attack, c.health], [m.cost, m.attack, m.health], `${f} matches catalog stats`);
}
});
// Card data the course ships: course/cards (every module's cards), module-local cards in
// course/NN-*/cards (Module 10's SpellGrid cards, staged from that module on), and the
// Expansion Drop answers in course/NN-*/expansion/solution/cards.
function courseCardFiles() {
const courseDir = join(here, "../../course");
const dirs = [join(courseDir, "cards")];
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
for (const sub of ["cards", "expansion/solution/cards"]) {
const d = join(courseDir, m, sub);
if (existsSync(d)) dirs.push(d);
}
}
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
}
test("course_cards_valid_and_match_catalog", () => {
const files = courseCardFiles();
assert.ok(files.length >= 20, `found ${files.length} card files`);
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
const seen = new Set();
for (const f of files) {
const c = json(f);
assert.deepEqual(validate(cardSchema, c), [], f);
assert.equal(basename(f), c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".json", `${f}: file name is the card's slug`);
assert.ok(!seen.has(c.name), `duplicate card file for ${c.name}`);
seen.add(c.name);
const m = cat.get(c.name);
assert.ok(m && m.kind === "card", `${c.name} in catalog`);
assert.deepEqual(
[c.name, c.type, c.cost, c.attack, c.health, c.fictional ?? false],
[m.name, m.type, m.cost, m.attack, m.health, m.fictional ?? false],
`${f} matches catalog`,
);
assert.ok(c.split <= c.text.length, `${f}: split within text`);
}
// Every card in the catalog (Hearthstone, fictional and SpellGrid's own) has its data.
for (const e of cat.values()) if (e.kind === "card") assert.ok(seen.has(e.name), `no card file for ${e.name}`);
});
test("card_schema_assemble_effect", () => {
const spell = (effect) => ({ name: "X", type: "spell", cost: 3, split: 0, text: [{ kind: "effect", effect }] });
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "deathrattle" } })), []);
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: {} })), []);
assert.deepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2, health: 2 })), []);
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble" })), []);
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "bogus" } })), []);
assert.notDeepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2 })), []);
});
test("card_schema_rejects_bad", () => {
const bad = { name: "X", type: "minion", cost: 1, split: 0, text: [{ kind: "trigger", on: "bogus" }] };
assert.notDeepEqual(validate(cardSchema, bad), []);
assert.notDeepEqual(validate(cardSchema, { name: "X" }), []);
});
test("level_schema", () => {
assert.deepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0, y: 0 }] }), []);
assert.notDeepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0 }] }), []);
});
test("level_schema_spellgrid_fields", () => {
const level = { width: 2, height: 1, objects: [], cards: [{ card: "Powder Keg", x: 1, y: 0 }], hand: ["Spark"] };
assert.deepEqual(validate(levelSchema, level), []);
assert.notDeepEqual(validate(levelSchema, { ...level, hand: [{ card: "Spark" }] }), []);
assert.notDeepEqual(validate(levelSchema, { ...level, cards: [{ card: "Powder Keg", x: 1 }] }), []);
});
// Puzzle levels the course ships: course/NN-*/levels/*.json (proofs.json beside them is
// verify-levels' data). Every one is valid, stays on its grid, writes only words its module's
// reference knows and places objects of its nouns, and names only cards that have data,
// minions with a noun on the grid.
test("course_levels_valid", () => {
const courseDir = join(here, "../../course");
const cards = new Map(courseCardFiles().map((f) => json(f)).map((c) => [c.name, c]));
const files = readdirSync(courseDir)
.filter((d) => /^\d\d-/.test(d) && existsSync(join(courseDir, d, "levels")))
.flatMap((d) =>
readdirSync(join(courseDir, d, "levels"))
.filter((f) => f.endsWith(".json") && f !== "proofs.json")
.map((f) => ({ mod: join(courseDir, d), f: join(courseDir, d, "levels", f) })),
);
assert.ok(files.length >= 3, `found ${files.length} level files`);
for (const { mod, f } of files) {
const level = json(f);
assert.deepEqual(validate(levelSchema, level), [], f);
const words = json(join(mod, "reference", "ts", "src", "content", "words.json"));
const nouns = new Set(words.filter((w) => w.role === "noun").map((w) => w.name));
const written = new Set([...words.map((w) => w.name), "IS", "AND", "NOT"]);
for (const { kind } of level.objects) {
const known = kind.startsWith("text:") ? written.has(kind.slice(5)) : nouns.has(kind.toUpperCase());
assert.ok(known, `${f}: ${kind} is not a word or noun of ${basename(mod)}`);
}
for (const o of [...level.objects, ...(level.cards ?? [])]) {
assert.ok(o.x < level.width && o.y < level.height, `${f}: (${o.x}, ${o.y}) is off the grid`);
}
for (const { card } of level.cards ?? []) {
const c = cards.get(card);
assert.ok(c && c.type === "minion" && c.noun, `${f}: ${card} is a minion with a noun`);
}
for (const card of level.hand ?? []) assert.ok(cards.has(card), `${f}: no card data for ${card}`);
}
});
test("spellgrid_cards_are_module_local", () => {
// course/cards reaches every workspace from Module 5; SpellGrid's cards must not reach
// Modules 5-9 (their spawnText effect and their place in Assemble pools are Module 10's).
const shared = join(here, "../../course/cards");
for (const f of readdirSync(shared).filter((f) => f.endsWith(".json"))) {
assert.notEqual(json(join(shared, f)).game, "spellgrid", `${f} belongs in course/10-spellgrid/cards`);
}
});