/** * Source-of-truth game state and the derived render state. * * 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. */ import { useMemo } from 'react'; import { create } from 'zustand'; import type { Candidate, Facing, 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; /** How the part is oriented on the board. */ facing: Facing; } /** Source-of-truth game state. */ export interface GameState { /** Enabled per surface id (`type#id`). */ surfaces: Record; /** Part id (`package:type#id`) -> placement state. */ parts: Record; } /** A single placed piece on a surface, ready for rendering. */ export interface Placement { /** Surface id (`type#id`). */ surface: string; /** The path key this placement came from. */ path: 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; /** How the piece is oriented on the board. */ facing: Facing; } interface TabletopState extends GameState { setSurfaces: (surfaces: Record) => void; setParts: (parts: Record) => void; seed: (state: GameState) => void; enableSurface: (id: string) => void; disableSurface: (id: string) => void; setPart: (id: string, patch: Partial) => void; movePart: (id: string, path: string, index: number) => void; } export const useTabletopStore = create((set) => ({ surfaces: {}, parts: {}, setSurfaces: (surfaces) => set({ surfaces }), 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 } })), 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 --- /** * 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 }; } /** * 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}`; 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 = ids.length; for (const piece of ids) { const ps = parts[piece]!; placements.push({ surface: surfaceId, path, route, candidate: match.candidate, piece, index: ps.index, stackSize, facing: ps.facing, }); } } 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.parts)); } return out; } /** * 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.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 parts = useTabletopStore((s) => s.parts); return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]); }