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:
+27
-20
@@ -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
|
This doc covers **command execution**: the async lifecycle, run contexts, and
|
||||||
tap interaction. The message layer above this — how commands are *declared*
|
tap interaction. The message layer above this — how commands are *declared*
|
||||||
and *fired* (triggers, orchestrators, the message queue) — is specified in
|
and *fired* (triggers, orchestrators, the message queue) — is specified in
|
||||||
[`bgm-engine.md`](./bgm-engine.md). The command host is the `@tts/tabletop`
|
[`bgm-engine.md`](./bgm-engine.md). Commands are async functions registered
|
||||||
implementation of the engine's `CommandHost` contract.
|
with the engine's handler registry; `@tts/tabletop` provides the concrete
|
||||||
|
commands that mutate the tabletop store and render layer.
|
||||||
|
|
||||||
## 1. async commands
|
## 1. async commands
|
||||||
|
|
||||||
A command is an async function that returns a result. Every command ends in
|
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.
|
- `:done` — completed normally.
|
||||||
- `cancel` — interrupted (a newer command superseded it, the user skipped, the
|
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
|
||||||
surface was disabled). **Not a failure.**
|
the surface was disabled). **Not a failure.**
|
||||||
- `error` — genuinely failed (asset missing, bad path, a thrown exception).
|
- `:error` — genuinely failed (asset missing, bad path, a thrown exception).
|
||||||
|
|
||||||
`cancel` is distinct from `error`: a superseded or skipped command stops
|
`cancel` is distinct from `error`: a superseded or skipped command stops
|
||||||
cleanly, while a broken command surfaces loudly. The runtime treats them
|
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.
|
but an `error` is reported.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type CommandResult =
|
type CommandResult<Name extends string, R = void> =
|
||||||
| { status: 'ok' }
|
| { type: `${Name}:done`; data: R }
|
||||||
| { status: 'cancel' }
|
| { type: `${Name}:cancel` }
|
||||||
| { status: 'error'; error: Error };
|
| { type: `${Name}:error`; error: Error };
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. run contexts
|
## 2. run contexts
|
||||||
@@ -71,16 +73,18 @@ lifecycle.
|
|||||||
|
|
||||||
**Supersede groups** cancel a running command when another in the same group
|
**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`
|
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
|
```ts
|
||||||
interface Command {
|
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
id: string;
|
|
||||||
supersede?: string; // group; starting one cancels others in it
|
|
||||||
execute(ctx: CommandContext): Promise<CommandResult>;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## 4. tap interaction
|
||||||
|
|
||||||
Only tap interaction is supported. A tap on a part is detected and reported to
|
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
|
Commands subscribe to the tap stream via the context and unsubscribe on
|
||||||
cancel, so a cancelled `wait: tap` never leaks a handler.
|
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
|
```ts
|
||||||
interface CommandContext {
|
interface TabletopRunContext extends RunContext {
|
||||||
pkg: Package;
|
pkg: Package;
|
||||||
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
||||||
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
||||||
|
|||||||
+95
-65
@@ -7,45 +7,52 @@ reactive loop: **messages** flow through a **queue**, and **handlers** react to
|
|||||||
them.
|
them.
|
||||||
|
|
||||||
This doc covers the message model (what flows), the queue and its tick (how it
|
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
|
flows), and the handlers (who reacts). Command *execution* — the async
|
||||||
async lifecycle, run contexts, and tap interaction — is specified in
|
lifecycle, run contexts, and tap interaction — is specified in
|
||||||
[`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it.
|
[`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it.
|
||||||
|
|
||||||
## package split
|
## package split
|
||||||
|
|
||||||
The engine is a **pure** package: the message bus, queue, tick, trigger
|
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
|
is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep
|
||||||
style). It defines the contract — `Message`, `Handler`, `Trigger`,
|
style). It defines the contract — `Message`, the handler registry, `Trigger`,
|
||||||
`Orchestrator`, and the `CommandHost` interface (how a command handler
|
`Orchestrator`, and `RunContext`.
|
||||||
registers with the bus).
|
|
||||||
|
|
||||||
[`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it
|
[`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it
|
||||||
implements the `CommandHost` with the built-in commands (`move`, `focus`,
|
registers the built-in commands (`move`, `focus`, `caption`, `enableSurface`,
|
||||||
`caption`, `enableSurface`, ...) that mutate the tabletop store and drive the
|
...) that mutate the tabletop store and drive the render layer. The engine
|
||||||
render layer. The engine never imports tabletop; tabletop depends on the engine
|
never imports tabletop; tabletop depends on the engine for the message types
|
||||||
for the message types and host interface. A headless sim or bot harness can
|
and the handler registry. A headless sim or bot harness can consume the engine
|
||||||
consume the engine without the render layer.
|
without the render layer.
|
||||||
|
|
||||||
## 1. messages
|
## 1. messages
|
||||||
|
|
||||||
A **message** is the unit of communication. It is both an *event* (something
|
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
|
happened) and an *intent* (something should happen) — the two are the same
|
||||||
thing. A message may have a registered handler (a command implementation); if
|
thing. A message is dispatched to the handlers registered for its `type`; a
|
||||||
it does, the runtime runs it. Either way, every handler observes it.
|
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
|
```ts
|
||||||
interface Message {
|
interface TapMessage {
|
||||||
type: string; // 'tap' | 'move' | 'focus' | 'move:done' | ...
|
type: 'tap';
|
||||||
data?: unknown; // payload, command-specific
|
data: TapEvent; // part, position, trigger
|
||||||
id?: string; // optional identity, for matching and dedup
|
}
|
||||||
|
|
||||||
|
interface CommandMessage<Name extends string, Args> {
|
||||||
|
type: Name;
|
||||||
|
data: Args;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A message is identified by `type` (and optionally `id`), matching the format's
|
A message is identified by `type`, matching the format's `type#id`
|
||||||
`type#id` convention. A `move` message both *runs* the move command and is
|
convention. A `move` message both *runs* the move command and is *observable*
|
||||||
*observable* as an event; the command's completion is itself a message
|
as an event; the command's completion is itself a message, which is what
|
||||||
(`move:done`), which is what triggers match and orchestrators await.
|
triggers match and orchestrators await.
|
||||||
|
|
||||||
The loop is just: **message → handler → message**. Handlers consume messages
|
The loop is just: **message → handler → message**. Handlers consume messages
|
||||||
and emit new ones; the queue serializes them.
|
and emit new ones; the queue serializes them.
|
||||||
@@ -78,9 +85,9 @@ The engine never assumes a render loop.
|
|||||||
|
|
||||||
### awaiting
|
### awaiting
|
||||||
|
|
||||||
An orchestrator suspends on `await ctx.wait({ type })` and resumes when a
|
A handler suspends on `await ctx.wait(pred)` and resumes when a matching
|
||||||
matching message is processed during a drain. Its own emissions go to the next
|
message is processed during a drain. Its own emissions go to the next tick, so
|
||||||
tick, so it cannot re-enter itself.
|
it cannot re-enter itself.
|
||||||
|
|
||||||
## 3. message types
|
## 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).
|
tap interaction is supported (see `bgm-commands.md` §4).
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface TapMessage extends Message {
|
interface TapMessage {
|
||||||
type: 'tap';
|
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
|
### command messages
|
||||||
|
|
||||||
A command message names a command to run. Its handler is the command
|
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
|
```ts
|
||||||
interface CommandMessage extends Message {
|
type CommandResult<Name extends string, R = void> =
|
||||||
type: 'move' | 'focus' | 'caption' | 'highlight' | 'enableSurface' | 'run' | ...;
|
| { type: `${Name}:done`; data: R }
|
||||||
data: unknown; // command args
|
| { 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
|
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
|
*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
|
## 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 |
|
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
|
||||||
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
|
| **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
|
### triggers — declarative reactive glue
|
||||||
|
|
||||||
@@ -162,27 +175,20 @@ per folder as `main.ts` — unique per folder like `package.yaml`.
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// main.ts
|
// main.ts
|
||||||
export const orchestrators = {
|
export default async function main(ctx: RunContext): Promise<void> {
|
||||||
intro: async (ctx) => {
|
await ctx.focus({ path: '/deck' });
|
||||||
await ctx.focus({ path: '/deck' });
|
await ctx.caption({ text: 'Draw a tile' });
|
||||||
await ctx.caption({ text: 'Draw a tile' });
|
const tap = await ctx.wait((m) => m.type === 'tap');
|
||||||
const tap = await ctx.wait({ type: 'tap', part: 'carcassonne:tile#a' });
|
await ctx.move({ part: tap.data.part, to: '/grid/5/5' });
|
||||||
await ctx.move({ part: tap.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
|
- **`main.ts` is executable code, loaded by the host, not the engine.** The
|
||||||
engine defines the contract (the orchestrator type and runner); the host
|
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.
|
engine. The engine never imports user code.
|
||||||
- **Export shape.** `export const orchestrators = { intro, scoring }` keys
|
- **A default export async function.** `main.ts` exports a single async
|
||||||
orchestrators by `type#id` like everything else, so they're addressable and
|
function as its default export, taking the `RunContext`. It is the folder's
|
||||||
collision-checked the same way. A `default` export is the folder's primary
|
|
||||||
orchestrator.
|
orchestrator.
|
||||||
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
|
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
|
||||||
by their `type#id`:
|
by their `type#id`:
|
||||||
@@ -196,16 +202,47 @@ export const orchestrators = {
|
|||||||
logic ("no more placements this turn" → disable the trigger), while the
|
logic ("no more placements this turn" → disable the trigger), while the
|
||||||
trigger stays a dumb declarative mapping.
|
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
|
A command is an async function, the same shape as an orchestrator. It takes a
|
||||||
interface `@tts/engine` defines and `@tts/tabletop` implements: it starts a run,
|
`RunContext` (with its `args`), returns its result, and throws on error. The
|
||||||
tracks its status, cancels it when superseded, and emits the `type:done`
|
engine wraps it: it builds the context from the message, runs the function, and
|
||||||
result message (see `bgm-commands.md`). Commands are the **single mutation
|
emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on
|
||||||
path** — the only way state changes. Triggers and orchestrators never mutate
|
throw.
|
||||||
state directly; they emit command messages, and the host executes them.
|
|
||||||
|
|
||||||
## 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
|
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
|
exist or are automated with an automata. An automata is just another message
|
||||||
@@ -217,14 +254,7 @@ producers.
|
|||||||
|
|
||||||
## Open decisions
|
## 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
|
- **`main.ts` loading.** The host dynamically imports `main.ts`; the exact
|
||||||
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
|
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
|
||||||
implementation. The engine defines the orchestrator type; the host loads the
|
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.
|
||||||
Reference in New Issue
Block a user