From a4b72ad110fb2b5149eb2ff71732c6a5cd7e9879 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 00:00:21 +0100 Subject: [PATCH] feat: card/level schemas and verified catalog Co-Authored-By: Claude Sonnet 5.5 --- course/catalog.json | 388 ++++++++++++++++++ course/schema/card.schema.json | 45 ++ course/schema/level.schema.json | 13 + tools/schema.mjs | 30 ++ tools/test/fixtures/cards/armorsmith.json | 1 + .../test/fixtures/cards/baron-rivendare.json | 1 + tools/test/fixtures/cards/dread-infernal.json | 1 + tools/test/fixtures/cards/harvest-golem.json | 1 + tools/test/fixtures/cards/loot-hoarder.json | 1 + tools/test/schema.test.mjs | 64 +++ 10 files changed, 545 insertions(+) create mode 100644 course/catalog.json create mode 100644 course/schema/card.schema.json create mode 100644 course/schema/level.schema.json create mode 100644 tools/schema.mjs create mode 100644 tools/test/fixtures/cards/armorsmith.json create mode 100644 tools/test/fixtures/cards/baron-rivendare.json create mode 100644 tools/test/fixtures/cards/dread-infernal.json create mode 100644 tools/test/fixtures/cards/harvest-golem.json create mode 100644 tools/test/fixtures/cards/loot-hoarder.json create mode 100644 tools/test/schema.test.mjs diff --git a/course/catalog.json b/course/catalog.json new file mode 100644 index 0000000..ba738f2 --- /dev/null +++ b/course/catalog.json @@ -0,0 +1,388 @@ +[ + { + "name": "Armorsmith", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 4, + "text": "Whenever a friendly minion takes damage, gain 1 Armor.", + "source": "https://hearthstone.wiki.gg/wiki/Armorsmith" + }, + { + "name": "Acolyte of Pain", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 1, + "health": 4, + "text": "Whenever this minion takes damage, draw a card.", + "source": "https://hearthstone.wiki.gg/wiki/Acolyte_of_Pain" + }, + { + "name": "Frothing Berserker", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 2, + "health": 4, + "text": "Whenever a minion takes damage, gain +1 Attack.", + "source": "https://hearthstone.wiki.gg/wiki/Frothing_Berserker" + }, + { + "name": "Loot Hoarder", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 2, + "health": 1, + "text": "Deathrattle: Draw a card.", + "source": "https://hearthstone.wiki.gg/wiki/Loot_Hoarder" + }, + { + "name": "Baron Rivendare", + "kind": "card", + "type": "minion", + "cost": 4, + "attack": 1, + "health": 7, + "text": "Your minions trigger their Deathrattles twice.", + "source": "https://hearthstone.wiki.gg/wiki/Baron_Rivendare" + }, + { + "name": "Twisting Nether", + "kind": "card", + "type": "spell", + "cost": 8, + "text": "Destroy all minions and locations.", + "source": "https://hearthstone.wiki.gg/wiki/Twisting_Nether" + }, + { + "name": "Whirlwind", + "kind": "card", + "type": "spell", + "cost": 1, + "text": "Deal 1 damage to ALL minions.", + "source": "https://hearthstone.wiki.gg/wiki/Whirlwind" + }, + { + "name": "Unstable Ghoul", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 3, + "text": "Taunt. Deathrattle: Deal 1 damage to all minions.", + "source": "https://hearthstone.wiki.gg/wiki/Unstable_Ghoul" + }, + { + "name": "Harvest Golem", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 2, + "health": 3, + "text": "Deathrattle: Summon a 2/1 Damaged Golem.", + "source": "https://hearthstone.wiki.gg/wiki/Harvest_Golem" + }, + { + "name": "Damaged Golem", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 2, + "health": 1, + "text": "", + "source": "https://hearthstone.wiki.gg/wiki/Damaged_Golem", + "uncollectible": true + }, + { + "name": "Brann Bronzebeard", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 2, + "health": 4, + "text": "Your Battlecries trigger twice.", + "source": "https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard" + }, + { + "name": "Drakkari Enchanter", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 1, + "health": 5, + "text": "Your end of turn effects trigger twice.", + "source": "https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter" + }, + { + "name": "Novice Engineer", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 1, + "text": "Battlecry: Draw a card.", + "source": "https://hearthstone.wiki.gg/wiki/Novice_Engineer" + }, + { + "name": "Dread Infernal", + "kind": "card", + "type": "minion", + "cost": 6, + "attack": 6, + "health": 6, + "text": "Battlecry: Deal 1 damage to ALL other characters.", + "source": "https://hearthstone.wiki.gg/wiki/Dread_Infernal" + }, + { + "name": "Fleshshape", + "kind": "card", + "type": "spell", + "cost": 3, + "text": "Assemble a Deathrattle minion. Give all minions in your hand +2/+2.", + "source": "https://hearthstone.wiki.gg/wiki/Fleshshape" + }, + { + "name": "Test Echo", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 5, + "text": "Whenever a minion takes damage, deal 1 damage to the enemy hero.", + "fictional": true + }, + { + "name": "Test Reactor", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 5, + "text": "Whenever a hero takes damage, gain +1 Attack.", + "fictional": true + }, + { + "name": "Test Filler", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "text": "", + "fictional": true + }, + { + "name": "BABA", + "kind": "word", + "role": "noun", + "text": "the Baba creature", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "ROCK", + "kind": "word", + "role": "noun", + "text": "a pushable boulder", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "WALL", + "kind": "word", + "role": "noun", + "text": "a wall", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "FLAG", + "kind": "word", + "role": "noun", + "text": "a flag", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "LAVA", + "kind": "word", + "role": "noun", + "text": "lava", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "KEY", + "kind": "word", + "role": "noun", + "text": "a key", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "DOOR", + "kind": "word", + "role": "noun", + "text": "a door", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "SKULL", + "kind": "word", + "role": "noun", + "text": "a skull", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "WATER", + "kind": "word", + "role": "noun", + "text": "water", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "IS", + "kind": "word", + "role": "operator", + "text": "links a noun to a property or another noun", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "AND", + "kind": "word", + "role": "operator", + "text": "joins nouns or properties", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "NOT", + "kind": "word", + "role": "operator", + "text": "negates the property or noun after IS", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "YOU", + "kind": "word", + "role": "property", + "text": "player controls these", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "WIN", + "kind": "word", + "role": "property", + "text": "touching a YOU object wins", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "PUSH", + "kind": "word", + "role": "property", + "text": "can be pushed", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "STOP", + "kind": "word", + "role": "property", + "text": "blocks movement", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "OPEN", + "kind": "word", + "role": "property", + "text": "opens SHUT objects on contact", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "SHUT", + "kind": "word", + "role": "property", + "text": "blocks until opened", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "HOT", + "kind": "word", + "role": "property", + "text": "melts MELT objects", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "MELT", + "kind": "word", + "role": "property", + "text": "destroyed by HOT", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "SINK", + "kind": "word", + "role": "property", + "text": "destroys itself and what shares its tile", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "DEFEAT", + "kind": "word", + "role": "property", + "text": "destroys YOU objects on contact", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "FLOAT", + "kind": "word", + "role": "property", + "text": "shares a tile layer with non-floating objects", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "WEAK", + "kind": "word", + "role": "property", + "text": "destroyed on contact", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "PULL", + "kind": "word", + "role": "property", + "text": "pulled by a mover", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "DOUBLE", + "kind": "word", + "role": "property", + "text": "effects apply twice", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "TEXT", + "kind": "word", + "role": "meta", + "text": "every text object (a noun about text itself)", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "ALL", + "kind": "word", + "role": "meta", + "text": "every object", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "EMPTY", + "kind": "word", + "role": "meta", + "text": "empty tiles", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md" + }, + { + "name": "DEATHRATTLE", + "kind": "word", + "role": "noun", + "text": "a trigger type, so DEATHRATTLE IS DOUBLE doubles deathrattles", + "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md", + "game": "spellgrid" + } +] diff --git a/course/schema/card.schema.json b/course/schema/card.schema.json new file mode 100644 index 0000000..9207072 --- /dev/null +++ b/course/schema/card.schema.json @@ -0,0 +1,45 @@ +{ + "$comment": "Structured card text. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. split divides top (text[0:split]) from bottom (text[split:]).", + "type": "object", + "required": ["name", "type", "cost", "text", "split"], + "properties": { + "name": { "type": "string" }, + "type": { "enum": ["minion", "spell"] }, + "cost": { "type": "integer", "minimum": 0 }, + "attack": { "type": "integer", "minimum": 0 }, + "health": { "type": "integer", "minimum": 1 }, + "noun": { "type": "string" }, + "split": { "type": "integer", "minimum": 0 }, + "fictional": { "type": "boolean" }, + "game": { "type": "string" }, + "text": { + "type": "array", + "items": { + "oneOf": [ + { "type": "object", "required": ["kind", "keyword"], "properties": { "kind": { "const": "keyword" }, "keyword": { "type": "string" } } }, + { "type": "object", "required": ["kind", "on"], "properties": { + "kind": { "const": "trigger" }, + "on": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] } } }, + { "type": "object", "required": ["kind", "modifier"], "properties": { + "kind": { "const": "aura" }, + "modifier": { "type": "object", "required": ["type", "trigger", "times", "stacking"], "properties": { + "type": { "const": "triggerMultiplier" }, + "trigger": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] }, + "times": { "type": "integer", "minimum": 2 }, + "stacking": { "enum": ["max", "additive"] } } } } }, + { "type": "object", "required": ["kind", "effect"], "properties": { + "kind": { "const": "effect" }, + "effect": { "oneOf": [ + { "type": "object", "required": ["type", "target", "amount"], "properties": { "type": { "const": "dealDamage" }, "target": { "enum": ["self", "allMinions", "allOtherMinions", "allOtherCharacters", "enemyHero", "chosen"] }, "amount": { "type": "integer", "minimum": 0 } } }, + { "type": "object", "required": ["type", "count"], "properties": { "type": { "const": "draw" }, "count": { "type": "integer", "minimum": 1 } } }, + { "type": "object", "required": ["type", "card", "count"], "properties": { "type": { "const": "summon" }, "card": { "type": "string" }, "count": { "type": "integer", "minimum": 1 } } }, + { "type": "object", "required": ["type", "amount"], "properties": { "type": { "const": "gainArmor" }, "amount": { "type": "integer", "minimum": 1 } } }, + { "type": "object", "required": ["type", "target", "amount"], "properties": { "type": { "const": "gainAttack" }, "target": { "enum": ["self", "allMinions", "allOtherMinions", "allOtherCharacters", "enemyHero", "chosen"] }, "amount": { "type": "integer" } } }, + { "type": "object", "required": ["type", "target"], "properties": { "type": { "const": "destroy" }, "target": { "enum": ["self", "allMinions", "allOtherMinions", "allOtherCharacters", "enemyHero", "chosen"] } } }, + { "type": "object", "required": ["type", "word", "at"], "properties": { "type": { "const": "spawnText" }, "word": { "type": "string" }, "at": { "const": "self" } } } + ] } } } + ] + } + } + } +} diff --git a/course/schema/level.schema.json b/course/schema/level.schema.json new file mode 100644 index 0000000..8bbf50f --- /dev/null +++ b/course/schema/level.schema.json @@ -0,0 +1,13 @@ +{ + "$comment": "x grows right, y grows down.", + "type": "object", + "required": ["width", "height", "objects"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "objects": { "type": "array", "items": { "type": "object", "required": ["kind", "x", "y"], + "properties": { "kind": { "type": "string" }, "x": { "type": "integer", "minimum": 0 }, "y": { "type": "integer", "minimum": 0 } } } }, + "cards": { "type": "array", "items": { "type": "object", "required": ["card", "x", "y"], + "properties": { "card": { "type": "string" }, "x": { "type": "integer", "minimum": 0 }, "y": { "type": "integer", "minimum": 0 } } } } + } +} diff --git a/tools/schema.mjs b/tools/schema.mjs new file mode 100644 index 0000000..f2389f6 --- /dev/null +++ b/tools/schema.mjs @@ -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; +} diff --git a/tools/test/fixtures/cards/armorsmith.json b/tools/test/fixtures/cards/armorsmith.json new file mode 100644 index 0000000..ac1ec55 --- /dev/null +++ b/tools/test/fixtures/cards/armorsmith.json @@ -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}}]} diff --git a/tools/test/fixtures/cards/baron-rivendare.json b/tools/test/fixtures/cards/baron-rivendare.json new file mode 100644 index 0000000..4a949bb --- /dev/null +++ b/tools/test/fixtures/cards/baron-rivendare.json @@ -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"}}]} diff --git a/tools/test/fixtures/cards/dread-infernal.json b/tools/test/fixtures/cards/dread-infernal.json new file mode 100644 index 0000000..7fa5a55 --- /dev/null +++ b/tools/test/fixtures/cards/dread-infernal.json @@ -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}}]} diff --git a/tools/test/fixtures/cards/harvest-golem.json b/tools/test/fixtures/cards/harvest-golem.json new file mode 100644 index 0000000..b63051d --- /dev/null +++ b/tools/test/fixtures/cards/harvest-golem.json @@ -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}}]} diff --git a/tools/test/fixtures/cards/loot-hoarder.json b/tools/test/fixtures/cards/loot-hoarder.json new file mode 100644 index 0000000..f3a11d1 --- /dev/null +++ b/tools/test/fixtures/cards/loot-hoarder.json @@ -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}}]} diff --git a/tools/test/schema.test.mjs b/tools/test/schema.test.mjs new file mode 100644 index 0000000..75027d8 --- /dev/null +++ b/tools/test/schema.test.mjs @@ -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 }] }), []); +});