docs: design command execution for tabletop scripts
Add bgm-commands.md covering async commands with ok/cancel/error results, per-invocation run contexts, fire-and-forget vs self-managed waiting, and tap interaction with part-local trigger points. Link it from the tabletop design and plan docs, and record the decisions in decisions.md.
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
# 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. How commands are *declared* (the `script` role, trigger
|
||||||
|
points on parts) is a separate concern, deferred to `bgm-format.md`.
|
||||||
|
|
||||||
|
## 1. async commands
|
||||||
|
|
||||||
|
A command is an async function that returns a result. Every command ends in
|
||||||
|
one of three states:
|
||||||
|
|
||||||
|
- `ok` — 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 =
|
||||||
|
| { status: 'ok' }
|
||||||
|
| { status: 'cancel' }
|
||||||
|
| { status: '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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Command {
|
||||||
|
id: string;
|
||||||
|
supersede?: string; // group; starting one cancels others in it
|
||||||
|
execute(ctx: CommandContext): Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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. command context
|
||||||
|
|
||||||
|
The context a command receives is the handle to everything it can affect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CommandContext {
|
||||||
|
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?
|
||||||
@@ -172,6 +172,14 @@ consumers share them (see Open decisions).
|
|||||||
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
from the library (work item 2), proving it end-to-end.
|
from the library (work item 2), proving it end-to-end.
|
||||||
|
|
||||||
|
## Commands (not yet implemented)
|
||||||
|
|
||||||
|
Scripted interaction is designed in [`bgm-commands.md`](./bgm-commands.md):
|
||||||
|
async commands with `ok`/`cancel`/`error` results, per-invocation run
|
||||||
|
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
|
||||||
|
part-local trigger points. Implementation order: types + run-context manager,
|
||||||
|
tap detection, then the first commands (`wait: tap`, `focus`).
|
||||||
|
|
||||||
## Open decisions (defaults in bold)
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ the render map is per enabled surface: a piece may appear on more than one enabl
|
|||||||
|
|
||||||
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||||
|
|
||||||
## 5. usage
|
## 5. commands
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 6. usage
|
||||||
|
|
||||||
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
||||||
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
||||||
|
|||||||
+41
-1
@@ -259,4 +259,44 @@ boundary.
|
|||||||
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
|
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
|
||||||
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
||||||
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
||||||
geometry directly.
|
geometry directly.
|
||||||
|
|
||||||
|
## D19 — Commands are async with ok/cancel/error results
|
||||||
|
|
||||||
|
**Decision:** Scripted interaction is built on async commands. Each command
|
||||||
|
returns `ok`, `cancel` (interrupted — superseded, skipped, surface disabled),
|
||||||
|
or `error` (genuinely failed). Each invocation gets its own run context — the
|
||||||
|
unit of cancellation and the carrier of command state. Commands are either
|
||||||
|
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
|
||||||
|
resolve their own promise when a condition is met); both get a run context and
|
||||||
|
cancel path. Supersede groups cancel a running command when another in the
|
||||||
|
group starts (e.g. a `camera` group so a second focus cancels the first).
|
||||||
|
|
||||||
|
**Context:** The user wants to script interaction sequences — focus, caption,
|
||||||
|
title, highlight, tap-to-advance, move, camera away. The state store and
|
||||||
|
render layer already exist; what's missing is a way to drive them over time
|
||||||
|
and react to input. Design: [`bgm-commands.md`](./bgm-commands.md).
|
||||||
|
|
||||||
|
**Alternatives considered:** A single monolithic script interpreter. Rejected
|
||||||
|
— commands as self-contained async units are testable in isolation and let
|
||||||
|
the runtime stay a thin orchestrator.
|
||||||
|
|
||||||
|
## D20 — Tap interaction reports every tap with the nearest trigger point
|
||||||
|
|
||||||
|
**Decision:** Only tap interaction is supported. A tap on a part is reported
|
||||||
|
to the command layer as a `TapEvent` carrying the part, the tap position in
|
||||||
|
the part's local frame, and the nearest trigger point within its `radius` (or
|
||||||
|
`null` on a miss). Trigger points are authored in the part's local frame with
|
||||||
|
mm radius; distance is measured in the part's plane; ties go to the first
|
||||||
|
declared. The command decides how to react to a miss — resolve, reject, or
|
||||||
|
ignore.
|
||||||
|
|
||||||
|
**Context:** Commands need to wait on player input (`wait: tap`). Reporting
|
||||||
|
every tap with the nearest trigger point keeps the runtime dumb and lets the
|
||||||
|
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
|
||||||
|
the part's local frame keeps them valid as the part moves, rotates, and
|
||||||
|
flips.
|
||||||
|
|
||||||
|
**Alternatives considered:** Reporting only a hit and silently dropping
|
||||||
|
misses. Rejected — a command that needs to react to a wrong tap has no way to
|
||||||
|
do so. World-space trigger points. Rejected — they break when the part moves.
|
||||||
Reference in New Issue
Block a user