Compare commits
10
Commits
498cf1b633
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf6785f9e | ||
|
|
d0e4269ad6 | ||
|
|
999b7a0771 | ||
|
|
8eff44712f | ||
|
|
3813287489 | ||
|
|
71ae91960f | ||
|
|
addfc03ab6 | ||
|
|
211c151971 | ||
|
|
345832e389 | ||
|
|
cf8ca07850 |
+2
-1
@@ -1,8 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated TTS save dumps (scripts/dump-save.mjs)
|
||||
scripts/dumps/
|
||||
scripts/dumps/
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const objects = useMemo(
|
||||
() => (mod ? flattenObjects(mod) : []),
|
||||
() => (mod ? flattenObjects(mod.mod) : []),
|
||||
[mod],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 | — |
|
||||
@@ -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 1–8 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
@@ -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
|
||||
|
||||
@@ -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 1–8 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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+144
-6
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user