From 81c115cb4d3eba4c516f610615b18be4f9043e43 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 10 Aug 2026 18:40:30 +0800 Subject: [PATCH] docs: unify commands and orchestrators in bgm-engine Messages are a discriminated union with generic command/result types; the command host is dropped for a handler registry; commands and orchestrators are the same async shape taking a cancellable RunContext. --- docs/bgm-commands.md | 47 +++++++------ docs/bgm-engine.md | 160 +++++++++++++++++++++++++------------------ 2 files changed, 122 insertions(+), 85 deletions(-) diff --git a/docs/bgm-commands.md b/docs/bgm-commands.md index 6d4729a..a3f3ece 100644 --- a/docs/bgm-commands.md +++ b/docs/bgm-commands.md @@ -8,18 +8,20 @@ that runs against the tabletop state store and render layer. This doc covers **command execution**: the async lifecycle, run contexts, and tap interaction. The message layer above this — how commands are *declared* and *fired* (triggers, orchestrators, the message queue) — is specified in -[`bgm-engine.md`](./bgm-engine.md). The command host is the `@tts/tabletop` -implementation of the engine's `CommandHost` contract. +[`bgm-engine.md`](./bgm-engine.md). Commands are async functions registered +with the engine's handler registry; `@tts/tabletop` provides the concrete +commands that mutate the tabletop store and render layer. ## 1. async commands A command is an async function that returns a result. Every command ends in -one of three states: +one of three states, emitted as a message discriminated on the type suffix +(see `bgm-engine.md` §3): -- `ok` — completed normally. -- `cancel` — interrupted (a newer command superseded it, the user skipped, the - surface was disabled). **Not a failure.** -- `error` — genuinely failed (asset missing, bad path, a thrown exception). +- `:done` — completed normally. +- `:cancel` — interrupted (a newer command superseded it, the user skipped, + the surface was disabled). **Not a failure.** +- `:error` — genuinely failed (asset missing, bad path, a thrown exception). `cancel` is distinct from `error`: a superseded or skipped command stops cleanly, while a broken command surfaces loudly. The runtime treats them @@ -27,10 +29,10 @@ differently — a script that is superseded unwinds without alarming the player, but an `error` is reported. ```ts -type CommandResult = - | { status: 'ok' } - | { status: 'cancel' } - | { status: 'error'; error: Error }; +type CommandResult = + | { type: `${Name}:done`; data: R } + | { type: `${Name}:cancel` } + | { type: `${Name}:error`; error: Error }; ``` ## 2. run contexts @@ -71,16 +73,18 @@ lifecycle. **Supersede groups** cancel a running command when another in the same group starts. A `focus` command belongs to a `camera` group, so a second `focus` -cancels the first. +cancels the first. A superseded command's `signal` is aborted, and it emits +`:cancel`. + +A command is an async function taking the `RunContext` (with its `args`): ```ts -interface Command { - id: string; - supersede?: string; // group; starting one cancels others in it - execute(ctx: CommandContext): Promise; -} +type Command = (ctx: RunContext & { args: Args }) => Promise; ``` +The engine wraps it: it builds the context from the message, runs the function, +and emits `:done` on resolve, `:cancel` on abort, `:error` on throw. + ## 4. tap interaction Only tap interaction is supported. A tap on a part is detected and reported to @@ -117,12 +121,15 @@ Rules: Commands subscribe to the tap stream via the context and unsubscribe on cancel, so a cancelled `wait: tap` never leaks a handler. -## 5. command context +## 5. run context -The context a command receives is the handle to everything it can affect: +The context a command receives is the handle to everything it can affect. The +engine defines the base `RunContext` (see `bgm-engine.md` §5): `signal` +(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop +extends it with the handles commands need to mutate the board: ```ts -interface CommandContext { +interface TabletopRunContext extends RunContext { pkg: Package; store: TabletopStore; // movePart, setPart, enableSurface, ... onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe diff --git a/docs/bgm-engine.md b/docs/bgm-engine.md index f21c885..8ba9102 100644 --- a/docs/bgm-engine.md +++ b/docs/bgm-engine.md @@ -7,45 +7,52 @@ reactive loop: **messages** flow through a **queue**, and **handlers** react to them. This doc covers the message model (what flows), the queue and its tick (how it -flows), and the three handler kinds (who reacts). Command *execution* — the -async lifecycle, run contexts, and tap interaction — is specified in +flows), and the handlers (who reacts). Command *execution* — the async +lifecycle, run contexts, and tap interaction — is specified in [`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it. ## package split The engine is a **pure** package: the message bus, queue, tick, trigger -registry, and orchestrator runner. It has no r3f, no React, and no store, so it +registry, and the handler runner. It has no r3f, no React, and no store, so it is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep -style). It defines the contract — `Message`, `Handler`, `Trigger`, -`Orchestrator`, and the `CommandHost` interface (how a command handler -registers with the bus). +style). It defines the contract — `Message`, the handler registry, `Trigger`, +`Orchestrator`, and `RunContext`. [`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it -implements the `CommandHost` with the built-in commands (`move`, `focus`, -`caption`, `enableSurface`, ...) that mutate the tabletop store and drive the -render layer. The engine never imports tabletop; tabletop depends on the engine -for the message types and host interface. A headless sim or bot harness can -consume the engine without the render layer. +registers the built-in commands (`move`, `focus`, `caption`, `enableSurface`, +...) that mutate the tabletop store and drive the render layer. The engine +never imports tabletop; tabletop depends on the engine for the message types +and the handler registry. A headless sim or bot harness can consume the engine +without the render layer. ## 1. messages A **message** is the unit of communication. It is both an *event* (something happened) and an *intent* (something should happen) — the two are the same -thing. A message may have a registered handler (a command implementation); if -it does, the runtime runs it. Either way, every handler observes it. +thing. A message is dispatched to the handlers registered for its `type`; a +handler may emit new messages in response. + +Messages are a **discriminated union** on `type`. The engine defines the +generic shapes; the host's concrete union extends them with its own command +types. ```ts -interface Message { - type: string; // 'tap' | 'move' | 'focus' | 'move:done' | ... - data?: unknown; // payload, command-specific - id?: string; // optional identity, for matching and dedup +interface TapMessage { + type: 'tap'; + data: TapEvent; // part, position, trigger +} + +interface CommandMessage { + type: Name; + data: Args; } ``` -A message is identified by `type` (and optionally `id`), matching the format's -`type#id` convention. A `move` message both *runs* the move command and is -*observable* as an event; the command's completion is itself a message -(`move:done`), which is what triggers match and orchestrators await. +A message is identified by `type`, matching the format's `type#id` +convention. A `move` message both *runs* the move command and is *observable* +as an event; the command's completion is itself a message, which is what +triggers match and orchestrators await. The loop is just: **message → handler → message**. Handlers consume messages and emit new ones; the queue serializes them. @@ -78,9 +85,9 @@ The engine never assumes a render loop. ### awaiting -An orchestrator suspends on `await ctx.wait({ type })` and resumes when a -matching message is processed during a drain. Its own emissions go to the next -tick, so it cannot re-enter itself. +A handler suspends on `await ctx.wait(pred)` and resumes when a matching +message is processed during a drain. Its own emissions go to the next tick, so +it cannot re-enter itself. ## 3. message types @@ -90,9 +97,9 @@ Interaction is the player's input, reported to the engine as messages. Only tap interaction is supported (see `bgm-commands.md` §4). ```ts -interface TapMessage extends Message { +interface TapMessage { type: 'tap'; - data: TapEvent; // part, position, trigger + data: TapEvent; } ``` @@ -103,18 +110,24 @@ shake, or ignore. The runtime stays dumb; the handler owns the UX. ### command messages A command message names a command to run. Its handler is the command -implementation; its completion is emitted as a `type:done` message. +implementation; its completion is emitted as a result message. A command's +result is a **discriminated union on the type suffix**, carrying the terminal +state: ```ts -interface CommandMessage extends Message { - type: 'move' | 'focus' | 'caption' | 'highlight' | 'enableSurface' | 'run' | ...; - data: unknown; // command args -} +type CommandResult = + | { type: `${Name}:done`; data: R } + | { type: `${Name}:cancel` } + | { type: `${Name}:error`; error: Error }; + +// e.g. move:done { data: MoveResult } | move:cancel | move:error ``` The command-id-as-key convention means a message both *is* the intent and *observes* the result. `move:done`, `focus:done`, etc. are the messages that -triggers match and orchestrators await. +triggers match and orchestrators await. A cancelled command emits `:cancel`, an +errored one `:error` — a trigger matching `move:done` does not fire on a +cancel. ## 4. handlers @@ -125,7 +138,7 @@ messages; they differ in how they're declared and how they run. | --- | --- | --- | --- | | **Trigger** | data (yaml) | synchronously on match | declarative reactive glue | | **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow | -| **Command host** | code (built-in) | on its message | atomic execution | +| **Command** | code (built-in) | async, on its message | atomic execution | ### triggers — declarative reactive glue @@ -162,27 +175,20 @@ per folder as `main.ts` — unique per folder like `package.yaml`. ```ts // main.ts -export const orchestrators = { - intro: async (ctx) => { - await ctx.focus({ path: '/deck' }); - await ctx.caption({ text: 'Draw a tile' }); - const tap = await ctx.wait({ type: 'tap', part: 'carcassonne:tile#a' }); - await ctx.move({ part: tap.part, to: '/grid/5/5' }); - }, -}; +export default async function main(ctx: RunContext): Promise { + await ctx.focus({ path: '/deck' }); + await ctx.caption({ text: 'Draw a tile' }); + const tap = await ctx.wait((m) => m.type === 'tap'); + await ctx.move({ part: tap.data.part, to: '/grid/5/5' }); +} ``` -- **An orchestrator is a long-running command.** It is a `Command` whose - `execute` awaits events instead of resolving immediately. It inherits the - run-context machinery — supersede groups, cancellation, tap subscription — - for free. No new lifecycle. - **`main.ts` is executable code, loaded by the host, not the engine.** The engine defines the contract (the orchestrator type and runner); the host - dynamically imports `main.ts` and hands the exported orchestrators to the + dynamically imports `main.ts` and hands the exported orchestrator to the engine. The engine never imports user code. -- **Export shape.** `export const orchestrators = { intro, scoring }` keys - orchestrators by `type#id` like everything else, so they're addressable and - collision-checked the same way. A `default` export is the folder's primary +- **A default export async function.** `main.ts` exports a single async + function as its default export, taking the `RunContext`. It is the folder's orchestrator. - **Trigger control lives here.** The orchestrator toggles triggers at runtime by their `type#id`: @@ -196,16 +202,47 @@ export const orchestrators = { logic ("no more placements this turn" → disable the trigger), while the trigger stays a dumb declarative mapping. -### command host — atomic execution +### commands — atomic execution -The command host is the built-in handler that runs a command message. It is the -interface `@tts/engine` defines and `@tts/tabletop` implements: it starts a run, -tracks its status, cancels it when superseded, and emits the `type:done` -result message (see `bgm-commands.md`). Commands are the **single mutation -path** — the only way state changes. Triggers and orchestrators never mutate -state directly; they emit command messages, and the host executes them. +A command is an async function, the same shape as an orchestrator. It takes a +`RunContext` (with its `args`), returns its result, and throws on error. The +engine wraps it: it builds the context from the message, runs the function, and +emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on +throw. -## 5. solo-only +```ts +type Command = (ctx: RunContext & { args: Args }) => Promise; +``` + +Commands are the **single mutation path** — the only way state changes. +Triggers and orchestrators never mutate state directly; they emit command +messages, and the command handlers execute them. + +## 5. run context + +Every handler runs against a `RunContext`, the handle to everything it can +affect and the unit of cancellation. + +```ts +interface RunContext { + signal: AbortSignal; // cancellation: superseded, skipped, surface disabled + emit(msg: Message): void; + wait(pred: (m: Message) => boolean): Promise; // rejects on abort + enableTrigger(type: string, id?: string): void; + disableTrigger(type: string, id?: string): void; +} +``` + +- **Cancellation** is an `AbortSignal`. A superseded command or a disabled + surface aborts the signal; a `wait` rejects on abort, and the command's + `:cancel` result is emitted. +- **Errors** are thrown. A command that throws emits `:error`; an orchestrator + that throws surfaces loudly. +- Commands and orchestrators are the same shape: an async function taking the + context. An orchestrator is a command that returns `void` and is never + awaited by a parent. + +## 6. solo-only This design is **solo-only** — no multiplayer. Other players either don't exist or are automated with an automata. An automata is just another message @@ -217,14 +254,7 @@ producers. ## Open decisions -- **Loop protection.** A trigger that emits a command whose completion message - it also matches → infinite loop. Guard with "don't re-trigger on your own - emitted message" or a depth cap. -- **Command-completion messages.** Orchestrators' `await` and triggers' `match` - both depend on commands emitting a result message (`move:done`, - `focus:done`). This is a small addition to the `CommandRun.done` lifecycle - in `bgm-commands.md`. - **`main.ts` loading.** The host dynamically imports `main.ts`; the exact loading boundary (Vite dynamic import, error handling, HMR) is deferred to implementation. The engine defines the orchestrator type; the host loads the - module and hands the exported orchestrators to the engine. \ No newline at end of file + module and hands the exported orchestrator to the engine. \ No newline at end of file