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.
This commit is contained in:
2026-08-10 18:40:30 +08:00
parent 3167d26bd6
commit 81c115cb4d
2 changed files with 122 additions and 85 deletions
+27 -20
View File
@@ -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<Name extends string, R = void> =
| { 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<CommandResult>;
}
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
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
+95 -65
View File
@@ -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<Name extends string, Args> {
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<Name extends string, R = void> =
| { 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<void> {
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<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
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<Message>; // 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.
module and hands the exported orchestrator to the engine.