/** * 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 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; } 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, path, 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, paths, and pieces. */ export function placementKey(p: Placement): string { return `${p.surface}:${p.path}:${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]); }