feat: card/level schemas and verified catalog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-29 00:00:21 +01:00
1 parent 9a00b8ac18
commit a4b72ad110
10 files changed
+545

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
// Minimal JSON-schema subset validator (node built-ins only).
// Supports: type, required, enum, const, properties, items, minimum, oneOf, anyOf.
const typeOk = (t, v) =>
t === "array" ? Array.isArray(v)
: t === "null" ? v === null
: t === "integer" ? Number.isInteger(v)
: t === "object" ? v !== null && typeof v === "object" && !Array.isArray(v)
: typeof v === t;
export function validate(schema, value, path = "$") {
const errs = [];
if (schema.type) {
const types = [].concat(schema.type);
if (!types.some((t) => typeOk(t, value))) return [`${path}: expected ${types.join("|")}`];
}
if (schema.enum && !schema.enum.includes(value)) errs.push(`${path}: not in enum ${JSON.stringify(schema.enum)}`);
if ("const" in schema && value !== schema.const) errs.push(`${path}: expected ${JSON.stringify(schema.const)}`);
if (schema.minimum !== undefined && typeof value === "number" && value < schema.minimum) errs.push(`${path}: below minimum ${schema.minimum}`);
if (typeOk("object", value)) {
for (const k of schema.required || []) if (!(k in value)) errs.push(`${path}: missing "${k}"`);
for (const [k, s] of Object.entries(schema.properties || {})) if (k in value) errs.push(...validate(s, value[k], `${path}.${k}`));
}
if (Array.isArray(value) && schema.items) value.forEach((v, i) => errs.push(...validate(schema.items, v, `${path}[${i}]`)));
if (schema.anyOf && !schema.anyOf.some((s) => validate(s, value, path).length === 0)) errs.push(`${path}: matches no anyOf branch`);
if (schema.oneOf) {
const n = schema.oneOf.filter((s) => validate(s, value, path).length === 0).length;
if (n !== 1) errs.push(`${path}: matches ${n} oneOf branches (need 1)`);
}
return errs;
}
+1
View File
@@ -0,0 +1 @@
{"name":"Armorsmith","type":"minion","cost":2,"attack":1,"health":4,"split":1,"text":[{"kind":"trigger","on":"friendlyMinionDamaged"},{"kind":"effect","effect":{"type":"gainArmor","amount":1}}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Baron Rivendare","type":"minion","cost":4,"attack":1,"health":7,"split":0,"text":[{"kind":"aura","modifier":{"type":"triggerMultiplier","trigger":"deathrattle","times":2,"stacking":"max"}}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Dread Infernal","type":"minion","cost":6,"attack":6,"health":6,"split":1,"text":[{"kind":"trigger","on":"battlecry"},{"kind":"effect","effect":{"type":"dealDamage","target":"allOtherCharacters","amount":1}}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Harvest Golem","type":"minion","cost":3,"attack":2,"health":3,"split":1,"text":[{"kind":"trigger","on":"deathrattle"},{"kind":"effect","effect":{"type":"summon","card":"Damaged Golem","count":1}}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Loot Hoarder","type":"minion","cost":2,"attack":2,"health":1,"split":1,"text":[{"kind":"trigger","on":"deathrattle"},{"kind":"effect","effect":{"type":"draw","count":1}}]}
+64
View File
@@ -0,0 +1,64 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { 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"] } } };
test("catalog_valid", () => {
const cat = loadCatalog();
assert.ok(Array.isArray(cat) && cat.length > 0);
const names = new Set();
for (const e of cat) {
assert.deepEqual(validate({ ...entry, required: e.fictional ? ["name", "kind", "text"] : entry.required }, 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" && !e.fictional) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name);
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`);
}
});
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 }] }), []);
});