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.
721 lines
22 KiB
721 lines
22 KiB
// Executable specification. Not imported by the production application.
|
|
import { z } from 'zod';
|
|
|
|
export const key = z.string().regex(/^[a-z][a-zA-Z0-9.-]{0,79}$/);
|
|
export const version = z
|
|
.string()
|
|
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
|
|
export const relativePath = z
|
|
.string()
|
|
.regex(
|
|
/^[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?(?:\/[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?)*$/,
|
|
);
|
|
export const revision = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
export const json = z.json();
|
|
export const jsonObject = z.record(z.string(), json);
|
|
export const seat = z.string().regex(/^p(0|[1-9]\d*)$/);
|
|
export const reference = z.discriminatedUnion('source', [
|
|
z.strictObject({ source: z.literal('local'), path: relativePath }),
|
|
z.strictObject({
|
|
source: z.literal('dependency'),
|
|
packageId: key,
|
|
export: key,
|
|
}),
|
|
]);
|
|
const dependency = z.strictObject({
|
|
id: key,
|
|
version,
|
|
kind: z.enum(['components', 'assets']),
|
|
});
|
|
const resource = z.strictObject({
|
|
kind: z.enum(['image', 'atlas', 'audio', 'font', 'graphic']),
|
|
path: relativePath,
|
|
load: z.enum(['catalog', 'board', 'phase', 'lazy']),
|
|
phase: key.optional(),
|
|
required: z.boolean(),
|
|
altKey: key.optional(),
|
|
width: z.number().int().positive().optional(),
|
|
height: z.number().int().positive().optional(),
|
|
regions: z
|
|
.record(
|
|
key,
|
|
z.strictObject({
|
|
x: z.number().int().nonnegative(),
|
|
y: z.number().int().nonnegative(),
|
|
width: z.number().int().positive(),
|
|
height: z.number().int().positive(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
fallback: z.discriminatedUnion('kind', [
|
|
z.strictObject({ kind: z.literal('text'), key }),
|
|
z.strictObject({ kind: z.literal('resource'), resourceId: key }),
|
|
z.strictObject({ kind: z.literal('omit') }),
|
|
]),
|
|
});
|
|
const metadata = z.strictObject({
|
|
name: z.string().min(1).max(120),
|
|
description: z.string().min(1).max(2000),
|
|
authors: z.array(z.string().min(1)).min(1),
|
|
license: z.string().min(1),
|
|
defaultLocale: z.string().regex(/^[a-z]{2}(?:-[A-Z]{2})?$/),
|
|
locales: z.record(z.string(), relativePath),
|
|
});
|
|
const common = {
|
|
format: z.literal('juegoland.package'),
|
|
formatVersion: z.literal(1),
|
|
id: key,
|
|
version,
|
|
engineApiVersion: z.literal(1),
|
|
metadata,
|
|
dependencies: z.array(dependency),
|
|
};
|
|
export const gamePackage = z.strictObject({
|
|
...common,
|
|
kind: z.literal('game'),
|
|
classification: z.strictObject({
|
|
category: key,
|
|
tags: z.array(key),
|
|
competition: z.enum(['individual', 'teams', 'cooperative', 'solo', 'open']),
|
|
}),
|
|
players: z.strictObject({
|
|
min: z.number().int().min(1).max(64),
|
|
max: z.number().int().min(1).max(64),
|
|
}),
|
|
capabilities: z.strictObject({
|
|
hiddenInformation: z.boolean(),
|
|
simultaneousActions: z.boolean(),
|
|
deadlines: z.boolean(),
|
|
}),
|
|
components: z.record(
|
|
key,
|
|
z.strictObject({
|
|
kind: z.enum(['deck', 'dice', 'wheel', 'board', 'pieces', 'custom']),
|
|
definition: reference,
|
|
copies: z.number().int().min(1).max(100),
|
|
}),
|
|
),
|
|
rules: z.strictObject({
|
|
entry: relativePath,
|
|
version,
|
|
stateSchemaVersion: z.number().int().positive(),
|
|
viewSchemaVersion: z.number().int().positive(),
|
|
eventSchemaVersion: z.number().int().positive(),
|
|
schemas: z.strictObject({
|
|
config: relativePath,
|
|
data: relativePath,
|
|
view: relativePath,
|
|
context: relativePath,
|
|
event: relativePath,
|
|
visibleEvent: relativePath,
|
|
}),
|
|
}),
|
|
presentation: z.strictObject({
|
|
entry: relativePath,
|
|
apiVersion: z.literal(1),
|
|
bundleVersion: version,
|
|
resources: z.record(key, resource),
|
|
bindings: z.record(key, reference),
|
|
catalog: z.strictObject({ cover: key, thumbnail: key, icon: key }),
|
|
}),
|
|
});
|
|
export const libraryPackage = z.strictObject({
|
|
...common,
|
|
kind: z.enum(['components', 'assets']),
|
|
exports: z.record(key, relativePath),
|
|
});
|
|
export const packageSchema = z.union([gamePackage, libraryPackage]);
|
|
export type GamePackage = z.infer<typeof gamePackage>;
|
|
export type Package = z.infer<typeof packageSchema>;
|
|
|
|
const cardDefinition = z.strictObject({
|
|
id: key,
|
|
suit: key,
|
|
rank: z.number().int(),
|
|
attributes: jsonObject,
|
|
});
|
|
export const componentSchema = z.discriminatedUnion('kind', [
|
|
z.strictObject({
|
|
kind: z.literal('deck'),
|
|
schemaVersion: z.literal(1),
|
|
cards: z.array(cardDefinition).min(1),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('dice'),
|
|
schemaVersion: z.literal(1),
|
|
dice: z
|
|
.array(
|
|
z.strictObject({
|
|
id: key,
|
|
faces: z
|
|
.array(
|
|
z.strictObject({
|
|
id: key,
|
|
value: json,
|
|
weight: z.number().int().positive().max(1000000),
|
|
}),
|
|
)
|
|
.min(2),
|
|
}),
|
|
)
|
|
.min(1),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('wheel'),
|
|
schemaVersion: z.literal(1),
|
|
sectors: z
|
|
.array(
|
|
z.strictObject({
|
|
id: key,
|
|
weight: z.number().int().positive().max(1000000),
|
|
}),
|
|
)
|
|
.min(1),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('board'),
|
|
schemaVersion: z.literal(1),
|
|
layout: z.discriminatedUnion('kind', [
|
|
z.strictObject({
|
|
kind: z.literal('grid'),
|
|
rows: z.number().int().positive(),
|
|
columns: z.number().int().positive(),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('graph'),
|
|
cells: z
|
|
.array(z.strictObject({ id: key, neighbors: z.array(key) }))
|
|
.min(1),
|
|
}),
|
|
]),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('pieces'),
|
|
schemaVersion: z.literal(1),
|
|
definitions: z
|
|
.array(
|
|
z.strictObject({
|
|
id: key,
|
|
count: z.number().int().positive(),
|
|
attributes: jsonObject,
|
|
}),
|
|
)
|
|
.min(1),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('custom'),
|
|
schemaVersion: z.literal(1),
|
|
typeId: key,
|
|
data: jsonObject,
|
|
}),
|
|
]);
|
|
export type ComponentDefinition = z.infer<typeof componentSchema>;
|
|
export const resourceSetSchema = z.strictObject({
|
|
schemaVersion: z.literal(1),
|
|
resources: z.record(key, resource),
|
|
slots: z.record(
|
|
key,
|
|
z.strictObject({ resourceId: key, region: key.optional() }),
|
|
),
|
|
back: z.strictObject({ resourceId: key, region: key.optional() }).optional(),
|
|
aspectRatio: z.number().positive().optional(),
|
|
});
|
|
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
export const lockSchema = z.strictObject({
|
|
formatVersion: z.literal(1),
|
|
root: z.strictObject({ id: key, version, sha256: digest }),
|
|
resolved: z.array(z.strictObject({ id: key, version, sha256: digest })),
|
|
files: z.array(
|
|
z.strictObject({
|
|
packageId: key,
|
|
path: relativePath,
|
|
sha256: digest,
|
|
bytes: revision,
|
|
mediaType: z.string().min(1),
|
|
url: z.string().url().nullable(),
|
|
}),
|
|
),
|
|
});
|
|
export const presentationPlanSchema = z.strictObject({
|
|
id: z.string().min(1),
|
|
fromRevision: revision,
|
|
toRevision: revision,
|
|
steps: z
|
|
.array(
|
|
z.strictObject({
|
|
id: key,
|
|
effect: key,
|
|
trigger: z.discriminatedUnion('kind', [
|
|
z.strictObject({ kind: z.literal('after'), steps: z.array(key) }),
|
|
z.strictObject({ kind: z.literal('marker'), step: key, marker: key }),
|
|
]),
|
|
params: jsonObject,
|
|
emits: z.array(key),
|
|
blocking: z.boolean(),
|
|
timeoutMs: z.number().int().min(1).max(12000),
|
|
}),
|
|
)
|
|
.max(128),
|
|
});
|
|
|
|
export const result = z.strictObject({
|
|
kind: z.literal('completed'),
|
|
reasonCode: key,
|
|
participants: z.record(
|
|
seat,
|
|
z.strictObject({
|
|
outcome: z.enum(['win', 'loss', 'draw', 'neutral']),
|
|
score: z.number().finite().optional(),
|
|
rank: z.number().int().positive().optional(),
|
|
teamId: key.optional(),
|
|
metrics: jsonObject,
|
|
}),
|
|
),
|
|
});
|
|
export const matchOutcome = z.union([
|
|
result,
|
|
z.strictObject({ kind: z.literal('cancelled'), reasonCode: key }),
|
|
]);
|
|
export type MatchOutcome = z.infer<typeof matchOutcome>;
|
|
|
|
export const action = z.strictObject({ type: key, payload: jsonObject });
|
|
export const precondition = z.discriminatedUnion('kind', [
|
|
z.strictObject({ kind: z.literal('match'), revision }),
|
|
z.strictObject({
|
|
kind: z.literal('actor'),
|
|
phaseEpoch: revision,
|
|
actorRevision: revision,
|
|
}),
|
|
]);
|
|
const base = { protocolVersion: z.literal(1), matchId: z.uuid() };
|
|
const request = { ...base, requestId: z.uuid() };
|
|
const gameViewContract = z.strictObject({
|
|
id: key,
|
|
rulesVersion: version,
|
|
viewSchemaVersion: z.number().int().positive(),
|
|
eventSchemaVersion: z.number().int().positive(),
|
|
});
|
|
export const command = z.strictObject({
|
|
...base,
|
|
type: z.literal('game.command'),
|
|
commandId: z.uuid(),
|
|
expected: precondition,
|
|
action,
|
|
});
|
|
export const errorCode = z.enum([
|
|
'INVALID_MESSAGE',
|
|
'UNSUPPORTED_PROTOCOL',
|
|
'UNAUTHORIZED',
|
|
'NOT_PARTICIPANT',
|
|
'STALE_REVISION',
|
|
'STALE_PHASE',
|
|
'STALE_ACTOR',
|
|
'ILLEGAL_ACTION',
|
|
'MATCH_FINISHED',
|
|
'COMMAND_ID_REUSED',
|
|
'PACKAGE_UNAVAILABLE',
|
|
'INCOMPATIBLE_CLIENT',
|
|
'RATE_LIMITED',
|
|
'INTERNAL_ERROR',
|
|
]);
|
|
export const publicEvent = z.strictObject({
|
|
eventId: z.uuid(),
|
|
revision,
|
|
order: revision,
|
|
type: key,
|
|
payload: jsonObject,
|
|
});
|
|
export const offer = z.discriminatedUnion('kind', [
|
|
z.strictObject({
|
|
kind: z.literal('concrete'),
|
|
action,
|
|
expected: precondition,
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal('parameterized'),
|
|
actionType: key,
|
|
inputSchemaId: key,
|
|
constraints: jsonObject,
|
|
expected: precondition,
|
|
}),
|
|
]);
|
|
export const snapshot = z.strictObject({
|
|
...base,
|
|
type: z.literal('game.snapshot'),
|
|
subscriptionId: z.uuid(),
|
|
delivery: z.discriminatedUnion('kind', [
|
|
z.strictObject({ kind: z.literal('sync'), requestId: z.uuid() }),
|
|
z.strictObject({ kind: z.literal('live'), fromRevision: revision }),
|
|
]),
|
|
revision,
|
|
game: gameViewContract,
|
|
status: z.enum(['active', 'completed', 'cancelled']),
|
|
view: jsonObject,
|
|
context: jsonObject,
|
|
availableActions: z.array(offer),
|
|
outcome: matchOutcome.nullable(),
|
|
events: z.array(publicEvent),
|
|
});
|
|
export const ack = z.strictObject({
|
|
...base,
|
|
type: z.literal('game.ack'),
|
|
commandId: z.uuid(),
|
|
duplicate: z.boolean(),
|
|
receipt: z.discriminatedUnion('status', [
|
|
z.strictObject({ status: z.literal('applied'), appliedRevision: revision }),
|
|
z.strictObject({
|
|
status: z.literal('rejected'),
|
|
code: z.enum([
|
|
'STALE_REVISION',
|
|
'STALE_PHASE',
|
|
'STALE_ACTOR',
|
|
'ILLEGAL_ACTION',
|
|
'MATCH_FINISHED',
|
|
]),
|
|
observedRevision: revision,
|
|
}),
|
|
]),
|
|
});
|
|
export const wireSchema = z.union([
|
|
command,
|
|
snapshot,
|
|
ack,
|
|
z.strictObject({
|
|
...request,
|
|
type: z.literal('game.sync'),
|
|
knownRevision: revision.nullable(),
|
|
client: z.strictObject({
|
|
engineApiVersion: z.literal(1),
|
|
presentationApiVersion: z.literal(1),
|
|
presentations: z.array(gameViewContract).min(1),
|
|
}),
|
|
}),
|
|
z.strictObject({
|
|
...base,
|
|
type: z.literal('game.unsubscribe'),
|
|
subscriptionId: z.uuid(),
|
|
}),
|
|
z.strictObject({
|
|
...request,
|
|
type: z.literal('game.command-status'),
|
|
commandId: z.uuid(),
|
|
}),
|
|
z.strictObject({
|
|
...request,
|
|
type: z.literal('game.command-unknown'),
|
|
commandId: z.uuid(),
|
|
}),
|
|
z.strictObject({
|
|
...base,
|
|
type: z.literal('game.cursor'),
|
|
subscriptionId: z.uuid(),
|
|
revision,
|
|
}),
|
|
z.strictObject({
|
|
protocolVersion: z.literal(1),
|
|
matchId: z.uuid().optional(),
|
|
type: z.literal('game.error'),
|
|
requestId: z.uuid().optional(),
|
|
commandId: z.uuid().optional(),
|
|
code: errorCode,
|
|
recovery: z.enum([
|
|
'none',
|
|
'sync',
|
|
'retry-same-command',
|
|
'reload-client',
|
|
'reauthenticate',
|
|
]),
|
|
messageKey: key,
|
|
}),
|
|
]);
|
|
export type WireMessage = z.infer<typeof wireSchema>;
|
|
export type Command = z.infer<typeof command>;
|
|
export type Snapshot = z.infer<typeof snapshot>;
|
|
export type ActionOffer = z.infer<typeof offer>;
|
|
|
|
// Cross-field and cross-package rules are a second validation layer, documented
|
|
// in the specification. JSON Schema describes the structural layer only.
|
|
export function validatePackage(
|
|
value: unknown,
|
|
registry: ReadonlyMap<string, Package> = new Map(),
|
|
): Package {
|
|
const parsed = packageSchema.parse(value);
|
|
const fail = (message: string): never => {
|
|
throw new Error(`${parsed.id}: ${message}`);
|
|
};
|
|
const dependencies = new Map<string, Package>();
|
|
for (const dep of parsed.dependencies) {
|
|
if (dependencies.has(dep.id) || dep.id === parsed.id)
|
|
fail('duplicate or self dependency');
|
|
const resolved = registry.get(`${dep.id}@${dep.version}`);
|
|
if (!resolved || resolved.kind !== dep.kind)
|
|
fail(`unresolved dependency ${dep.id}@${dep.version}`);
|
|
dependencies.set(dep.id, resolved!);
|
|
}
|
|
if (!Object.hasOwn(parsed.metadata.locales, parsed.metadata.defaultLocale))
|
|
fail('missing default locale');
|
|
if (parsed.kind !== 'game') {
|
|
if (!Object.keys(parsed.exports).length) fail('library has no exports');
|
|
if (parsed.dependencies.some((dep) => dep.kind !== parsed.kind))
|
|
fail('library dependency crosses server/client boundary');
|
|
return parsed;
|
|
}
|
|
if (parsed.players.min > parsed.players.max) fail('invalid player bounds');
|
|
if (parsed.classification.competition === 'solo' && parsed.players.max !== 1)
|
|
fail('solo requires one player');
|
|
const resolve = (
|
|
ref: z.infer<typeof reference>,
|
|
expected: 'components' | 'assets',
|
|
) => {
|
|
if (ref.source === 'local') return;
|
|
const dep = dependencies.get(ref.packageId);
|
|
if (
|
|
!dep ||
|
|
dep.kind === 'game' ||
|
|
dep.kind !== expected ||
|
|
!Object.hasOwn(dep.exports, ref.export)
|
|
)
|
|
fail('invalid dependency export');
|
|
};
|
|
for (const component of Object.values(parsed.components))
|
|
resolve(component.definition, 'components');
|
|
for (const [id, binding] of Object.entries(parsed.presentation.bindings)) {
|
|
if (!Object.hasOwn(parsed.components, id))
|
|
fail(`binding without component ${id}`);
|
|
resolve(binding, 'assets');
|
|
}
|
|
const resources = parsed.presentation.resources;
|
|
for (const id of Object.values(parsed.presentation.catalog)) {
|
|
const asset = resources[id];
|
|
if (
|
|
!asset ||
|
|
!['image', 'graphic'].includes(asset.kind) ||
|
|
asset.load !== 'catalog'
|
|
)
|
|
fail(`invalid catalog resource ${id}`);
|
|
}
|
|
validateResources(resources);
|
|
return parsed;
|
|
}
|
|
|
|
function validateResources(
|
|
resources: z.infer<typeof resourceSetSchema>['resources'],
|
|
) {
|
|
const fail = (message: string): never => {
|
|
throw new Error(message);
|
|
};
|
|
for (const [id, asset] of Object.entries(resources)) {
|
|
if ((asset.load === 'phase') !== (asset.phase !== undefined))
|
|
fail(`invalid phase loading ${id}`);
|
|
if (
|
|
['image', 'atlas'].includes(asset.kind) &&
|
|
(!asset.width || !asset.height)
|
|
)
|
|
fail(`missing image dimensions ${id}`);
|
|
if (asset.kind === 'atlas' && !Object.keys(asset.regions ?? {}).length)
|
|
fail(`empty atlas ${id}`);
|
|
if (asset.regions && asset.kind !== 'atlas')
|
|
fail(`regions outside atlas ${id}`);
|
|
for (const region of Object.values(asset.regions ?? {})) {
|
|
if (
|
|
region.x + region.width > asset.width! ||
|
|
region.y + region.height > asset.height!
|
|
)
|
|
fail(`atlas bounds ${id}`);
|
|
}
|
|
if (asset.required && asset.fallback.kind === 'omit')
|
|
fail(`required resource cannot be omitted ${id}`);
|
|
const visited = new Set([id]);
|
|
let cursor = asset;
|
|
while (cursor.fallback.kind === 'resource') {
|
|
const next = cursor.fallback.resourceId;
|
|
if (visited.has(next) || !Object.hasOwn(resources, next))
|
|
fail(`fallback cycle or missing resource ${id}`);
|
|
visited.add(next);
|
|
cursor = resources[next];
|
|
}
|
|
if (asset.required && cursor.fallback.kind === 'omit')
|
|
fail(`required fallback chain ends in omission ${id}`);
|
|
}
|
|
}
|
|
|
|
export function validateRegistry(
|
|
values: unknown[],
|
|
): ReadonlyMap<string, Package> {
|
|
const registry = new Map<string, Package>();
|
|
for (const value of values) {
|
|
const parsed = packageSchema.parse(value);
|
|
const identity = `${parsed.id}@${parsed.version}`;
|
|
if (registry.has(identity))
|
|
throw new Error(`duplicate package ${identity}`);
|
|
registry.set(identity, parsed);
|
|
}
|
|
const visiting = new Set<string>(),
|
|
complete = new Set<string>();
|
|
const visit = (identity: string) => {
|
|
if (visiting.has(identity))
|
|
throw new Error(`dependency cycle at ${identity}`);
|
|
if (complete.has(identity)) return;
|
|
const value = registry.get(identity);
|
|
if (!value) throw new Error(`unresolved package ${identity}`);
|
|
visiting.add(identity);
|
|
validatePackage(value, registry);
|
|
for (const dep of value.dependencies) visit(`${dep.id}@${dep.version}`);
|
|
visiting.delete(identity);
|
|
complete.add(identity);
|
|
};
|
|
for (const identity of registry.keys()) visit(identity);
|
|
// A registry can host several releases, but one package closure must resolve
|
|
// exactly one release per package id (the lock uses packageId for its files).
|
|
for (const root of registry.values()) {
|
|
const selected = new Map<string, string>();
|
|
const resolve = (value: Package) => {
|
|
const previous = selected.get(value.id);
|
|
if (previous && previous !== value.version)
|
|
throw new Error(`conflicting dependency versions for ${value.id}`);
|
|
if (previous) return;
|
|
selected.set(value.id, value.version);
|
|
for (const dep of value.dependencies)
|
|
resolve(registry.get(`${dep.id}@${dep.version}`)!);
|
|
};
|
|
resolve(root);
|
|
}
|
|
return registry;
|
|
}
|
|
|
|
function unique(ids: string[], label: string) {
|
|
if (new Set(ids).size !== ids.length) throw new Error(`duplicate ${label}`);
|
|
}
|
|
|
|
export function validateComponent(value: unknown): ComponentDefinition {
|
|
const component = componentSchema.parse(value);
|
|
if (component.kind === 'deck')
|
|
unique(
|
|
component.cards.map((card) => card.id),
|
|
'card definition',
|
|
);
|
|
if (component.kind === 'dice') {
|
|
unique(
|
|
component.dice.map((die) => die.id),
|
|
'die',
|
|
);
|
|
for (const die of component.dice)
|
|
unique(
|
|
die.faces.map((face) => face.id),
|
|
'die face',
|
|
);
|
|
}
|
|
if (component.kind === 'wheel')
|
|
unique(
|
|
component.sectors.map((sector) => sector.id),
|
|
'wheel sector',
|
|
);
|
|
if (component.kind === 'pieces')
|
|
unique(
|
|
component.definitions.map((piece) => piece.id),
|
|
'piece definition',
|
|
);
|
|
if (component.kind === 'board' && component.layout.kind === 'graph') {
|
|
const ids = component.layout.cells.map((cell) => cell.id);
|
|
unique(ids, 'board cell');
|
|
const known = new Set(ids);
|
|
for (const cell of component.layout.cells) {
|
|
unique(cell.neighbors, 'neighbor');
|
|
if (cell.neighbors.some((id) => !known.has(id)))
|
|
throw new Error('unknown board neighbor');
|
|
}
|
|
}
|
|
return component;
|
|
}
|
|
|
|
export function validateResourceSet(
|
|
value: unknown,
|
|
requiredSlots: readonly string[] = [],
|
|
) {
|
|
const set = resourceSetSchema.parse(value);
|
|
validateResources(set.resources);
|
|
for (const slot of [
|
|
...Object.values(set.slots),
|
|
...(set.back ? [set.back] : []),
|
|
]) {
|
|
if (!Object.hasOwn(set.resources, slot.resourceId))
|
|
throw new Error('unknown slot resource');
|
|
const resource = set.resources[slot.resourceId];
|
|
if (!['image', 'atlas', 'graphic'].includes(resource.kind))
|
|
throw new Error('nonvisual slot resource');
|
|
if (
|
|
slot.region &&
|
|
(resource.kind !== 'atlas' ||
|
|
!Object.hasOwn(resource.regions ?? {}, slot.region))
|
|
)
|
|
throw new Error('unknown slot region');
|
|
}
|
|
if (requiredSlots.some((id) => !Object.hasOwn(set.slots, id)))
|
|
throw new Error('missing component artwork slot');
|
|
return set;
|
|
}
|
|
|
|
export function validateMessage(value: unknown): WireMessage {
|
|
const parsed = wireSchema.parse(value);
|
|
if (parsed.type !== 'game.snapshot') return parsed;
|
|
const fail = (message: string): never => {
|
|
throw new Error(message);
|
|
};
|
|
if (parsed.delivery.kind === 'sync' && parsed.events.length)
|
|
fail('sync must not replay events');
|
|
if (
|
|
parsed.delivery.kind === 'live' &&
|
|
parsed.revision !== parsed.delivery.fromRevision + 1
|
|
)
|
|
fail('noncontiguous live revision');
|
|
const ids = new Set<string>();
|
|
parsed.events.forEach((event, index) => {
|
|
if (
|
|
ids.has(event.eventId) ||
|
|
event.revision !== parsed.revision ||
|
|
event.order !== index
|
|
)
|
|
fail('invalid visible event order');
|
|
ids.add(event.eventId);
|
|
});
|
|
if ((parsed.status === 'active') !== (parsed.outcome === null))
|
|
fail('status/outcome mismatch');
|
|
if (parsed.outcome && parsed.outcome.kind !== parsed.status)
|
|
fail('terminal outcome mismatch');
|
|
if (parsed.status !== 'active' && parsed.availableActions.length)
|
|
fail('finished match offers actions');
|
|
if (
|
|
parsed.outcome?.kind === 'completed' &&
|
|
!Object.keys(parsed.outcome.participants).length
|
|
)
|
|
fail('empty result');
|
|
return parsed;
|
|
}
|
|
|
|
export function validatePlan(value: unknown) {
|
|
const plan = presentationPlanSchema.parse(value);
|
|
if (plan.toRevision !== plan.fromRevision + 1)
|
|
throw new Error('noncontiguous plan');
|
|
const steps = new Map(plan.steps.map((step) => [step.id, step]));
|
|
if (steps.size !== plan.steps.length) throw new Error('duplicate step');
|
|
const visiting = new Set<string>(),
|
|
done = new Set<string>();
|
|
const visit = (id: string) => {
|
|
if (visiting.has(id)) throw new Error('plan cycle');
|
|
if (done.has(id)) return;
|
|
const step = steps.get(id);
|
|
if (!step) throw new Error('unknown step');
|
|
visiting.add(id);
|
|
if (step.trigger.kind === 'marker') {
|
|
const source = steps.get(step.trigger.step);
|
|
if (!source?.emits.includes(step.trigger.marker))
|
|
throw new Error('unknown marker');
|
|
visit(step.trigger.step);
|
|
} else for (const dependency of step.trigger.steps) visit(dependency);
|
|
visiting.delete(id);
|
|
done.add(id);
|
|
};
|
|
for (const id of steps.keys()) visit(id);
|
|
return plan;
|
|
}
|