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.
311 lines
8.3 KiB
311 lines
8.3 KiB
// SDK contract for the proposed engine. Types only; no production runtime.
|
|
import type { z } from 'zod';
|
|
import type {
|
|
action,
|
|
json,
|
|
lockSchema,
|
|
presentationPlanSchema,
|
|
MatchOutcome,
|
|
GamePackage,
|
|
publicEvent,
|
|
ActionOffer,
|
|
} from './schema.ts';
|
|
|
|
export type Json = z.infer<typeof json>;
|
|
export type JsonObject = { [key: string]: Json };
|
|
export type SeatId = `p${number}`;
|
|
export type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
|
? T
|
|
: T extends readonly (infer V)[]
|
|
? readonly DeepReadonly<V>[]
|
|
: T extends object
|
|
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
|
: T;
|
|
export interface RuntimeSchema<T> {
|
|
parse(input: unknown): T;
|
|
}
|
|
export type GameAction = z.infer<typeof action>;
|
|
export type DomainEvent = { type: string; payload: JsonObject };
|
|
export type VisibleEvent = DomainEvent;
|
|
export type DeliveredEvent = z.infer<typeof publicEvent>;
|
|
|
|
export interface Participant {
|
|
seat: SeatId;
|
|
teamId: string | null;
|
|
role: string;
|
|
status: 'active' | 'withdrawn';
|
|
}
|
|
export interface Flow {
|
|
phase: string;
|
|
round: number;
|
|
turn: number;
|
|
actors: Partial<Record<SeatId, string>>; // seat -> stage
|
|
}
|
|
export interface PhaseDefinition {
|
|
participation: 'single' | 'multiple' | 'automatic';
|
|
stages: Readonly<Record<string, readonly string[]>>; // stage -> action types
|
|
}
|
|
export interface Position<Config, Data> {
|
|
matchId: string;
|
|
config: Config;
|
|
participants: Participant[];
|
|
data: Data;
|
|
flow: Flow;
|
|
outcome: MatchOutcome | null;
|
|
}
|
|
export type Recipient =
|
|
{ kind: 'participant'; seat: SeatId; role: string } | { kind: 'observer' }; // Only when the platform explicitly authorizes it.
|
|
|
|
export interface CardDefinition {
|
|
id: string;
|
|
suit: string;
|
|
rank: number;
|
|
attributes: JsonObject;
|
|
}
|
|
export interface CardInstance {
|
|
id: string;
|
|
definitionId: string;
|
|
copy: number;
|
|
componentId: string;
|
|
}
|
|
export interface Zone {
|
|
id: string;
|
|
owner: SeatId | null;
|
|
ordered: boolean;
|
|
visibility: 'public' | 'owner' | 'hidden';
|
|
items: string[];
|
|
}
|
|
export interface DeckState {
|
|
instances: CardInstance[];
|
|
zones: Zone[];
|
|
}
|
|
export interface DieFace {
|
|
id: string;
|
|
value: Json;
|
|
weight: number;
|
|
}
|
|
export interface WheelSector {
|
|
id: string;
|
|
weight: number;
|
|
}
|
|
export interface RuleServices {
|
|
readonly nowMs: number; // Captured once and recorded with this command.
|
|
readonly definitions: DeepReadonly<Record<string, Json>>;
|
|
readonly random: {
|
|
int(tag: string, minInclusive: number, maxExclusive: number): number;
|
|
shuffle<T extends Json>(tag: string, items: readonly T[]): T[];
|
|
weighted(
|
|
tag: string,
|
|
options: readonly { id: string; weight: number }[],
|
|
): string;
|
|
};
|
|
readonly cards: {
|
|
instantiate(
|
|
componentId: string,
|
|
definitions: readonly CardDefinition[],
|
|
copies: number,
|
|
): DeckState;
|
|
move(
|
|
state: DeepReadonly<DeckState>,
|
|
ids: readonly string[],
|
|
source: string,
|
|
destination: string,
|
|
index: number,
|
|
): DeckState;
|
|
};
|
|
}
|
|
export type DeadlineIntent =
|
|
| { type: 'schedule'; id: string; dueAtMs: number; payload: JsonObject }
|
|
| { type: 'cancel'; id: string };
|
|
export interface Transition<Data, Event> {
|
|
data: Data;
|
|
flow: Flow;
|
|
outcome: MatchOutcome | null;
|
|
events: Event[];
|
|
deadlines: DeadlineIntent[];
|
|
}
|
|
export type RuleResult<Data, Event> =
|
|
| { ok: true; transition: Transition<Data, Event> }
|
|
| { ok: false; code: 'ILLEGAL_ACTION' };
|
|
export interface ActionHandler<Config, Data, Action, Event> {
|
|
schema: RuntimeSchema<Action>;
|
|
conflictPolicy: 'match' | 'actor';
|
|
apply(
|
|
position: DeepReadonly<Position<Config, Data>>,
|
|
command: DeepReadonly<{ actor: SeatId; action: Action }>,
|
|
services: RuleServices,
|
|
): RuleResult<Data, Event>;
|
|
}
|
|
export type OfferDraft<Action extends GameAction> =
|
|
| { kind: 'concrete'; action: Action }
|
|
| {
|
|
kind: 'parameterized';
|
|
actionType: Action['type'];
|
|
inputSchemaId: string;
|
|
constraints: JsonObject;
|
|
};
|
|
export interface Projection<View> {
|
|
view: View;
|
|
context: JsonObject; // Explicit projection; never the raw Flow or config.
|
|
outcome: MatchOutcome | null;
|
|
}
|
|
export type LifecycleEvent =
|
|
| { type: 'participant.withdrawn'; seat: SeatId }
|
|
| { type: 'deadline.reached'; deadlineId: string; payload: JsonObject };
|
|
export interface GameDefinition<
|
|
Config,
|
|
Data,
|
|
View,
|
|
Action extends GameAction,
|
|
Event extends DomainEvent,
|
|
PublicEvent extends VisibleEvent,
|
|
> {
|
|
id: string;
|
|
rulesVersion: string;
|
|
schemas: {
|
|
config: RuntimeSchema<Config>;
|
|
data: RuntimeSchema<Data>;
|
|
view: RuntimeSchema<View>;
|
|
context: RuntimeSchema<JsonObject>;
|
|
event: RuntimeSchema<Event>;
|
|
visibleEvent: RuntimeSchema<PublicEvent>;
|
|
};
|
|
phases: Readonly<Record<string, PhaseDefinition>>;
|
|
setup(
|
|
input: DeepReadonly<{
|
|
matchId: string;
|
|
config: Config;
|
|
participants: Participant[];
|
|
}>,
|
|
services: RuleServices,
|
|
): Transition<Data, Event>;
|
|
validatePosition(position: DeepReadonly<Position<Config, Data>>): void;
|
|
actions: {
|
|
[K in Action['type']]: ActionHandler<
|
|
Config,
|
|
Data,
|
|
Extract<Action, { type: K }>,
|
|
Event
|
|
>;
|
|
};
|
|
availableActions(
|
|
position: DeepReadonly<Position<Config, Data>>,
|
|
recipient: DeepReadonly<Recipient>,
|
|
): OfferDraft<Action>[];
|
|
project(
|
|
position: DeepReadonly<Position<Config, Data>>,
|
|
recipient: DeepReadonly<Recipient>,
|
|
): Projection<View>;
|
|
projectEvent(
|
|
input: DeepReadonly<{
|
|
before: Position<Config, Data> | null;
|
|
after: Position<Config, Data>;
|
|
event: Event;
|
|
recipient: Recipient;
|
|
}>,
|
|
): PublicEvent[];
|
|
onLifecycle(
|
|
position: DeepReadonly<Position<Config, Data>>,
|
|
event: DeepReadonly<LifecycleEvent>,
|
|
services: RuleServices,
|
|
): Transition<Data, Event>;
|
|
}
|
|
|
|
export interface PersistedEnvelope<Config, Data> {
|
|
schemaVersion: 1;
|
|
game: {
|
|
id: string;
|
|
rulesVersion: string;
|
|
stateSchemaVersion: number;
|
|
packageDigest: string;
|
|
dependencyLockDigest: string;
|
|
};
|
|
revision: number;
|
|
phaseEpoch: number;
|
|
actorRevisions: Partial<Record<SeatId, number>>;
|
|
position: Position<Config, Data>;
|
|
}
|
|
export type PackageLock = z.infer<typeof lockSchema>;
|
|
export type PresentationPlan = z.infer<typeof presentationPlanSchema>;
|
|
export type PresentationStep = PresentationPlan['steps'][number];
|
|
export type StepTrigger = PresentationStep['trigger'];
|
|
export interface Preferences {
|
|
reducedMotion: boolean;
|
|
sound: boolean;
|
|
volume: number;
|
|
announcements: boolean;
|
|
}
|
|
// The module at a `graphic` resource path exports this definition as default.
|
|
// Renderers own insertion and removal; a graphic creates no timers or commands.
|
|
export interface GraphicDefinition {
|
|
render(
|
|
input: DeepReadonly<{
|
|
resourceId: string;
|
|
slotId: string | null;
|
|
definition: Json | null;
|
|
locale: string;
|
|
}>,
|
|
): SVGElement;
|
|
}
|
|
export type FinishReason =
|
|
'completed' | 'skipped' | 'cancelled' | 'failed' | 'timeout';
|
|
export interface EffectContext {
|
|
signal: AbortSignal;
|
|
preferences: DeepReadonly<Preferences>;
|
|
resource(id: string): Promise<unknown>;
|
|
frame(key: string, value: Json): void;
|
|
marker(name: string): void;
|
|
audio(input: {
|
|
resource: string;
|
|
channel: string;
|
|
policy: 'mix' | 'replace' | 'skip-if-busy';
|
|
maxLateMs: number;
|
|
}): void;
|
|
}
|
|
export interface EffectHandle {
|
|
play(): Promise<void>;
|
|
dispose(reason: FinishReason): void;
|
|
}
|
|
export interface EffectDefinition {
|
|
params: RuntimeSchema<JsonObject>;
|
|
prepare(
|
|
input: DeepReadonly<JsonObject>,
|
|
context: EffectContext,
|
|
): Promise<EffectHandle>;
|
|
}
|
|
export interface RenderFrame<View> {
|
|
presented: Projection<View>;
|
|
availableActions: ActionOffer[];
|
|
presentation: JsonObject;
|
|
busy: boolean;
|
|
connected: boolean;
|
|
}
|
|
export interface Renderer<View> {
|
|
update(frame: DeepReadonly<RenderFrame<View>>): void;
|
|
dispose(): void;
|
|
}
|
|
export interface PresentationDefinition<
|
|
View,
|
|
Action extends GameAction,
|
|
PublicEvent extends VisibleEvent,
|
|
> {
|
|
apiVersion: 1;
|
|
mount(
|
|
host: HTMLElement,
|
|
frame: DeepReadonly<RenderFrame<View>>,
|
|
intent: (action: Action) => void,
|
|
): Renderer<View>;
|
|
buildTransitionPlan(
|
|
input: DeepReadonly<{
|
|
from: Projection<View>;
|
|
to: Projection<View>;
|
|
fromRevision: number;
|
|
toRevision: number;
|
|
events: (PublicEvent & { eventId: string })[];
|
|
manifest: GamePackage;
|
|
preferences: Preferences;
|
|
}>,
|
|
): PresentationPlan;
|
|
effects: Readonly<Record<string, EffectDefinition>>;
|
|
}
|