diff --git a/docs/architecture.md b/docs/architecture.md index 9e2c7a8..750740a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ shared types/validation package. | `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node | | `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic | | `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic | +| `packages/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic | | `packages/shared` | Shared types + zod schemas | Isomorphic | ## Dependency graph diff --git a/docs/bgm-commands.md b/docs/bgm-commands.md index 32ae063..6d4729a 100644 --- a/docs/bgm-commands.md +++ b/docs/bgm-commands.md @@ -6,8 +6,10 @@ interaction — focus the camera, wait for a tap, move a part, show a caption that runs against the tabletop state store and render layer. This doc covers **command execution**: the async lifecycle, run contexts, and -tap interaction. How commands are *declared* (the `script` role, trigger -points on parts) is a separate concern, deferred to `bgm-format.md`. +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. ## 1. async commands diff --git a/docs/bgm-engine.md b/docs/bgm-engine.md new file mode 100644 index 0000000..f21c885 --- /dev/null +++ b/docs/bgm-engine.md @@ -0,0 +1,230 @@ +# 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 three handler kinds (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 +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). + +[`@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. + +## 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. + +```ts +interface Message { + type: string; // 'tap' | 'move' | 'focus' | 'move:done' | ... + data?: unknown; // payload, command-specific + id?: string; // optional identity, for matching and dedup +} +``` + +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. + +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 + +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. + +## 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 extends Message { + type: 'tap'; + data: TapEvent; // part, position, trigger +} +``` + +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 `type:done` message. + +```ts +interface CommandMessage extends Message { + type: 'move' | 'focus' | 'caption' | 'highlight' | 'enableSurface' | 'run' | ...; + data: unknown; // command args +} +``` + +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. + +## 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 host** | code (built-in) | 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 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' }); + }, +}; +``` + +- **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 + 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 + 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. + +### command host — 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. + +## 5. 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 + +- **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 diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md index 0e14bc1..87d8002 100644 --- a/docs/bgm-tabletop.md +++ b/docs/bgm-tabletop.md @@ -53,7 +53,9 @@ the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / Scripted interaction — focus, tap-to-advance, move, caption — is built on an async command layer. See [`bgm-commands.md`](./bgm-commands.md) for command -execution (lifecycle, run contexts, tap interaction). +execution (lifecycle, run contexts, tap interaction), and +[`bgm-engine.md`](./bgm-engine.md) for the message layer above it (the queue, +triggers, and orchestrators that declare and fire commands). ## 6. usage