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

// 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;
}

Powered by TurnKey Linux.