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

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

Powered by TurnKey Linux.