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.
260 lines
9.6 KiB
Markdown
260 lines
9.6 KiB
Markdown
# bgm-engine
|
|
|
|
The message layer that drives [bgm](./bgm-format.md) board games, built into
|
|
[`@tts/engine`](./architecture.md). It unifies the two halves of scripted
|
|
interaction — *declaring* what should happen and *executing* it — into a single
|
|
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 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 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`, the handler registry, `Trigger`,
|
|
`Orchestrator`, and `RunContext`.
|
|
|
|
[`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it
|
|
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 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 TapMessage {
|
|
type: 'tap';
|
|
data: TapEvent; // part, position, trigger
|
|
}
|
|
|
|
interface CommandMessage<Name extends string, Args> {
|
|
type: Name;
|
|
data: Args;
|
|
}
|
|
```
|
|
|
|
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.
|
|
|
|
## 2. the queue and ticking
|
|
|
|
Messages are not processed inline. They are **enqueued** and handled on the
|
|
next **tick**. This kills reentrancy (a handler cannot cause unbounded
|
|
recursion), gives a natural debounce, and makes the whole system a
|
|
deterministic frame.
|
|
|
|
### tick contract
|
|
|
|
The engine is pure — it has no render loop and must stay node-testable. It
|
|
exposes `tick()`, and the host calls it:
|
|
|
|
- In `@tts/tabletop`, a `useFrame` drives `tick()`.
|
|
- In tests, `tick()` is called manually.
|
|
|
|
The engine never assumes a render loop.
|
|
|
|
### drain semantics
|
|
|
|
- **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it.
|
|
Messages emitted *during* the drain go to the *next* tick. This guarantees
|
|
no reentrancy within a drain and makes ordering deterministic.
|
|
- **FIFO within a tick.** Simple and predictable.
|
|
- **One tick drains the whole snapshot** (not one message per tick), so a
|
|
burst of messages all resolve in one frame.
|
|
|
|
### awaiting
|
|
|
|
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
|
|
|
|
### interaction messages
|
|
|
|
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 {
|
|
type: 'tap';
|
|
data: TapEvent;
|
|
}
|
|
```
|
|
|
|
A tap on a part is reported with the nearest trigger point (or `null` on a
|
|
miss). The handler decides how to react — resolve, reject with a "wrong spot"
|
|
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 result message. A command's
|
|
result is a **discriminated union on the type suffix**, carrying the terminal
|
|
state:
|
|
|
|
```ts
|
|
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. A cancelled command emits `:cancel`, an
|
|
errored one `:error` — a trigger matching `move:done` does not fire on a
|
|
cancel.
|
|
|
|
## 4. handlers
|
|
|
|
There are three kinds of handler. All three consume messages and emit
|
|
messages; they differ in how they're declared and how they run.
|
|
|
|
| Handler | Declared | Runs | Purpose |
|
|
| --- | --- | --- | --- |
|
|
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
|
|
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
|
|
| **Command** | code (built-in) | async, on its message | atomic execution |
|
|
|
|
### triggers — declarative reactive glue
|
|
|
|
A trigger matches a message by `type` and named params, and emits messages in
|
|
response. It is declared as data, keyed by `role+type+id` like other defs, and
|
|
collision-checked the same way.
|
|
|
|
```yaml
|
|
role: trigger
|
|
type: tap
|
|
id: draw
|
|
match:
|
|
part: carcassonne:tile#a
|
|
trigger: draw
|
|
emit:
|
|
- move: { part: carcassonne:tile#a, to: /grid/5/5 }
|
|
- focus: { path: /grid/5/5 }
|
|
```
|
|
|
|
- `type` selects the message kind; `match` binds named params from the
|
|
payload (like a route's candidates).
|
|
- `emit` uses the command-id-as-key convention.
|
|
- Multiple triggers can match the same message — both fire, which is usually
|
|
what you want.
|
|
- A trigger is a **pre-registered handler**: it's a message consumer that
|
|
emits commands. An orchestrator can do the same thing imperatively with
|
|
`ctx.on(...)`.
|
|
|
|
### orchestrators — imperative async flow
|
|
|
|
An orchestrator is the code counterpart to a trigger: an async function that
|
|
emits messages and awaits matching ones. It is a proper TS module, declared
|
|
per folder as `main.ts` — unique per folder like `package.yaml`.
|
|
|
|
```ts
|
|
// main.ts
|
|
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' });
|
|
}
|
|
```
|
|
|
|
- **`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 orchestrator to the
|
|
engine. The engine never imports user code.
|
|
- **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`:
|
|
|
|
```ts
|
|
ctx.enableTrigger('tap', 'draw');
|
|
ctx.disableTrigger('tap', 'draw');
|
|
```
|
|
|
|
Declaration is data; activation is code. The orchestrator owns game-flow
|
|
logic ("no more placements this turn" → disable the trigger), while the
|
|
trigger stays a dumb declarative mapping.
|
|
|
|
### commands — atomic execution
|
|
|
|
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.
|
|
|
|
```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
|
|
consumer that emits commands: a stateful trigger or orchestrator. The engine
|
|
doesn't care whether a `tap` message came from a human or a bot decision —
|
|
same queue, same handlers. Solo-only simplifies the design: no network, no
|
|
sync, no authoritative-server concerns. "Other players" are just more message
|
|
producers.
|
|
|
|
## Open decisions
|
|
|
|
- **`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 orchestrator to the engine. |