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:
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+106
-42
@@ -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<string, Part>();
|
||||
readonly surfaces = new Map<string, Surface>();
|
||||
readonly setups = new Map<string, Setup>();
|
||||
readonly dialogs = new Map<string, Dialog>();
|
||||
readonly byRole = new Map<string, string[]>();
|
||||
|
||||
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<string, unknown>, baseDir: string, source: string): Record<string, unknown>[] {
|
||||
if (!('$variants' in obj)) return [obj];
|
||||
const rows = expandVariants(obj['$variants'], baseNameFor(baseDir), this.defs.files, source);
|
||||
private expand(
|
||||
obj: Record<string, unknown>,
|
||||
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;
|
||||
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<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
|
||||
// (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<string, DefFile[]>,
|
||||
): Surface {
|
||||
const value: Record<string, unknown> = { ...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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||
}
|
||||
@@ -288,7 +342,7 @@ function asSurface(
|
||||
|
||||
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||
const value: Record<string, unknown> = { ...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<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. */
|
||||
function wrapZod(err: unknown, source: string): BgmError {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -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<string, unknown>): 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. */
|
||||
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
||||
return packageSchema.parse(value) as unknown as PackageDef;
|
||||
|
||||
+79
-20
@@ -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<Role> = 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<Role> = 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<string, Surface>;
|
||||
/** All setups by `type#id`. */
|
||||
setups: Map<string, Setup>;
|
||||
/** All dialogs by `type#id`. */
|
||||
dialogs: Map<string, Dialog>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,12 +368,17 @@ export interface SerializedPackage {
|
||||
surfaces: Record<string, Surface>;
|
||||
/** All setups by `type#id`. */
|
||||
setups: Record<string, Setup>;
|
||||
/** All dialogs by `type#id`. */
|
||||
dialogs: Record<string, Dialog>;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
}
|
||||
|
||||
+16
-13
@@ -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. `<repo>/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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> = 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 {
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
|
||||
isEntry: false,
|
||||
});
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
|
||||
id,
|
||||
undefined,
|
||||
{
|
||||
isEntry: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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. */
|
||||
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<Record<string, any>>;
|
||||
const packages = parseModule(
|
||||
load(plugin, "\0bgm:virtual:bgm/packages"),
|
||||
) as Array<Record<string, any>>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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<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
|
||||
// `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<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
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,177 +1,222 @@
|
||||
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> = {}): 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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user