Files
hypercross 71ae91960f docs: mark stale bgm engine/loader docs as in progress
Call out that the tabletop and engine package split is not yet landed, add a status note to tabletop.md, and drop the retired WIP banner in bgm-loader.md.
2026-08-16 13:04:14 +08:00

263 lines
9.9 KiB
Markdown

# bgm-engine
The message layer that drives [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
[`commands.md`](./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`](./tabletop.md) is the intended consumer of that contract:
it will register the built-in commands (`move`, `focus`, `caption`,
`enableSurface`, ...) that mutate the tabletop store and drive the render layer.
The engine never imports tabletop, and tabletop is expected to depend on the
engine for the message types and the handler registry. **This wiring is designed
but not yet landed** — today `@tts/tabletop` has no dependency on the engine;
it ships its state store and render layer standalone (see
[`../status/bgm-tabletop.md`](../status/bgm-tabletop.md)). 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 `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.