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
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.',
|
|
);
|