Compare commits

...
10 Commits
Author SHA1 Message Date
hyper ddf6785f9e test(tabletop): cover store actions, interactions, and path sampling
Add tests for the tabletop and interaction stores (seed, surface toggles,
setPart, movePart reindexing/clamping), the interaction store's hold/drop
and dialog stack, and the SVG path sampler (Q/T/S/A commands, unsupported
commands, pointAt edge cases, and zero-length curves). stacking.ts goes
from 61% to 99.5% line coverage.
2026-08-18 10:30:37 +08:00
hyper d0e4269ad6 chore: add vitest coverage provider and ignore coverage output
Add @vitest/coverage-v8 as a dev dependency so coverage reports can run,
and ignore generated coverage/ directories.
2026-08-18 10:30:34 +08:00
hyper 999b7a0771 feat(bgm): add dialog role and interaction affordances
Add the dialog role and the interaction affordances the free-interaction
layer depends on: Setup.interactions, Package.dialogs, and the Interaction,
DialogAction, and Dialog types. Wire the dialog role through the zod
schemas, package collection (with duplicate checking), and vite
serialization, and update the tabletop serializedToPackage and test
fixtures to carry dialogs.
2026-08-18 10:24:21 +08:00
hyper 8eff44712f feat(tabletop): add free-interaction layer (layer 3)
Add the interaction half of the sandbox: a held part and dialog stack as
UI state outside the game store, with pure helpers (interactionsFor,
dropPaths, pickPath, partFacings, nextFacing). PartPlacement is now
interactive (pick up, drag to move, click to cycle facing), and a drop on
a path with a stack-dialog interaction opens the deck dialog instead of
moving directly. DialogLayer renders the stack-inspector dialog whose
insert button issues a move. Add the dialog role, Setup.interactions, and
Package.dialogs to @tts/bgm, and declare the deck insert dialog in
games/poker.
2026-08-18 10:20:22 +08:00
hyper 3813287489 fix(web): pass TTSMod to flattenObjects in full setup 2026-08-17 17:59:01 +08:00
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
hypercross addfc03ab6 docs: add free interaction design to bgm specs
Add the command/dialog split to the state-model and interactions docs, declare role dialog and zone facing in the format spec, and let setups declare which dialogs power open interactions.
2026-08-16 12:59:03 +08:00
hypercross 211c151971 docs: add bgm state model design
Describe the format's state model: components as constants, setup as the shape of states, path-to-stack by facing (part affordance x zone restriction), and the stacks-on-paths anchoring scope cut.
2026-08-16 12:38:58 +08:00
hypercross 345832e389 docs: reorganize docs into bgm and status folders
Group the bgm spec cluster under docs/bgm and move dev logs and plans under docs/status, add an overview index, and update cross-references in the docs, README, and source comments.
2026-08-16 11:57:19 +08:00
hypercross cf8ca07850 fix: allow recovering from part viewer error
Add a Try again button to the ErrorBoundary fallback and key the ModPage boundary by selection so picking another object clears a stuck error.
2026-08-15 14:17:41 +08:00
39 changed files with 1965 additions and 423 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
node_modules/
dist/
coverage/
*.log
.env
.env.local
# Generated TTS save dumps (scripts/dump-save.mjs)
scripts/dumps/
scripts/dumps/
+3 -2
View File
@@ -14,8 +14,9 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic |
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
See [`docs/overview.md`](docs/overview.md) for the docs index,
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
## Setup
@@ -30,7 +30,7 @@ export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
* the same image.
*
* These caches live for the session (like drei's global texture cache) and are
* not disposed on unmount; see `docs/full-setup-view.md`.
* not disposed on unmount; see `docs/status/full-setup-view.md`.
*/
const geometryCache = new Map<string, THREE.BufferGeometry>();
+1 -1
View File
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
}, [id, item?.fileUrl, load]);
const objects = useMemo(
() => (mod ? flattenObjects(mod) : []),
() => (mod ? flattenObjects(mod.mod) : []),
[mod],
);
+2 -2
View File
@@ -53,7 +53,7 @@ export default function ModPage() {
{selected && Viewer ? (
/* Key by selection path so the Canvas remounts and the camera
refits to the newly selected object. */
<ErrorBoundary>
<ErrorBoundary key={selectedPath}>
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
@@ -61,7 +61,7 @@ export default function ModPage() {
</div>
}
>
<Viewer key={selectedPath} object={selected.object} fill />
<Viewer object={selected.object} fill />
</Suspense>
</ErrorBoundary>
) : (
+2 -2
View File
@@ -2,8 +2,8 @@
> **Scope:** The system's architecture and dependency graph. For implementation
> details (files, endpoints, build order), see
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
> key decisions, see [`decisions.md`](./decisions.md).
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
## Overview
@@ -1,14 +1,14 @@
# 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
Command execution for [bgm](./format.md) board games, built into
[`@tts/tabletop`](./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
[`engine.md`](./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.
@@ -16,7 +16,7 @@ commands that mutate the tabletop store and render layer.
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):
(see `engine.md` §3):
- `:done` — completed normally.
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
@@ -124,7 +124,7 @@ 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`
engine defines the base `RunContext` (see `engine.md` §5): `signal`
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
extends it with the handles commands need to mutate the board:
@@ -142,12 +142,12 @@ interface TabletopRunContext extends RunContext {
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`.
`format.md`.
## Open decisions
- **Where commands are declared** — the `script` role and its schema
(`bgm-format.md`), deferred.
(`format.md`), deferred.
- **Animation** — a general "ease toward target placement" layer (preferred)
vs explicit per-move tweens.
- **Camera**`CameraControls` (drei) vs hand-rolled.
+13 -10
View File
@@ -1,7 +1,7 @@
# 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
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.
@@ -9,7 +9,7 @@ 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
[`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it.
[`commands.md`](./commands.md); this doc is the layer above it.
## package split
@@ -19,12 +19,15 @@ 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`](./bgm-tabletop.md) is one consumer of that contract: it
registers 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 the handler registry. A headless sim or bot harness can consume the engine
without the render layer.
[`@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
@@ -94,7 +97,7 @@ it cannot re-enter itself.
### interaction messages
Interaction is the player's input, reported to the engine as messages. Only
tap interaction is supported (see `bgm-commands.md` §4).
tap interaction is supported (see `commands.md` §4).
```ts
interface TapMessage {
+62
View File
@@ -180,6 +180,7 @@ by their `role=` (or, for real files, their filename) for either
- `part`
- `surface`
- `setup`
- `dialog`
a valid object can either be the root or in the list of the yaml block.
@@ -236,6 +237,10 @@ placed on the board via `setup`, and visualized by routes.
- `size``size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
- `facing` — the **physical affordance**: the facings the piece supports.
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
Declares what's *possible*, not what's legal on a given zone (see `surface`
`layout`). Defaults to `[face]`.
#### example
@@ -366,6 +371,49 @@ initialization.
A part's `facing` is seeded into the game state and can change at runtime; it
only affects orientation, never the part's texture.
A setup also declares the **interaction affordances** — which dialogs are the
tool for which open interactions on which paths:
```yaml
interactions:
- dialog: insert
on: [draw, grid] # insertion uses the `insert` dialog on these paths
- dialog: shuffle # open the shuffle dialog on any deck
```
`interactions` is a list of declarations: each names a `dialog` (a `role:
dialog` definition, see below) and the paths it applies to (`on`, matching by
path or by stack). It declares the *interaction surface*, not the legality of
the resulting command — rule scripts (later) gate legality. A dialog can be
opened by a player gesture or pushed by a rule script; only the trigger differs.
### dialog
A `dialog` is declarative content shown in the layer-3 shell. Opening and
closing it never issues a command or mutates state; it is pure UI. Its **action
buttons** issue commands — the bridge between the dialog and the rule seam.
```yaml
role: dialog
type: prompt
id: discard
title: Choose a card to discard
body: |
Select a card from your hand.
actions:
- label: Confirm
command: { move: { part: "#chosen", to: /discard } }
widget: stack # a stack-of-parts content type
```
A dialog's content is a **title**, **body**, **action buttons**, and an optional
`widget`. Supported content types include a **stack of parts** — an ordered,
scrolled view of a path's stack with an insertion cursor — which is what powers
insertion and shuffling dialogs against the state store.
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
---
## 4. Concepts
@@ -384,6 +432,11 @@ A surface is enabled or disabled; a disabled surface is not rendered. Setup
seeds the enabled set (see §3), and it changes at runtime as the game
progresses (e.g. enabling the main board after an expansion-chooser scene).
Dialogs are **not** part of the state store. The dialog stack is UI state
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
action buttons issue commands (see `role: dialog` §3), which is the one-way
bridge onto the state.
### Routing
A route is a **visualization route**: it maps a part to a location on a
@@ -399,6 +452,15 @@ A route is an express-style URL path with named params, plus the `x`, `y`, and
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once:
A route may also declare a zone `facing` — the **legal facing** on that zone.
This constrains what the placed parts may be, independent of each part's
physical affordance (see `part` `facing`). A state is legal only when the part's
facing is in both the part's affordance *and* the zone's legal set. When omitted,
the zone is unrestricted beyond the part's affordance. An MTG discard pile
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
`[back]`. The same card is face-up in the discard but tapped in play — the
piece's affordance is unchanged, only the zone's rule differs.
```yaml
layout:
- route: /dock/:seat
+134
View File
@@ -0,0 +1,134 @@
# bgm Interactions
The free-interaction layer (layer 3 of the layering) — how a player interacts
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
facing, and the anchoring scope.
> **Status:** Implemented (layer 3, sandbox). The operation set, the
> command/dialog split, and the deck-pick-up dialog are built in
> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
## 1. The operation set is closed and tiny
Two assumptions from the state model do most of the work:
1. **Parts are never created or destroyed** — the set of parts is fixed (the
setup's parts).
2. **State is only path, stack, and facing** — there is nowhere to store a free
position in space.
Together they collapse the *entire* space of free interaction into three
operations:
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
position (default: top of stack).
2. **`setFacing(id, facing)`** — change facing, within the part's physical
affordance.
3. **reorder** — a `move` with an explicit `index`.
That's it. There is no arbitrary positioning and no free placement in 3D because
the state model has nowhere to store one. "Free" means *unconstrained over these
three operations*, not "free in space."
## 2. Free interaction and rule play are the same operations
This is the payoff. Rule-enforced play (layer 4) is **the same three
operations, gated by a legality check**:
- The interaction layer produces an **intent** — "player wants
`move(card, /discard)`."
- **Sandbox mode**: apply it directly.
- **Rule mode**: validate the intent against the setup's shape + the rule script
first; reject if illegal.
So the rule engine needs no interaction vocabulary of its own — it is a **filter
over free interaction**. `move`/`setFacing` are the shared primitives; rules
decide which are legal in the current state. Drag-and-drop and a scripted move
both funnel through the same store mutation. That is the seam between layer 3
and layer 4.
## 3. Commands vs the dialog stack
There are two channels, and they do not mix:
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
the rule seam and mutate the store.
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
discard," a hint prompt). Opening/closing a dialog **never issues a command**
and never touches state; it is pure UI.
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
How they connect — one direction only:
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
insert button issues a `move` command.
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
discard) and awaits the player's `move` through it. The script's open/close
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
game state.
So the deck dialog is **one implementation, driven either way** — by a player
click or by a rule script. Only the *trigger* differs.
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
definition declares the title, body, action buttons, and an optional widget
(a stack of parts). Setups declare which dialogs are the tool for which
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
rule seam stays clean because a dialog's buttons issue commands while its
open/close is stack-only.
## 4. Compound interactions: the deck pick-up dialog
A dialog is where compound, multi-step manipulation lives, because it owns the
transient sub-state that the store must not. The prime example — inserting a
card into the middle of a deck:
1. pick up the deck
2. scroll through it to find the place
3. insert the card at the cursor
4. put the deck back
(even 0: put down your held hand of cards first).
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
table into the dialog (it stays on its path; the rest of the table becomes
backdrop). Its contents are shown **in order**, with an **insertion cursor**
between cards that you scroll. The cursor *is* the index: `move(id, path,
index)`'s index is discovered by scrolling the visible deck, not typed.
This is a real interaction *mode*, owned by the dialog:
- The scroll position is state.
- Everything else pauses while it's open (or the held part is kept visible).
- It only exists for **stacks**; single parts just snap onto a path.
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
same lifted-deck view.
## 5. The primitives map to concrete gestures
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
resolve the nearest path anchor within a threshold; drop → `move` (drop on
nothing returns to origin).
- **Facing** — click cycles the part through its physical affordance
(`face → back → standing`, or the declared list).
- **Reorder / insert** — the deck dialog above.
The "in hand" state is transient and illegal, so it lives **outside the store**
(a UI-level held part); only the committed outcome (drop) mutates the store.
## Open questions
- **Held source for insertion** — single held card (fits the physical model; the
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
fight the hand step.
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
single-part primitives are the foundation.
- **Scroll window** — a window over a subset of the deck can return with the
cursor's scroll position; exact widget is a render concern, not a state one.
+137
View File
@@ -0,0 +1,137 @@
# bgm State Model
The format's model of what a board game *is*, and the shape of the states it can
be in. This is the organizing model behind the concrete specs:
[`format.md`](./format.md) (the manifest), [`tabletop.md`](./tabletop.md) (the
render library), and [`engine.md`](./engine.md) / [`commands.md`](./commands.md)
(scripted interaction).
> **Status:** Design. The loader and render layer implement components and a
> static setup today; the state-shape framing, zone-facing, and the anchoring
> scope cut below are proposals to guide the next phase.
## 1. Components are constants; setup is the shape of states
A bgm game is authored two ways, and they answer different questions:
- **Components** (`part`, `surface`) describe the **constants** — the physical
pieces and the board geometry that do not change during play. A tile is a
45×45 mm square with a meadow/road/city face; the play board has a draw pile
and an 11×11 grid. These are timeless facts about the game, not state.
- **setup** describes the **shape of the game's states** — which parts can be
where, how many, and with what facing. It is a *schema* over the state space,
not a concrete snapshot of one run.
The runtime store is then an **instance** of the setup's shape: the current
state of play, a point in the state space the setup describes. This buys three
things:
- **Validation** — a state is legal iff it matches the setup's shape (on the
right paths, right counts, right facing).
- **A contract for the rule engine** — legal play is a *transition between
shapes*; the rule layer (layer 4 in the layering vision) reasons over them.
- **A clean boundary** — components are timeless; setup is the state space; the
store is the current point.
### Open: does setup carry the initial state?
A game needs a concrete starting position, not just a schema. The default is
that a `setup` is **shape + initial instance** — one role that both describes
the legal state space and seeds the store. The alternative (schema-only, with
the initial state derived from the shape) is explored but not preferred.
## 2. The state: path → stack × facing
The state space is made of two axes.
### Path → stack
Every part lives on a **path** (a URL-style key like `/grid/5/5` or `/draw`),
and multiple parts on a path form an ordered **stack** (a deck, a pile of meeples,
a tile with a meeple on it). Placement is one axis. This is already in use.
### facing — part affordance × zone restriction
Facing is the second axis, and it has two distinct sources of constraint:
- **The part** declares the **physical affordance** — the facings the piece
physically supports. A card supports face/back/standing; a tile supports
face/back but not "tapped".
- **The path (zone)** declares the **legal facing** — what's allowed on that
zone. An MTG discard pile requires face-up; a play area allows tapped; a
facedown deck requires face-down. The same card is face-up in the discard,
tapped in play, face-up in exile — the piece's affordance doesn't change, only
the zone's rule does.
A state's facing is legal iff it is **both physically possible (part) and
zone-legal (path)** — the effective set is the intersection.
```yaml
role: part
type: card
id: basic
facing: [face, back, standing] # physical affordance
role: surface
type: board
id: main
layout:
- route: /discard
facing: [face] # zone restriction
- route: /play
facing: [face, tap] # zone restriction
```
Deck / the current facing lives in the store and must be in the intersection.
### Open questions
- **Default when a path declares no `facing`** — unrestricted (only the part's
affordance bounds it), or a sensible default like `face`? Lean unrestricted.
- **Is "tapped" a facing or a rotation?** In MTG it's a 90° in-plane rotation.
Default: fold common rotations into the facing enum (`face` / `back` /
`standing` / `tap`) for schema simplicity; arbitrary rotation is a later
extension.
- **Naming** — the part-side and path-side are different constraints wearing the
same word. Worth distinct terms (capability/typ) so they don't collide.
## 3. Anchoring scope: stacks-on-paths, not part-to-part networks
Real TTS mods anchor components to each other — a meeple on a tile, a fanned
hand, tokens scattered on a board. We are **not** modeling part-aligned-to-part
relative placement networks. Instead:
- **Stacks absorb part-on-part.** "Meeple on tile" is a stack `[tile, meeple]`
on a path. Much of TTS's anchoring collapses into the path→stack model.
- **Free relative placement is out of scope** — a meeple at an offset on a tile,
a fanned hand, arbitrary token scatter are not modeled.
This is a deliberate scope cut. It keeps the state model closed and simple, but
it is a **fidelity loss for converted games** and part of the "with some fixing"
cost of the TTS→bgm conversion. Components (layer 1), not the state model, are
the place to extend later if needed.
### The grid compromise
Because we don't model parts-aligned-to-parts, common layouts are expressed
explicitly — and grid layouts become verbose (the Carcassonne board is 121
hand-written row coordinates in `grid.csv`). The mitigations:
- **Grid shorthand** — a declarative `grid` (cols/rows, cell size, origin) that
expands to routes, instead of authored coordinates.
- **Free-placement shorthand** — a `free`/scatter mode for loose collections,
when the exact positions don't matter to play.
But there is **no general part-to-part network** on the roadmap. If a converted
game needs it, that is a format extension to design deliberately, not an
implicit assumption.
## Summary
| Concept | Role | Where |
| --- | --- | --- |
| Components | Constants — the physical pieces & board geometry | `part`, `surface` |
| Setup | The shape of the state space (+ initial instance) | `setup` |
| Store | The current state, an instance of the setup | `@tts/tabletop` |
| Facing | Part affordance × zone restriction | part + path/zone restriction |
| Anchoring | Stacks-on-paths only; no part networks | — |
+10 -5
View File
@@ -1,10 +1,15 @@
# bgm-tabletop
a r3f based interactive component library to work with [bgm](./bgm-format.md) board games. will be used somewhere in the `web` app's bgm inspector routes.
> **Status:** Implemented (items 18 of the plan). A few features below are
> designed but not yet wired (commands, HUD rendering); see §5 and
> [`../status/bgm-tabletop.md`](../status/bgm-tabletop.md).
An r3f-based interactive component library for working with [bgm](./format.md)
board games. It is used in the `web` app's bgm inspector routes.
## 1. stack
`react` - react, react router, tailwindv4
`react` - react, react router, tailwind v4
`r3f` - r3f, drei, postprocessing
`zustand` - for state management
@@ -47,14 +52,14 @@ the render map is per enabled surface: a piece may appear on more than one enabl
## 4. stacking
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 `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. 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
async command layer. See [`commands.md`](./commands.md) for command
execution (lifecycle, run contexts, tap interaction), and
[`bgm-engine.md`](./bgm-engine.md) for the message layer above it (the queue,
[`engine.md`](./engine.md) for the message layer above it (the queue,
triggers, and orchestrators that declare and fire commands).
## 6. usage
+2 -2
View File
@@ -2,7 +2,7 @@
> **Scope:** The rationale behind key design decisions. For the system's
> architecture, see [`architecture.md`](./architecture.md). For the concrete
> build plan, see [`implementation-plan.md`](./implementation-plan.md).
> build plan, see [`status/implementation-plan.md`](./status/implementation-plan.md).
>
> Each entry records the decision, the context, and the alternatives considered.
> New entries are appended; existing entries are updated only to correct facts,
@@ -275,7 +275,7 @@ 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).
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
+27
View File
@@ -0,0 +1,27 @@
# Docs
The documentation is split into the living specs, which describe how the
system works today, and the status/plans, which track development iterations
and go stale as work lands.
## Specs — how the system works
| Doc | Covers |
| --- | --- |
| [`architecture.md`](./architecture.md) | System architecture and the package dependency graph |
| [`decisions.md`](./decisions.md) | Key design decisions and the rationale behind them |
| [`bgm/format.md`](./bgm/format.md) | The board game manifest (bgm) format spec |
| [`bgm/engine.md`](./bgm/engine.md) | The bgm message layer: queue, triggers, orchestrators |
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
## Status & plans (dev logs)
| Doc | Covers |
| --- | --- |
| [`status/implementation-plan.md`](./status/implementation-plan.md) | Original build plan |
| [`status/bgm-loader.md`](./status/bgm-loader.md) | bgm loader — what's built, works, missing |
| [`status/bgm-tabletop.md`](./status/bgm-tabletop.md) | bgm tabletop — implementation plan / status |
| [`status/full-setup-view.md`](./status/full-setup-view.md) | Full-setup view plan |
@@ -1,7 +1,7 @@
# bgm Loader — Status
> WIP. What's built, what works, what's missing, and the known issues.
> Spec: [`bgm-format.md`](./bgm-format.md).
> Status: current. What's built, what works, what's missing, and the known
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
## What's built
@@ -20,9 +20,9 @@
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
### `games/harbor/harbor.md` — example game (new)
### Example game: harbor (fixture)
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Lives as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/` (and a copy under `packages/tabletop/src/__fixtures__/vite-build/games/harbor/`).
### `games/poker/poker.md` — example game (new)
@@ -30,7 +30,7 @@ A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards,
### `packages/tabletop` — rendering library (new)
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop.md`](./bgm-tabletop.md).
### `packages/http` — shared proxy HTTP (new)
@@ -71,5 +71,5 @@ The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
- **Surface mounting is validated but not resolved**`mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `docs/bgm-tabletop.md`).
- **Surface mounting is validated but not resolved**`mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `../bgm/tabletop.md`).
- Docs for the loader itself (this file is the start).
@@ -1,8 +1,9 @@
# bgm-tabletop — Implementation Plan / Status
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
> board games: a state store, surface mounting, part placement with stacking,
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
> **Scope:** A standalone r3f component library that renders
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
> placement with stacking, and per-part meshes. Design:
> [`../bgm/tabletop.md`](../bgm/tabletop.md).
> **Status:** items 18 implemented and the full tabletop scene is wired into
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
> part-inspection route renders `PartView` from the library.
@@ -75,7 +76,7 @@ useful slice and unblocks the web app's part inspection route immediately.
### 3. State store (`state.ts`) ✅
Source-of-truth game state per `bgm-tabletop.md` §2:
Source-of-truth game state per `../bgm/tabletop.md` §2:
```ts
interface GameState {
@@ -96,7 +97,7 @@ interface PartState {
Computed with a selector/memo so the render list is stable. A path's ordered
children (for stacking) are derived from the parts map by sorting on `index`.
- **Assumption**: each piece id is unique on the board (documented in
`bgm-tabletop.md`); the render map is keyed by piece id.
`../bgm/tabletop.md`); the render map is keyed by piece id.
### 4. Setup seeding (`setup.ts`) ✅
@@ -104,7 +105,7 @@ interface PartState {
`Setup` — enables its `surfaces` (or all when omitted) and applies its
ordered `setup` placements (each moves its `parts` to a `path`).
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
type (documented in `../bgm/format.md` §3; the loader doesn't do this — it's a
game-state init concern, so it lives here).
### 5. Surface mounting (`mount.ts`) ✅
@@ -126,7 +127,7 @@ interface PartState {
### 7. Stacking (`stacking.ts`) ✅
- `useStacking(route.stacking, index, stackSize)``{ x, y, rotation, z, tilt }`.
- Implements the format's positioning process (`bgm-format.md` §4): step
- Implements the format's positioning process (`../bgm/format.md` §4): step
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
@@ -172,9 +173,31 @@ 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.
## Free interaction (layer 3) ✅
Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
- `interactions.ts` — the held part + dialog stack (UI state, outside the game
store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
`partFacings`, `nextFacing`).
- `state.ts``setFacing` alongside `movePart` (the `move(id, path, index?)`
primitive, defaulting to top of stack).
- `placement.tsx``PartPlacement` is now interactive: pick up a part (held,
lifted above the board), drag to a path anchor to `move`, click to cycle
facing. A drop on a path with a stack-dialog interaction opens the deck
dialog instead of moving directly.
- `dialog.tsx``DialogLayer`, the stack-inspector dialog: an alternate view
of a stack with an insertion cursor; its insert button issues a `move`.
- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
(schema + collection + serialization).
- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
insert) on the deck.
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
## Commands (not yet implemented)
Scripted interaction is designed in [`bgm-commands.md`](./bgm-commands.md):
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,
@@ -2,8 +2,8 @@
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
> order. For the system's architecture and dependency graph, see
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
> see [`decisions.md`](./decisions.md).
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
> see [`../decisions.md`](../decisions.md).
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
Steam Workshop, fetching full TTS save files, and analyzing their contents.
@@ -55,7 +55,16 @@ tts-workshop/
├── .npmrc
├── .env.example # STEAM_API_KEY, PORT
├── docs/
── implementation-plan.md # this file
── overview.md
│ ├── architecture.md
│ ├── decisions.md
│ ├── bgm/
│ │ ├── format.md
│ │ ├── engine.md
│ │ ├── commands.md
│ │ └── tabletop.md
│ └── status/
│ └── implementation-plan.md # this file
├── apps/
│ ├── proxy/
│ │ ├── package.json
+17
View File
@@ -142,4 +142,21 @@ setup:
- path: /community/4
parts: poker:card#2h
facing: standing
interactions:
- dialog: prompt#insert
on: [/deck]
```
## Dialog
Inserting a card into the middle of the deck is a compound interaction: the
deck is lifted into a stack-inspector dialog with an insertion cursor. The
cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling,
not typed.
```yaml role=dialog.prompt
id: insert
title: Insert into deck
body: Scroll to the insertion point, then insert the held card.
widget: stack
```
+1
View File
@@ -13,6 +13,7 @@
},
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@vitest/coverage-v8": "^4.1.10",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
+83 -52
View File
@@ -1,103 +1,134 @@
import { describe, expect, it } from 'vitest';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadDefs, collectPackages } from './collect.js';
import { describe, expect, it } from "vitest";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { loadDefs, collectPackages } from "./collect.js";
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
const multiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'vite-build', 'games');
const fixtureRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"harbor",
);
const multiRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"vite-build",
"games",
);
describe('collectPackages', () => {
it('collects the harbor package from markdown code blocks', () => {
const defMap = loadDefs('', fixtureRoot);
describe("collectPackages", () => {
it("collects the harbor package from markdown code blocks", () => {
const defMap = loadDefs("", fixtureRoot);
const packages = collectPackages(defMap, fixtureRoot);
expect(packages).toHaveLength(1);
const harbor = packages[0]!;
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
expect(harbor.meta).toMatchObject({
id: "harbor",
title: "Harbor",
designer: "Jane Doe",
});
// Two tokens from two yaml blocks sharing a `role=part.token` name.
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
const wood = harbor.parts.get('token#wood')!;
expect([...harbor.parts.keys()].sort()).toEqual([
"token#grain",
"token#wood",
]);
const wood = harbor.parts.get("token#wood")!;
expect(wood).toMatchObject({
type: 'token',
id: 'wood',
type: "token",
id: "wood",
size: [20, 20, 3],
fillet: 2,
});
expect(wood.face).toBe('./assets/tokens.png');
expect(wood.face).toBe("./assets/tokens.png");
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
// Relative assets resolve against the markdown file's directory. The
// fixture markdown sits at the games root, so baseUrl is empty.
expect(wood.baseUrl).toBe('');
expect(wood.baseUrl).toBe("");
// Two surfaces: the table board and its child player board.
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
const board = harbor.surfaces.get('board#harbor')!;
expect([...harbor.surfaces.keys()].sort()).toEqual([
"board#harbor",
"board#player",
]);
const board = harbor.surfaces.get("board#harbor")!;
expect(board.size).toEqual([300, 200]);
expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 });
expect(board.children).toEqual(['board#player']);
expect(board.mount).toEqual({ kind: "table", x: 0, y: 0, rotation: 0 });
expect(board.children).toEqual(["board#player"]);
expect(board.layout).toHaveLength(2);
const dock = board.layout[0]!;
expect(dock.route).toBe('/dock/:seat');
expect(dock.route).toBe("/dock/:seat");
expect(dock.candidates).toEqual([
{ seat: '0', x: 40, y: 0, rotation: 0 },
{ seat: '1', x: 40, y: 20, rotation: 0 },
{ seat: "0", x: 40, y: 0, rotation: 0 },
{ seat: "1", x: 40, y: 20, rotation: 0 },
]);
const deck = board.layout[1]!;
expect(deck.route).toBe('/deck');
expect(deck.route).toBe("/deck");
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
const player = harbor.surfaces.get('board#player')!;
expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 });
const player = harbor.surfaces.get("board#player")!;
expect(player.mount).toEqual({ kind: "child", x: 100, y: 50, rotation: 0 });
expect(player.layout).toHaveLength(1);
// One setup, declaring the enabled surfaces.
expect([...harbor.setups.keys()]).toEqual(['game#main']);
const setup = harbor.setups.get('game#main')!;
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
const setup = harbor.setups.get("game#main")!;
expect(setup.surfaces).toEqual(["board#harbor", "board#player"]);
expect(setup.setup).toEqual([
{ path: '/dock/0', parts: 'harbor:token#wood' },
{ path: '/deck', parts: 'harbor:token#grain' },
{ path: "/dock/0", parts: "harbor:token#wood" },
{ path: "/deck", parts: "harbor:token#grain" },
]);
});
it('scopes include patterns to the package declaration directory', () => {
it("scopes include patterns to the package declaration directory", () => {
// Two packages share a games root. Each uses the default `./**/*.yaml`
// include, which must resolve relative to its own folder so neither
// absorbs the other's defs (both define a `game#main` setup).
const defMap = loadDefs('', multiRoot);
const defMap = loadDefs("", multiRoot);
const packages = collectPackages(defMap, multiRoot);
expect(packages).toHaveLength(2);
const azul = packages.find((p) => p.meta.id === 'azul')!;
const harbor = packages.find((p) => p.meta.id === 'harbor')!;
const azul = packages.find((p) => p.meta.id === "azul")!;
const harbor = packages.find((p) => p.meta.id === "harbor")!;
expect([...azul.parts.keys()]).toEqual(['tile#blue']);
expect([...azul.setups.keys()]).toEqual(['game#main']);
expect([...harbor.parts.keys()]).toEqual(['token#wood']);
expect([...harbor.setups.keys()]).toEqual(['game#main']);
expect([...azul.parts.keys()]).toEqual(["tile#blue"]);
expect([...azul.setups.keys()]).toEqual(["game#main"]);
expect([...harbor.parts.keys()]).toEqual(["token#wood"]);
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
});
it('throws on a duplicate type#id', () => {
const defMap = loadDefs('', fixtureRoot);
it("throws on a duplicate type#id", () => {
const defMap = loadDefs("", fixtureRoot);
// Inject a duplicate part into the map under a new file name.
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
const tokensKey = [...defMap.defs.keys()].find((k) =>
k.endsWith("part.token.yaml"),
)!;
const tokens = defMap.defs.get(tokensKey)!;
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [tokens[0]!]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
tokens[0]!,
]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
/Duplicate part/,
);
});
it('throws when an info-string id combines with $variants', () => {
const defMap = loadDefs('', fixtureRoot);
it("throws when an info-string id combines with $variants", () => {
const defMap = loadDefs("", fixtureRoot);
// A part whose `id` comes from $variants rows, but with an info-string id.
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('part.token.yaml'))!;
const tokensKey = [...defMap.defs.keys()].find((k) =>
k.endsWith("part.token.yaml"),
)!;
const tokens = defMap.defs.get(tokensKey)!;
const variant = {
...tokens[0]!,
value: { ...tokens[0]!.value, id: undefined, $variants: './seats.csv' },
role: { role: 'part', type: 'token', id: 'wood' },
value: { ...tokens[0]!.value, id: undefined, $variants: "./seats.csv" },
role: { role: "part" as const, type: "token", id: "wood" },
};
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [variant]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/);
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
variant,
]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
/can't combine with \$variants/,
);
});
});
});
+107 -43
View File
@@ -11,18 +11,25 @@
* their `include` patterns, and assembles the package's parts, surfaces,
* and setups.
*
* See docs/bgm-format.md for the format's concrete behavior.
* See docs/bgm/format.md for the format's concrete behavior.
*/
import * as path from 'node:path';
import picomatch from 'picomatch';
import { collectVirtualFiles } from './markdown.js';
import { parseDefText, readDefFiles } from './parse.js';
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
import { expandVariants } from './variants.js';
import * as path from "node:path";
import picomatch from "picomatch";
import { collectVirtualFiles } from "./markdown.js";
import { parseDefText, readDefFiles } from "./parse.js";
import {
validateDialog,
validatePackage,
validatePart,
validateSetup,
validateSurface,
} from "./schemas.js";
import { expandVariants } from "./variants.js";
import {
BgmError,
ROLES,
type DefFile,
type Dialog,
type ParsedDef,
type Package,
type PackageDef,
@@ -30,7 +37,7 @@ import {
type Role,
type Setup,
type Surface,
} from './types.js';
} from "./types.js";
/** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap {
@@ -52,7 +59,7 @@ export function loadDefs(root: string, rootDir: string): DefMap {
const others: DefFile[] = [];
for (const file of realFiles) {
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
if (file.kind === "markdown") markdownFiles.set(file.name, file.text);
else others.push(file);
}
@@ -86,8 +93,12 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
for (const [file, defs] of defMap.defs) {
const list: ParsedDef[] = [];
for (const def of defs) {
const role = def.value['role'];
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
const role = def.value["role"];
if (
role !== undefined &&
typeof role === "string" &&
ROLES.has(role as Role)
) {
list.push(def);
byRole.set(file, list);
}
@@ -97,10 +108,10 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
const accs: PackageAcc[] = [];
for (const [file, defs] of byRole) {
for (const def of defs) {
const role = def.value['role'] as Role;
if (role === 'package') {
const role = def.value["role"] as Role;
if (role === "package") {
const pkg = asPackage(def, file);
const baseDir = path.posix.dirname(file).replace(/^\/+/, '');
const baseDir = path.posix.dirname(file).replace(/^\/+/, "");
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
}
}
@@ -119,6 +130,7 @@ class PackageAcc {
readonly parts = new Map<string, Part>();
readonly surfaces = new Map<string, Surface>();
readonly setups = new Map<string, Setup>();
readonly dialogs = new Map<string, Dialog>();
readonly byRole = new Map<string, string[]>();
constructor(
@@ -130,23 +142,37 @@ class PackageAcc {
) {}
collect() {
const include = this.pkg.include ?? ['./**/*.yaml'];
const include = this.pkg.include ?? ["./**/*.yaml"];
const names = this.expandIncludes(include);
for (const name of names) {
const fileDefs = this.defs.defs.get(name);
if (!fileDefs) continue;
for (const def of fileDefs) {
const role = def.value['role'];
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
const role = def.value["role"];
if (
typeof role !== "string" ||
!ROLES.has(role as Role) ||
role === "package"
)
continue;
this.add(role as Role, def, name);
}
}
}
/** Expand `$variants` on a def object into a list of concrete objects. */
private expand(obj: Record<string, unknown>, baseDir: string, source: string): Record<string, unknown>[] {
if (!('$variants' in obj)) return [obj];
const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source);
private expand(
obj: Record<string, unknown>,
baseDir: string,
source: string,
): Record<string, unknown>[] {
if (!("$variants" in obj)) return [obj];
const rows = expandVariants(
obj["$variants"],
baseNameFor(baseDir),
this.defs.files,
source,
);
const { $variants: _v, ...base } = obj;
return rows.map((row) => ({ ...base, ...row }));
}
@@ -165,7 +191,7 @@ class PackageAcc {
// relative to the package declaration's directory. When the package is
// at the games root (empty baseDir), the pattern has no leading slash:
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
const resolved = pattern.startsWith('/')
const resolved = pattern.startsWith("/")
? pattern
: this.baseDir
? `/${path.posix.join(this.baseDir, pattern)}`
@@ -181,14 +207,17 @@ class PackageAcc {
private add(role: Role, def: ParsedDef, fileName: string) {
// `id` on the info string can't combine with `$variants`, since every
// row supplies its own `id` and would override it.
if (def.role?.id && '$variants' in def.value) {
throw new BgmError(`id on the info string can't combine with $variants`, fileName);
if (def.role?.id && "$variants" in def.value) {
throw new BgmError(
`id on the info string can't combine with $variants`,
fileName,
);
}
const expanded = this.expand(def.value, def.baseDir ?? '', def.source);
const expanded = this.expand(def.value, def.baseDir ?? "", def.source);
for (const obj of expanded) {
switch (role) {
case 'part': {
const part = asPart(obj, def.baseDir ?? '');
case "part": {
const part = asPart(obj, def.baseDir ?? "");
const key = `${part.type}#${part.id}`;
if (this.parts.has(key)) {
throw new BgmError(`Duplicate part "${key}"`, fileName);
@@ -196,8 +225,8 @@ class PackageAcc {
this.parts.set(key, part);
break;
}
case 'surface': {
const surface = asSurface(obj, def.baseDir ?? '', this.defs.files);
case "surface": {
const surface = asSurface(obj, def.baseDir ?? "", this.defs.files);
const key = `${surface.type}#${surface.id}`;
if (this.surfaces.has(key)) {
throw new BgmError(`Duplicate surface "${key}"`, fileName);
@@ -205,7 +234,7 @@ class PackageAcc {
this.surfaces.set(key, surface);
break;
}
case 'setup': {
case "setup": {
const setup = asSetup(obj, fileName);
const key = `${setup.type}#${setup.id}`;
if (this.setups.has(key)) {
@@ -214,12 +243,27 @@ class PackageAcc {
this.setups.set(key, setup);
break;
}
case "dialog": {
const dialog = asDialog(obj, fileName);
const key = `${dialog.type}#${dialog.id}`;
if (this.dialogs.has(key)) {
throw new BgmError(`Duplicate dialog "${key}"`, fileName);
}
this.dialogs.set(key, dialog);
break;
}
}
}
}
toPackage(): Package {
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
return {
meta: metaOf(this.pkg),
parts: this.parts,
surfaces: this.surfaces,
setups: this.setups,
dialogs: this.dialogs,
};
}
}
@@ -239,8 +283,8 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
/** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
function baseNameFor(baseDir: string): string {
const dir = baseDir.replace(/^\/+/, '');
return dir ? `/${dir}/def.yaml` : '/def.yaml';
const dir = baseDir.replace(/^\/+/, "");
return dir ? `/${dir}/def.yaml` : "/def.yaml";
}
function asPart(obj: Record<string, unknown>, baseDir: string): Part {
@@ -249,11 +293,11 @@ function asPart(obj: Record<string, unknown>, baseDir: string): Part {
// Resolve relative asset paths against the directory of the source file
// (path-style name relative to the games root). For a code block this is
// the markdown file's directory; for a real file, its own directory.
const dir = baseDir.replace(/^\/+/, '');
part.baseUrl = dir ? `${dir}/` : '';
const dir = baseDir.replace(/^\/+/, "");
part.baseUrl = dir ? `${dir}/` : "";
return part;
} catch (err) {
throw wrapZod(err, '');
throw wrapZod(err, "");
}
}
@@ -263,16 +307,26 @@ function asSurface(
defs: Map<string, DefFile[]>,
): Surface {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
delete value["role"];
// Expand `candidates.$variants` on each route into a concrete array.
if (Array.isArray(value['layout'])) {
value['layout'] = value['layout'].map((route) => {
if (typeof route !== 'object' || route === null) return route;
if (Array.isArray(value["layout"])) {
value["layout"] = value["layout"].map((route) => {
if (typeof route !== "object" || route === null) return route;
const r = route as Record<string, unknown>;
const cand = r['candidates'];
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
const rows = expandVariants(cand['$variants'], baseNameFor(baseDir), defs, baseDir);
const cand = r["candidates"];
if (
cand &&
typeof cand === "object" &&
!Array.isArray(cand) &&
"$variants" in cand
) {
const rows = expandVariants(
cand["$variants"],
baseNameFor(baseDir),
defs,
baseDir,
);
const { $variants: _v, ...base } = cand as Record<string, unknown>;
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
}
@@ -288,7 +342,7 @@ function asSurface(
function asSetup(obj: Record<string, unknown>, source: string): Setup {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
delete value["role"];
try {
return validateSetup(value) as unknown as Setup;
} catch (err) {
@@ -296,6 +350,16 @@ function asSetup(obj: Record<string, unknown>, source: string): Setup {
}
}
function asDialog(obj: Record<string, unknown>, source: string): Dialog {
const value: Record<string, unknown> = { ...obj };
delete value["role"];
try {
return validateDialog(value) as unknown as Dialog;
} catch (err) {
throw wrapZod(err, source);
}
}
/** Wrap a zod error with the source location. */
function wrapZod(err: unknown, source: string): BgmError {
const message = err instanceof Error ? err.message : String(err);
+1 -1
View File
@@ -2,7 +2,7 @@
* Parse raw definition files (yaml/json/toml text) into JSON objects.
*
* A def file's document can be either a single JSON object (the root) or a
* list of objects; both are handled per docs/bgm-format.md §3. In list mode,
* list of objects; both are handled per docs/bgm/format.md §3. In list mode,
* each object is a separate definition.
*/
import * as fs from 'node:fs';
+32 -7
View File
@@ -3,10 +3,10 @@
*
* These validate the raw definition objects (after `$variants` expansion)
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
* See docs/bgm-format.md for the format's concrete behavior.
* See docs/bgm/format.md for the format's concrete behavior.
*/
import { z } from 'zod';
import type { PackageDef, Part, Setup, Surface } from './types.js';
import { z } from "zod";
import type { Dialog, PackageDef, Part, Setup, Surface } from "./types.js";
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
const size = z.tuple([z.number(), z.number(), z.number()]);
@@ -15,7 +15,7 @@ const surfaceSize = z.tuple([z.number(), z.number()]);
const stacking = z.object({
curve: z.string().optional(),
limit: z.number().optional(),
align: z.enum(['start', 'end', 'center']).optional(),
align: z.enum(["start", "end", "center"]).optional(),
steps: z.number().optional(),
tilt: z.number().optional(),
zStart: z.number().optional(),
@@ -44,7 +44,7 @@ const partSchema = z.object({
});
const surfaceMount = z.object({
kind: z.enum(['table', 'hud', 'child']),
kind: z.enum(["table", "hud", "child"]),
x: z.number().optional(),
y: z.number().optional(),
rotation: z.number().optional(),
@@ -65,7 +65,12 @@ const setupValue = z.union([z.string(), z.array(z.string())]);
const setupPlacement = z.object({
path: z.string(),
parts: setupValue,
facing: z.enum(['face', 'back', 'standing']).optional(),
facing: z.enum(["face", "back", "standing"]).optional(),
});
const interaction = z.object({
dialog: z.string().min(1),
on: z.array(z.string()).optional(),
});
const setupSchema = z.object({
@@ -73,10 +78,25 @@ const setupSchema = z.object({
id: z.string().min(1),
surfaces: z.array(z.string()).optional(),
setup: z.array(setupPlacement),
interactions: z.array(interaction).optional(),
});
const dialogAction = z.object({
label: z.string().min(1),
command: z.unknown(),
});
const dialogSchema = z.object({
type: z.string().min(1),
id: z.string().min(1),
title: z.string().optional(),
body: z.string().optional(),
actions: z.array(dialogAction).optional(),
widget: z.string().optional(),
});
const packageSchema = z.object({
role: z.literal('package'),
role: z.literal("package"),
id: z.string().min(1),
title: z.string().optional(),
designer: z.string().optional(),
@@ -102,6 +122,11 @@ export function validateSetup(value: Record<string, unknown>): Setup {
return setupSchema.parse(value) as unknown as Setup;
}
/** Validate a raw dialog definition. */
export function validateDialog(value: Record<string, unknown>): Dialog {
return dialogSchema.parse(value) as unknown as Dialog;
}
/** Validate a raw package definition. */
export function validatePackage(value: Record<string, unknown>): PackageDef {
return packageSchema.parse(value) as unknown as PackageDef;
+80 -21
View File
@@ -5,11 +5,11 @@
* discovered as JSON objects from yaml/json/toml files and from markdown
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
*
* The concrete behavior of the format is described in `docs/bgm-format.md`.
* The concrete behavior of the format is described in `docs/bgm/format.md`.
*/
/** Part value types. */
export type PartValueType = 'image' | 'crop' | 'size' | 'sprite';
export type PartValueType = "image" | "crop" | "size" | "sprite";
/**
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
@@ -106,7 +106,7 @@ export interface Stacking {
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
limit?: number;
/** `start`, `end`, or `center` of the curve. */
align?: 'start' | 'end' | 'center';
align?: "start" | "end" | "center";
/** Maximum parts per curve length unit; defaults to `1`. */
steps?: number;
/**
@@ -126,7 +126,7 @@ export interface Stacking {
}
/** How a surface is mounted. `kind` selects the mount type. */
export type SurfaceMountKind = 'table' | 'hud' | 'child';
export type SurfaceMountKind = "table" | "hud" | "child";
/**
* How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation`
@@ -161,7 +161,7 @@ export type SetupValue = string | string[];
* How a part is oriented on the board. `face` lays it flat front-up, `back`
* flips it over front-down, and `standing` stands it on its bottom edge.
*/
export type Facing = 'face' | 'back' | 'standing';
export type Facing = "face" | "back" | "standing";
/**
* One setup placement: move `parts` to `path`. Entries are applied in order,
@@ -184,12 +184,60 @@ export interface Setup {
surfaces?: string[];
/** Ordered placements; each moves its parts to its path. */
setup: SetupPlacement[];
/**
* Interaction affordances: which dialogs are the tool for which open
* interactions on which paths. Declares the interaction surface, not the
* legality of the resulting command (rules gate that, later).
*/
interactions?: Interaction[];
}
export type Role = 'package' | 'part' | 'surface' | 'setup';
/**
* One interaction affordance: a `role: dialog` ref (`type#id`) and the paths
* it applies to. `on` omitted means the dialog applies to any path.
*/
export interface Interaction {
/** A `role: dialog` ref (`type#id`). */
dialog: string;
/** Paths this interaction applies to; omitted = any path. */
on?: string[];
}
/** The four definition roles. */
export const ROLES: ReadonlySet<Role> = new Set(['package', 'part', 'surface', 'setup']);
/** A dialog's action button: a label and the command it issues. */
export interface DialogAction {
label: string;
/** The command the button issues (e.g. a `move`); the rule seam gates it. */
command: unknown;
}
/**
* A `role: dialog` definition: declarative content shown in the layer-3 shell.
* Opening/closing it never issues a command or mutates state; its action
* buttons issue commands. `widget` selects the content type (e.g. `stack`).
*/
export interface Dialog {
type: string;
id: string;
title?: string;
body?: string;
/** Action buttons; each issues a command. */
actions?: DialogAction[];
/** Content widget type, e.g. `stack` for a stack-of-parts view. */
widget?: string;
/** Extra fields from the source definition, kept for forwards compatibility. */
[key: string]: unknown;
}
export type Role = "package" | "part" | "surface" | "setup" | "dialog";
/** The five definition roles. */
export const ROLES: ReadonlySet<Role> = new Set([
"package",
"part",
"surface",
"setup",
"dialog",
]);
/**
* Role metadata declared on a code block's info string (`role=part.cargo`) or
@@ -204,8 +252,8 @@ export interface RoleMeta {
}
/** The canonical file name for a role, e.g. `part.cargo.yaml`. */
export function roleToName(role: RoleMeta, ext = 'yaml'): string {
if (role.role === 'package') return `package.${ext}`;
export function roleToName(role: RoleMeta, ext = "yaml"): string {
if (role.role === "package") return `package.${ext}`;
return `${role.role}.${role.type}.${ext}`;
}
@@ -214,9 +262,9 @@ export function roleToName(role: RoleMeta, ext = 'yaml'): string {
* the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
*/
export function roleFromName(name: string): RoleMeta | undefined {
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: 'package' };
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: "package" };
const m = /^(\w+)\.(.+)\.(ya?ml|json|toml)$/i.exec(name);
if (m && ROLES.has(m[1] as Role) && m[1] !== 'package') {
if (m && ROLES.has(m[1] as Role) && m[1] !== "package") {
return { role: m[1] as Role, type: m[2] };
}
return undefined;
@@ -231,25 +279,29 @@ export interface RawDef {
[key: string]: unknown;
}
/** The four definition roles. */
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
/** The five definition roles. */
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef | DialogDef;
export interface PackageDef extends PackageMeta {
role: 'package';
role: "package";
/** Git-style path patterns of the defs that make up the package. */
include?: string[];
}
export interface PartDef extends Part {
role: 'part';
role: "part";
}
export interface SurfaceDef extends Surface {
role: 'surface';
role: "surface";
}
export interface SetupDef extends Setup {
role: 'setup';
role: "setup";
}
export interface DialogDef extends Dialog {
role: "dialog";
}
/** A virtual definition file: a real file or a markdown code block. */
@@ -261,7 +313,7 @@ export interface DefFile {
/** Source location for error messages (real path or `file.md:12-19`). */
source: string;
/** File type derived from the name's extension. */
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
kind: "yaml" | "json" | "toml" | "markdown" | "csv";
/** Role declared on a code block's info string or a real file's name. */
role?: RoleMeta;
/**
@@ -299,6 +351,8 @@ export interface Package {
surfaces: Map<string, Surface>;
/** All setups by `type#id`. */
setups: Map<string, Setup>;
/** All dialogs by `type#id`. */
dialogs: Map<string, Dialog>;
}
/**
@@ -314,12 +368,17 @@ export interface SerializedPackage {
surfaces: Record<string, Surface>;
/** All setups by `type#id`. */
setups: Record<string, Setup>;
/** All dialogs by `type#id`. */
dialogs: Record<string, Dialog>;
}
/** Errors during loading, carrying the source location when available. */
export class BgmError extends Error {
constructor(message: string, readonly location?: string) {
constructor(
message: string,
readonly location?: string,
) {
super(location ? `${location}: ${message}` : message);
this.name = 'BgmError';
this.name = "BgmError";
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
* The `$variants` directive: parse a CSV into a typed object array and
* extend the original object with each row.
*
* Per docs/bgm-format.md §1:
* Per docs/bgm/format.md §1:
* - The CSV's first row is the header, the second row is the type declaration
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
* the remaining rows are data.
+16 -13
View File
@@ -15,17 +15,17 @@
* be mistaken for real installed packages. Editing a game definition
* hot-reloads the app via `addWatchFile`.
*/
import * as path from 'node:path';
import { normalizePath, type ModuleNode, type Plugin } from 'vite';
import { collectPackages, loadDefs } from './collect.js';
import { readDefFiles } from './parse.js';
import type { Package, SerializedPackage } from './types.js';
import * as path from "node:path";
import { normalizePath, type ModuleNode, type Plugin } from "vite";
import { collectPackages, loadDefs } from "./collect.js";
import { readDefFiles } from "./parse.js";
import type { Package, SerializedPackage } from "./types.js";
const VIRTUAL_PREFIX = '\0bgm:';
const VIRTUAL_PREFIX = "\0bgm:";
/** Public specifier for the module that lists every package. */
const PACKAGES = 'virtual:bgm/packages';
const PACKAGES = "virtual:bgm/packages";
/** Public specifier prefix for a single package module. */
const PACKAGE = 'virtual:bgm/package/';
const PACKAGE = "virtual:bgm/package/";
export interface BgmOptions {
/** Absolute path to the games root (e.g. `<repo>/games`). */
@@ -39,23 +39,23 @@ export function bgm(options: BgmOptions): Plugin {
const root = normalizePath(options.root);
const collect = (): Package[] => {
const defMap = loadDefs('', root);
const defMap = loadDefs("", root);
return collectPackages(defMap, root);
};
return {
name: 'bgm',
name: "bgm",
buildStart() {
// Watch every real definition source under the games root so edits
// trigger a re-collect. `defMap.files` only holds the virtual code-block
// files plus real non-markdown files; the markdown files themselves are
// consumed for their code blocks and never appear there, so watch the
// real files on disk too (markdown and anything else the loader reads).
const defMap = loadDefs('', root);
const defMap = loadDefs("", root);
for (const name of defMap.files.keys()) {
this.addWatchFile(path.join(root, name));
}
for (const file of readDefFiles(root, '')) {
for (const file of readDefFiles(root, "")) {
this.addWatchFile(file.source);
}
},
@@ -68,7 +68,9 @@ export function bgm(options: BgmOptions): Plugin {
// changes, so edits hot-reload instead of requiring a manual refresh.
if (!ctx.file.startsWith(root)) return;
const invalidated: ModuleNode[] = [];
const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES);
const mod = ctx.server.moduleGraph.getModuleById(
VIRTUAL_PREFIX + PACKAGES,
);
if (mod) {
ctx.server.moduleGraph.invalidateModule(mod);
invalidated.push(mod);
@@ -102,5 +104,6 @@ function toJson(pkg: Package): SerializedPackage {
parts: Object.fromEntries(pkg.parts),
surfaces: Object.fromEntries(pkg.surfaces),
setups: Object.fromEntries(pkg.setups),
dialogs: Object.fromEntries(pkg.dialogs),
};
}
+70 -40
View File
@@ -1,22 +1,31 @@
import { describe, expect, it } from 'vitest';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bgm } from './vite.js';
import { describe, expect, it } from "vitest";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { bgm } from "./vite.js";
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
const gamesRoot = path.join(fixtureRoot, 'harbor');
const fixtureRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
);
const gamesRoot = path.join(fixtureRoot, "harbor");
/**
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
* hook to a callable for direct invocation in tests.
*/
type Callable<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never;
type Callable<T> = T extends (...args: infer A) => infer R
? (...args: A) => R
: never;
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
isEntry: false,
});
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
id,
undefined,
{
isEntry: false,
},
);
}
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
@@ -25,73 +34,94 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
/** Parse the JSON payload out of an emitted `export default <json>` module. */
function parseModule(code: unknown): unknown {
expect(String(code).startsWith('export default ')).toBe(true);
return JSON.parse(String(code).slice('export default '.length));
expect(String(code).startsWith("export default ")).toBe(true);
return JSON.parse(String(code).slice("export default ".length));
}
describe('bgm vite plugin', () => {
it('resolves bgm imports to the virtual module', () => {
describe("bgm vite plugin", () => {
it("resolves bgm imports to the virtual module", () => {
const plugin = bgm({ root: gamesRoot });
expect(resolveId(plugin, 'virtual:bgm/packages')).toBe('\0bgm:virtual:bgm/packages');
expect(resolveId(plugin, 'virtual:bgm/package/harbor')).toBe('\0bgm:virtual:bgm/package/harbor');
expect(resolveId(plugin, 'virtual:bgm/package/nope')).toBe('\0bgm:virtual:bgm/package/nope');
expect(resolveId(plugin, 'other')).toBeUndefined();
expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
"\0bgm:virtual:bgm/packages",
);
expect(resolveId(plugin, "virtual:bgm/package/harbor")).toBe(
"\0bgm:virtual:bgm/package/harbor",
);
expect(resolveId(plugin, "virtual:bgm/package/nope")).toBe(
"\0bgm:virtual:bgm/package/nope",
);
expect(resolveId(plugin, "other")).toBeUndefined();
});
it('loads every package as a JSON module', () => {
it("loads every package as a JSON module", () => {
const plugin = bgm({ root: gamesRoot });
const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array<Record<string, any>>;
const packages = parseModule(
load(plugin, "\0bgm:virtual:bgm/packages"),
) as Array<Record<string, any>>;
expect(packages).toHaveLength(1);
expect(packages[0].meta.id).toBe('harbor');
expect(packages[0].parts).toHaveProperty('token#wood');
expect(packages[0].meta.id).toBe("harbor");
expect(packages[0].parts).toHaveProperty("token#wood");
});
it('loads a package as a JSON module', () => {
it("loads a package as a JSON module", () => {
const plugin = bgm({ root: gamesRoot });
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
expect(pkg.meta.id).toBe('harbor');
expect(pkg.parts).toHaveProperty('token#wood');
expect(pkg.surfaces).toHaveProperty('board#harbor');
const pkg = parseModule(
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
) as Record<string, any>;
expect(pkg.meta.id).toBe("harbor");
expect(pkg.parts).toHaveProperty("token#wood");
expect(pkg.surfaces).toHaveProperty("board#harbor");
});
it('serializes maps as plain objects, not Map instances', () => {
it("serializes maps as plain objects, not Map instances", () => {
const plugin = bgm({ root: gamesRoot });
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
const pkg = parseModule(
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
) as Record<string, any>;
// The emitted shape must be JSON-serializable: plain objects keyed by
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
for (const key of ['parts', 'surfaces', 'setups'] as const) {
for (const key of ["parts", "surfaces", "setups", "dialogs"] as const) {
expect(pkg[key]).not.toBeInstanceOf(Map);
expect(pkg[key]).toEqual(expect.any(Object));
}
// Consumers read the collections with Object.values / Object.keys.
expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']);
expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']);
expect(Object.keys(pkg.setups)).toEqual(['game#main']);
expect(
Object.values(pkg.parts)
.map((p: any) => p.id)
.sort(),
).toEqual(["grain", "wood"]);
expect(Object.keys(pkg.surfaces)).toEqual(["board#harbor", "board#player"]);
expect(Object.keys(pkg.setups)).toEqual(["game#main"]);
});
it('errors on an unknown package', () => {
it("errors on an unknown package", () => {
const plugin = bgm({ root: gamesRoot });
expect(() => load(plugin, '\0bgm:virtual:bgm/package/unknown')).toThrow(/not found/);
expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow(
/not found/,
);
});
it('watches every source file for reloads', () => {
it("watches every source file for reloads", () => {
const plugin = bgm({ root: gamesRoot });
const watched: string[] = [];
const context = { addWatchFile: (file: string) => watched.push(file) };
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
// the options, so pass a placeholder.
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(
context,
{} as never,
);
// Every def file (real + virtual code blocks) is watched so edits
// trigger a re-collect. The real markdown source must be watched too:
// `defMap.files` only lists virtual code-block files, so without watching
// the on-disk `.md` file the dev server would never notice an edit.
expect(watched.length).toBeGreaterThan(0);
expect(watched.some((f) => f.endsWith('.yaml'))).toBe(true);
expect(watched.some((f) => f.endsWith('.csv'))).toBe(true);
expect(watched.some((f) => f.endsWith('.md'))).toBe(true);
expect(watched.some((f) => f.endsWith(".yaml"))).toBe(true);
expect(watched.some((f) => f.endsWith(".csv"))).toBe(true);
expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
});
});
+12 -2
View File
@@ -27,17 +27,21 @@ export default class ErrorBoundary extends Component<Props, State> {
console.error('Part viewer error:', error, info.componentStack);
}
private retry = () => {
this.setState({ error: null });
};
override render() {
if (this.state.error) {
return this.props.fallback
? this.props.fallback(this.state.error)
: <DefaultFallback error={this.state.error} />;
: <DefaultFallback error={this.state.error} onRetry={this.retry} />;
}
return this.props.children;
}
}
function DefaultFallback({ error }: { error: Error }) {
function DefaultFallback({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
<p className="text-sm font-medium text-zinc-200">Couldn't render this part</p>
@@ -49,6 +53,12 @@ function DefaultFallback({ error }: { error: Error }) {
{error.stack}
</pre>
)}
<button
onClick={onRetry}
className="mt-2 rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Try again
</button>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
/**
* The dialog layer — transient UI contexts hosted by the layer-3 shell.
*
* A dialog is an alternate view of a stack (`docs/bgm/interactions.md` §4):
* the deck is "lifted" off the table into the dialog (it stays on its path),
* shown in order with an insertion cursor. The cursor *is* the index:
* `move(id, path, index)`'s index is discovered by scrolling the visible deck.
*
* Opening/closing a dialog never issues a command and never touches the game
* state — it is pure UI. Only its action buttons issue commands.
*/
import { useMemo, useState } from 'react';
import type { Package } from '@tts/bgm';
import { useInteractionStore } from './interactions.js';
import { useTabletopStore, childrenByPath } from './state.js';
/**
* Render the topmost dialog on the interaction store's stack as an HTML
* overlay. Renders nothing when the stack is empty. The dialog shows the
* stack at its path in order, with an insertion cursor the player scrolls;
* the insert button issues a `move` of the held part (or the top of the
* stack) to that path at the cursor.
*/
export function DialogLayer({ pkg }: { pkg: Package }) {
const dialogs = useInteractionStore((s) => s.dialogs);
const top = dialogs[dialogs.length - 1];
if (!top) return null;
const dialog = pkg.dialogs.get(top.id);
if (!dialog) return null;
return (
<StackDialog
key={top.path}
pkg={pkg}
title={dialog.title}
body={dialog.body}
path={top.path}
/>
);
}
/** The stack-inspector dialog: a lifted, ordered view of a path's stack. */
function StackDialog({
pkg,
title,
body,
path,
}: {
pkg: Package;
title?: string;
body?: string;
path: string;
}) {
const parts = useTabletopStore((s) => s.parts);
const movePart = useTabletopStore((s) => s.movePart);
const held = useInteractionStore((s) => s.held);
const drop = useInteractionStore((s) => s.drop);
const popDialog = useInteractionStore((s) => s.popDialog);
// The stack at `path`, in order.
const stack = useMemo(() => childrenByPath(parts)[path] ?? [], [parts, path]);
const [cursor, setCursor] = useState(stack.length);
// The part to insert: the held part, or the top of the stack when none is
// held (a "look at the top" / reorder use).
const source = held?.id;
const insert = () => {
if (!source) return;
movePart(source, path, cursor);
drop();
popDialog();
};
return (
<div className="pointer-events-auto absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/60">
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-4 shadow-xl">
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{body && <p className="mt-1 text-sm text-zinc-400">{body}</p>}
<div className="mt-3 flex flex-wrap items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 p-2">
{stack.length === 0 && (
<span className="text-xs text-zinc-500">Empty stack</span>
)}
{stack.map((id, i) => (
<div key={id} className="flex items-center gap-1">
{i === cursor && <Cursor />}
<button
onClick={() => setCursor(i)}
className="rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-700"
title={`Insert before ${label(pkg, id)}`}
>
{label(pkg, id)}
</button>
</div>
))}
{stack.length > 0 && cursor === stack.length && <Cursor />}
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={popDialog}
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800"
>
Close
</button>
<button
onClick={insert}
disabled={!source}
className="rounded-md bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-white disabled:opacity-40"
>
{source ? `Insert ${label(pkg, source)}` : 'Insert'}
</button>
</div>
</div>
</div>
);
}
/** A small marker between cards showing the insertion point. */
function Cursor() {
return (
<span className="h-6 w-0.5 rounded bg-zinc-100" title="Insertion point" />
);
}
/** A short label for a part id (`package:type#id` → `type#id`). */
function label(pkg: Package, id: string): string {
const key = id.split(':').slice(1).join(':');
const part = pkg.parts.get(key);
return part?.id ?? key;
}
+188
View File
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { Package, Setup } from "@tts/bgm";
import {
interactionsFor,
dropPaths,
pickPath,
partFacings,
nextFacing,
useInteractionStore,
} from "./interactions.js";
const pkg: Package = {
meta: { id: "harbor" },
parts: new Map([
["card#a", { type: "card", id: "a" }],
["card#b", { type: "card", id: "b", facing: ["face", "back"] }],
]),
surfaces: new Map([
[
"board#harbor",
{
type: "board",
id: "harbor",
layout: [
{ route: "/deck", x: 0, y: 0, rotation: 0 },
{ route: "/discard", x: 40, y: 0, rotation: 0 },
],
},
],
]),
setups: new Map(),
dialogs: new Map(),
};
const setup: Setup = {
type: "game",
id: "main",
setup: [],
interactions: [
{ dialog: "prompt#insert", on: ["/deck"] },
{ dialog: "prompt#shuffle" },
],
};
describe("interactionsFor", () => {
it("returns the interactions whose `on` matches the path, plus any without `on`", () => {
expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([
"prompt#insert",
"prompt#shuffle",
]);
});
it("includes interactions without `on` for any path", () => {
expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([
"prompt#shuffle",
]);
});
it("returns [] when the setup declares no interactions", () => {
expect(
interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"),
).toEqual([]);
});
});
describe("dropPaths", () => {
it("uses the declared `on` paths when interactions exist", () => {
expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]);
});
it("falls back to every literal routed path when no interactions are declared", () => {
const bare: Setup = { type: "game", id: "main", setup: [] };
expect([...dropPaths(bare, pkg)].sort()).toEqual(["/deck", "/discard"]);
});
it("skips :param routes in the fallback, since they need a candidate", () => {
const paramPkg: Package = {
...pkg,
surfaces: new Map([
[
"board#harbor",
{
type: "board",
id: "harbor",
layout: [
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
{ route: "/deck", x: 40, y: 0, rotation: 0 },
],
},
],
]),
};
const bare: Setup = { type: "game", id: "main", setup: [] };
expect([...dropPaths(bare, paramPkg)]).toEqual(["/deck"]);
});
});
describe("pickPath", () => {
it("returns the nearest literal route within the threshold", () => {
expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard");
});
it("returns null when nothing is within the threshold", () => {
expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull();
});
it("returns null for an unknown surface", () => {
expect(pickPath(pkg, "board#nope", 0, 0, 40)).toBeNull();
});
it("skips :param routes, which have no fixed anchor", () => {
const paramPkg: Package = {
...pkg,
surfaces: new Map([
[
"board#harbor",
{
type: "board",
id: "harbor",
layout: [
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
{ route: "/deck", x: 40, y: 0, rotation: 0 },
],
},
],
]),
};
expect(pickPath(paramPkg, "board#harbor", 100, 100, 40)).toBeNull();
expect(pickPath(paramPkg, "board#harbor", 41, 1, 40)).toBe("/deck");
});
});
describe("useInteractionStore", () => {
beforeEach(() => {
useInteractionStore.setState({ held: null, dialogs: [] });
});
it("holds and drops a part", () => {
useInteractionStore
.getState()
.hold({ id: "harbor:card#a", origin: "/deck" });
expect(useInteractionStore.getState().held).toEqual({
id: "harbor:card#a",
origin: "/deck",
});
useInteractionStore.getState().drop();
expect(useInteractionStore.getState().held).toBeNull();
});
it("pushes and pops the dialog stack", () => {
const { pushDialog, popDialog } = useInteractionStore.getState();
pushDialog({ id: "prompt#insert", path: "/deck" });
pushDialog({ id: "prompt#shuffle", path: "/deck" });
expect(useInteractionStore.getState().dialogs).toEqual([
{ id: "prompt#insert", path: "/deck" },
{ id: "prompt#shuffle", path: "/deck" },
]);
popDialog();
expect(useInteractionStore.getState().dialogs).toEqual([
{ id: "prompt#insert", path: "/deck" },
]);
});
});
describe("partFacings / nextFacing", () => {
it("defaults to the full set when the part declares none", () => {
expect(partFacings(pkg.parts.get("card#a")!)).toEqual([
"face",
"back",
"standing",
]);
});
it("uses the declared affordance", () => {
expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]);
});
it("cycles forward through the affordance", () => {
const part = pkg.parts.get("card#b")!;
expect(nextFacing(part, "face")).toBe("back");
expect(nextFacing(part, "back")).toBe("face");
});
it("wraps to the first facing after the last", () => {
const part = pkg.parts.get("card#b")!;
expect(nextFacing(part, "back")).toBe("face");
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* The free-interaction layer (layer 3 of the layering) — how a player
* interacts with a bgm game that has no rules yet: a sandbox.
*
* The operation set is closed and tiny (`docs/bgm/interactions.md` §1):
* `move(id, path, index?)`, `setFacing(id, facing)`, and reorder (a `move`
* with an explicit `index`). The game-state store in `state.ts` already
* implements `movePart`/`setFacing`; this module adds the *interaction* half:
*
* - The **held part** — transient "in hand" state that lives outside the store
* (a UI-level held part; only the committed drop mutates the store).
* - The **dialog stack** — transient UI contexts (the deck pick-up). Opening
* or closing a dialog never issues a command and never touches state.
*
* Both are UI state, hosted by the layer-3 shell, not by the game-state store.
*/
import { create } from 'zustand';
import type { Facing, Interaction, Package, Part, Setup } from '@tts/bgm';
import type { Placement } from './state.js';
/** The transient "in hand" part, held above the board. */
export interface HeldPart {
/** The part id (`package:type#id`). */
id: string;
/** The path the part was lifted from, so dropping on nothing returns it. */
origin: string;
}
/** A dialog currently open on the dialog stack. */
export interface OpenDialog {
/** The dialog definition (`type#id`). */
id: string;
/** The path the dialog was opened on (e.g. the deck being inspected). */
path: string;
}
interface InteractionState {
/** The part currently held "in hand", or null. */
held: HeldPart | null;
/** The dialog stack; the last entry is the topmost dialog. */
dialogs: OpenDialog[];
/** Lift a part into hand. */
hold: (part: HeldPart) => void;
/** Drop the held part (returns it to its origin). */
drop: () => void;
/** Push a dialog onto the stack. */
pushDialog: (dialog: OpenDialog) => void;
/** Pop the topmost dialog. */
popDialog: () => void;
}
export const useInteractionStore = create<InteractionState>((set) => ({
held: null,
dialogs: [],
hold: (part) => set({ held: part }),
drop: () => set({ held: null }),
pushDialog: (dialog) => set((s) => ({ dialogs: [...s.dialogs, dialog] })),
popDialog: () => set((s) => ({ dialogs: s.dialogs.slice(0, -1) })),
}));
// --- Pure helpers ---
/**
* Resolve the interaction declarations for a path from a setup: the `dialog`
* refs whose `on` matches the path (or that apply to any path). Returns the
* matching `Interaction`s in declaration order.
*/
export function interactionsFor(setup: Setup, path: string): Interaction[] {
if (!setup.interactions) return [];
return setup.interactions.filter(
(i) => !i.on || i.on.some((p) => p === path),
);
}
/**
* The set of paths a held part may be dropped on, given a setup's declared
* interactions. When the setup declares interactions, only the `on` paths of
* those interactions are valid drop targets; otherwise any routed path is.
*/
export function dropPaths(setup: Setup, pkg: Package): Set<string> {
const paths = new Set<string>();
if (setup.interactions?.length) {
for (const i of setup.interactions) {
if (i.on) for (const p of i.on) paths.add(p);
}
return paths;
}
// No interactions declared: every routed path is a valid target.
for (const surface of pkg.surfaces.values()) {
for (const route of surface.layout) {
// A literal route is a concrete path; a `:param` route matches many.
if (route.route.includes(':')) continue;
paths.add(route.route);
}
}
return paths;
}
/**
* Find the nearest routed path anchor to a point in a surface's local plane
* (in mm, origin at the surface's anchor), within `threshold` mm. Returns the
* matched path, or null when none is within range. Used to resolve a drop.
*/
export function pickPath(
pkg: Package,
surfaceId: string,
x: number,
y: number,
threshold: number,
): string | null {
const surface = pkg.surfaces.get(surfaceId);
if (!surface) return null;
let best: { path: string; dist: number } | null = null;
for (const route of surface.layout) {
// A `:param` route's anchor depends on its candidate; without a candidate
// we can't place a part there, so skip it.
if (route.route.includes(':')) continue;
const dx = x - route.x;
const dy = y - route.y;
const dist = Math.hypot(dx, dy);
if (dist <= threshold && (!best || dist < best.dist)) {
best = { path: route.route, dist };
}
}
return best?.path ?? null;
}
/**
* The physical facing affordance of a part: the facings it supports. Defaults
* to the full set (`face`, `back`, `standing`) when the part doesn't declare
* one. A part's `facing` field (a list of supported facings) is an optional
* extra on the `Part` definition.
*/
export function partFacings(part: Part): Facing[] {
const declared = part.facing;
const list = Array.isArray(declared) ? (declared as Facing[]) : [];
return list.length ? list : ['face', 'back', 'standing'];
}
/**
* The next facing in a part's affordance, cycling forward. Used by a click to
* cycle `face → back → standing` (or the declared list).
*/
export function nextFacing(part: Part, current: Facing): Facing {
const facings = partFacings(part);
const i = facings.indexOf(current);
return facings[(i + 1) % facings.length] ?? facings[0]!;
}
+17 -13
View File
@@ -1,19 +1,23 @@
import { describe, expect, it } from 'vitest';
import { serializedToPackage } from './package.js';
import { describe, expect, it } from "vitest";
import { serializedToPackage } from "./package.js";
describe('serializedToPackage', () => {
it('converts plain-object maps to Map instances', () => {
describe("serializedToPackage", () => {
it("converts plain-object maps to Map instances", () => {
const serialized = {
meta: { id: 'harbor' },
parts: { 'token#wood': { type: 'token', id: 'wood' } },
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
meta: { id: "harbor" },
parts: { "token#wood": { type: "token", id: "wood" } },
surfaces: { "board#harbor": { type: "board", id: "harbor", layout: [] } },
setups: { "game#main": { type: "game", id: "main", setup: [] } },
dialogs: {
"prompt#insert": { type: "prompt", id: "insert", title: "Insert" },
},
};
const pkg = serializedToPackage(serialized);
expect(pkg.meta.id).toBe('harbor');
expect(pkg.meta.id).toBe("harbor");
expect(pkg.parts).toBeInstanceOf(Map);
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
expect(pkg.parts.get("token#wood")).toEqual({ type: "token", id: "wood" });
expect(pkg.surfaces.get("board#harbor")).toMatchObject({ type: "board" });
expect(pkg.setups.get("game#main")).toMatchObject({ type: "game" });
expect(pkg.dialogs.get("prompt#insert")).toMatchObject({ type: "prompt" });
});
});
});
+3 -2
View File
@@ -4,7 +4,7 @@
* `virtual:bgm/*` get the serialized form; the tabletop components take the
* `Package` form.
*/
import type { Package, SerializedPackage } from '@tts/bgm';
import type { Package, SerializedPackage } from "@tts/bgm";
export function serializedToPackage(serialized: SerializedPackage): Package {
return {
@@ -12,5 +12,6 @@ export function serializedToPackage(serialized: SerializedPackage): Package {
parts: new Map(Object.entries(serialized.parts)),
surfaces: new Map(Object.entries(serialized.surfaces)),
setups: new Map(Object.entries(serialized.setups)),
dialogs: new Map(Object.entries(serialized.dialogs)),
};
}
}
+81 -51
View File
@@ -1,88 +1,118 @@
import { describe, expect, it } from 'vitest';
import type { Package } from '@tts/bgm';
import { expandSetupValue, seedFromSetup } from './setup.js';
import { describe, expect, it } from "vitest";
import type { Facing, Package } from "@tts/bgm";
import { expandSetupValue, seedFromSetup } from "./setup.js";
const pkg: Package = {
meta: { id: 'harbor' },
meta: { id: "harbor" },
parts: new Map([
['token#wood', { type: 'token', id: 'wood' }],
['token#grain', { type: 'token', id: 'grain' }],
['card#fleet', { type: 'card', id: 'fleet' }],
["token#wood", { type: "token", id: "wood" }],
["token#grain", { type: "token", id: "grain" }],
["card#fleet", { type: "card", id: "fleet" }],
]),
surfaces: new Map([
['board#harbor', { type: 'board', id: 'harbor', layout: [] }],
['hud#hand', { type: 'hud', id: 'hand', layout: [] }],
["board#harbor", { type: "board", id: "harbor", layout: [] }],
["hud#hand", { type: "hud", id: "hand", layout: [] }],
]),
setups: new Map(),
dialogs: new Map(),
};
describe('expandSetupValue', () => {
it('keeps a full part id', () => {
expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']);
});
it('expands a bare type to all parts of that type', () => {
expect(expandSetupValue(pkg, 'harbor:token')).toEqual(['harbor:token#wood', 'harbor:token#grain']);
});
it('expands each entry of a list', () => {
expect(expandSetupValue(pkg, ['harbor:card#fleet', 'harbor:token'])).toEqual([
'harbor:card#fleet',
'harbor:token#wood',
'harbor:token#grain',
describe("expandSetupValue", () => {
it("keeps a full part id", () => {
expect(expandSetupValue(pkg, "harbor:card#fleet")).toEqual([
"harbor:card#fleet",
]);
});
it("expands a bare type to all parts of that type", () => {
expect(expandSetupValue(pkg, "harbor:token")).toEqual([
"harbor:token#wood",
"harbor:token#grain",
]);
});
it("expands each entry of a list", () => {
expect(
expandSetupValue(pkg, ["harbor:card#fleet", "harbor:token"]),
).toEqual(["harbor:card#fleet", "harbor:token#wood", "harbor:token#grain"]);
});
});
describe('seedFromSetup', () => {
it('enables listed surfaces and places parts', () => {
describe("seedFromSetup", () => {
it("enables listed surfaces and places parts", () => {
const setup = {
type: 'game',
id: 'main',
surfaces: ['board#harbor'],
setup: [{ path: '/deck', parts: 'harbor:card#fleet' }],
type: "game",
id: "main",
surfaces: ["board#harbor"],
setup: [{ path: "/deck", parts: "harbor:card#fleet" }],
};
const state = seedFromSetup(pkg, setup);
expect(state.surfaces).toEqual({ 'board#harbor': true });
expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, facing: 'face' } });
expect(state.surfaces).toEqual({ "board#harbor": true });
expect(state.parts).toEqual({
"harbor:card#fleet": { path: "/deck", index: 0, facing: "face" },
});
});
it('enables all surfaces when omitted', () => {
const setup = { type: 'game', id: 'main', setup: [] };
it("enables all surfaces when omitted", () => {
const setup = { type: "game", id: "main", setup: [] };
const state = seedFromSetup(pkg, setup);
expect(state.surfaces).toEqual({ 'board#harbor': true, 'hud#hand': true });
expect(state.surfaces).toEqual({ "board#harbor": true, "hud#hand": true });
});
it('applies placements in order, last path wins', () => {
it("applies placements in order, last path wins", () => {
// `harbor:card#fleet` is placed on /deck first, then moved to /hand. Each
// path's indices stay contiguous 0..n-1 so stacking stays valid.
const setup = {
type: 'game',
id: 'main',
type: "game",
id: "main",
setup: [
{ path: '/deck', parts: ['harbor:card#fleet', 'harbor:token#wood'] },
{ path: '/hand', parts: 'harbor:card#fleet' },
{ path: "/deck", parts: ["harbor:card#fleet", "harbor:token#wood"] },
{ path: "/hand", parts: "harbor:card#fleet" },
],
};
const state = seedFromSetup(pkg, setup);
expect(state.parts['harbor:card#fleet']).toEqual({ path: '/hand', index: 0, facing: 'face' });
expect(state.parts['harbor:token#wood']).toEqual({ path: '/deck', index: 0, facing: 'face' });
expect(state.parts["harbor:card#fleet"]).toEqual({
path: "/hand",
index: 0,
facing: "face",
});
expect(state.parts["harbor:token#wood"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
});
it('seeds the facing from the placement, defaulting to face', () => {
it("seeds the facing from the placement, defaulting to face", () => {
const setup = {
type: 'game',
id: 'main',
type: "game",
id: "main",
setup: [
{ path: '/deck', parts: 'harbor:card#fleet', facing: 'back' },
{ path: '/table', parts: 'harbor:token#wood', facing: 'standing' },
{ path: '/hand', parts: 'harbor:token#grain' },
{ path: "/deck", parts: "harbor:card#fleet", facing: "back" as Facing },
{
path: "/table",
parts: "harbor:token#wood",
facing: "standing" as Facing,
},
{ path: "/hand", parts: "harbor:token#grain" },
],
};
const state = seedFromSetup(pkg, setup);
expect(state.parts['harbor:card#fleet']).toEqual({ path: '/deck', index: 0, facing: 'back' });
expect(state.parts['harbor:token#wood']).toEqual({ path: '/table', index: 0, facing: 'standing' });
expect(state.parts["harbor:card#fleet"]).toEqual({
path: "/deck",
index: 0,
facing: "back",
});
expect(state.parts["harbor:token#wood"]).toEqual({
path: "/table",
index: 0,
facing: "standing",
});
// No `facing` on the placement defaults to `face`.
expect(state.parts['harbor:token#grain']).toEqual({ path: '/hand', index: 0, facing: 'face' });
expect(state.parts["harbor:token#grain"]).toEqual({
path: "/hand",
index: 0,
facing: "face",
});
});
});
});
+154 -47
View File
@@ -1,131 +1,238 @@
import { describe, expect, it } from 'vitest';
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
import { describe, expect, it } from "vitest";
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
describe('parsePath', () => {
it('measures a straight line', () => {
const path = parsePath('M 0 0 L 10 0');
describe("parsePath", () => {
it("measures a straight line", () => {
const path = parsePath("M 0 0 L 10 0");
expect(path.length).toBeCloseTo(10);
});
it('measures a cubic curve', () => {
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
it("measures a cubic curve", () => {
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
// Longer than the chord (60) but finite.
expect(path.length).toBeGreaterThan(60);
expect(path.length).toBeLessThan(80);
});
it('handles relative commands', () => {
const path = parsePath('m 0 0 l 10 0 l 0 10');
it("handles relative commands", () => {
const path = parsePath("m 0 0 l 10 0 l 0 10");
expect(path.length).toBeCloseTo(20);
});
it('supports h/v/z', () => {
const path = parsePath('M 0 0 H 10 V 10 Z');
it("supports h/v/z", () => {
const path = parsePath("M 0 0 H 10 V 10 Z");
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
});
it("supports quadratic curves (Q)", () => {
const path = parsePath("M 0 0 Q 50 50 100 0");
// Longer than the chord (100) but finite.
expect(path.length).toBeGreaterThan(100);
expect(path.length).toBeLessThan(120);
// The curve passes through the midpoint of the control point.
const mid = pointAt(path, path.length / 2);
expect(mid.y).toBeGreaterThan(0);
});
it("supports smooth quadratic continuation (T)", () => {
const path = parsePath("M 0 0 Q 50 50 100 0 T 200 0");
// Two quadratic segments; the second reflects the first's control point.
expect(path.length).toBeGreaterThan(200);
});
it("supports smooth cubic continuation (S)", () => {
const path = parsePath("M 0 0 C 25 50 75 50 100 0 S 175 -50 200 0");
expect(path.length).toBeGreaterThan(200);
});
it("supports elliptical arcs (A)", () => {
// A semicircle of radius 50: length ≈ π * 50.
const path = parsePath("M 0 0 A 50 50 0 0 1 100 0");
expect(path.length).toBeCloseTo(Math.PI * 50, 0);
});
it("supports relative variants of each command", () => {
const path = parsePath("m 0 0 q 50 50 100 0 t 100 0");
expect(path.length).toBeGreaterThan(200);
});
it("throws on an unsupported command", () => {
expect(() => parsePath("M 0 0 R 10 10")).toThrow(
/Unsupported SVG path command/,
);
});
});
describe('pointAt', () => {
it('returns the start at distance 0 and end at full length', () => {
const path = parsePath('M 0 0 L 10 0');
describe("pointAt", () => {
it("returns the start at distance 0 and end at full length", () => {
const path = parsePath("M 0 0 L 10 0");
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
const end = pointAt(path, path.length);
expect(end.x).toBeCloseTo(10);
expect(end.y).toBeCloseTo(0);
});
it('interpolates along the path', () => {
const path = parsePath('M 0 0 L 10 0');
it("interpolates along the path", () => {
const path = parsePath("M 0 0 L 10 0");
const mid = pointAt(path, 5);
expect(mid.x).toBeCloseTo(5);
expect(mid.angle).toBeCloseTo(0);
});
it('returns the tangent angle in degrees', () => {
const path = parsePath('M 0 0 L 10 10');
it("returns the tangent angle in degrees", () => {
const path = parsePath("M 0 0 L 10 10");
expect(pointAt(path, 5).angle).toBeCloseTo(45);
const down = parsePath('M 0 0 L 0 10');
const down = parsePath("M 0 0 L 0 10");
expect(pointAt(down, 5).angle).toBeCloseTo(90);
});
it('returns the tangent angle at the start of the path', () => {
const path = parsePath('M 0 0 L 10 10');
it("returns the tangent angle at the start of the path", () => {
const path = parsePath("M 0 0 L 10 10");
expect(pointAt(path, 0).angle).toBeCloseTo(45);
});
it("clamps distance to the path length", () => {
const path = parsePath("M 0 0 L 10 0");
expect(pointAt(path, 999).x).toBeCloseTo(10);
expect(pointAt(path, -5).x).toBeCloseTo(0);
});
it("returns the origin for an empty path", () => {
expect(pointAt({ points: [], length: 0 }, 5)).toEqual({
x: 0,
y: 0,
angle: 0,
});
});
it("returns the single point for a degenerate path", () => {
const path = parsePath("M 5 5");
expect(pointAt(path, 0)).toEqual({ x: 5, y: 5, angle: 0 });
});
});
describe('stackingOffset', () => {
it('defaults to a 1° tilt without a curve', () => {
expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
describe("stackingOffset", () => {
it("defaults to a 1° tilt without a curve", () => {
expect(stackingOffset(undefined, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
});
it('spreads parts evenly along a straight curve', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
it("spreads parts evenly along a straight curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
// step = length / max(steps=1, 2) = 50; part 1 at 50.
expect(offset.x).toBeCloseTo(50);
expect(offset.y).toBeCloseTo(0);
});
it('aligns to center', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
it("aligns to center", () => {
const offset = stackingOffset(
{ curve: "M 0 0 L 100 0", align: "center" },
0,
3,
);
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
expect(offset.x).toBeCloseTo(0);
});
it('aligns to end', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
it("aligns to end", () => {
const offset = stackingOffset(
{ curve: "M 0 0 L 100 0", align: "end" },
2,
3,
);
// start = 100 - 100 = 0; part 2 at 100.
expect(offset.x).toBeCloseTo(100);
});
it('respects a positive limit (first n)', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
it("respects a positive limit (first n)", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: 2 }, 2, 4);
// Part 2 is beyond the first 2 shown -> not placed.
expect(offset).toBe(NO_OFFSET);
});
it('respects a negative limit (last n)', () => {
it("respects a negative limit (last n)", () => {
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: -2 }, 2, 4);
expect(offset.x).toBeCloseTo(0);
});
it('uses steps to densify the curve', () => {
it("uses steps to densify the curve", () => {
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
const offset = stackingOffset({ curve: "M 0 0 L 100 0", steps: 4 }, 1, 3);
expect(offset.x).toBeCloseTo(25);
});
it('tilts every part the same amount without a curve', () => {
it("tilts every part the same amount without a curve", () => {
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
});
it('tilts every part the same amount along the curve', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
it("tilts every part the same amount along the curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
expect(offset.x).toBeCloseTo(50);
expect(offset.tilt).toBeCloseTo(0.1);
});
it('tilts only the shown parts', () => {
it("tilts only the shown parts", () => {
// limit 2 shows indices 0,1; index 2 is dropped.
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET);
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
});
it('ramps z from zStart to zEnd across the curve', () => {
it("ramps z from zStart to zEnd across the curve", () => {
// 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40.
const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3);
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
const first = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
0,
3,
);
const mid = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
1,
3,
);
const last = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
2,
3,
);
expect(first.z).toBeCloseTo(0);
expect(mid.z).toBeCloseTo(20);
expect(last.z).toBeCloseTo(40);
});
it('returns no offset for an empty stack', () => {
it("returns no offset for an empty stack", () => {
expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET);
});
});
it("applies only the default tilt when a curve has zero length", () => {
// A degenerate curve (a single point) has length 0, so no horizontal
// offset applies, but the default 1° tilt still does.
expect(stackingOffset({ curve: "M 5 5" }, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
});
it("places a single part at the start of the curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 0, 1);
// span = max(steps=1, 0) = 1; step = 100; part 0 at 0.
expect(offset.x).toBeCloseTo(0);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Stacking — the format's positioning process (`bgm-format.md` §4).
* Stacking — the format's positioning process (`docs/bgm/format.md` §4).
*
* Given a route's `stacking` strategy and a piece's position in its path's
* stack, compute the offset/rotation to apply. Parts are spread along an SVG
+224 -73
View File
@@ -1,178 +1,329 @@
import { describe, expect, it } from 'vitest';
import type { Package, Surface } from '@tts/bgm';
import { beforeEach, describe, expect, it } from "vitest";
import type { Facing, Package, Surface } from "@tts/bgm";
import {
matchRoute,
childrenByPath,
computeSurfacePlacements,
computeRenderState,
placementKey,
} from './state.js';
useTabletopStore,
} from "./state.js";
function makeSurface(overrides: Partial<Surface> = {}): Surface {
return {
type: 'board',
id: 'harbor',
type: "board",
id: "harbor",
layout: [],
...overrides,
};
}
const pkg: Package = {
meta: { id: 'harbor' },
meta: { id: "harbor" },
parts: new Map(),
surfaces: new Map([
['board#harbor', makeSurface()],
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
["board#harbor", makeSurface()],
["hud#hand", makeSurface({ type: "hud", id: "hand" })],
]),
setups: new Map(),
dialogs: new Map(),
};
describe('matchRoute', () => {
it('matches a literal path', () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined });
expect(matchRoute(route, '/other')).toBeNull();
describe("matchRoute", () => {
it("matches a literal path", () => {
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, "/deck")).toEqual({ candidate: undefined });
expect(matchRoute(route, "/other")).toBeNull();
});
it('matches a :param against a candidate', () => {
it("matches a :param against a candidate", () => {
const route = {
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [
{ seat: '0', x: 40, y: 0 },
{ seat: '1', x: 40, y: 20 },
{ seat: "0", x: 40, y: 0 },
{ seat: "1", x: 40, y: 20 },
],
};
expect(matchRoute(route, '/dock/1')).toEqual({ candidate: { seat: '1', x: 40, y: 20 } });
expect(matchRoute(route, "/dock/1")).toEqual({
candidate: { seat: "1", x: 40, y: 20 },
});
});
it('fails when no candidate matches the param', () => {
it("fails when no candidate matches the param", () => {
const route = {
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [{ seat: '0', x: 40, y: 0 }],
candidates: [{ seat: "0", x: 40, y: 0 }],
};
expect(matchRoute(route, '/dock/9')).toBeNull();
expect(matchRoute(route, "/dock/9")).toBeNull();
});
it('fails on length mismatch', () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck/extra')).toBeNull();
it("fails on length mismatch", () => {
const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, "/deck/extra")).toBeNull();
});
});
describe('childrenByPath', () => {
it('groups parts by path, ordered by index', () => {
const parts = {
'harbor:card#a': { path: '/deck', index: 1, facing: 'face' },
'harbor:card#b': { path: '/deck', index: 0, facing: 'back' },
'harbor:card#c': { path: '/community/0', index: 0, facing: 'standing' },
describe("childrenByPath", () => {
it("groups parts by path, ordered by index", () => {
const parts: Record<
string,
{ path: string; index: number; facing: Facing }
> = {
"harbor:card#a": { path: "/deck", index: 1, facing: "face" },
"harbor:card#b": { path: "/deck", index: 0, facing: "back" },
"harbor:card#c": { path: "/community/0", index: 0, facing: "standing" },
};
expect(childrenByPath(parts)).toEqual({
'/deck': ['harbor:card#b', 'harbor:card#a'],
'/community/0': ['harbor:card#c'],
"/deck": ["harbor:card#b", "harbor:card#a"],
"/community/0": ["harbor:card#c"],
});
});
});
describe('computeSurfacePlacements', () => {
it('places parts on a matching route with index, stackSize, and facing', () => {
describe("computeSurfacePlacements", () => {
it("places parts on a matching route with index, stackSize, and facing", () => {
const surface = makeSurface({
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
layout: [{ route: "/deck", x: -100, y: 0, rotation: 0 }],
});
const placements = computeSurfacePlacements(surface, {
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
'harbor:card#b': { path: '/deck', index: 1, facing: 'back' },
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
"harbor:card#b": { path: "/deck", index: 1, facing: "back" as Facing },
});
expect(placements).toHaveLength(2);
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, facing: 'face' });
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, facing: 'back' });
expect(placements[0]).toMatchObject({
piece: "harbor:card#a",
index: 0,
stackSize: 2,
facing: "face",
});
expect(placements[1]).toMatchObject({
piece: "harbor:card#b",
index: 1,
stackSize: 2,
facing: "back",
});
});
it('drops parts with no matching route', () => {
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
it("drops parts with no matching route", () => {
const surface = makeSurface({
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
});
const placements = computeSurfacePlacements(surface, {
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
'harbor:card#b': { path: '/elsewhere', index: 0, facing: 'face' },
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
"harbor:card#b": {
path: "/elsewhere",
index: 0,
facing: "face" as Facing,
},
});
expect(placements).toHaveLength(1);
expect(placements[0]!.piece).toBe('harbor:card#a');
expect(placements[0]!.piece).toBe("harbor:card#a");
});
it('uses the candidate anchor for a :param route', () => {
it("uses the candidate anchor for a :param route", () => {
const surface = makeSurface({
layout: [
{
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [{ seat: '0', x: 40, y: 5, rotation: 1 }],
candidates: [{ seat: "0", x: 40, y: 5, rotation: 1 }],
},
],
});
const placements = computeSurfacePlacements(surface, {
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
"harbor:boat#fleet": {
path: "/dock/0",
index: 0,
facing: "face" as Facing,
},
});
expect(placements[0]!.candidate).toEqual({
seat: "0",
x: 40,
y: 5,
rotation: 1,
});
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
});
it('keeps the candidate stacking alongside its anchor', () => {
it("keeps the candidate stacking alongside its anchor", () => {
const surface = makeSurface({
layout: [
{
route: '/dock/:seat',
route: "/dock/:seat",
x: 0,
y: 0,
rotation: 0,
candidates: [{ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }],
candidates: [{ seat: "0", x: 40, y: 5, stacking: { tilt: 2 } }],
},
],
});
const placements = computeSurfacePlacements(surface, {
'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' },
"harbor:boat#fleet": {
path: "/dock/0",
index: 0,
facing: "face" as Facing,
},
});
expect(placements[0]!.candidate).toEqual({
seat: "0",
x: 40,
y: 5,
stacking: { tilt: 2 },
});
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
});
it('orders a path by index regardless of insertion order', () => {
it("orders a path by index regardless of insertion order", () => {
const surface = makeSurface({
layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }],
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
});
const placements = computeSurfacePlacements(surface, {
'harbor:card#b': { path: '/deck', index: 1, facing: 'face' },
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' },
"harbor:card#b": { path: "/deck", index: 1, facing: "face" as Facing },
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
});
expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']);
expect(placements.map((p) => p.piece)).toEqual([
"harbor:card#a",
"harbor:card#b",
]);
});
});
describe('computeRenderState', () => {
it('only includes enabled surfaces', () => {
describe("computeRenderState", () => {
it("only includes enabled surfaces", () => {
const state = {
surfaces: { 'board#harbor': true, 'hud#hand': false },
parts: { 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' } },
surfaces: { "board#harbor": true, "hud#hand": false },
parts: {
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
},
};
pkg.surfaces.set(
'board#harbor',
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
"board#harbor",
makeSurface({ layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }] }),
);
const placements = computeRenderState(pkg, state);
expect(placements).toHaveLength(1);
expect(placements[0]!.surface).toBe('board#harbor');
expect(placements[0]!.surface).toBe("board#harbor");
});
});
describe('placementKey', () => {
it('is unique per surface and piece', () => {
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never;
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never;
const c = { surface: 'hud#hand', piece: 'harbor:card#a' } as never;
describe("useTabletopStore", () => {
beforeEach(() => {
useTabletopStore.setState({ surfaces: {}, parts: {} });
});
it("seeds surfaces and parts", () => {
useTabletopStore
.getState()
.seed({ surfaces: { "board#harbor": true }, parts: {} });
expect(useTabletopStore.getState().surfaces).toEqual({
"board#harbor": true,
});
});
it("enables and disables a surface", () => {
useTabletopStore.getState().enableSurface("board#harbor");
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(true);
useTabletopStore.getState().disableSurface("board#harbor");
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(false);
});
it("setPart patches an existing part and ignores an unknown id", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
});
useTabletopStore.getState().setPart("harbor:card#a", { facing: "back" });
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 0,
facing: "back",
});
useTabletopStore.getState().setPart("harbor:card#nope", { facing: "back" });
expect(
useTabletopStore.getState().parts["harbor:card#nope"],
).toBeUndefined();
});
it("movePart reindexes source and destination, inserting at index", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
"harbor:card#c": { path: "/deck", index: 2, facing: "face" },
});
// Move the top card to the bottom (index 0).
useTabletopStore.getState().movePart("harbor:card#c", "/deck", 0);
const parts = useTabletopStore.getState().parts;
expect(parts["harbor:card#c"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
expect(parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 1,
facing: "face",
});
expect(parts["harbor:card#b"]).toEqual({
path: "/deck",
index: 2,
facing: "face",
});
});
it("movePart moves between paths, closing the source gap", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
"harbor:card#c": { path: "/discard", index: 0, facing: "face" },
});
useTabletopStore.getState().movePart("harbor:card#a", "/discard", 0);
const parts = useTabletopStore.getState().parts;
expect(parts["harbor:card#a"]).toEqual({
path: "/discard",
index: 0,
facing: "face",
});
expect(parts["harbor:card#c"]).toEqual({
path: "/discard",
index: 1,
facing: "face",
});
expect(parts["harbor:card#b"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
});
it("movePart clamps index and ignores an unknown id", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
});
useTabletopStore.getState().movePart("harbor:card#a", "/deck", 99);
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
useTabletopStore.getState().movePart("harbor:card#nope", "/deck", 0);
expect(
useTabletopStore.getState().parts["harbor:card#nope"],
).toBeUndefined();
});
});
describe("placementKey", () => {
it("is unique per surface and piece", () => {
const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;
const b = { surface: "board#harbor", piece: "harbor:card#b" } as never;
const c = { surface: "hud#hand", piece: "harbor:card#a" } as never;
expect(placementKey(a)).not.toBe(placementKey(b));
expect(placementKey(a)).not.toBe(placementKey(c));
});
});
});
+144 -6
View File
@@ -8,12 +8,15 @@ importers:
.:
devDependencies:
'@vitest/coverage-v8':
specifier: ^4.1.10
version: 4.1.10(vitest@4.1.10)
typescript:
specifier: ^5.7.2
version: 5.9.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
apps/proxy:
dependencies:
@@ -147,7 +150,7 @@ importers:
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/bgm:
dependencies:
@@ -184,7 +187,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/engine:
devDependencies:
@@ -216,7 +219,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/mesh:
dependencies:
@@ -294,7 +297,7 @@ importers:
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/tts:
dependencies:
@@ -314,10 +317,31 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
@@ -999,6 +1023,15 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vitest/coverage-v8@4.1.10':
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
peerDependencies:
'@vitest/browser': 4.1.10
vitest: 4.1.10
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.10':
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
@@ -1032,6 +1065,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.5:
resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1142,6 +1178,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hls.js@1.6.17:
resolution: {integrity: sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==}
@@ -1149,6 +1189,9 @@ packages:
resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1164,6 +1207,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
its-fine@2.0.0:
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
peerDependencies:
@@ -1173,6 +1228,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -1339,6 +1397,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.4:
resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -1497,6 +1562,10 @@ packages:
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
suspend-react@0.1.3:
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
peerDependencies:
@@ -1740,8 +1809,23 @@ snapshots:
package-manager-detector: 1.8.0
tinyexec: 1.3.0
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/runtime@7.29.7': {}
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@bcoe/v8-coverage@1.0.2': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/runtime@1.11.3':
@@ -2224,6 +2308,20 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
'@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.10
ast-v8-to-istanbul: 1.0.5
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.4
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
'@vitest/expect@4.1.10':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2267,6 +2365,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.5:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
base64-js@1.5.1: {}
bidi-js@1.0.3:
@@ -2375,10 +2479,14 @@ snapshots:
graceful-fs@4.2.11: {}
has-flag@4.0.0: {}
hls.js@1.6.17: {}
hono@4.13.1: {}
html-escaper@2.0.2: {}
ieee754@1.2.1: {}
immediate@3.0.6: {}
@@ -2389,6 +2497,19 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
its-fine@2.0.0(@types/react@19.2.18)(react@19.2.8):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.18)
@@ -2398,6 +2519,8 @@ snapshots:
jiti@2.7.0: {}
js-tokens@10.0.0: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -2514,6 +2637,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.4:
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.8.5
marked@16.4.2: {}
meshline@3.3.1(three@0.185.1):
@@ -2669,6 +2802,10 @@ snapshots:
std-env@4.2.0: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
suspend-react@0.1.3(react@19.2.8):
dependencies:
react: 19.2.8
@@ -2768,7 +2905,7 @@ snapshots:
tsx: 4.23.11
yaml: 2.9.0
vitest@4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)):
vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
@@ -2792,6 +2929,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
transitivePeerDependencies:
- msw