// 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; export type Package = z.infer; 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; 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; 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; export type Command = z.infer; export type Snapshot = z.infer; export type ActionOffer = z.infer; // 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 = new Map(), ): Package { const parsed = packageSchema.parse(value); const fail = (message: string): never => { throw new Error(`${parsed.id}: ${message}`); }; const dependencies = new Map(); 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, 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['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 { const registry = new Map(); 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(), complete = new Set(); 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(); 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(); 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(), done = new Set(); 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; }