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.
156 lines
6.1 KiB
Markdown
156 lines
6.1 KiB
Markdown
# bgm-commands
|
|
|
|
Command execution for [bgm](./bgm-format.md) board games, built into
|
|
[`@tts/tabletop`](./bgm-tabletop.md). A command is a unit of scripted
|
|
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. 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). 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, emitted as a message discriminated on the type suffix
|
|
(see `bgm-engine.md` §3):
|
|
|
|
- `: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
|
|
differently — a script that is superseded unwinds without alarming the player,
|
|
but an `error` is reported.
|
|
|
|
```ts
|
|
type CommandResult<Name extends string, R = void> =
|
|
| { type: `${Name}:done`; data: R }
|
|
| { type: `${Name}:cancel` }
|
|
| { type: `${Name}:error`; error: Error };
|
|
```
|
|
|
|
## 2. run contexts
|
|
|
|
Each command invocation creates its own **run context**: the unit of
|
|
cancellation and the carrier of command-specific state.
|
|
|
|
```ts
|
|
interface CommandRun {
|
|
id: string;
|
|
command: Command;
|
|
status: 'running' | 'ok' | 'cancel' | 'error';
|
|
data: unknown; // command-specific state, e.g. a pending tap target
|
|
cancel(): void;
|
|
done: Promise<CommandResult>;
|
|
}
|
|
```
|
|
|
|
A command **owns its own state and its own waiting**; the runtime only
|
|
orchestrates. Its job is to start a run, track its status, cancel it when
|
|
superseded, and react to its terminal state. This keeps commands
|
|
self-contained and testable in isolation.
|
|
|
|
## 3. fire-and-forget vs self-managed waiting
|
|
|
|
Commands fall into two categories:
|
|
|
|
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
|
|
`ok` immediately (or when their tween settles). The runtime does not block
|
|
on them.
|
|
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
|
|
own promise when its condition is met. The runtime just awaits it.
|
|
|
|
Fire-and-forget commands still get a run context and a cancel path. A `focus`
|
|
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
|
|
"Fire-and-forget" means the runtime doesn't await it, not that it has no
|
|
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. A superseded command's `signal` is aborted, and it emits
|
|
`:cancel`.
|
|
|
|
A command is an async function taking the `RunContext` (with its `args`):
|
|
|
|
```ts
|
|
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
|
|
the command layer as a `TapEvent`. Parts may declare **trigger points** —
|
|
named, circular regions the author wants to be tappable.
|
|
|
|
```ts
|
|
interface TriggerPoint {
|
|
id: string;
|
|
position: [number, number]; // part-local frame, mm
|
|
radius: number; // mm
|
|
}
|
|
|
|
interface TapEvent {
|
|
part: string; // package:type#id
|
|
position: [number, number]; // part-local frame, mm
|
|
trigger: TriggerPoint | null; // nearest within radius, or null
|
|
}
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Trigger points are authored in the **part's local frame** (mm, relative to
|
|
the part's origin), not world space. A part moves, rotates, and flips
|
|
(facing), so a world-space point would break the moment it moves. The tap
|
|
point is transformed into the part's local frame at tap time.
|
|
- Distance is measured in the part's plane. The reported trigger point is the
|
|
nearest one within its `radius`; ties go to the first declared.
|
|
- **Every tap on the part is reported**, with the nearest trigger point (or
|
|
`null` when none is in range). The command decides how to react — resolve,
|
|
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
|
|
command owns the UX.
|
|
|
|
Commands subscribe to the tap stream via the context and unsubscribe on
|
|
cancel, so a cancelled `wait: tap` never leaks a handler.
|
|
|
|
## 5. run context
|
|
|
|
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 TabletopRunContext extends RunContext {
|
|
pkg: Package;
|
|
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
|
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
|
// camera, highlight, and overlay handles are added as those subsystems land
|
|
}
|
|
```
|
|
|
|
## 6. where trigger points come from
|
|
|
|
The tap detector reads trigger points from a runtime map keyed by part id; it
|
|
does not care where they are declared. Declaration (on the part definition, in
|
|
a setup, or in a script) is the deferred "how to declare" half and lives in
|
|
`bgm-format.md`.
|
|
|
|
## Open decisions
|
|
|
|
- **Where commands are declared** — the `script` role and its schema
|
|
(`bgm-format.md`), deferred.
|
|
- **Animation** — a general "ease toward target placement" layer (preferred)
|
|
vs explicit per-move tweens.
|
|
- **Camera** — `CameraControls` (drei) vs hand-rolled.
|
|
- **Triggering** — does a setup reference a script to auto-run, or is a script
|
|
a separate page the player picks?
|
|
- **Narration** — pre-recorded audio assets per script, or TTS at runtime? |