diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md
index ff761b6..b0ed9f9 100644
--- a/docs/bgm-tabletop-plan.md
+++ b/docs/bgm-tabletop-plan.md
@@ -3,7 +3,8 @@
> **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:** planning — no code yet.
+> **Status:** items 1–8 implemented; web part-inspection route renders `PartView`
+> from the library. Remaining: wiring the full tabletop scene into a web route.
## Goal
@@ -42,7 +43,7 @@ packages/tabletop/
## Work items
-### 1. Package scaffold
+### 1. Package scaffold ✅
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
globs `packages/*`).
@@ -51,7 +52,7 @@ packages/tabletop/
`@types/three`.
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
-### 2. Part meshes + export + web integration
+### 2. Part meshes + export + web integration ✅
First deliverable: `PartView` renders a single part's mesh from its definition,
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
@@ -71,7 +72,7 @@ useful slice and unblocks the web app's part inspection route immediately.
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
it end-to-end.
-### 3. State store (`state.ts`)
+### 3. State store (`state.ts`) ✅
Source-of-truth game state per `bgm-tabletop.md` §2:
@@ -89,7 +90,7 @@ interface GameState {
- **Assumption**: each piece id is unique within a path (documented in
`bgm-tabletop.md`); the render map is keyed by piece id.
-### 4. Setup seeding (`setup.ts`)
+### 4. Setup seeding (`setup.ts`) ✅
- `SetupLoader`: side-effect-only component that seeds the store from a
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
@@ -98,7 +99,7 @@ interface GameState {
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`)
+### 5. Surface mounting (`mount.ts`) ✅
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
- `kind: table` — root, world space.
@@ -108,13 +109,13 @@ interface GameState {
surface isn't rendered. Child surfaces mount relative to their parent's
anchor (`x`/`y`/`rotation`).
-### 6. Part placement (`placement.ts`)
+### 6. Part placement (`placement.ts`) ✅
- `PartPlacement`: stable per-part component that positions a part on a surface
location from the derived render state (route anchor + candidate anchor).
- Applies the route's stacking strategy via `useStacking`.
-### 7. Stacking (`stacking.ts`)
+### 7. Stacking (`stacking.ts`) ✅
- `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`.
- Implements the format's positioning process (`bgm-format.md` §4): step
@@ -122,7 +123,7 @@ interface GameState {
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
- Curve length from an SVG path string (small helper; no new dep).
-### 8. Public API (`index.ts`)
+### 8. Public API (`index.ts`) ✅
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
diff --git a/packages/tabletop/package.json b/packages/tabletop/package.json
index a538e14..04f739c 100644
--- a/packages/tabletop/package.json
+++ b/packages/tabletop/package.json
@@ -29,9 +29,11 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@types/node": "^22.12.0",
"@types/react": "^19.2.18",
"@types/three": "^0.185.4",
"typescript": "^5.7.2",
+ "vite": "^8.2.1",
"vitest": "^4.1.10"
}
}
\ No newline at end of file
diff --git a/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md b/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md
new file mode 100644
index 0000000..65a38a3
--- /dev/null
+++ b/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md
@@ -0,0 +1,41 @@
+# Harbor
+
+A tiny example game used to exercise the tabletop library end-to-end through a
+real vite build.
+
+```yaml file=harbor.yaml
+role: package
+id: harbor
+title: Harbor
+include: ['**/harbor/**/*.yaml']
+```
+
+```yaml file=parts/tokens.yaml
+role: part
+type: token
+id: wood
+size: [20, 20, 3]
+```
+
+```yaml file=parts/board.yaml
+type: board
+id: harbor
+role: surface
+size: [300, 200]
+layout:
+ - route: /deck
+ x: -100
+ y: 0
+ rotation: 0
+ stacking:
+ curve: M 0 0 L 100 0
+ align: center
+```
+
+```yaml file=setup/main.yaml
+role: setup
+type: game
+id: main
+setup:
+ /deck: harbor:token
+```
\ No newline at end of file
diff --git a/packages/tabletop/src/__fixtures__/vite-build/index.html b/packages/tabletop/src/__fixtures__/vite-build/index.html
new file mode 100644
index 0000000..1cff2ce
--- /dev/null
+++ b/packages/tabletop/src/__fixtures__/vite-build/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ tabletop build fixture
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/tabletop/src/__fixtures__/vite-build/src/main.ts b/packages/tabletop/src/__fixtures__/vite-build/src/main.ts
new file mode 100644
index 0000000..54c6709
--- /dev/null
+++ b/packages/tabletop/src/__fixtures__/vite-build/src/main.ts
@@ -0,0 +1,34 @@
+import harbor from 'virtual:bgm/package/harbor';
+import type { SerializedPackage } from '@tts/bgm';
+import { seedFromSetup, expandSetupValue } from '@tts/tabletop';
+import { computeRenderState, matchRoute } from '@tts/tabletop';
+import { stackingOffset } from '@tts/tabletop';
+import { resolveMountTree } from '@tts/tabletop';
+
+// Re-export the results so the test can assert the bundled output.
+const pkg = harbor as unknown as SerializedPackage;
+
+// The setup seeds the store; a bare type expands to all parts of that type.
+const setup = pkg.setups['game#main']!;
+const seeded = seedFromSetup(pkg as never, setup);
+
+// A part with a matching route is placed.
+const placements = computeRenderState(pkg as never, seeded);
+
+// Stacking spreads parts along a curve.
+const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 1, 3);
+
+// Mount resolution separates world and hud surfaces.
+const tree = resolveMountTree(
+ new Map(Object.entries(pkg.surfaces)),
+ new Set(Object.keys(seeded.surfaces)),
+);
+
+export const seededPaths = Object.keys(seeded.paths);
+export const expanded = expandSetupValue(pkg as never, 'harbor:token');
+export const placementCount = placements.length;
+export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
+export const offsetX = offset.x;
+export const worldCount = tree.world.length;
+
+console.log(seededPaths, expanded, placementCount, matched, offsetX, worldCount);
\ No newline at end of file
diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts
index dee3a27..b389b8a 100644
--- a/packages/tabletop/src/index.ts
+++ b/packages/tabletop/src/index.ts
@@ -9,4 +9,31 @@ export {
traceToShape,
traceToUvBounds,
} from './part.js';
-export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
\ No newline at end of file
+export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
+
+// State store + derived render state.
+export {
+ useTabletopStore,
+ useRenderState,
+ matchRoute,
+ computeRenderState,
+ computeSurfacePlacements,
+ placementKey,
+} from './state.js';
+export type { GameState, Placement } from './state.js';
+
+// Setup seeding.
+export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
+
+// Surface mounting.
+export { resolveMountTree } from './mount.js';
+export type { MountNode, MountTree } from './mount.js';
+export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js';
+export { HudSurfaceView } from './surfaces/HudSurfaceView.js';
+
+// Part placement.
+export { PartPlacement } from './placement.js';
+
+// Stacking.
+export { useStacking, stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
+export type { StackOffset } from './stacking.js';
\ No newline at end of file
diff --git a/packages/tabletop/src/mount.test.ts b/packages/tabletop/src/mount.test.ts
new file mode 100644
index 0000000..f64e3dd
--- /dev/null
+++ b/packages/tabletop/src/mount.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import type { Surface } from '@tts/bgm';
+import { resolveMountTree } from './mount.js';
+
+function surface(type: string, id: string, overrides: Partial = {}): [string, Surface] {
+ return [`${type}#${id}`, { type, id, layout: [], ...overrides }];
+}
+
+describe('resolveMountTree', () => {
+ it('separates world and hud roots', () => {
+ const surfaces = new Map([
+ surface('board', 'harbor', { mount: { kind: 'table' } }),
+ surface('hud', 'hand', { mount: { kind: 'hud', area: 'bottom-left' } }),
+ ]);
+ const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'hud#hand']));
+ expect(tree.world).toHaveLength(1);
+ expect(tree.hud).toHaveLength(1);
+ expect(tree.hud[0]!.area).toBe('bottom-left');
+ });
+
+ it('attaches child surfaces to their parent', () => {
+ const surfaces = new Map([
+ surface('board', 'harbor', {
+ mount: { kind: 'table' },
+ children: ['board#player'],
+ }),
+ surface('board', 'player', { mount: { kind: 'child', x: 100, y: 50 } }),
+ ]);
+ const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'board#player']));
+ expect(tree.world).toHaveLength(1);
+ expect(tree.world[0]!.children).toHaveLength(1);
+ expect(tree.world[0]!.children[0]!.id).toBe('board#player');
+ expect(tree.world[0]!.children[0]!.x).toBe(100);
+ });
+
+ it('drops a child with no matching parent', () => {
+ const surfaces = new Map([
+ surface('board', 'player', { mount: { kind: 'child' } }),
+ ]);
+ const tree = resolveMountTree(surfaces, new Set(['board#player']));
+ expect(tree.world).toHaveLength(0);
+ expect(tree.hud).toHaveLength(0);
+ });
+
+ it('excludes disabled surfaces', () => {
+ const surfaces = new Map([
+ surface('board', 'harbor', { mount: { kind: 'table' } }),
+ surface('board', 'other', { mount: { kind: 'table' } }),
+ ]);
+ const tree = resolveMountTree(surfaces, new Set(['board#harbor']));
+ expect(tree.world).toHaveLength(1);
+ expect(tree.world[0]!.id).toBe('board#harbor');
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/mount.ts b/packages/tabletop/src/mount.ts
new file mode 100644
index 0000000..1d5cc4d
--- /dev/null
+++ b/packages/tabletop/src/mount.ts
@@ -0,0 +1,94 @@
+/**
+ * Surface mounting — resolve the surface mount tree from `Surface.mount` +
+ * `Surface.children` (`bgm-tabletop.md` §5).
+ *
+ * - `kind: table` — root, world space.
+ * - `kind: hud` — HUD area (`mount.area`).
+ * - `kind: child` — mounted relative to a parent surface that lists it in
+ * `children`.
+ *
+ * A child surface's own `mount.x`/`y`/`rotation` is relative to its parent's
+ * anchor. The tree is built from the enabled surfaces only.
+ */
+import type { Surface } from '@tts/bgm';
+
+/** A node in the resolved mount tree. */
+export interface MountNode {
+ /** The surface. */
+ surface: Surface;
+ /** The surface id (`type#id`). */
+ id: string;
+ /** The mount kind. */
+ kind: 'table' | 'hud' | 'child';
+ /** Anchor x/y/rotation (world for table, relative for child, HUD area for hud). */
+ x: number;
+ y: number;
+ rotation: number;
+ /** HUD area, when `kind: hud`. */
+ area?: string;
+ /** Child surfaces mounted relative to this one. */
+ children: MountNode[];
+}
+
+/** The resolved mount tree: world roots, HUD roots, and their children. */
+export interface MountTree {
+ /** Surfaces mounted in world space (`kind: table`). */
+ world: MountNode[];
+ /** Surfaces mounted to a HUD area (`kind: hud`). */
+ hud: MountNode[];
+}
+
+/**
+ * Resolve the mount tree for the given surfaces. A `child` surface is attached
+ * to the first parent that lists it in `children`; a child with no matching
+ * parent is dropped. Surfaces not in `enabled` are excluded.
+ */
+export function resolveMountTree(
+ surfaces: Map,
+ enabled: Set,
+): MountTree {
+ const nodes = new Map();
+ for (const [id, surface] of surfaces) {
+ if (!enabled.has(id)) continue;
+ const mount = surface.mount ?? { kind: 'table' };
+ nodes.set(id, {
+ surface,
+ id,
+ kind: mount.kind,
+ x: mount.x ?? 0,
+ y: mount.y ?? 0,
+ rotation: mount.rotation ?? 0,
+ area: mount.area,
+ children: [],
+ });
+ }
+
+ const world: MountNode[] = [];
+ const hud: MountNode[] = [];
+ const attached = new Set();
+
+ // Attach children to their parents first.
+ for (const node of nodes.values()) {
+ if (node.kind !== 'child') continue;
+ const parent = findParent(nodes, node.id);
+ if (!parent) continue;
+ parent.children.push(node);
+ attached.add(node.id);
+ }
+
+ // Roots are world/hud surfaces; unattached children are dropped.
+ for (const node of nodes.values()) {
+ if (node.kind === 'table') world.push(node);
+ else if (node.kind === 'hud') hud.push(node);
+ // A child that wasn't attached to a parent is not rendered.
+ }
+ return { world, hud };
+}
+
+/** Find the parent surface that lists `childId` in its `children`. */
+function findParent(nodes: Map, childId: string): MountNode | undefined {
+ for (const node of nodes.values()) {
+ if (node.surface.children?.includes(childId)) return node;
+ }
+ return undefined;
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx
new file mode 100644
index 0000000..18e0742
--- /dev/null
+++ b/packages/tabletop/src/placement.tsx
@@ -0,0 +1,30 @@
+/**
+ * `PartPlacement` — position a part on a surface location.
+ *
+ * A stable per-part component that places a part at its route's anchor (plus
+ * the candidate's anchor when there is one) and applies the route's stacking
+ * strategy via `useStacking`. Renders the part's mesh with `PartView`.
+ */
+import type { Package } from '@tts/bgm';
+import { useStacking } from './stacking.js';
+import type { Placement } from './state.js';
+import { PartView } from './partView.js';
+
+export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
+ const { route, candidate, piece, index, stackSize } = placement;
+ // `piece` is `package:type#id`; the parts map is keyed by `type#id`.
+ const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
+ if (!part) return null;
+
+ const { x, y, rotation } = useStacking(route.stacking, index, stackSize);
+
+ const anchorX = (candidate?.x ?? route.x ?? 0) + x;
+ const anchorY = (candidate?.y ?? route.y ?? 0) + y;
+ const anchorRotation = (candidate?.rotation ?? route.rotation ?? 0) + rotation;
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/setup.test.ts b/packages/tabletop/src/setup.test.ts
new file mode 100644
index 0000000..e4777e8
--- /dev/null
+++ b/packages/tabletop/src/setup.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import type { Package } from '@tts/bgm';
+import { expandSetupValue, seedFromSetup } from './setup.js';
+
+const pkg: Package = {
+ meta: { id: 'harbor' },
+ parts: new Map([
+ ['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: [] }],
+ ]),
+ setups: 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('seedFromSetup', () => {
+ it('enables listed surfaces and places parts', () => {
+ const setup = {
+ type: 'game',
+ id: 'main',
+ surfaces: ['board#harbor'],
+ setup: { '/deck': 'harbor:card#fleet' },
+ };
+ const state = seedFromSetup(pkg, setup);
+ expect(state.surfaces).toEqual({ 'board#harbor': true });
+ expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
+ });
+
+ 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 });
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/setup.ts b/packages/tabletop/src/setup.ts
new file mode 100644
index 0000000..a9a1814
--- /dev/null
+++ b/packages/tabletop/src/setup.ts
@@ -0,0 +1,68 @@
+/**
+ * `SetupLoader` — seed the game state from a `Setup`.
+ *
+ * A side-effect-only component: it enables the setup's `surfaces` (or all when
+ * omitted) and places parts on the setup's paths. A setup value that is a bare
+ * `type` (no id) expands to all parts of that type during initialization.
+ */
+import { useEffect } from 'react';
+import type { Package, Setup } from '@tts/bgm';
+import { useTabletopStore } from './state.js';
+
+/**
+ * Expand a setup value into a list of part ids (`package:type#id`). A bare
+ * `type` (no `#id`) expands to every part of that type in the package.
+ */
+export function expandSetupValue(
+ pkg: Package,
+ value: string | string[],
+): string[] {
+ const values = Array.isArray(value) ? value : [value];
+ const out: string[] = [];
+ for (const v of values) {
+ // A full ref is `package:type#id`; a bare type is `package:type` (no id).
+ const [ref, id] = v.split('#');
+ if (id) {
+ out.push(v);
+ continue;
+ }
+ // Bare type: expand to all parts of that type, in package order.
+ const [pkgId, bareType] = ref?.split(':') ?? [];
+ if (pkgId !== pkg.meta.id) continue;
+ for (const [key, part] of pkg.parts) {
+ if (part.type === bareType) out.push(`${pkg.meta.id}:${key}`);
+ }
+ }
+ return out;
+}
+
+/** Seed the store from a setup. Returns the resulting game state. */
+export function seedFromSetup(pkg: Package, setup: Setup): {
+ surfaces: Record;
+ paths: Record;
+} {
+ const surfaces: Record = {};
+ if (setup.surfaces) {
+ for (const id of setup.surfaces) surfaces[id] = true;
+ } else {
+ for (const key of pkg.surfaces.keys()) surfaces[key] = true;
+ }
+
+ const paths: Record = {};
+ for (const [path, value] of Object.entries(setup.setup)) {
+ paths[path] = expandSetupValue(pkg, value);
+ }
+ return { surfaces, paths };
+}
+
+/**
+ * Side-effect-only: seeds the store from `setup` on mount (and whenever the
+ * setup changes). Renders nothing.
+ */
+export function SetupLoader({ pkg, setup }: { pkg: Package; setup: Setup }) {
+ const seed = useTabletopStore((s) => s.seed);
+ useEffect(() => {
+ seed(seedFromSetup(pkg, setup));
+ }, [pkg, setup, seed]);
+ return null;
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/stacking.test.ts b/packages/tabletop/src/stacking.test.ts
new file mode 100644
index 0000000..94946b8
--- /dev/null
+++ b/packages/tabletop/src/stacking.test.ts
@@ -0,0 +1,88 @@
+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');
+ expect(path.length).toBeCloseTo(10);
+ });
+
+ 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');
+ expect(path.length).toBeCloseTo(20);
+ });
+
+ 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));
+ });
+});
+
+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');
+ const mid = pointAt(path, 5);
+ expect(mid.x).toBeCloseTo(5);
+ expect(mid.angle).toBeCloseTo(0);
+ });
+});
+
+describe('stackingOffset', () => {
+ it('returns no offset without a curve', () => {
+ expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET);
+ expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET);
+ });
+
+ 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);
+ // 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);
+ // 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);
+ // Part 2 is beyond the first 2 shown -> not placed.
+ expect(offset).toBe(NO_OFFSET);
+ });
+
+ 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);
+ expect(offset.x).toBeCloseTo(0);
+ });
+
+ 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);
+ expect(offset.x).toBeCloseTo(25);
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/stacking.ts b/packages/tabletop/src/stacking.ts
new file mode 100644
index 0000000..f517464
--- /dev/null
+++ b/packages/tabletop/src/stacking.ts
@@ -0,0 +1,370 @@
+/**
+ * Stacking — the format's positioning process (`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
+ * `curve` relative to the route's anchor, `step length` apart, aligned per the
+ * strategy.
+ */
+import { useMemo } from 'react';
+import type { Stacking } from '@tts/bgm';
+
+export interface StackOffset {
+ /** x offset from the anchor. */
+ x: number;
+ /** y offset from the anchor. */
+ y: number;
+ /** Rotation in radians. */
+ rotation: number;
+}
+
+/** The identity offset: no stacking applied. */
+export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0 };
+
+/**
+ * Compute the offset/rotation for the piece at `index` of a `stackSize`-piece
+ * stack, given the route's stacking strategy. Returns `NO_OFFSET` when there's
+ * no curve or the stack is empty.
+ */
+export function stackingOffset(
+ stacking: Stacking | undefined,
+ index: number,
+ stackSize: number,
+): StackOffset {
+ if (!stacking?.curve || stackSize <= 0) return NO_OFFSET;
+
+ // `limit` selects which pieces are shown; the offset is computed over the
+ // shown span. `0` (or absent) shows all.
+ const shown = applyLimit(stacking.limit, stackSize);
+ const shownIndex = shown.indexOf(index);
+ if (shownIndex < 0) return NO_OFFSET;
+
+ const curve = parsePath(stacking.curve);
+ const length = curve.length;
+ if (length <= 0) return NO_OFFSET;
+
+ // Step length: curve length / max(steps, # parts − 1). A single part sits
+ // at the start of the curve.
+ const steps = stacking.steps ?? 1;
+ const span = Math.max(steps, shown.length - 1);
+ const step = length / span;
+
+ // Alignment: how far the whole span is inset from the curve's start.
+ const spanLength = step * (shown.length - 1);
+ let start = 0;
+ if (stacking.align === 'end') start = length - spanLength;
+ else if (stacking.align === 'center') start = (length - spanLength) / 2;
+
+ const distance = start + shownIndex * step;
+ const { x, y, angle } = pointAt(curve, distance);
+ return { x, y, rotation: angle };
+}
+
+/** The stacking hook: memoized `stackingOffset` for a piece. */
+export function useStacking(
+ stacking: Stacking | undefined,
+ index: number,
+ stackSize: number,
+): StackOffset {
+ return useMemo(() => stackingOffset(stacking, index, stackSize), [stacking, index, stackSize]);
+}
+
+/** Apply a stacking `limit` to a stack size, returning the shown indices. */
+function applyLimit(limit: number | undefined, stackSize: number): number[] {
+ const indices = Array.from({ length: stackSize }, (_, i) => i);
+ if (!limit || limit === 0) return indices;
+ if (limit > 0) return indices.slice(0, limit);
+ return indices.slice(limit);
+}
+
+// --- SVG path sampling ---
+
+/** A sampled point along a path. */
+interface PathPoint {
+ x: number;
+ y: number;
+ /** Cumulative arc length from the path start. */
+ t: number;
+}
+
+/** A parsed path: a dense polyline approximation with cumulative lengths. */
+interface SampledPath {
+ points: PathPoint[];
+ length: number;
+}
+
+/**
+ * Parse an SVG path `d` string into a dense polyline approximation. Supports
+ * the common commands (M/L/H/V/C/S/Q/T/A/Z, absolute and relative). This is a
+ * small, dependency-free helper for curve length and point-at-distance.
+ */
+export function parsePath(d: string): SampledPath {
+ const tokens = tokenize(d);
+ const points: PathPoint[] = [];
+ let cx = 0;
+ let cy = 0;
+ let startX = 0;
+ let startY = 0;
+ let i = 0;
+ let cmd = 'M';
+
+ const push = (x: number, y: number) => {
+ cx = x;
+ cy = y;
+ points.push({ x, y, t: 0 });
+ };
+
+ const rel = (v: number, base: number) => (cmd === cmd.toLowerCase() ? base + v : v);
+
+ while (i < tokens.length) {
+ const tok = tokens[i]!;
+ if (/[a-zA-Z]/.test(tok)) {
+ cmd = tok;
+ i++;
+ // `Z` closes the path and takes no arguments; handle it immediately.
+ if (cmd.toUpperCase() === 'Z') {
+ push(startX, startY);
+ continue;
+ }
+ continue;
+ }
+ const num = () => {
+ const v = parseFloat(tokens[i]!);
+ i++;
+ return v;
+ };
+
+ switch (cmd.toUpperCase()) {
+ case 'M': {
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ push(x, y);
+ startX = x;
+ startY = y;
+ break;
+ }
+ case 'L': {
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ push(x, y);
+ break;
+ }
+ case 'H': {
+ const x = rel(num(), cx);
+ push(x, cy);
+ break;
+ }
+ case 'V': {
+ const y = rel(num(), cy);
+ push(cx, y);
+ break;
+ }
+ case 'C': {
+ const x1 = rel(num(), cx);
+ const y1 = rel(num(), cy);
+ const x2 = rel(num(), cx);
+ const y2 = rel(num(), cy);
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y);
+ push(x, y);
+ break;
+ }
+ case 'S': {
+ // Reflect the previous control point; without one, use the current point.
+ const prev = points[points.length - 2];
+ const x1 = prev ? 2 * cx - prev.x : cx;
+ const y1 = prev ? 2 * cy - prev.y : cy;
+ const x2 = rel(num(), cx);
+ const y2 = rel(num(), cy);
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y);
+ push(x, y);
+ break;
+ }
+ case 'Q': {
+ const x1 = rel(num(), cx);
+ const y1 = rel(num(), cy);
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ sampleQuadratic(points, cx, cy, x1, y1, x, y);
+ push(x, y);
+ break;
+ }
+ case 'T': {
+ const prev = points[points.length - 2];
+ const x1 = prev ? 2 * cx - prev.x : cx;
+ const y1 = prev ? 2 * cy - prev.y : cy;
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ sampleQuadratic(points, cx, cy, x1, y1, x, y);
+ push(x, y);
+ break;
+ }
+ case 'A': {
+ const rx = Math.abs(num());
+ const ry = Math.abs(num());
+ const rot = (num() * Math.PI) / 180;
+ const largeArc = num() !== 0;
+ const sweep = num() !== 0;
+ const x = rel(num(), cx);
+ const y = rel(num(), cy);
+ sampleArc(points, cx, cy, rx, ry, rot, largeArc, sweep, x, y);
+ push(x, y);
+ break;
+ }
+ default:
+ throw new Error(`Unsupported SVG path command: ${cmd}`);
+ }
+ }
+
+ // Compute cumulative arc length.
+ let t = 0;
+ for (let k = 1; k < points.length; k++) {
+ const a = points[k - 1]!;
+ const b = points[k]!;
+ t += Math.hypot(b.x - a.x, b.y - a.y);
+ b.t = t;
+ }
+ return { points, length: t };
+}
+
+/** Split a path `d` string into command letters and numbers. */
+function tokenize(d: string): string[] {
+ const out: string[] = [];
+ const re = /([a-zA-Z])|(-?\d*\.?\d+(?:[eE][+-]?\d+)?)/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(d)) !== null) {
+ out.push(m[1] ?? m[2]!);
+ }
+ return out;
+}
+
+/** Sample a cubic Bezier into the point list (excluding the endpoint). */
+function sampleCubic(
+ points: PathPoint[],
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ x3: number,
+ y3: number,
+) {
+ for (let s = 1; s < SEGMENTS; s++) {
+ const u = s / SEGMENTS;
+ const v = 1 - u;
+ const x =
+ v * v * v * x0 + 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u * x3;
+ const y =
+ v * v * v * y0 + 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u * y3;
+ points.push({ x, y, t: 0 });
+ }
+}
+
+/** Sample a quadratic Bezier into the point list (excluding the endpoint). */
+function sampleQuadratic(
+ points: PathPoint[],
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+) {
+ for (let s = 1; s < SEGMENTS; s++) {
+ const u = s / SEGMENTS;
+ const v = 1 - u;
+ const x = v * v * x0 + 2 * v * u * x1 + u * u * x2;
+ const y = v * v * y0 + 2 * v * u * y1 + u * u * y2;
+ points.push({ x, y, t: 0 });
+ }
+}
+
+/** Sample an elliptical arc into the point list (excluding the endpoint). */
+function sampleArc(
+ points: PathPoint[],
+ x0: number,
+ y0: number,
+ rx: number,
+ ry: number,
+ rot: number,
+ largeArc: boolean,
+ sweep: boolean,
+ x1: number,
+ y1: number,
+) {
+ // Convert endpoint parameterization to center parameterization.
+ const dx = (x0 - x1) / 2;
+ const dy = (y0 - y1) / 2;
+ const cos = Math.cos(rot);
+ const sin = Math.sin(rot);
+ const px = cos * dx + sin * dy;
+ const py = -sin * dx + cos * dy;
+ const rx2 = rx * rx;
+ const ry2 = ry * ry;
+ const px2 = px * px;
+ const py2 = py * py;
+ const radicand = Math.max(0, (rx2 * ry2 - rx2 * py2 - ry2 * px2) / (rx2 * py2 + ry2 * px2));
+ const sign = largeArc !== sweep ? 1 : -1;
+ const factor = sign * Math.sqrt(radicand);
+ const cx = (factor * (rx * py)) / ry;
+ const cy = (factor * (-ry * px)) / rx;
+ const cxp = cx * cos - cy * sin + (x0 + x1) / 2;
+ const cyp = cx * sin + cy * cos + (y0 + y1) / 2;
+
+ const angle = (ux: number, uy: number, vx: number, vy: number) => {
+ const dot = ux * vx + uy * vy;
+ const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
+ let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
+ if (ux * vy - uy * vx < 0) a = -a;
+ return a;
+ };
+
+ const ux = (px - cx) / rx;
+ const uy = (py - cy) / ry;
+ const vx = (-px - cx) / rx;
+ const vy = (-py - cy) / ry;
+ let theta1 = angle(1, 0, ux, uy);
+ let dtheta = angle(ux, uy, vx, vy);
+ if (!sweep && dtheta > 0) dtheta -= Math.PI * 2;
+ else if (sweep && dtheta < 0) dtheta += Math.PI * 2;
+
+ for (let s = 1; s < SEGMENTS; s++) {
+ const a = theta1 + (s / SEGMENTS) * dtheta;
+ const cosA = Math.cos(a);
+ const sinA = Math.sin(a);
+ const x = cxp + rx * cosA * cos - ry * sinA * sin;
+ const y = cyp + rx * cosA * sin + ry * sinA * cos;
+ points.push({ x, y, t: 0 });
+ }
+}
+
+/** Sample density per curve segment. */
+const SEGMENTS = 32;
+
+/** The point (and tangent angle) at a distance along a sampled path. */
+export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } {
+ const { points, length } = path;
+ if (points.length === 0) return { x: 0, y: 0, angle: 0 };
+ const d = Math.max(0, Math.min(distance, length));
+ if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y, angle: 0 };
+
+ let lo = 0;
+ let hi = points.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (points[mid]!.t < d) lo = mid + 1;
+ else hi = mid;
+ }
+ const b = points[lo]!;
+ const a = points[lo - 1] ?? b;
+ const seg = b.t - a.t;
+ const u = seg > 0 ? (d - a.t) / seg : 0;
+ const x = a.x + (b.x - a.x) * u;
+ const y = a.y + (b.y - a.y) * u;
+ const angle = Math.atan2(b.y - a.y, b.x - a.x);
+ return { x, y, angle };
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts
new file mode 100644
index 0000000..741686a
--- /dev/null
+++ b/packages/tabletop/src/state.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import type { Package, Surface } from '@tts/bgm';
+import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
+
+function makeSurface(overrides: Partial = {}): Surface {
+ return {
+ type: 'board',
+ id: 'harbor',
+ layout: [],
+ ...overrides,
+ };
+}
+
+const pkg: Package = {
+ meta: { id: 'harbor' },
+ parts: new Map(),
+ surfaces: new Map([
+ ['board#harbor', makeSurface()],
+ ['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
+ ]),
+ setups: 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();
+ });
+
+ it('matches a :param against a candidate', () => {
+ const route = {
+ route: '/dock/:seat',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ candidates: [
+ { 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 } });
+ });
+
+ it('fails when no candidate matches the param', () => {
+ const route = {
+ route: '/dock/:seat',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ candidates: [{ seat: '0', x: 40, y: 0 }],
+ };
+ 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();
+ });
+});
+
+describe('computeSurfacePlacements', () => {
+ it('places parts on a matching route with index and stackSize', () => {
+ const surface = makeSurface({
+ layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
+ });
+ const placements = computeSurfacePlacements(surface, {
+ '/deck': ['harbor:card#a', 'harbor:card#b'],
+ });
+ expect(placements).toHaveLength(2);
+ expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
+ expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
+ });
+
+ it('drops parts with no matching route', () => {
+ const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
+ const placements = computeSurfacePlacements(surface, {
+ '/deck': ['harbor:card#a'],
+ '/elsewhere': ['harbor:card#b'],
+ });
+ expect(placements).toHaveLength(1);
+ expect(placements[0]!.piece).toBe('harbor:card#a');
+ });
+
+ it('uses the candidate anchor for a :param route', () => {
+ const surface = makeSurface({
+ layout: [
+ {
+ route: '/dock/:seat',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ candidates: [{ seat: '0', x: 40, y: 5, rotation: 1 }],
+ },
+ ],
+ });
+ const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
+ expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
+ });
+});
+
+describe('computeRenderState', () => {
+ it('only includes enabled surfaces', () => {
+ const state = {
+ surfaces: { 'board#harbor': true, 'hud#hand': false },
+ paths: { '/deck': ['harbor:card#a'] },
+ };
+ pkg.surfaces.set(
+ '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');
+ });
+});
+
+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));
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/state.ts b/packages/tabletop/src/state.ts
new file mode 100644
index 0000000..fa84326
--- /dev/null
+++ b/packages/tabletop/src/state.ts
@@ -0,0 +1,133 @@
+/**
+ * Source-of-truth game state and the derived render state.
+ *
+ * The store holds the enabled surfaces and the path -> part placement map
+ * (`bgm-tabletop.md` §2). The derived render state is computed from the game
+ * state plus a package's surface routes: a stable list of placements, one per
+ * (surface, piece) pair, keyed for rendering.
+ */
+import { useMemo } from 'react';
+import { create } from 'zustand';
+import type { Candidate, Package, Route, Surface } from '@tts/bgm';
+
+/** Source-of-truth game state. */
+export interface GameState {
+ /** Enabled per surface id (`type#id`). */
+ surfaces: Record;
+ /** Path -> part list (`package:type#id`). */
+ paths: Record;
+}
+
+/** A single placed piece on a surface, ready for rendering. */
+export interface Placement {
+ /** Surface id (`type#id`). */
+ surface: string;
+ /** The matched route. */
+ route: Route;
+ /** The matched candidate, when the route has `:param`s. */
+ candidate?: Candidate;
+ /** The piece id (`package:type#id`). */
+ piece: string;
+ /** The piece's position in its path's stack. */
+ index: number;
+ /** The number of pieces on the path. */
+ stackSize: number;
+}
+
+interface TabletopState extends GameState {
+ setSurfaces: (surfaces: Record) => void;
+ setPaths: (paths: Record) => void;
+ seed: (state: GameState) => void;
+ enableSurface: (id: string) => void;
+ disableSurface: (id: string) => void;
+ setPath: (path: string, parts: string[]) => void;
+}
+
+export const useTabletopStore = create((set) => ({
+ surfaces: {},
+ paths: {},
+ setSurfaces: (surfaces) => set({ surfaces }),
+ setPaths: (paths) => set({ paths }),
+ seed: (state) => set(state),
+ enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
+ disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
+ setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })),
+}));
+
+// --- Route matching ---
+
+/**
+ * Match a route pattern against a path key. Returns the matched candidate (or
+ * undefined when the route has no `:param`s), or null when the route doesn't
+ * match. A route with candidates matches only when a candidate's props match
+ * every `:param`; the first such candidate wins.
+ */
+export function matchRoute(route: Route, path: string): { candidate?: Candidate } | null {
+ const routeSegs = route.route.split('/').filter(Boolean);
+ const pathSegs = path.split('/').filter(Boolean);
+ if (routeSegs.length !== pathSegs.length) return null;
+
+ const params: Record = {};
+ for (let i = 0; i < routeSegs.length; i++) {
+ const rs = routeSegs[i]!;
+ const ps = pathSegs[i]!;
+ if (rs.startsWith(':')) params[rs.slice(1)] = ps;
+ else if (rs !== ps) return null;
+ }
+
+ if (route.candidates) {
+ for (const cand of route.candidates) {
+ const allMatch = Object.entries(params).every(([k, v]) => cand[k] === v);
+ if (allMatch) return { candidate: cand };
+ }
+ return null;
+ }
+ return { candidate: undefined };
+}
+
+/** Compute the placements for a single surface from the game state's paths. */
+export function computeSurfacePlacements(surface: Surface, paths: Record): Placement[] {
+ const placements: Placement[] = [];
+ const surfaceId = `${surface.type}#${surface.id}`;
+ for (const [path, parts] of Object.entries(paths)) {
+ const route = surface.layout.find((r) => matchRoute(r, path));
+ if (!route) continue;
+ const match = matchRoute(route, path)!;
+ const stackSize = parts.length;
+ for (const piece of parts) {
+ placements.push({
+ surface: surfaceId,
+ route,
+ candidate: match.candidate,
+ piece,
+ index: parts.indexOf(piece),
+ stackSize,
+ });
+ }
+ }
+ return placements;
+}
+
+/** Compute the full render state across all enabled surfaces. */
+export function computeRenderState(pkg: Package, state: GameState): Placement[] {
+ const out: Placement[] = [];
+ for (const [surfaceId, enabled] of Object.entries(state.surfaces)) {
+ if (!enabled) continue;
+ const surface = pkg.surfaces.get(surfaceId);
+ if (!surface) continue;
+ out.push(...computeSurfacePlacements(surface, state.paths));
+ }
+ return out;
+}
+
+/** A stable key for a placement, unique across surfaces and pieces. */
+export function placementKey(p: Placement): string {
+ return `${p.surface}:${p.piece}`;
+}
+
+/** The derived render state for a package, from the current game state. */
+export function useRenderState(pkg: Package): Placement[] {
+ const surfaces = useTabletopStore((s) => s.surfaces);
+ const paths = useTabletopStore((s) => s.paths);
+ return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/surfaces/HudSurfaceView.tsx b/packages/tabletop/src/surfaces/HudSurfaceView.tsx
new file mode 100644
index 0000000..a3da682
--- /dev/null
+++ b/packages/tabletop/src/surfaces/HudSurfaceView.tsx
@@ -0,0 +1,34 @@
+/**
+ * `HudSurfaceView` — mount a surface to a HUD area.
+ *
+ * Renders a `kind: hud` mount node (and its child surfaces) anchored to a HUD
+ * area. The default is a drei `Html` overlay so world and HUD share one scene
+ * (see `bgm-tabletop.md` Open decisions). Parts are placed via `PartPlacement`.
+ */
+import { Html } from '@react-three/drei';
+import type { Package } from '@tts/bgm';
+import { useRenderState } from '../state.js';
+import { PartPlacement } from '../placement.js';
+import type { MountNode } from '../mount.js';
+import { SurfaceNode } from './WorldSurfaceView.js';
+
+export function HudSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) {
+ const placements = useRenderState(pkg);
+ const own = placements.filter((p) => p.surface === node.id);
+
+ return (
+
+ {own.map((p) => (
+
+ ))}
+ {node.children.map((child) => (
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/surfaces/WorldSurfaceView.tsx b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx
new file mode 100644
index 0000000..631664e
--- /dev/null
+++ b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx
@@ -0,0 +1,33 @@
+/**
+ * `WorldSurfaceView` — mount a surface in world space.
+ *
+ * Renders a world mount node (a `kind: table` surface and its child surfaces)
+ * at its anchor. Parts on the surface are placed via `PartPlacement` from the
+ * derived render state. A disabled surface isn't part of the mount tree, so
+ * it's never rendered.
+ */
+import type { Package } from '@tts/bgm';
+import { useRenderState } from '../state.js';
+import { PartPlacement } from '../placement.js';
+import type { MountNode } from '../mount.js';
+
+export function WorldSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) {
+ return ;
+}
+
+/** Render a mount node at its anchor, placing parts and recursing into children. */
+export function SurfaceNode({ pkg, node }: { pkg: Package; node: MountNode }) {
+ const placements = useRenderState(pkg);
+ const own = placements.filter((p) => p.surface === node.id);
+
+ return (
+
+ {own.map((p) => (
+
+ ))}
+ {node.children.map((child) => (
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/vite.test.ts b/packages/tabletop/src/vite.test.ts
new file mode 100644
index 0000000..6846d2e
--- /dev/null
+++ b/packages/tabletop/src/vite.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { build } from 'vite';
+import { bgm } from '@tts/bgm';
+
+const fixtureRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '__fixtures__',
+ 'vite-build',
+);
+const gamesRoot = path.join(fixtureRoot, 'games');
+
+describe('tabletop vite build (integration)', () => {
+ it('bundles the library logic against a fixture package', async () => {
+ const outDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tabletop-build-'));
+
+ try {
+ await build({
+ root: fixtureRoot,
+ logLevel: 'silent',
+ build: {
+ outDir,
+ write: true,
+ emptyOutDir: true,
+ // Keep identifiers readable so the test can assert on them.
+ minify: false,
+ },
+ plugins: [bgm({ root: gamesRoot })],
+ });
+
+ const chunk = walk(outDir).find((f) => f.endsWith('.js'))!;
+ const code = fs.readFileSync(path.join(outDir, chunk), 'utf8');
+
+ // The bgm plugin serialized the package's parts and setup into the
+ // emitted module, and the library's logic is bundled alongside.
+ expect(code).toContain('token#wood');
+ expect(code).toContain('game#main');
+ // The library's pure logic (setup seeding, render state, stacking,
+ // mount resolution) is reachable from the fixture entry.
+ expect(code).toContain('placementCount');
+ expect(code).toContain('offsetX');
+ expect(code).toContain('worldCount');
+ } finally {
+ await fs.promises.rm(outDir, { recursive: true, force: true });
+ }
+ });
+});
+
+/** Recursively list files under a directory, as paths relative to it. */
+function walk(dir: string): string[] {
+ const out: string[] = [];
+ const visit = (current: string) => {
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) visit(full);
+ else out.push(path.relative(dir, full));
+ }
+ };
+ visit(dir);
+ return out;
+}
\ No newline at end of file
diff --git a/packages/tabletop/tsconfig.json b/packages/tabletop/tsconfig.json
index 3ce634c..0dca2e1 100644
--- a/packages/tabletop/tsconfig.json
+++ b/packages/tabletop/tsconfig.json
@@ -7,5 +7,5 @@
"jsx": "react-jsx"
},
"include": ["src"],
- "exclude": ["src/**/*.test.ts"]
+ "exclude": ["src/__fixtures__", "src/**/*.test.ts"]
}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21eba27..37ea1b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -268,6 +268,9 @@ importers:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
devDependencies:
+ '@types/node':
+ specifier: ^22.12.0
+ version: 22.20.1
'@types/react':
specifier: ^19.2.18
version: 19.2.18
@@ -277,6 +280,9 @@ importers:
typescript:
specifier: ^5.7.2
version: 5.9.3
+ vite:
+ specifier: ^8.2.1
+ 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))