// A playground file says how a spec is shown, and nothing else: which actors read as one // thing, how a node, a token or a link is drawn, and what panel opens beside a node. Render // functions receive typed state and can act on the model the way the playground's own // controls do. The spec stays the model; the checker never loads this file. import type { ReactNode } from "react"; import type { Actor, ActionsOf, Appearance, GroupInfo, Icon, InputsOf, Mood, Shape, Spec, StateOf, Token, WorldView } from "../spec/index.ts"; export interface Self { id: string; label: string; } /** Run an action of this instance: `act("pair")`, `act("run", { host, command })`. */ export type Act = & string>( key: K, ...inputs: keyof ActionsOf[K] extends never ? [] : [InputsOf[K]>] ) => void; /** What a render function for an instance receives. */ export interface NodeProps> { instance: Self; state: Readonly>; /** The display the spec gives it, to build on. */ display: Appearance; world: WorldView; /** False while looking at the past: acting does nothing then. */ live: boolean; act: Act; } /** One instance inside a group node. */ export interface Member { instance: Self; actor: string; state: unknown; display: Appearance; } /** What a render function for a group receives: its members, and the world to read them in. */ export interface GroupProps { /** The node's name: the group's, or the shared part of its members' labels ("Studio"). */ label: string; members: Member[]; /** Its members' displays, merged: their rows and tokens, the most telling mood. */ display: Appearance; world: WorldView; live: boolean; /** The member of an actor, typed: `member(Manager).state.pairings`. */ member>(actor: A): { instance: Self; state: Readonly>; display: Appearance } | undefined; /** Run an action of one member. */ act(instance: string, key: string, inputs?: Record): void; } export interface TokenProps { token: Token; holder: Self; } export interface LinkStyle { style?: "solid" | "dashed" | "dotted"; /** Dashes that move from the one holding the link, as data would. */ flowing?: boolean; /** "owner": the colour of whose it is; or a mood. */ color?: "owner" | Mood; } /** Room a custom node takes on the map, in pixels. */ export interface Size { w: number; h: number; } export interface ActorPresentation { /** Replaces how the node is drawn; the playground still handles clicks and drops on it. */ node?: (props: NodeProps) => ReactNode; /** Opens beside the node while it returns something: a terminal, a Settings pane. */ panel?: (props: NodeProps) => ReactNode; size?: Size; } export interface GroupPresentation { render?: (props: GroupProps) => ReactNode; panel?: (props: GroupProps) => ReactNode; size?: Size; /** A few words under the name while closed: "1 code waiting". */ status?: (props: GroupProps) => string | null; } export interface Playground { readonly kind: "feynman.playground"; /** The spec's name, so the view pairs them. */ spec: string; /** Groups the playground adds, or redefines (same name as the spec's). */ groups: GroupInfo[]; group: Record; actor: Record; /** Preferred lifeline order in the sequence diagram, by scenario name. */ flowOrder: Record; token: Record ReactNode>; link: Record; } type Actors = Actor[] | Record>; interface GroupOptions extends GroupPresentation { icon?: Icon; shape?: Shape; description?: string; collapsed?: boolean; names?: string[]; } export interface PlayBuilder { /** * Show actors as one node: a list of singletons (the relay), or a record of other actors * grouped by label (`{ app: App, daemon: Axod }`: "Studio app" and "Studio daemon" are one * "Studio"). Without actors, it styles the spec's group of that name. */ group(name: string, actors: Actors, options?: GroupOptions): void; group(name: string, options: GroupOptions): void; actor>(actor: A, presentation: ActorPresentation): void; /** Set the preferred left-to-right actor order in a scenario's sequence diagram, e.g. `play.flow("Import", [Person, Worker, Artifacts])`. */ flow(scenario: string, actors: readonly (Actor | string)[]): void; /** How a token of this kind is drawn, such as a ticket. */ token(kind: string, render: (props: TokenProps) => ReactNode): void; link(kind: string, style: LinkStyle): void; } function isActors(value: unknown): value is Actors { if (Array.isArray(value)) return true; if (!value || typeof value !== "object") return false; const values = Object.values(value); return values.length > 0 && values.every((v) => v && typeof v === "object" && "transitions" in v && "name" in v); } export function definePlayground(spec: S, body: (play: PlayBuilder) => void): Playground { const out: Playground = { kind: "feynman.playground", spec: spec.name, groups: [], group: {}, actor: {}, flowOrder: {}, token: {}, link: {} }; const known = (actor: Actor) => { if (!spec.actors.some((a) => a.name === actor.name)) throw new Error(`The playground names ${actor.name}, which is not an actor of ${spec.name}`); }; const builder: PlayBuilder = { group(name: string, second: Actors | GroupOptions, third?: GroupOptions) { const actors = isActors(second) ? second : null; const options = (actors ? third : (second as GroupOptions)) ?? {}; const { render, panel, size, status, ...info } = options; out.group[name] = { render, panel, size, status }; const existing = spec.groups.find((g) => g.name === name); if (!actors) { if (!existing) throw new Error(`The playground styles group "${name}", which ${spec.name} does not define; pass its actors`); if (Object.keys(info).length) out.groups.push({ ...existing, ...info }); return; } const members = Array.isArray(actors) ? actors.map((a) => ({ actor: a.name, suffix: null })) : Object.entries(actors).map(([suffix, a]) => ({ actor: a.name, suffix })); (Array.isArray(actors) ? actors : Object.values(actors)).forEach(known); out.groups.push({ name, description: info.description, icon: info.icon, shape: info.shape, collapsed: info.collapsed ?? true, names: info.names, members }); }, actor(actor, presentation) { known(actor); out.actor[actor.name] = presentation; }, flow(scenario, actors) { if (!spec.scenarios.some((item) => item.name === scenario)) throw new Error(`The playground orders a flow for unknown scenario “${scenario}”`); const names = actors.map((actor) => typeof actor === "string" ? actor : actor.name); if (names.some((name) => !spec.actors.some((actor) => actor.name === name))) throw new Error(`The playground flow “${scenario}” names an unknown actor`); if (new Set(names).size !== names.length) throw new Error(`The playground flow “${scenario}” lists an actor more than once`); out.flowOrder[scenario] = names; }, token(kind, render) { out.token[kind] = render; }, link(kind, style) { out.link[kind] = style; }, }; body(builder); return out; } /** Whether a module's default export is a playground. */ export function isPlayground(value: unknown): value is Playground { return Boolean(value && typeof value === "object" && (value as Playground).kind === "feynman.playground"); } /** The spec's groups with the playground's added or redefined. */ export function groupsOf(spec: Spec, playground: Playground | null): GroupInfo[] { const extra = playground?.groups ?? []; return [...spec.groups.filter((g) => !extra.some((x) => x.name === g.name)), ...extra]; }