diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index 68f253c..23ace6f 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -80,15 +80,22 @@ Source-of-truth game state per `bgm-tabletop.md` §2: ```ts interface GameState { surfaces: Record; // enabled per surface id - paths: Record; // path -> part list + parts: Record; // part id -> placement state +} + +interface PartState { + path: string; // the path key this part is on + index: number; // the part's position in its path's stack + face: boolean; // whether the part's face is up } ``` - A zustand store holding `GameState`. - **Derived render state**: `game state + surface routes => map of piece id to - `{ surface, route, candidate, index, stackSize }``, per enabled surface. - Computed with a selector/memo so the render list is stable. -- **Assumption**: each piece id is unique within a path (documented in + `{ surface, route, candidate, index, stackSize, face }``, per enabled surface. + 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. ### 4. Setup seeding (`setup.ts`) ✅ diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md index b96064a..1f05270 100644 --- a/docs/bgm-tabletop.md +++ b/docs/bgm-tabletop.md @@ -15,18 +15,25 @@ source-of-truth game state: ```ts { surfaces: Record, // enabled per surface id - paths: Record, // path -> part list + parts: Record, // part id -> placement state +} + +interface PartState { + path: string, // the path key this part is on + index: number, // the part's position in its path's stack + face: boolean, // whether the part's face is up } ``` -**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous. +**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous. -derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list. +derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, face }` for rendering on a surface. keys of this map makes a stable render list. - `route` - the matched route. - `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates. - `index` - the piece's position in its path's stack. - `stackSize` - the number of pieces on the path. +- `face` - whether the piece's face is up. the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently. diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts index 5b8669a..61b9e8b 100644 --- a/packages/tabletop/src/index.ts +++ b/packages/tabletop/src/index.ts @@ -24,7 +24,7 @@ export { computeSurfacePlacements, placementKey, } from './state.js'; -export type { GameState, Placement } from './state.js'; +export type { GameState, Placement, PartState } from './state.js'; // Setup seeding. export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js'; diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx index 99032be..da6b301 100644 --- a/packages/tabletop/src/placement.tsx +++ b/packages/tabletop/src/placement.tsx @@ -12,7 +12,7 @@ import { PartView } from './partView.js'; import { MM_TO_WORLD, DEG_TO_RAD } from './part.js'; export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) { - const { route, candidate, piece, index, stackSize } = placement; + const { route, candidate, piece, index, stackSize, face } = 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; @@ -36,9 +36,12 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla return ( - {/* The part mesh extrudes along +Z; lay it flat so its face points up. */} + {/* The part mesh extrudes along +Z; lay it flat so its face points up. + A face-down part is flipped over about its local X axis. */} - + + + ); diff --git a/packages/tabletop/src/setup.test.ts b/packages/tabletop/src/setup.test.ts index e4777e8..a908ed3 100644 --- a/packages/tabletop/src/setup.test.ts +++ b/packages/tabletop/src/setup.test.ts @@ -44,7 +44,7 @@ describe('seedFromSetup', () => { }; const state = seedFromSetup(pkg, setup); expect(state.surfaces).toEqual({ 'board#harbor': true }); - expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] }); + expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, face: true } }); }); it('enables all surfaces when omitted', () => { diff --git a/packages/tabletop/src/setup.ts b/packages/tabletop/src/setup.ts index a9a1814..4574e92 100644 --- a/packages/tabletop/src/setup.ts +++ b/packages/tabletop/src/setup.ts @@ -7,7 +7,7 @@ */ import { useEffect } from 'react'; import type { Package, Setup } from '@tts/bgm'; -import { useTabletopStore } from './state.js'; +import { useTabletopStore, type PartState } from './state.js'; /** * Expand a setup value into a list of part ids (`package:type#id`). A bare @@ -39,7 +39,7 @@ export function expandSetupValue( /** Seed the store from a setup. Returns the resulting game state. */ export function seedFromSetup(pkg: Package, setup: Setup): { surfaces: Record; - paths: Record; + parts: Record; } { const surfaces: Record = {}; if (setup.surfaces) { @@ -48,11 +48,14 @@ export function seedFromSetup(pkg: Package, setup: Setup): { for (const key of pkg.surfaces.keys()) surfaces[key] = true; } - const paths: Record = {}; + const parts: Record = {}; for (const [path, value] of Object.entries(setup.setup)) { - paths[path] = expandSetupValue(pkg, value); + const ids = expandSetupValue(pkg, value); + ids.forEach((id, index) => { + parts[id] = { path, index, face: true }; + }); } - return { surfaces, paths }; + return { surfaces, parts }; } /** diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts index 04da27a..0bcd781 100644 --- a/packages/tabletop/src/state.test.ts +++ b/packages/tabletop/src/state.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { Package, Surface } from '@tts/bgm'; -import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js'; +import { + matchRoute, + childrenByPath, + computeSurfacePlacements, + computeRenderState, + placementKey, +} from './state.js'; function makeSurface(overrides: Partial = {}): Surface { return { @@ -59,24 +65,39 @@ describe('matchRoute', () => { }); }); +describe('childrenByPath', () => { + it('groups parts by path, ordered by index', () => { + const parts = { + 'harbor:card#a': { path: '/deck', index: 1, face: true }, + 'harbor:card#b': { path: '/deck', index: 0, face: false }, + 'harbor:card#c': { path: '/community/0', index: 0, face: true }, + }; + expect(childrenByPath(parts)).toEqual({ + '/deck': ['harbor:card#b', 'harbor:card#a'], + '/community/0': ['harbor:card#c'], + }); + }); +}); + describe('computeSurfacePlacements', () => { - it('places parts on a matching route with index and stackSize', () => { + it('places parts on a matching route with index, stackSize, and face', () => { const surface = makeSurface({ layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }], }); const placements = computeSurfacePlacements(surface, { - '/deck': ['harbor:card#a', 'harbor:card#b'], + 'harbor:card#a': { path: '/deck', index: 0, face: true }, + 'harbor:card#b': { path: '/deck', index: 1, face: false }, }); 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 }); + expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, face: true }); + expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, face: false }); }); 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'], + 'harbor:card#a': { path: '/deck', index: 0, face: true }, + 'harbor:card#b': { path: '/elsewhere', index: 0, face: true }, }); expect(placements).toHaveLength(1); expect(placements[0]!.piece).toBe('harbor:card#a'); @@ -94,7 +115,9 @@ describe('computeSurfacePlacements', () => { }, ], }); - const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] }); + const placements = computeSurfacePlacements(surface, { + 'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true }, + }); expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 }); }); @@ -110,29 +133,21 @@ describe('computeSurfacePlacements', () => { }, ], }); - const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] }); + const placements = computeSurfacePlacements(surface, { + 'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true }, + }); expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }); }); - it('keeps the same piece on two paths as distinct placements', () => { + it('orders a path by index regardless of insertion order', () => { const surface = makeSurface({ - layout: [ - { route: '/deck', x: 0, y: 0, rotation: 0 }, - { route: '/community/:slot', x: 0, y: 0, rotation: 0 }, - ], + layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }], }); - // The deck expands to every card (including `as`); the flop also places `as`. const placements = computeSurfacePlacements(surface, { - '/deck': ['poker:card#as', 'poker:card#kh'], - '/community/0': ['poker:card#as'], + 'harbor:card#b': { path: '/deck', index: 1, face: true }, + 'harbor:card#a': { path: '/deck', index: 0, face: true }, }); - expect(placements).toHaveLength(3); - const deck = placements.filter((p) => p.path === '/deck'); - const flop = placements.filter((p) => p.path === '/community/0'); - expect(deck).toHaveLength(2); - expect(flop).toHaveLength(1); - // The same piece on two paths yields distinct placement keys. - expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!)); + expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']); }); }); @@ -140,7 +155,7 @@ describe('computeRenderState', () => { it('only includes enabled surfaces', () => { const state = { surfaces: { 'board#harbor': true, 'hud#hand': false }, - paths: { '/deck': ['harbor:card#a'] }, + parts: { 'harbor:card#a': { path: '/deck', index: 0, face: true } }, }; pkg.surfaces.set( 'board#harbor', @@ -153,14 +168,11 @@ describe('computeRenderState', () => { }); describe('placementKey', () => { - it('is unique per surface, path, and piece', () => { - const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never; - const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never; - const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never; - // The same piece on two paths of the same surface is a distinct placement. - const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never; + 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)); - expect(placementKey(a)).not.toBe(placementKey(d)); }); }); \ No newline at end of file diff --git a/packages/tabletop/src/state.ts b/packages/tabletop/src/state.ts index 17ca2c9..08cc651 100644 --- a/packages/tabletop/src/state.ts +++ b/packages/tabletop/src/state.ts @@ -1,8 +1,11 @@ /** * 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 + * The store holds the enabled surfaces and a per-part placement map + * (`bgm-tabletop.md` §2). Each part on the board is keyed by its id + * (`package:type#id`) and records which path it's on, its index in that path's + * stack, and which face is up. A path's ordered children (for stacking) are + * derived from this map. 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. */ @@ -10,12 +13,22 @@ import { useMemo } from 'react'; import { create } from 'zustand'; import type { Candidate, Package, Route, Surface } from '@tts/bgm'; +/** The placement state of a single part on the board. */ +export interface PartState { + /** The path key this part is on. */ + path: string; + /** The part's position in its path's stack. */ + index: number; + /** Whether the part's face is up (`true`) or down (`false`). */ + face: boolean; +} + /** Source-of-truth game state. */ export interface GameState { /** Enabled per surface id (`type#id`). */ surfaces: Record; - /** Path -> part list (`package:type#id`). */ - paths: Record; + /** Part id (`package:type#id`) -> placement state. */ + parts: Record; } /** A single placed piece on a surface, ready for rendering. */ @@ -34,26 +47,59 @@ export interface Placement { index: number; /** The number of pieces on the path. */ stackSize: number; + /** Whether the piece's face is up. */ + face: boolean; } interface TabletopState extends GameState { setSurfaces: (surfaces: Record) => void; - setPaths: (paths: Record) => void; + setParts: (parts: Record) => void; seed: (state: GameState) => void; enableSurface: (id: string) => void; disableSurface: (id: string) => void; - setPath: (path: string, parts: string[]) => void; + setPart: (id: string, patch: Partial) => void; + movePart: (id: string, path: string, index: number) => void; } export const useTabletopStore = create((set) => ({ surfaces: {}, - paths: {}, + parts: {}, setSurfaces: (surfaces) => set({ surfaces }), - setPaths: (paths) => set({ paths }), + setParts: (parts) => set({ parts }), 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 } })), + setPart: (id, patch) => + set((s) => { + const cur = s.parts[id]; + if (!cur) return s; + return { parts: { ...s.parts, [id]: { ...cur, ...patch } } }; + }), + movePart: (id, path, index) => + set((s) => { + const cur = s.parts[id]; + if (!cur) return s; + const parts = { ...s.parts }; + // Siblings on the source path, in order, excluding the moved part. + const srcIds = Object.keys(parts) + .filter((pid) => pid !== id && parts[pid]!.path === cur.path) + .sort((a, b) => parts[a]!.index - parts[b]!.index); + // Siblings on the destination path, in order, excluding the moved part. + const dstIds = Object.keys(parts) + .filter((pid) => pid !== id && parts[pid]!.path === path) + .sort((a, b) => parts[a]!.index - parts[b]!.index); + // Reindex the source path so the gap closes. + srcIds.forEach((pid, i) => { + parts[pid] = { ...parts[pid]!, index: i }; + }); + // Reindex the destination path with the moved part inserted at `index`. + const clamped = Math.max(0, Math.min(index, dstIds.length)); + dstIds.forEach((pid, i) => { + parts[pid] = { ...parts[pid]!, index: i >= clamped ? i + 1 : i }; + }); + parts[id] = { ...cur, path, index: clamped }; + return { parts }; + }), })); // --- Route matching --- @@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate return { candidate: undefined }; } -/** Compute the placements for a single surface from the game state's paths. */ -export function computeSurfacePlacements(surface: Surface, paths: Record): Placement[] { +/** + * Derive each path's ordered children from the parts map. A path's children are + * its part ids sorted by `index`, used for stacking (`stackSize` and per-piece + * `index`). + */ +export function childrenByPath(parts: Record): Record { + const children: Record = {}; + for (const [id, ps] of Object.entries(parts)) { + (children[ps.path] ??= []).push(id); + } + for (const list of Object.values(children)) { + list.sort((a, b) => parts[a]!.index - parts[b]!.index); + } + return children; +} + +/** Compute the placements for a single surface from the game state's parts. */ +export function computeSurfacePlacements(surface: Surface, parts: Record): Placement[] { const placements: Placement[] = []; const surfaceId = `${surface.type}#${surface.id}`; - for (const [path, parts] of Object.entries(paths)) { + const children = childrenByPath(parts); + for (const [path, ids] of Object.entries(children)) { 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) { + const stackSize = ids.length; + for (const piece of ids) { + const ps = parts[piece]!; placements.push({ surface: surfaceId, path, route, candidate: match.candidate, piece, - index: parts.indexOf(piece), + index: ps.index, stackSize, + face: ps.face, }); } } @@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[] if (!enabled) continue; const surface = pkg.surfaces.get(surfaceId); if (!surface) continue; - out.push(...computeSurfacePlacements(surface, state.paths)); + out.push(...computeSurfacePlacements(surface, state.parts)); } return out; } -/** A stable key for a placement, unique across surfaces, paths, and pieces. */ +/** + * A stable key for a placement, unique across surfaces and pieces. A piece is + * on exactly one path, so `path` is implied; it may still render on more than + * one enabled surface, so the surface is part of the key. + */ export function placementKey(p: Placement): string { - return `${p.surface}:${p.path}:${p.piece}`; + 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]); + const parts = useTabletopStore((s) => s.parts); + return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]); } \ No newline at end of file