feat(bgm): add dialog role and interaction affordances

Add the dialog role and the interaction affordances the free-interaction
layer depends on: Setup.interactions, Package.dialogs, and the Interaction,
DialogAction, and Dialog types. Wire the dialog role through the zod
schemas, package collection (with duplicate checking), and vite
serialization, and update the tabletop serializedToPackage and test
fixtures to carry dialogs.
This commit is contained in:
hyper
2026-08-18 10:24:21 +08:00
parent 8eff44712f
commit 999b7a0771
10 changed files with 604 additions and 312 deletions
+82 -51
View File
@@ -1,103 +1,134 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import * as path from 'node:path'; import * as path from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
import { loadDefs, collectPackages } from './collect.js'; import { loadDefs, collectPackages } from "./collect.js";
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor'); const fixtureRoot = path.resolve(
const multiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'vite-build', 'games'); path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"harbor",
);
const multiRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"vite-build",
"games",
);
describe('collectPackages', () => { describe("collectPackages", () => {
it('collects the harbor package from markdown code blocks', () => { it("collects the harbor package from markdown code blocks", () => {
const defMap = loadDefs('', fixtureRoot); const defMap = loadDefs("", fixtureRoot);
const packages = collectPackages(defMap, fixtureRoot); const packages = collectPackages(defMap, fixtureRoot);
expect(packages).toHaveLength(1); expect(packages).toHaveLength(1);
const harbor = packages[0]!; 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. // Two tokens from two yaml blocks sharing a `role=part.token` name.
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']); expect([...harbor.parts.keys()].sort()).toEqual([
const wood = harbor.parts.get('token#wood')!; "token#grain",
"token#wood",
]);
const wood = harbor.parts.get("token#wood")!;
expect(wood).toMatchObject({ expect(wood).toMatchObject({
type: 'token', type: "token",
id: 'wood', id: "wood",
size: [20, 20, 3], size: [20, 20, 3],
fillet: 2, fillet: 2,
}); });
expect(wood.face).toBe('./assets/tokens.png'); expect(wood.face).toBe("./assets/tokens.png");
expect(wood.faceCrop).toEqual([1, 0, 5, 2]); expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
// Relative assets resolve against the markdown file's directory. The // Relative assets resolve against the markdown file's directory. The
// fixture markdown sits at the games root, so baseUrl is empty. // 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. // Two surfaces: the table board and its child player board.
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']); expect([...harbor.surfaces.keys()].sort()).toEqual([
const board = harbor.surfaces.get('board#harbor')!; "board#harbor",
"board#player",
]);
const board = harbor.surfaces.get("board#harbor")!;
expect(board.size).toEqual([300, 200]); expect(board.size).toEqual([300, 200]);
expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 }); expect(board.mount).toEqual({ kind: "table", x: 0, y: 0, rotation: 0 });
expect(board.children).toEqual(['board#player']); expect(board.children).toEqual(["board#player"]);
expect(board.layout).toHaveLength(2); expect(board.layout).toHaveLength(2);
const dock = board.layout[0]!; const dock = board.layout[0]!;
expect(dock.route).toBe('/dock/:seat'); expect(dock.route).toBe("/dock/:seat");
expect(dock.candidates).toEqual([ expect(dock.candidates).toEqual([
{ seat: '0', x: 40, y: 0, rotation: 0 }, { seat: "0", x: 40, y: 0, rotation: 0 },
{ seat: '1', x: 40, y: 20, rotation: 0 }, { seat: "1", x: 40, y: 20, rotation: 0 },
]); ]);
const deck = board.layout[1]!; const deck = board.layout[1]!;
expect(deck.route).toBe('/deck'); expect(deck.route).toBe("/deck");
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 }); expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
const player = harbor.surfaces.get('board#player')!; const player = harbor.surfaces.get("board#player")!;
expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 }); expect(player.mount).toEqual({ kind: "child", x: 100, y: 50, rotation: 0 });
expect(player.layout).toHaveLength(1); expect(player.layout).toHaveLength(1);
// One setup, declaring the enabled surfaces. // One setup, declaring the enabled surfaces.
expect([...harbor.setups.keys()]).toEqual(['game#main']); expect([...harbor.setups.keys()]).toEqual(["game#main"]);
const setup = harbor.setups.get('game#main')!; const setup = harbor.setups.get("game#main")!;
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']); expect(setup.surfaces).toEqual(["board#harbor", "board#player"]);
expect(setup.setup).toEqual([ expect(setup.setup).toEqual([
{ path: '/dock/0', parts: 'harbor:token#wood' }, { path: "/dock/0", parts: "harbor:token#wood" },
{ path: '/deck', parts: 'harbor:token#grain' }, { 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` // Two packages share a games root. Each uses the default `./**/*.yaml`
// include, which must resolve relative to its own folder so neither // include, which must resolve relative to its own folder so neither
// absorbs the other's defs (both define a `game#main` setup). // absorbs the other's defs (both define a `game#main` setup).
const defMap = loadDefs('', multiRoot); const defMap = loadDefs("", multiRoot);
const packages = collectPackages(defMap, multiRoot); const packages = collectPackages(defMap, multiRoot);
expect(packages).toHaveLength(2); expect(packages).toHaveLength(2);
const azul = packages.find((p) => p.meta.id === 'azul')!; const azul = packages.find((p) => p.meta.id === "azul")!;
const harbor = packages.find((p) => p.meta.id === 'harbor')!; const harbor = packages.find((p) => p.meta.id === "harbor")!;
expect([...azul.parts.keys()]).toEqual(['tile#blue']); expect([...azul.parts.keys()]).toEqual(["tile#blue"]);
expect([...azul.setups.keys()]).toEqual(['game#main']); expect([...azul.setups.keys()]).toEqual(["game#main"]);
expect([...harbor.parts.keys()]).toEqual(['token#wood']); expect([...harbor.parts.keys()]).toEqual(["token#wood"]);
expect([...harbor.setups.keys()]).toEqual(['game#main']); expect([...harbor.setups.keys()]).toEqual(["game#main"]);
}); });
it('throws on a duplicate type#id', () => { it("throws on a duplicate type#id", () => {
const defMap = loadDefs('', fixtureRoot); const defMap = loadDefs("", fixtureRoot);
// Inject a duplicate part into the map under a new file name. // 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)!; const tokens = defMap.defs.get(tokensKey)!;
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [tokens[0]!]); defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/); tokens[0]!,
]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
/Duplicate part/,
);
}); });
it('throws when an info-string id combines with $variants', () => { it("throws when an info-string id combines with $variants", () => {
const defMap = loadDefs('', fixtureRoot); const defMap = loadDefs("", fixtureRoot);
// A part whose `id` comes from $variants rows, but with an info-string id. // 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 tokens = defMap.defs.get(tokensKey)!;
const variant = { const variant = {
...tokens[0]!, ...tokens[0]!,
value: { ...tokens[0]!.value, id: undefined, $variants: './seats.csv' }, value: { ...tokens[0]!.value, id: undefined, $variants: "./seats.csv" },
role: { role: 'part', type: 'token', id: 'wood' }, role: { role: "part" as const, type: "token", id: "wood" },
}; };
defMap.defs.set(tokensKey.replace('part.token.yaml', 'part.dup.yaml'), [variant]); defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/can't combine with \$variants/); variant,
]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
/can't combine with \$variants/,
);
}); });
}); });
+106 -42
View File
@@ -13,16 +13,23 @@
* *
* See docs/bgm/format.md for the format's concrete behavior. * See docs/bgm/format.md for the format's concrete behavior.
*/ */
import * as path from 'node:path'; import * as path from "node:path";
import picomatch from 'picomatch'; import picomatch from "picomatch";
import { collectVirtualFiles } from './markdown.js'; import { collectVirtualFiles } from "./markdown.js";
import { parseDefText, readDefFiles } from './parse.js'; import { parseDefText, readDefFiles } from "./parse.js";
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js'; import {
import { expandVariants } from './variants.js'; validateDialog,
validatePackage,
validatePart,
validateSetup,
validateSurface,
} from "./schemas.js";
import { expandVariants } from "./variants.js";
import { import {
BgmError, BgmError,
ROLES, ROLES,
type DefFile, type DefFile,
type Dialog,
type ParsedDef, type ParsedDef,
type Package, type Package,
type PackageDef, type PackageDef,
@@ -30,7 +37,7 @@ import {
type Role, type Role,
type Setup, type Setup,
type Surface, type Surface,
} from './types.js'; } from "./types.js";
/** Every definition parsed from a def file, keyed by its path-style name. */ /** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap { export interface DefMap {
@@ -52,7 +59,7 @@ export function loadDefs(root: string, rootDir: string): DefMap {
const others: DefFile[] = []; const others: DefFile[] = [];
for (const file of realFiles) { 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); else others.push(file);
} }
@@ -86,8 +93,12 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
for (const [file, defs] of defMap.defs) { for (const [file, defs] of defMap.defs) {
const list: ParsedDef[] = []; const list: ParsedDef[] = [];
for (const def of defs) { for (const def of defs) {
const role = def.value['role']; const role = def.value["role"];
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) { if (
role !== undefined &&
typeof role === "string" &&
ROLES.has(role as Role)
) {
list.push(def); list.push(def);
byRole.set(file, list); byRole.set(file, list);
} }
@@ -97,10 +108,10 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
const accs: PackageAcc[] = []; const accs: PackageAcc[] = [];
for (const [file, defs] of byRole) { for (const [file, defs] of byRole) {
for (const def of defs) { for (const def of defs) {
const role = def.value['role'] as Role; const role = def.value["role"] as Role;
if (role === 'package') { if (role === "package") {
const pkg = asPackage(def, file); 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)); accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
} }
} }
@@ -119,6 +130,7 @@ class PackageAcc {
readonly parts = new Map<string, Part>(); readonly parts = new Map<string, Part>();
readonly surfaces = new Map<string, Surface>(); readonly surfaces = new Map<string, Surface>();
readonly setups = new Map<string, Setup>(); readonly setups = new Map<string, Setup>();
readonly dialogs = new Map<string, Dialog>();
readonly byRole = new Map<string, string[]>(); readonly byRole = new Map<string, string[]>();
constructor( constructor(
@@ -130,23 +142,37 @@ class PackageAcc {
) {} ) {}
collect() { collect() {
const include = this.pkg.include ?? ['./**/*.yaml']; const include = this.pkg.include ?? ["./**/*.yaml"];
const names = this.expandIncludes(include); const names = this.expandIncludes(include);
for (const name of names) { for (const name of names) {
const fileDefs = this.defs.defs.get(name); const fileDefs = this.defs.defs.get(name);
if (!fileDefs) continue; if (!fileDefs) continue;
for (const def of fileDefs) { for (const def of fileDefs) {
const role = def.value['role']; const role = def.value["role"];
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue; if (
typeof role !== "string" ||
!ROLES.has(role as Role) ||
role === "package"
)
continue;
this.add(role as Role, def, name); this.add(role as Role, def, name);
} }
} }
} }
/** Expand `$variants` on a def object into a list of concrete objects. */ /** Expand `$variants` on a def object into a list of concrete objects. */
private expand(obj: Record<string, unknown>, baseDir: string, source: string): Record<string, unknown>[] { private expand(
if (!('$variants' in obj)) return [obj]; obj: Record<string, unknown>,
const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source); baseDir: string,
source: string,
): Record<string, unknown>[] {
if (!("$variants" in obj)) return [obj];
const rows = expandVariants(
obj["$variants"],
baseNameFor(baseDir),
this.defs.files,
source,
);
const { $variants: _v, ...base } = obj; const { $variants: _v, ...base } = obj;
return rows.map((row) => ({ ...base, ...row })); return rows.map((row) => ({ ...base, ...row }));
} }
@@ -165,7 +191,7 @@ class PackageAcc {
// relative to the package declaration's directory. When the package is // relative to the package declaration's directory. When the package is
// at the games root (empty baseDir), the pattern has no leading slash: // at the games root (empty baseDir), the pattern has no leading slash:
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not. // `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
const resolved = pattern.startsWith('/') const resolved = pattern.startsWith("/")
? pattern ? pattern
: this.baseDir : this.baseDir
? `/${path.posix.join(this.baseDir, pattern)}` ? `/${path.posix.join(this.baseDir, pattern)}`
@@ -181,14 +207,17 @@ class PackageAcc {
private add(role: Role, def: ParsedDef, fileName: string) { private add(role: Role, def: ParsedDef, fileName: string) {
// `id` on the info string can't combine with `$variants`, since every // `id` on the info string can't combine with `$variants`, since every
// row supplies its own `id` and would override it. // row supplies its own `id` and would override it.
if (def.role?.id && '$variants' in def.value) { if (def.role?.id && "$variants" in def.value) {
throw new BgmError(`id on the info string can't combine with $variants`, fileName); 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) { for (const obj of expanded) {
switch (role) { switch (role) {
case 'part': { case "part": {
const part = asPart(obj, def.baseDir ?? ''); const part = asPart(obj, def.baseDir ?? "");
const key = `${part.type}#${part.id}`; const key = `${part.type}#${part.id}`;
if (this.parts.has(key)) { if (this.parts.has(key)) {
throw new BgmError(`Duplicate part "${key}"`, fileName); throw new BgmError(`Duplicate part "${key}"`, fileName);
@@ -196,8 +225,8 @@ class PackageAcc {
this.parts.set(key, part); this.parts.set(key, part);
break; break;
} }
case 'surface': { case "surface": {
const surface = asSurface(obj, def.baseDir ?? '', this.defs.files); const surface = asSurface(obj, def.baseDir ?? "", this.defs.files);
const key = `${surface.type}#${surface.id}`; const key = `${surface.type}#${surface.id}`;
if (this.surfaces.has(key)) { if (this.surfaces.has(key)) {
throw new BgmError(`Duplicate surface "${key}"`, fileName); throw new BgmError(`Duplicate surface "${key}"`, fileName);
@@ -205,7 +234,7 @@ class PackageAcc {
this.surfaces.set(key, surface); this.surfaces.set(key, surface);
break; break;
} }
case 'setup': { case "setup": {
const setup = asSetup(obj, fileName); const setup = asSetup(obj, fileName);
const key = `${setup.type}#${setup.id}`; const key = `${setup.type}#${setup.id}`;
if (this.setups.has(key)) { if (this.setups.has(key)) {
@@ -214,12 +243,27 @@ class PackageAcc {
this.setups.set(key, setup); this.setups.set(key, setup);
break; 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 { 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. */ /** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
function baseNameFor(baseDir: string): string { function baseNameFor(baseDir: string): string {
const dir = baseDir.replace(/^\/+/, ''); const dir = baseDir.replace(/^\/+/, "");
return dir ? `/${dir}/def.yaml` : '/def.yaml'; return dir ? `/${dir}/def.yaml` : "/def.yaml";
} }
function asPart(obj: Record<string, unknown>, baseDir: string): Part { function asPart(obj: Record<string, unknown>, baseDir: string): Part {
@@ -249,11 +293,11 @@ function asPart(obj: Record<string, unknown>, baseDir: string): Part {
// Resolve relative asset paths against the directory of the source file // 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 // (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. // the markdown file's directory; for a real file, its own directory.
const dir = baseDir.replace(/^\/+/, ''); const dir = baseDir.replace(/^\/+/, "");
part.baseUrl = dir ? `${dir}/` : ''; part.baseUrl = dir ? `${dir}/` : "";
return part; return part;
} catch (err) { } catch (err) {
throw wrapZod(err, ''); throw wrapZod(err, "");
} }
} }
@@ -263,16 +307,26 @@ function asSurface(
defs: Map<string, DefFile[]>, defs: Map<string, DefFile[]>,
): Surface { ): Surface {
const value: Record<string, unknown> = { ...obj }; const value: Record<string, unknown> = { ...obj };
delete value['role']; delete value["role"];
// Expand `candidates.$variants` on each route into a concrete array. // Expand `candidates.$variants` on each route into a concrete array.
if (Array.isArray(value['layout'])) { if (Array.isArray(value["layout"])) {
value['layout'] = value['layout'].map((route) => { value["layout"] = value["layout"].map((route) => {
if (typeof route !== 'object' || route === null) return route; if (typeof route !== "object" || route === null) return route;
const r = route as Record<string, unknown>; const r = route as Record<string, unknown>;
const cand = r['candidates']; const cand = r["candidates"];
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) { if (
const rows = expandVariants(cand['$variants'], baseNameFor(baseDir), defs, baseDir); 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<string, unknown>; const { $variants: _v, ...base } = cand as Record<string, unknown>;
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) }; return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
} }
@@ -288,7 +342,7 @@ function asSurface(
function asSetup(obj: Record<string, unknown>, source: string): Setup { function asSetup(obj: Record<string, unknown>, source: string): Setup {
const value: Record<string, unknown> = { ...obj }; const value: Record<string, unknown> = { ...obj };
delete value['role']; delete value["role"];
try { try {
return validateSetup(value) as unknown as Setup; return validateSetup(value) as unknown as Setup;
} catch (err) { } catch (err) {
@@ -296,6 +350,16 @@ function asSetup(obj: Record<string, unknown>, source: string): Setup {
} }
} }
function asDialog(obj: Record<string, unknown>, source: string): Dialog {
const value: Record<string, unknown> = { ...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. */ /** Wrap a zod error with the source location. */
function wrapZod(err: unknown, source: string): BgmError { function wrapZod(err: unknown, source: string): BgmError {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
+31 -6
View File
@@ -5,8 +5,8 @@
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values. * and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
* See docs/bgm/format.md for the format's concrete behavior. * See docs/bgm/format.md for the format's concrete behavior.
*/ */
import { z } from 'zod'; import { z } from "zod";
import type { PackageDef, Part, Setup, Surface } from './types.js'; import type { Dialog, PackageDef, Part, Setup, Surface } from "./types.js";
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]); const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
const size = z.tuple([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({ const stacking = z.object({
curve: z.string().optional(), curve: z.string().optional(),
limit: z.number().optional(), limit: z.number().optional(),
align: z.enum(['start', 'end', 'center']).optional(), align: z.enum(["start", "end", "center"]).optional(),
steps: z.number().optional(), steps: z.number().optional(),
tilt: z.number().optional(), tilt: z.number().optional(),
zStart: z.number().optional(), zStart: z.number().optional(),
@@ -44,7 +44,7 @@ const partSchema = z.object({
}); });
const surfaceMount = z.object({ const surfaceMount = z.object({
kind: z.enum(['table', 'hud', 'child']), kind: z.enum(["table", "hud", "child"]),
x: z.number().optional(), x: z.number().optional(),
y: z.number().optional(), y: z.number().optional(),
rotation: 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({ const setupPlacement = z.object({
path: z.string(), path: z.string(),
parts: setupValue, 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({ const setupSchema = z.object({
@@ -73,10 +78,25 @@ const setupSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
surfaces: z.array(z.string()).optional(), surfaces: z.array(z.string()).optional(),
setup: z.array(setupPlacement), 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({ const packageSchema = z.object({
role: z.literal('package'), role: z.literal("package"),
id: z.string().min(1), id: z.string().min(1),
title: z.string().optional(), title: z.string().optional(),
designer: z.string().optional(), designer: z.string().optional(),
@@ -102,6 +122,11 @@ export function validateSetup(value: Record<string, unknown>): Setup {
return setupSchema.parse(value) as unknown as Setup; return setupSchema.parse(value) as unknown as Setup;
} }
/** Validate a raw dialog definition. */
export function validateDialog(value: Record<string, unknown>): Dialog {
return dialogSchema.parse(value) as unknown as Dialog;
}
/** Validate a raw package definition. */ /** Validate a raw package definition. */
export function validatePackage(value: Record<string, unknown>): PackageDef { export function validatePackage(value: Record<string, unknown>): PackageDef {
return packageSchema.parse(value) as unknown as PackageDef; return packageSchema.parse(value) as unknown as PackageDef;
+79 -20
View File
@@ -9,7 +9,7 @@
*/ */
/** Part value types. */ /** 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 * 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. */ /** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
limit?: number; limit?: number;
/** `start`, `end`, or `center` of the curve. */ /** `start`, `end`, or `center` of the curve. */
align?: 'start' | 'end' | 'center'; align?: "start" | "end" | "center";
/** Maximum parts per curve length unit; defaults to `1`. */ /** Maximum parts per curve length unit; defaults to `1`. */
steps?: number; steps?: number;
/** /**
@@ -126,7 +126,7 @@ export interface Stacking {
} }
/** How a surface is mounted. `kind` selects the mount type. */ /** 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` * 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` * 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. * 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, * One setup placement: move `parts` to `path`. Entries are applied in order,
@@ -184,12 +184,60 @@ export interface Setup {
surfaces?: string[]; surfaces?: string[];
/** Ordered placements; each moves its parts to its path. */ /** Ordered placements; each moves its parts to its path. */
setup: SetupPlacement[]; 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. */ /** A dialog's action button: a label and the command it issues. */
export const ROLES: ReadonlySet<Role> = new Set(['package', 'part', 'surface', 'setup']); 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<Role> = new Set([
"package",
"part",
"surface",
"setup",
"dialog",
]);
/** /**
* Role metadata declared on a code block's info string (`role=part.cargo`) or * 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`. */ /** The canonical file name for a role, e.g. `part.cargo.yaml`. */
export function roleToName(role: RoleMeta, ext = 'yaml'): string { export function roleToName(role: RoleMeta, ext = "yaml"): string {
if (role.role === 'package') return `package.${ext}`; if (role.role === "package") return `package.${ext}`;
return `${role.role}.${role.type}.${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`, ...). * the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
*/ */
export function roleFromName(name: string): RoleMeta | undefined { 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); 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 { role: m[1] as Role, type: m[2] };
} }
return undefined; return undefined;
@@ -231,25 +279,29 @@ export interface RawDef {
[key: string]: unknown; [key: string]: unknown;
} }
/** The four definition roles. */ /** The five definition roles. */
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef; export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef | DialogDef;
export interface PackageDef extends PackageMeta { export interface PackageDef extends PackageMeta {
role: 'package'; role: "package";
/** Git-style path patterns of the defs that make up the package. */ /** Git-style path patterns of the defs that make up the package. */
include?: string[]; include?: string[];
} }
export interface PartDef extends Part { export interface PartDef extends Part {
role: 'part'; role: "part";
} }
export interface SurfaceDef extends Surface { export interface SurfaceDef extends Surface {
role: 'surface'; role: "surface";
} }
export interface SetupDef extends Setup { 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. */ /** 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 location for error messages (real path or `file.md:12-19`). */
source: string; source: string;
/** File type derived from the name's extension. */ /** 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 declared on a code block's info string or a real file's name. */
role?: RoleMeta; role?: RoleMeta;
/** /**
@@ -299,6 +351,8 @@ export interface Package {
surfaces: Map<string, Surface>; surfaces: Map<string, Surface>;
/** All setups by `type#id`. */ /** All setups by `type#id`. */
setups: Map<string, Setup>; setups: Map<string, Setup>;
/** All dialogs by `type#id`. */
dialogs: Map<string, Dialog>;
} }
/** /**
@@ -314,12 +368,17 @@ export interface SerializedPackage {
surfaces: Record<string, Surface>; surfaces: Record<string, Surface>;
/** All setups by `type#id`. */ /** All setups by `type#id`. */
setups: Record<string, Setup>; setups: Record<string, Setup>;
/** All dialogs by `type#id`. */
dialogs: Record<string, Dialog>;
} }
/** Errors during loading, carrying the source location when available. */ /** Errors during loading, carrying the source location when available. */
export class BgmError extends Error { export class BgmError extends Error {
constructor(message: string, readonly location?: string) { constructor(
message: string,
readonly location?: string,
) {
super(location ? `${location}: ${message}` : message); super(location ? `${location}: ${message}` : message);
this.name = 'BgmError'; this.name = "BgmError";
} }
} }
+16 -13
View File
@@ -15,17 +15,17 @@
* be mistaken for real installed packages. Editing a game definition * be mistaken for real installed packages. Editing a game definition
* hot-reloads the app via `addWatchFile`. * hot-reloads the app via `addWatchFile`.
*/ */
import * as path from 'node:path'; import * as path from "node:path";
import { normalizePath, type ModuleNode, type Plugin } from 'vite'; import { normalizePath, type ModuleNode, type Plugin } from "vite";
import { collectPackages, loadDefs } from './collect.js'; import { collectPackages, loadDefs } from "./collect.js";
import { readDefFiles } from './parse.js'; import { readDefFiles } from "./parse.js";
import type { Package, SerializedPackage } from './types.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. */ /** 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. */ /** Public specifier prefix for a single package module. */
const PACKAGE = 'virtual:bgm/package/'; const PACKAGE = "virtual:bgm/package/";
export interface BgmOptions { export interface BgmOptions {
/** Absolute path to the games root (e.g. `<repo>/games`). */ /** Absolute path to the games root (e.g. `<repo>/games`). */
@@ -39,23 +39,23 @@ export function bgm(options: BgmOptions): Plugin {
const root = normalizePath(options.root); const root = normalizePath(options.root);
const collect = (): Package[] => { const collect = (): Package[] => {
const defMap = loadDefs('', root); const defMap = loadDefs("", root);
return collectPackages(defMap, root); return collectPackages(defMap, root);
}; };
return { return {
name: 'bgm', name: "bgm",
buildStart() { buildStart() {
// Watch every real definition source under the games root so edits // Watch every real definition source under the games root so edits
// trigger a re-collect. `defMap.files` only holds the virtual code-block // trigger a re-collect. `defMap.files` only holds the virtual code-block
// files plus real non-markdown files; the markdown files themselves are // files plus real non-markdown files; the markdown files themselves are
// consumed for their code blocks and never appear there, so watch the // consumed for their code blocks and never appear there, so watch the
// real files on disk too (markdown and anything else the loader reads). // 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()) { for (const name of defMap.files.keys()) {
this.addWatchFile(path.join(root, name)); this.addWatchFile(path.join(root, name));
} }
for (const file of readDefFiles(root, '')) { for (const file of readDefFiles(root, "")) {
this.addWatchFile(file.source); 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. // changes, so edits hot-reload instead of requiring a manual refresh.
if (!ctx.file.startsWith(root)) return; if (!ctx.file.startsWith(root)) return;
const invalidated: ModuleNode[] = []; const invalidated: ModuleNode[] = [];
const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES); const mod = ctx.server.moduleGraph.getModuleById(
VIRTUAL_PREFIX + PACKAGES,
);
if (mod) { if (mod) {
ctx.server.moduleGraph.invalidateModule(mod); ctx.server.moduleGraph.invalidateModule(mod);
invalidated.push(mod); invalidated.push(mod);
@@ -102,5 +104,6 @@ function toJson(pkg: Package): SerializedPackage {
parts: Object.fromEntries(pkg.parts), parts: Object.fromEntries(pkg.parts),
surfaces: Object.fromEntries(pkg.surfaces), surfaces: Object.fromEntries(pkg.surfaces),
setups: Object.fromEntries(pkg.setups), setups: Object.fromEntries(pkg.setups),
dialogs: Object.fromEntries(pkg.dialogs),
}; };
} }
+69 -39
View File
@@ -1,22 +1,31 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import * as path from 'node:path'; import * as path from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
import { bgm } from './vite.js'; import { bgm } from "./vite.js";
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__'); const fixtureRoot = path.resolve(
const gamesRoot = path.join(fixtureRoot, 'harbor'); 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 * 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 * a `{ handler, order }` object. Our plugin uses plain functions, so cast the
* hook to a callable for direct invocation in tests. * hook to a callable for direct invocation in tests.
*/ */
type Callable<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never; type Callable<T> = T extends (...args: infer A) => infer R
? (...args: A) => R
: never;
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown { function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, { return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
id,
undefined,
{
isEntry: false, isEntry: false,
}); },
);
} }
function load(plugin: ReturnType<typeof bgm>, id: string): unknown { function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
@@ -25,73 +34,94 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
/** Parse the JSON payload out of an emitted `export default <json>` module. */ /** Parse the JSON payload out of an emitted `export default <json>` module. */
function parseModule(code: unknown): unknown { function parseModule(code: unknown): unknown {
expect(String(code).startsWith('export default ')).toBe(true); expect(String(code).startsWith("export default ")).toBe(true);
return JSON.parse(String(code).slice('export default '.length)); return JSON.parse(String(code).slice("export default ".length));
} }
describe('bgm vite plugin', () => { describe("bgm vite plugin", () => {
it('resolves bgm imports to the virtual module', () => { it("resolves bgm imports to the virtual module", () => {
const plugin = bgm({ root: gamesRoot }); const plugin = bgm({ root: gamesRoot });
expect(resolveId(plugin, 'virtual:bgm/packages')).toBe('\0bgm:virtual:bgm/packages'); expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
expect(resolveId(plugin, 'virtual:bgm/package/harbor')).toBe('\0bgm:virtual:bgm/package/harbor'); "\0bgm:virtual:bgm/packages",
expect(resolveId(plugin, 'virtual:bgm/package/nope')).toBe('\0bgm:virtual:bgm/package/nope'); );
expect(resolveId(plugin, 'other')).toBeUndefined(); 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 plugin = bgm({ root: gamesRoot });
const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array<Record<string, any>>; const packages = parseModule(
load(plugin, "\0bgm:virtual:bgm/packages"),
) as Array<Record<string, any>>;
expect(packages).toHaveLength(1); expect(packages).toHaveLength(1);
expect(packages[0].meta.id).toBe('harbor'); expect(packages[0].meta.id).toBe("harbor");
expect(packages[0].parts).toHaveProperty('token#wood'); 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 plugin = bgm({ root: gamesRoot });
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>; const pkg = parseModule(
expect(pkg.meta.id).toBe('harbor'); load(plugin, "\0bgm:virtual:bgm/package/harbor"),
expect(pkg.parts).toHaveProperty('token#wood'); ) as Record<string, any>;
expect(pkg.surfaces).toHaveProperty('board#harbor'); 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 plugin = bgm({ root: gamesRoot });
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>; const pkg = parseModule(
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
) as Record<string, any>;
// The emitted shape must be JSON-serializable: plain objects keyed by // The emitted shape must be JSON-serializable: plain objects keyed by
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`). // `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]).not.toBeInstanceOf(Map);
expect(pkg[key]).toEqual(expect.any(Object)); expect(pkg[key]).toEqual(expect.any(Object));
} }
// Consumers read the collections with Object.values / Object.keys. // Consumers read the collections with Object.values / Object.keys.
expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']); expect(
expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']); Object.values(pkg.parts)
expect(Object.keys(pkg.setups)).toEqual(['game#main']); .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 }); 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 plugin = bgm({ root: gamesRoot });
const watched: string[] = []; const watched: string[] = [];
const context = { addWatchFile: (file: string) => watched.push(file) }; const context = { addWatchFile: (file: string) => watched.push(file) };
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores // Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
// the options, so pass a placeholder. // the options, so pass a placeholder.
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never); (plugin.buildStart as Callable<typeof plugin.buildStart>).call(
context,
{} as never,
);
// Every def file (real + virtual code blocks) is watched so edits // Every def file (real + virtual code blocks) is watched so edits
// trigger a re-collect. The real markdown source must be watched too: // trigger a re-collect. The real markdown source must be watched too:
// `defMap.files` only lists virtual code-block files, so without watching // `defMap.files` only lists virtual code-block files, so without watching
// the on-disk `.md` file the dev server would never notice an edit. // the on-disk `.md` file the dev server would never notice an edit.
expect(watched.length).toBeGreaterThan(0); expect(watched.length).toBeGreaterThan(0);
expect(watched.some((f) => f.endsWith('.yaml'))).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(".csv"))).toBe(true);
expect(watched.some((f) => f.endsWith('.md'))).toBe(true); expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
expect(watched.every((f) => path.isAbsolute(f))).toBe(true); expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
}); });
}); });
+16 -12
View File
@@ -1,19 +1,23 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { serializedToPackage } from './package.js'; import { serializedToPackage } from "./package.js";
describe('serializedToPackage', () => { describe("serializedToPackage", () => {
it('converts plain-object maps to Map instances', () => { it("converts plain-object maps to Map instances", () => {
const serialized = { const serialized = {
meta: { id: 'harbor' }, meta: { id: "harbor" },
parts: { 'token#wood': { type: 'token', id: 'wood' } }, parts: { "token#wood": { type: "token", id: "wood" } },
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } }, surfaces: { "board#harbor": { type: "board", id: "harbor", layout: [] } },
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } }, setups: { "game#main": { type: "game", id: "main", setup: [] } },
dialogs: {
"prompt#insert": { type: "prompt", id: "insert", title: "Insert" },
},
}; };
const pkg = serializedToPackage(serialized); const pkg = serializedToPackage(serialized);
expect(pkg.meta.id).toBe('harbor'); expect(pkg.meta.id).toBe("harbor");
expect(pkg.parts).toBeInstanceOf(Map); expect(pkg.parts).toBeInstanceOf(Map);
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' }); expect(pkg.parts.get("token#wood")).toEqual({ type: "token", id: "wood" });
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' }); expect(pkg.surfaces.get("board#harbor")).toMatchObject({ type: "board" });
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' }); expect(pkg.setups.get("game#main")).toMatchObject({ type: "game" });
expect(pkg.dialogs.get("prompt#insert")).toMatchObject({ type: "prompt" });
}); });
}); });
+2 -1
View File
@@ -4,7 +4,7 @@
* `virtual:bgm/*` get the serialized form; the tabletop components take the * `virtual:bgm/*` get the serialized form; the tabletop components take the
* `Package` form. * `Package` form.
*/ */
import type { Package, SerializedPackage } from '@tts/bgm'; import type { Package, SerializedPackage } from "@tts/bgm";
export function serializedToPackage(serialized: SerializedPackage): Package { export function serializedToPackage(serialized: SerializedPackage): Package {
return { return {
@@ -12,5 +12,6 @@ export function serializedToPackage(serialized: SerializedPackage): Package {
parts: new Map(Object.entries(serialized.parts)), parts: new Map(Object.entries(serialized.parts)),
surfaces: new Map(Object.entries(serialized.surfaces)), surfaces: new Map(Object.entries(serialized.surfaces)),
setups: new Map(Object.entries(serialized.setups)), setups: new Map(Object.entries(serialized.setups)),
dialogs: new Map(Object.entries(serialized.dialogs)),
}; };
} }
+80 -50
View File
@@ -1,88 +1,118 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import type { Package } from '@tts/bgm'; import type { Facing, Package } from "@tts/bgm";
import { expandSetupValue, seedFromSetup } from './setup.js'; import { expandSetupValue, seedFromSetup } from "./setup.js";
const pkg: Package = { const pkg: Package = {
meta: { id: 'harbor' }, meta: { id: "harbor" },
parts: new Map([ parts: new Map([
['token#wood', { type: 'token', id: 'wood' }], ["token#wood", { type: "token", id: "wood" }],
['token#grain', { type: 'token', id: 'grain' }], ["token#grain", { type: "token", id: "grain" }],
['card#fleet', { type: 'card', id: 'fleet' }], ["card#fleet", { type: "card", id: "fleet" }],
]), ]),
surfaces: new Map([ surfaces: new Map([
['board#harbor', { type: 'board', id: 'harbor', layout: [] }], ["board#harbor", { type: "board", id: "harbor", layout: [] }],
['hud#hand', { type: 'hud', id: 'hand', layout: [] }], ["hud#hand", { type: "hud", id: "hand", layout: [] }],
]), ]),
setups: new Map(), setups: new Map(),
dialogs: new Map(),
}; };
describe('expandSetupValue', () => { describe("expandSetupValue", () => {
it('keeps a full part id', () => { it("keeps a full part id", () => {
expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']); 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',
]); ]);
}); });
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', () => { describe("seedFromSetup", () => {
it('enables listed surfaces and places parts', () => { it("enables listed surfaces and places parts", () => {
const setup = { const setup = {
type: 'game', type: "game",
id: 'main', id: "main",
surfaces: ['board#harbor'], surfaces: ["board#harbor"],
setup: [{ path: '/deck', parts: 'harbor:card#fleet' }], setup: [{ path: "/deck", parts: "harbor:card#fleet" }],
}; };
const state = seedFromSetup(pkg, setup); const state = seedFromSetup(pkg, setup);
expect(state.surfaces).toEqual({ 'board#harbor': true }); expect(state.surfaces).toEqual({ "board#harbor": true });
expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, facing: 'face' } }); expect(state.parts).toEqual({
"harbor:card#fleet": { path: "/deck", index: 0, facing: "face" },
});
}); });
it('enables all surfaces when omitted', () => { it("enables all surfaces when omitted", () => {
const setup = { type: 'game', id: 'main', setup: [] }; const setup = { type: "game", id: "main", setup: [] };
const state = seedFromSetup(pkg, 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 // `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. // path's indices stay contiguous 0..n-1 so stacking stays valid.
const setup = { const setup = {
type: 'game', type: "game",
id: 'main', id: "main",
setup: [ setup: [
{ path: '/deck', parts: ['harbor:card#fleet', 'harbor:token#wood'] }, { path: "/deck", parts: ["harbor:card#fleet", "harbor:token#wood"] },
{ path: '/hand', parts: 'harbor:card#fleet' }, { path: "/hand", parts: "harbor:card#fleet" },
], ],
}; };
const state = seedFromSetup(pkg, setup); const state = seedFromSetup(pkg, setup);
expect(state.parts['harbor:card#fleet']).toEqual({ path: '/hand', index: 0, facing: 'face' }); expect(state.parts["harbor:card#fleet"]).toEqual({
expect(state.parts['harbor:token#wood']).toEqual({ path: '/deck', index: 0, facing: 'face' }); 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 = { const setup = {
type: 'game', type: "game",
id: 'main', id: "main",
setup: [ setup: [
{ path: '/deck', parts: 'harbor:card#fleet', facing: 'back' }, { path: "/deck", parts: "harbor:card#fleet", facing: "back" as Facing },
{ path: '/table', parts: 'harbor:token#wood', facing: 'standing' }, {
{ path: '/hand', parts: 'harbor:token#grain' }, path: "/table",
parts: "harbor:token#wood",
facing: "standing" as Facing,
},
{ path: "/hand", parts: "harbor:token#grain" },
], ],
}; };
const state = seedFromSetup(pkg, setup); const state = seedFromSetup(pkg, setup);
expect(state.parts['harbor:card#fleet']).toEqual({ path: '/deck', index: 0, facing: 'back' }); expect(state.parts["harbor:card#fleet"]).toEqual({
expect(state.parts['harbor:token#wood']).toEqual({ path: '/table', index: 0, facing: 'standing' }); 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`. // 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",
});
}); });
}); });
+117 -72
View File
@@ -1,177 +1,222 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import type { Package, Surface } from '@tts/bgm'; import type { Facing, Package, Surface } from "@tts/bgm";
import { import {
matchRoute, matchRoute,
childrenByPath, childrenByPath,
computeSurfacePlacements, computeSurfacePlacements,
computeRenderState, computeRenderState,
placementKey, placementKey,
} from './state.js'; } from "./state.js";
function makeSurface(overrides: Partial<Surface> = {}): Surface { function makeSurface(overrides: Partial<Surface> = {}): Surface {
return { return {
type: 'board', type: "board",
id: 'harbor', id: "harbor",
layout: [], layout: [],
...overrides, ...overrides,
}; };
} }
const pkg: Package = { const pkg: Package = {
meta: { id: 'harbor' }, meta: { id: "harbor" },
parts: new Map(), parts: new Map(),
surfaces: new Map([ surfaces: new Map([
['board#harbor', makeSurface()], ["board#harbor", makeSurface()],
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })], ["hud#hand", makeSurface({ type: "hud", id: "hand" })],
]), ]),
setups: new Map(), setups: new Map(),
dialogs: new Map(),
}; };
describe('matchRoute', () => { describe("matchRoute", () => {
it('matches a literal path', () => { it("matches a literal path", () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 }; const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined }); expect(matchRoute(route, "/deck")).toEqual({ candidate: undefined });
expect(matchRoute(route, '/other')).toBeNull(); expect(matchRoute(route, "/other")).toBeNull();
}); });
it('matches a :param against a candidate', () => { it("matches a :param against a candidate", () => {
const route = { const route = {
route: '/dock/:seat', route: "/dock/:seat",
x: 0, x: 0,
y: 0, y: 0,
rotation: 0, rotation: 0,
candidates: [ candidates: [
{ seat: '0', x: 40, y: 0 }, { seat: "0", x: 40, y: 0 },
{ seat: '1', x: 40, y: 20 }, { 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 = { const route = {
route: '/dock/:seat', route: "/dock/:seat",
x: 0, x: 0,
y: 0, y: 0,
rotation: 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', () => { it("fails on length mismatch", () => {
const route = { route: '/deck', x: 0, y: 0, rotation: 0 }; const route = { route: "/deck", x: 0, y: 0, rotation: 0 };
expect(matchRoute(route, '/deck/extra')).toBeNull(); expect(matchRoute(route, "/deck/extra")).toBeNull();
}); });
}); });
describe('childrenByPath', () => { describe("childrenByPath", () => {
it('groups parts by path, ordered by index', () => { it("groups parts by path, ordered by index", () => {
const parts = { const parts: Record<
'harbor:card#a': { path: '/deck', index: 1, facing: 'face' }, string,
'harbor:card#b': { path: '/deck', index: 0, facing: 'back' }, { path: string; index: number; facing: Facing }
'harbor:card#c': { path: '/community/0', index: 0, facing: 'standing' }, > = {
"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({ expect(childrenByPath(parts)).toEqual({
'/deck': ['harbor:card#b', 'harbor:card#a'], "/deck": ["harbor:card#b", "harbor:card#a"],
'/community/0': ['harbor:card#c'], "/community/0": ["harbor:card#c"],
}); });
}); });
}); });
describe('computeSurfacePlacements', () => { describe("computeSurfacePlacements", () => {
it('places parts on a matching route with index, stackSize, and facing', () => { it("places parts on a matching route with index, stackSize, and facing", () => {
const surface = makeSurface({ 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, { const placements = computeSurfacePlacements(surface, {
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, "harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
'harbor:card#b': { path: '/deck', index: 1, facing: 'back' }, "harbor:card#b": { path: "/deck", index: 1, facing: "back" as Facing },
}); });
expect(placements).toHaveLength(2); expect(placements).toHaveLength(2);
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, facing: 'face' }); expect(placements[0]).toMatchObject({
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, facing: 'back' }); 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', () => { it("drops parts with no matching route", () => {
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }); const surface = makeSurface({
layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }],
});
const placements = computeSurfacePlacements(surface, { const placements = computeSurfacePlacements(surface, {
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, "harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
'harbor:card#b': { path: '/elsewhere', index: 0, facing: 'face' }, "harbor:card#b": {
path: "/elsewhere",
index: 0,
facing: "face" as Facing,
},
}); });
expect(placements).toHaveLength(1); 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({ const surface = makeSurface({
layout: [ layout: [
{ {
route: '/dock/:seat', route: "/dock/:seat",
x: 0, x: 0,
y: 0, y: 0,
rotation: 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, { 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({ const surface = makeSurface({
layout: [ layout: [
{ {
route: '/dock/:seat', route: "/dock/:seat",
x: 0, x: 0,
y: 0, y: 0,
rotation: 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, { 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({ 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, { const placements = computeSurfacePlacements(surface, {
'harbor:card#b': { path: '/deck', index: 1, facing: 'face' }, "harbor:card#b": { path: "/deck", index: 1, facing: "face" as Facing },
'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, "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', () => { describe("computeRenderState", () => {
it('only includes enabled surfaces', () => { it("only includes enabled surfaces", () => {
const state = { const state = {
surfaces: { 'board#harbor': true, 'hud#hand': false }, surfaces: { "board#harbor": true, "hud#hand": false },
parts: { 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' } }, parts: {
"harbor:card#a": { path: "/deck", index: 0, facing: "face" as Facing },
},
}; };
pkg.surfaces.set( pkg.surfaces.set(
'board#harbor', "board#harbor",
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }), makeSurface({ layout: [{ route: "/deck", x: 0, y: 0, rotation: 0 }] }),
); );
const placements = computeRenderState(pkg, state); const placements = computeRenderState(pkg, state);
expect(placements).toHaveLength(1); expect(placements).toHaveLength(1);
expect(placements[0]!.surface).toBe('board#harbor'); expect(placements[0]!.surface).toBe("board#harbor");
}); });
}); });
describe('placementKey', () => { describe("placementKey", () => {
it('is unique per surface and piece', () => { it("is unique per surface and piece", () => {
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never; const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never; const b = { surface: "board#harbor", piece: "harbor:card#b" } as never;
const c = { surface: 'hud#hand', piece: 'harbor:card#a' } 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(b));
expect(placementKey(a)).not.toBe(placementKey(c)); expect(placementKey(a)).not.toBe(placementKey(c));
}); });