You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

331 lines
9.7 KiB

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<typeof gamePackage>) => 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<string, unknown>;
const cycleB = structuredClone(libraries[0]) as Record<string, unknown>;
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<string, any>;
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<string, any>;
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.',
);

Powered by TurnKey Linux.