diff --git a/packages/bgm/src/collect.test.ts b/packages/bgm/src/collect.test.ts index 01a1f8c..ae96f63 100644 --- a/packages/bgm/src/collect.test.ts +++ b/packages/bgm/src/collect.test.ts @@ -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/, + ); }); -}); \ No newline at end of file +}); diff --git a/packages/bgm/src/collect.ts b/packages/bgm/src/collect.ts index 3d5ce42..19d7d2e 100644 --- a/packages/bgm/src/collect.ts +++ b/packages/bgm/src/collect.ts @@ -13,16 +13,23 @@ * * 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(); readonly surfaces = new Map(); readonly setups = new Map(); + readonly dialogs = new Map(); readonly byRole = new Map(); 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, baseDir: string, source: string): Record[] { - if (!('$variants' in obj)) return [obj]; - const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source); + private expand( + obj: Record, + baseDir: string, + source: string, + ): Record[] { + 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, baseDir: string): Part { @@ -249,11 +293,11 @@ function asPart(obj: Record, 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, ): Surface { const value: Record = { ...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; - 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; return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) }; } @@ -288,7 +342,7 @@ function asSurface( function asSetup(obj: Record, source: string): Setup { const value: Record = { ...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, source: string): Setup { } } +function asDialog(obj: Record, source: string): Dialog { + const value: Record = { ...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); diff --git a/packages/bgm/src/schemas.ts b/packages/bgm/src/schemas.ts index 6a39924..a3275fe 100644 --- a/packages/bgm/src/schemas.ts +++ b/packages/bgm/src/schemas.ts @@ -5,8 +5,8 @@ * and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values. * 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): Setup { return setupSchema.parse(value) as unknown as Setup; } +/** Validate a raw dialog definition. */ +export function validateDialog(value: Record): Dialog { + return dialogSchema.parse(value) as unknown as Dialog; +} + /** Validate a raw package definition. */ export function validatePackage(value: Record): PackageDef { return packageSchema.parse(value) as unknown as PackageDef; diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index 7925ad7..8f9c811 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -9,7 +9,7 @@ */ /** 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 = 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 = 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; /** All setups by `type#id`. */ setups: Map; + /** All dialogs by `type#id`. */ + dialogs: Map; } /** @@ -314,12 +368,17 @@ export interface SerializedPackage { surfaces: Record; /** All setups by `type#id`. */ setups: Record; + /** All dialogs by `type#id`. */ + dialogs: Record; } /** 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"; } } diff --git a/packages/bgm/src/vite.ts b/packages/bgm/src/vite.ts index 7181f49..a32713e 100644 --- a/packages/bgm/src/vite.ts +++ b/packages/bgm/src/vite.ts @@ -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. `/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), }; } diff --git a/packages/bgm/src/vite.unit.test.ts b/packages/bgm/src/vite.unit.test.ts index 2e8b38e..f2767e4 100644 --- a/packages/bgm/src/vite.unit.test.ts +++ b/packages/bgm/src/vite.unit.test.ts @@ -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 extends (...args: infer A) => infer R ? (...args: A) => R : never; +type Callable = T extends (...args: infer A) => infer R + ? (...args: A) => R + : never; function resolveId(plugin: ReturnType, id: string): unknown { - return (plugin.resolveId as Callable)(id, undefined, { - isEntry: false, - }); + return (plugin.resolveId as Callable)( + id, + undefined, + { + isEntry: false, + }, + ); } function load(plugin: ReturnType, id: string): unknown { @@ -25,73 +34,94 @@ function load(plugin: ReturnType, id: string): unknown { /** Parse the JSON payload out of an emitted `export default ` 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>; + const packages = parseModule( + load(plugin, "\0bgm:virtual:bgm/packages"), + ) as Array>; 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; - 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; + 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; + const pkg = parseModule( + load(plugin, "\0bgm:virtual:bgm/package/harbor"), + ) as Record; // 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).call(context, {} as never); + (plugin.buildStart as Callable).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); }); }); diff --git a/packages/tabletop/src/package.test.ts b/packages/tabletop/src/package.test.ts index 3e58628..b9a6394 100644 --- a/packages/tabletop/src/package.test.ts +++ b/packages/tabletop/src/package.test.ts @@ -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" }); }); -}); \ No newline at end of file +}); diff --git a/packages/tabletop/src/package.ts b/packages/tabletop/src/package.ts index 6889864..0826a1a 100644 --- a/packages/tabletop/src/package.ts +++ b/packages/tabletop/src/package.ts @@ -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)), }; -} \ No newline at end of file +} diff --git a/packages/tabletop/src/setup.test.ts b/packages/tabletop/src/setup.test.ts index d8947fd..4bf6ce1 100644 --- a/packages/tabletop/src/setup.test.ts +++ b/packages/tabletop/src/setup.test.ts @@ -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", + }); }); -}); \ No newline at end of file +}); diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts index 4bf721d..39f93b9 100644 --- a/packages/tabletop/src/state.test.ts +++ b/packages/tabletop/src/state.test.ts @@ -1,178 +1,223 @@ -import { describe, expect, it } from 'vitest'; -import type { Package, Surface } from '@tts/bgm'; +import { describe, expect, it } from "vitest"; +import type { Facing, Package, Surface } from "@tts/bgm"; import { matchRoute, childrenByPath, computeSurfacePlacements, computeRenderState, placementKey, -} from './state.js'; +} from "./state.js"; function makeSurface(overrides: Partial = {}): 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("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 +});