import assert from 'node:assert/strict'; import { readFile, writeFile } from 'node:fs/promises'; import { z } from 'zod'; import { componentSchema, gamePackage, lockSchema, packageSchema, presentationPlanSchema, resourceSetSchema, validateComponent, validateResourceSet, validatePackage, validatePlan, validateRegistry, validateMessage, wireSchema, } from './schema.ts'; const read = async (file: string) => JSON.parse(await readFile(new URL(file, import.meta.url), 'utf8')); const libraries: unknown[] = await read('examples/libraries.json'); const games = await Promise.all( ['conecta4', 'hundido', 'solitario'].map((id) => read(`examples/${id}.game.json`), ), ); const registry = validateRegistry([...libraries, ...games]); const messages: unknown[] = await read('examples/wire.json'); for (const message of messages) validateMessage(message); let negativeCases = 0; function rejects(label: string, run: () => unknown) { assert.throws(run, `Must reject: ${label}`); negativeCases++; } function changeGame( index: number, edit: (game: z.infer) => void, ) { const game = structuredClone(games[index]); edit(game); return () => validatePackage(game, registry); } rejects('unknown manifest field', () => validatePackage({ ...games[0], runtimeMagic: true }, registry), ); rejects( 'missing rules entry', changeGame(0, (game) => { game.rules.entry = ''; }), ); rejects( 'path traversal', changeGame(0, (game) => { game.rules.entry = '../server/secrets.ts'; }), ); rejects( 'absolute path', changeGame(0, (game) => { game.rules.entry = '/server/rules.ts'; }), ); rejects( 'empty path segment', changeGame(0, (game) => { game.rules.entry = 'rules//index.ts'; }), ); rejects( 'directory used as entry', changeGame(0, (game) => { game.rules.entry = 'rules/'; }), ); rejects( 'floating dependency', changeGame(2, (game) => { game.dependencies[0].version = '^1.0.0'; }), ); rejects( 'missing dependency', changeGame(2, (game) => { game.dependencies[0].id = 'missing'; }), ); rejects( 'missing export', changeGame(2, (game) => { game.presentation.bindings.mainDeck = { source: 'dependency', packageId: 'cards-poker', export: 'missing', }; }), ); rejects( 'inherited export', changeGame(2, (game) => { game.presentation.bindings.mainDeck = { source: 'dependency', packageId: 'cards-poker', export: 'constructor', }; }), ); rejects( 'wrong dependency kind', changeGame(2, (game) => { game.presentation.bindings.mainDeck = { source: 'dependency', packageId: 'playing-cards', export: 'poker52', }; }), ); rejects( 'invalid player interval', changeGame(0, (game) => { game.players.min = 4; }), ); rejects( 'solo with multiple players', changeGame(2, (game) => { game.players.max = 2; }), ); rejects( 'missing cover', changeGame(0, (game) => { game.presentation.catalog.cover = 'missing'; }), ); rejects( 'required asset omitted', changeGame(0, (game) => { game.presentation.resources['catalog.cover'].fallback = { kind: 'omit' }; }), ); rejects( 'fallback cycle', changeGame(0, (game) => { game.presentation.resources['catalog.cover'].fallback = { kind: 'resource', resourceId: 'catalog.cover', }; }), ); rejects( 'atlas bounds', changeGame(1, (game) => { game.presentation.resources['fleet.ships'].regions!.lancha.x = 1024; }), ); rejects( 'phase load without phase', changeGame(1, (game) => { delete game.presentation.resources['sound.hit'].phase; }), ); rejects('duplicate package', () => validateRegistry([...libraries, ...games, games[0]]), ); const cycleA = structuredClone(libraries[0]) as Record; const cycleB = structuredClone(libraries[0]) as Record; cycleA.id = 'cycle-a'; cycleB.id = 'cycle-b'; cycleA.dependencies = [{ id: 'cycle-b', version: '1.0.0', kind: 'components' }]; cycleB.dependencies = [{ id: 'cycle-a', version: '1.0.0', kind: 'components' }]; rejects('dependency cycle', () => validateRegistry([cycleA, cycleB])); const firstVersion = structuredClone(libraries[0]) as Record; const secondVersion = structuredClone(firstVersion); secondVersion.version = '2.0.0'; const parentA = structuredClone(firstVersion); parentA.id = 'parent-a'; parentA.dependencies = [ { id: firstVersion.id, version: '1.0.0', kind: 'components' }, ]; const parentB = structuredClone(firstVersion); parentB.id = 'parent-b'; parentB.dependencies = [ { id: firstVersion.id, version: '2.0.0', kind: 'components' }, ]; const root = structuredClone(firstVersion); root.id = 'root'; root.dependencies = [ { id: 'parent-a', version: '1.0.0', kind: 'components' }, { id: 'parent-b', version: '1.0.0', kind: 'components' }, ]; rejects('conflicting transitive versions', () => validateRegistry([root, parentA, parentB, firstVersion, secondVersion]), ); validateRegistry([parentA, parentB, firstVersion, secondVersion]); const message = (index: number) => structuredClone(messages[index]) as Record; const badActor = message(1); badActor.actor = 'p1'; rejects('client cannot supply actor', () => validateMessage(badActor)); const badPolicy = message(2); badPolicy.expected.actorRevision = -1; rejects('negative revision', () => validateMessage(badPolicy)); const live = message(7); const gap = structuredClone(live); gap.revision = 3; rejects('live revision gap', () => validateMessage(gap)); const historyOnSync = structuredClone(live); historyOnSync.delivery = message(6).delivery; rejects('sync replays event history', () => validateMessage(historyOnSync)); const repeated = structuredClone(live); repeated.events.push({ ...repeated.events[0], order: 1 }); rejects('duplicate event', () => validateMessage(repeated)); const activeWithOutcome = structuredClone(live); activeWithOutcome.status = 'active'; rejects('active match with terminal outcome', () => validateMessage(activeWithOutcome), ); const terminalOffer = structuredClone(live); terminalOffer.availableActions = [ { kind: 'concrete', action: { type: 'draw', payload: {} }, expected: { kind: 'match', revision: 1 }, }, ]; rejects('finished match offering actions', () => validateMessage(terminalOffer), ); const emptyOutcome = structuredClone(live); emptyOutcome.outcome.participants = {}; rejects('empty completed outcome', () => validateMessage(emptyOutcome)); const transientRejection = message(5); transientRejection.receipt.code = 'INTERNAL_ERROR'; rejects('transient failure must not become terminal receipt', () => validateMessage(transientRejection), ); const cooperative = structuredClone(live); cooperative.outcome = { kind: 'completed', reasonCode: 'objective.failed', participants: { p0: { outcome: 'loss', metrics: {} }, p1: { outcome: 'loss', metrics: {} }, }, }; validateMessage(cooperative); assert.equal( (live.outcome.participants.p0 as { outcome: string }).outcome, 'loss', ); const plan = await read('examples/drop.plan.json'); validatePlan(plan); const cyclic = structuredClone(plan); cyclic.steps[0].trigger = { kind: 'after', steps: ['finish'] }; rejects('animation dependency cycle', () => validatePlan(cyclic)); const marker = structuredClone(plan); marker.steps[1].trigger.marker = 'nonexistent'; rejects('animation marker missing', () => validatePlan(marker)); const components = ((await read('examples/components.json')) as unknown[]).map( validateComponent, ); const poker = components.find((component) => component.kind === 'deck')!; assert.equal(poker.kind, 'deck'); if (poker.kind !== 'deck') throw new Error('missing poker fixture'); assert.equal(poker.cards.length, 52); const repeatedCard = structuredClone(poker); repeatedCard.cards.push(repeatedCard.cards[0]); rejects('duplicate card definition', () => validateComponent(repeatedCard)); rejects('unknown graph neighbor', () => validateComponent({ kind: 'board', schemaVersion: 1, layout: { kind: 'graph', cells: [{ id: 'a', neighbors: ['b'] }] }, }), ); const artwork = await read('examples/poker.resource-set.json'); validateResourceSet( artwork, poker.cards.map((card) => card.id), ); const incompleteArtwork = structuredClone(artwork); delete incompleteArtwork.slots[poker.cards[0].id]; rejects('missing card artwork', () => validateResourceSet( incompleteArtwork, poker.cards.map((card) => card.id), ), ); const missingRegion = structuredClone(artwork); missingRegion.slots[poker.cards[0].id].region = 'missing'; rejects('unknown artwork region', () => validateResourceSet(missingRegion)); const inheritedResource = structuredClone(artwork); inheritedResource.back.resourceId = 'constructor'; rejects('inherited artwork resource', () => validateResourceSet(inheritedResource), ); for (const [name, schema] of [ ['package', packageSchema], ['protocol', wireSchema], ['component', componentSchema], ['resource-set', resourceSetSchema], ['lock', lockSchema], ['presentation-plan', presentationPlanSchema], ] as const) { const generated = { ...z.toJSONSchema(schema, { target: 'draft-2020-12', reused: 'ref' }), $id: `urn:juegoland:${name}:1`, }; const file = `${name}.schema.json`; if (process.argv.includes('--write-schemas')) await writeFile( new URL(file, import.meta.url), JSON.stringify(generated, null, 2) + '\n', ); assert.deepEqual( await read(file), generated, `generated schema drift: ${file}`, ); } console.log( `${registry.size} manifests, ${messages.length + 1} messages, ${negativeCases} rejection cases, ${components.length} components, 52 artwork slots, presentation plan and 6 generated schemas verified.`, ); console.log( 'Scope: specification structure, references and invariants. Game payloads, package files and the engine runtime are separate implementation checks.', );