From 634a99dd25e2d72457445360accda7883304d384 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 10 Aug 2026 15:41:43 +0800 Subject: [PATCH] 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. --- docs/bgm-commands.md | 147 ++++++++++++++++++++++++++++++++++++++ docs/bgm-tabletop-plan.md | 8 +++ docs/bgm-tabletop.md | 8 ++- docs/decisions.md | 42 ++++++++++- 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 docs/bgm-commands.md diff --git a/docs/bgm-commands.md b/docs/bgm-commands.md new file mode 100644 index 0000000..32ae063 --- /dev/null +++ b/docs/bgm-commands.md @@ -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; +} +``` + +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; +} +``` + +## 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? \ No newline at end of file diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index ba1e1e4..4b42f01 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -172,6 +172,14 @@ consumers share them (see Open decisions). - `pnpm --filter @tts/web build` — the part inspection route renders `PartView` 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) - **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md index fcd7d39..0e14bc1 100644 --- a/docs/bgm-tabletop.md +++ b/docs/bgm-tabletop.md @@ -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. -## 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. - 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. diff --git a/docs/decisions.md b/docs/decisions.md index 40a89d9..c3f74c4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -259,4 +259,44 @@ boundary. **Alternatives considered:** A polling `CameraFit` that waited for non-empty bounds each frame; hand-rolled camera math. Rejected — Suspense already signals content readiness, so `Bounds` inside the boundary fits the loaded -geometry directly. \ No newline at end of file +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. \ No newline at end of file