Compare commits
3
Commits
8eff44712f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf6785f9e | ||
|
|
d0e4269ad6 | ||
|
|
999b7a0771 |
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
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,28 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Package, Setup } from '@tts/bgm';
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Package, Setup } from "@tts/bgm";
|
||||
import {
|
||||
interactionsFor,
|
||||
dropPaths,
|
||||
pickPath,
|
||||
partFacings,
|
||||
nextFacing,
|
||||
} from './interactions.js';
|
||||
useInteractionStore,
|
||||
} from "./interactions.js";
|
||||
|
||||
const pkg: Package = {
|
||||
meta: { id: 'harbor' },
|
||||
meta: { id: "harbor" },
|
||||
parts: new Map([
|
||||
['card#a', { type: 'card', id: 'a' }],
|
||||
['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }],
|
||||
["card#a", { type: "card", id: "a" }],
|
||||
["card#b", { type: "card", id: "b", facing: ["face", "back"] }],
|
||||
]),
|
||||
surfaces: new Map([
|
||||
[
|
||||
'board#harbor',
|
||||
"board#harbor",
|
||||
{
|
||||
type: 'board',
|
||||
id: 'harbor',
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: '/deck', x: 0, y: 0, rotation: 0 },
|
||||
{ route: '/discard', x: 40, y: 0, rotation: 0 },
|
||||
{ route: "/deck", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/discard", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -32,77 +33,156 @@ const pkg: Package = {
|
||||
};
|
||||
|
||||
const setup: Setup = {
|
||||
type: 'game',
|
||||
id: 'main',
|
||||
type: "game",
|
||||
id: "main",
|
||||
setup: [],
|
||||
interactions: [
|
||||
{ dialog: 'prompt#insert', on: ['/deck'] },
|
||||
{ dialog: 'prompt#shuffle' },
|
||||
{ dialog: "prompt#insert", on: ["/deck"] },
|
||||
{ dialog: "prompt#shuffle" },
|
||||
],
|
||||
};
|
||||
|
||||
describe('interactionsFor', () => {
|
||||
it('returns the interactions whose `on` matches the path, plus any without `on`', () => {
|
||||
expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([
|
||||
'prompt#insert',
|
||||
'prompt#shuffle',
|
||||
describe("interactionsFor", () => {
|
||||
it("returns the interactions whose `on` matches the path, plus any without `on`", () => {
|
||||
expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([
|
||||
"prompt#insert",
|
||||
"prompt#shuffle",
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes interactions without `on` for any path', () => {
|
||||
expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([
|
||||
'prompt#shuffle',
|
||||
it("includes interactions without `on` for any path", () => {
|
||||
expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([
|
||||
"prompt#shuffle",
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the setup declares no interactions', () => {
|
||||
it("returns [] when the setup declares no interactions", () => {
|
||||
expect(
|
||||
interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'),
|
||||
interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropPaths', () => {
|
||||
it('uses the declared `on` paths when interactions exist', () => {
|
||||
expect([...dropPaths(setup, pkg)]).toEqual(['/deck']);
|
||||
describe("dropPaths", () => {
|
||||
it("uses the declared `on` paths when interactions exist", () => {
|
||||
expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]);
|
||||
});
|
||||
|
||||
it('falls back to every literal routed path when no interactions are declared', () => {
|
||||
const bare: Setup = { type: 'game', id: 'main', setup: [] };
|
||||
expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']);
|
||||
it("falls back to every literal routed path when no interactions are declared", () => {
|
||||
const bare: Setup = { type: "game", id: "main", setup: [] };
|
||||
expect([...dropPaths(bare, pkg)].sort()).toEqual(["/deck", "/discard"]);
|
||||
});
|
||||
|
||||
it("skips :param routes in the fallback, since they need a candidate", () => {
|
||||
const paramPkg: Package = {
|
||||
...pkg,
|
||||
surfaces: new Map([
|
||||
[
|
||||
"board#harbor",
|
||||
{
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/deck", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const bare: Setup = { type: "game", id: "main", setup: [] };
|
||||
expect([...dropPaths(bare, paramPkg)]).toEqual(["/deck"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickPath', () => {
|
||||
it('returns the nearest literal route within the threshold', () => {
|
||||
expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard');
|
||||
describe("pickPath", () => {
|
||||
it("returns the nearest literal route within the threshold", () => {
|
||||
expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard");
|
||||
});
|
||||
|
||||
it('returns null when nothing is within the threshold', () => {
|
||||
expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull();
|
||||
it("returns null when nothing is within the threshold", () => {
|
||||
expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown surface', () => {
|
||||
expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull();
|
||||
it("returns null for an unknown surface", () => {
|
||||
expect(pickPath(pkg, "board#nope", 0, 0, 40)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips :param routes, which have no fixed anchor", () => {
|
||||
const paramPkg: Package = {
|
||||
...pkg,
|
||||
surfaces: new Map([
|
||||
[
|
||||
"board#harbor",
|
||||
{
|
||||
type: "board",
|
||||
id: "harbor",
|
||||
layout: [
|
||||
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
|
||||
{ route: "/deck", x: 40, y: 0, rotation: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
expect(pickPath(paramPkg, "board#harbor", 100, 100, 40)).toBeNull();
|
||||
expect(pickPath(paramPkg, "board#harbor", 41, 1, 40)).toBe("/deck");
|
||||
});
|
||||
});
|
||||
|
||||
describe('partFacings / nextFacing', () => {
|
||||
it('defaults to the full set when the part declares none', () => {
|
||||
expect(partFacings(pkg.parts.get('card#a')!)).toEqual([
|
||||
'face',
|
||||
'back',
|
||||
'standing',
|
||||
describe("useInteractionStore", () => {
|
||||
beforeEach(() => {
|
||||
useInteractionStore.setState({ held: null, dialogs: [] });
|
||||
});
|
||||
|
||||
it("holds and drops a part", () => {
|
||||
useInteractionStore
|
||||
.getState()
|
||||
.hold({ id: "harbor:card#a", origin: "/deck" });
|
||||
expect(useInteractionStore.getState().held).toEqual({
|
||||
id: "harbor:card#a",
|
||||
origin: "/deck",
|
||||
});
|
||||
useInteractionStore.getState().drop();
|
||||
expect(useInteractionStore.getState().held).toBeNull();
|
||||
});
|
||||
|
||||
it("pushes and pops the dialog stack", () => {
|
||||
const { pushDialog, popDialog } = useInteractionStore.getState();
|
||||
pushDialog({ id: "prompt#insert", path: "/deck" });
|
||||
pushDialog({ id: "prompt#shuffle", path: "/deck" });
|
||||
expect(useInteractionStore.getState().dialogs).toEqual([
|
||||
{ id: "prompt#insert", path: "/deck" },
|
||||
{ id: "prompt#shuffle", path: "/deck" },
|
||||
]);
|
||||
popDialog();
|
||||
expect(useInteractionStore.getState().dialogs).toEqual([
|
||||
{ id: "prompt#insert", path: "/deck" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partFacings / nextFacing", () => {
|
||||
it("defaults to the full set when the part declares none", () => {
|
||||
expect(partFacings(pkg.parts.get("card#a")!)).toEqual([
|
||||
"face",
|
||||
"back",
|
||||
"standing",
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the declared affordance', () => {
|
||||
expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']);
|
||||
it("uses the declared affordance", () => {
|
||||
expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]);
|
||||
});
|
||||
|
||||
it('cycles forward through the affordance', () => {
|
||||
const part = pkg.parts.get('card#b')!;
|
||||
expect(nextFacing(part, 'face')).toBe('back');
|
||||
expect(nextFacing(part, 'back')).toBe('face');
|
||||
it("cycles forward through the affordance", () => {
|
||||
const part = pkg.parts.get("card#b")!;
|
||||
expect(nextFacing(part, "face")).toBe("back");
|
||||
expect(nextFacing(part, "back")).toBe("face");
|
||||
});
|
||||
|
||||
it("wraps to the first facing after the last", () => {
|
||||
const part = pkg.parts.get("card#b")!;
|
||||
expect(nextFacing(part, "back")).toBe("face");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,131 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
|
||||
|
||||
describe('parsePath', () => {
|
||||
it('measures a straight line', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
describe("parsePath", () => {
|
||||
it("measures a straight line", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(path.length).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('measures a cubic curve', () => {
|
||||
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
|
||||
it("measures a cubic curve", () => {
|
||||
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
|
||||
// Longer than the chord (60) but finite.
|
||||
expect(path.length).toBeGreaterThan(60);
|
||||
expect(path.length).toBeLessThan(80);
|
||||
});
|
||||
|
||||
it('handles relative commands', () => {
|
||||
const path = parsePath('m 0 0 l 10 0 l 0 10');
|
||||
it("handles relative commands", () => {
|
||||
const path = parsePath("m 0 0 l 10 0 l 0 10");
|
||||
expect(path.length).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('supports h/v/z', () => {
|
||||
const path = parsePath('M 0 0 H 10 V 10 Z');
|
||||
it("supports h/v/z", () => {
|
||||
const path = parsePath("M 0 0 H 10 V 10 Z");
|
||||
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
|
||||
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
|
||||
});
|
||||
|
||||
it("supports quadratic curves (Q)", () => {
|
||||
const path = parsePath("M 0 0 Q 50 50 100 0");
|
||||
// Longer than the chord (100) but finite.
|
||||
expect(path.length).toBeGreaterThan(100);
|
||||
expect(path.length).toBeLessThan(120);
|
||||
// The curve passes through the midpoint of the control point.
|
||||
const mid = pointAt(path, path.length / 2);
|
||||
expect(mid.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports smooth quadratic continuation (T)", () => {
|
||||
const path = parsePath("M 0 0 Q 50 50 100 0 T 200 0");
|
||||
// Two quadratic segments; the second reflects the first's control point.
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("supports smooth cubic continuation (S)", () => {
|
||||
const path = parsePath("M 0 0 C 25 50 75 50 100 0 S 175 -50 200 0");
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("supports elliptical arcs (A)", () => {
|
||||
// A semicircle of radius 50: length ≈ π * 50.
|
||||
const path = parsePath("M 0 0 A 50 50 0 0 1 100 0");
|
||||
expect(path.length).toBeCloseTo(Math.PI * 50, 0);
|
||||
});
|
||||
|
||||
it("supports relative variants of each command", () => {
|
||||
const path = parsePath("m 0 0 q 50 50 100 0 t 100 0");
|
||||
expect(path.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("throws on an unsupported command", () => {
|
||||
expect(() => parsePath("M 0 0 R 10 10")).toThrow(
|
||||
/Unsupported SVG path command/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointAt', () => {
|
||||
it('returns the start at distance 0 and end at full length', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
describe("pointAt", () => {
|
||||
it("returns the start at distance 0 and end at full length", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
||||
const end = pointAt(path, path.length);
|
||||
expect(end.x).toBeCloseTo(10);
|
||||
expect(end.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('interpolates along the path', () => {
|
||||
const path = parsePath('M 0 0 L 10 0');
|
||||
it("interpolates along the path", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
const mid = pointAt(path, 5);
|
||||
expect(mid.x).toBeCloseTo(5);
|
||||
expect(mid.angle).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('returns the tangent angle in degrees', () => {
|
||||
const path = parsePath('M 0 0 L 10 10');
|
||||
it("returns the tangent angle in degrees", () => {
|
||||
const path = parsePath("M 0 0 L 10 10");
|
||||
expect(pointAt(path, 5).angle).toBeCloseTo(45);
|
||||
const down = parsePath('M 0 0 L 0 10');
|
||||
const down = parsePath("M 0 0 L 0 10");
|
||||
expect(pointAt(down, 5).angle).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('returns the tangent angle at the start of the path', () => {
|
||||
const path = parsePath('M 0 0 L 10 10');
|
||||
it("returns the tangent angle at the start of the path", () => {
|
||||
const path = parsePath("M 0 0 L 10 10");
|
||||
expect(pointAt(path, 0).angle).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it("clamps distance to the path length", () => {
|
||||
const path = parsePath("M 0 0 L 10 0");
|
||||
expect(pointAt(path, 999).x).toBeCloseTo(10);
|
||||
expect(pointAt(path, -5).x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("returns the origin for an empty path", () => {
|
||||
expect(pointAt({ points: [], length: 0 }, 5)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the single point for a degenerate path", () => {
|
||||
const path = parsePath("M 5 5");
|
||||
expect(pointAt(path, 0)).toEqual({ x: 5, y: 5, angle: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stackingOffset', () => {
|
||||
it('defaults to a 1° tilt without a curve', () => {
|
||||
expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||
describe("stackingOffset", () => {
|
||||
it("defaults to a 1° tilt without a curve", () => {
|
||||
expect(stackingOffset(undefined, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('spreads parts evenly along a straight curve', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
|
||||
it("spreads parts evenly along a straight curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
|
||||
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
||||
expect(offset.x).toBeCloseTo(50);
|
||||
expect(offset.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('aligns to center', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
|
||||
it("aligns to center", () => {
|
||||
const offset = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", align: "center" },
|
||||
0,
|
||||
3,
|
||||
);
|
||||
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('aligns to end', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
|
||||
it("aligns to end", () => {
|
||||
const offset = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", align: "end" },
|
||||
2,
|
||||
3,
|
||||
);
|
||||
// start = 100 - 100 = 0; part 2 at 100.
|
||||
expect(offset.x).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it('respects a positive limit (first n)', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
|
||||
it("respects a positive limit (first n)", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: 2 }, 2, 4);
|
||||
// Part 2 is beyond the first 2 shown -> not placed.
|
||||
expect(offset).toBe(NO_OFFSET);
|
||||
});
|
||||
|
||||
it('respects a negative limit (last n)', () => {
|
||||
it("respects a negative limit (last n)", () => {
|
||||
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: -2 }, 2, 4);
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('uses steps to densify the curve', () => {
|
||||
it("uses steps to densify the curve", () => {
|
||||
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", steps: 4 }, 1, 3);
|
||||
expect(offset.x).toBeCloseTo(25);
|
||||
});
|
||||
|
||||
it('tilts every part the same amount without a curve', () => {
|
||||
it("tilts every part the same amount without a curve", () => {
|
||||
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||
});
|
||||
|
||||
it('tilts every part the same amount along the curve', () => {
|
||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
|
||||
it("tilts every part the same amount along the curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
|
||||
expect(offset.x).toBeCloseTo(50);
|
||||
expect(offset.tilt).toBeCloseTo(0.1);
|
||||
});
|
||||
|
||||
it('tilts only the shown parts', () => {
|
||||
it("tilts only the shown parts", () => {
|
||||
// limit 2 shows indices 0,1; index 2 is dropped.
|
||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET);
|
||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
|
||||
});
|
||||
|
||||
it('ramps z from zStart to zEnd across the curve', () => {
|
||||
it("ramps z from zStart to zEnd across the curve", () => {
|
||||
// 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40.
|
||||
const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3);
|
||||
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
|
||||
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
|
||||
const first = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
0,
|
||||
3,
|
||||
);
|
||||
const mid = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
1,
|
||||
3,
|
||||
);
|
||||
const last = stackingOffset(
|
||||
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||
2,
|
||||
3,
|
||||
);
|
||||
expect(first.z).toBeCloseTo(0);
|
||||
expect(mid.z).toBeCloseTo(20);
|
||||
expect(last.z).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
it('returns no offset for an empty stack', () => {
|
||||
it("returns no offset for an empty stack", () => {
|
||||
expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET);
|
||||
});
|
||||
|
||||
it("applies only the default tilt when a curve has zero length", () => {
|
||||
// A degenerate curve (a single point) has length 0, so no horizontal
|
||||
// offset applies, but the default 1° tilt still does.
|
||||
expect(stackingOffset({ curve: "M 5 5" }, 0, 3)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
tilt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("places a single part at the start of the curve", () => {
|
||||
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 0, 1);
|
||||
// span = max(steps=1, 0) = 1; step = 100; part 0 at 0.
|
||||
expect(offset.x).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +1,328 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Package, Surface } from '@tts/bgm';
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Facing, Package, Surface } from "@tts/bgm";
|
||||
import {
|
||||
matchRoute,
|
||||
childrenByPath,
|
||||
computeSurfacePlacements,
|
||||
computeRenderState,
|
||||
placementKey,
|
||||
} from './state.js';
|
||||
useTabletopStore,
|
||||
} 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("useTabletopStore", () => {
|
||||
beforeEach(() => {
|
||||
useTabletopStore.setState({ surfaces: {}, parts: {} });
|
||||
});
|
||||
|
||||
it("seeds surfaces and parts", () => {
|
||||
useTabletopStore
|
||||
.getState()
|
||||
.seed({ surfaces: { "board#harbor": true }, parts: {} });
|
||||
expect(useTabletopStore.getState().surfaces).toEqual({
|
||||
"board#harbor": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("enables and disables a surface", () => {
|
||||
useTabletopStore.getState().enableSurface("board#harbor");
|
||||
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(true);
|
||||
useTabletopStore.getState().disableSurface("board#harbor");
|
||||
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(false);
|
||||
});
|
||||
|
||||
it("setPart patches an existing part and ignores an unknown id", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().setPart("harbor:card#a", { facing: "back" });
|
||||
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "back",
|
||||
});
|
||||
useTabletopStore.getState().setPart("harbor:card#nope", { facing: "back" });
|
||||
expect(
|
||||
useTabletopStore.getState().parts["harbor:card#nope"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("movePart reindexes source and destination, inserting at index", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
|
||||
"harbor:card#c": { path: "/deck", index: 2, facing: "face" },
|
||||
});
|
||||
// Move the top card to the bottom (index 0).
|
||||
useTabletopStore.getState().movePart("harbor:card#c", "/deck", 0);
|
||||
const parts = useTabletopStore.getState().parts;
|
||||
expect(parts["harbor:card#c"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 1,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#b"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 2,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
|
||||
it("movePart moves between paths, closing the source gap", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
|
||||
"harbor:card#c": { path: "/discard", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#a", "/discard", 0);
|
||||
const parts = useTabletopStore.getState().parts;
|
||||
expect(parts["harbor:card#a"]).toEqual({
|
||||
path: "/discard",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#c"]).toEqual({
|
||||
path: "/discard",
|
||||
index: 1,
|
||||
facing: "face",
|
||||
});
|
||||
expect(parts["harbor:card#b"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
});
|
||||
|
||||
it("movePart clamps index and ignores an unknown id", () => {
|
||||
useTabletopStore.getState().setParts({
|
||||
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#a", "/deck", 99);
|
||||
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
|
||||
path: "/deck",
|
||||
index: 0,
|
||||
facing: "face",
|
||||
});
|
||||
useTabletopStore.getState().movePart("harbor:card#nope", "/deck", 0);
|
||||
expect(
|
||||
useTabletopStore.getState().parts["harbor:card#nope"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
Generated
+144
-6
@@ -8,12 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(vitest@4.1.10)
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
apps/proxy:
|
||||
dependencies:
|
||||
@@ -147,7 +150,7 @@ importers:
|
||||
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
packages/bgm:
|
||||
dependencies:
|
||||
@@ -184,7 +187,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
packages/engine:
|
||||
devDependencies:
|
||||
@@ -216,7 +219,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
packages/mesh:
|
||||
dependencies:
|
||||
@@ -294,7 +297,7 @@ importers:
|
||||
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
packages/tts:
|
||||
dependencies:
|
||||
@@ -314,10 +317,31 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.8':
|
||||
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
@@ -999,6 +1023,15 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.10':
|
||||
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.10':
|
||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||
|
||||
@@ -1032,6 +1065,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.5:
|
||||
resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1142,6 +1178,10 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hls.js@1.6.17:
|
||||
resolution: {integrity: sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==}
|
||||
|
||||
@@ -1149,6 +1189,9 @@ packages:
|
||||
resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -1164,6 +1207,18 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
its-fine@2.0.0:
|
||||
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
|
||||
peerDependencies:
|
||||
@@ -1173,6 +1228,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
@@ -1339,6 +1397,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.4:
|
||||
resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked@16.4.2:
|
||||
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -1497,6 +1562,10 @@ packages:
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
suspend-react@0.1.3:
|
||||
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
|
||||
peerDependencies:
|
||||
@@ -1740,8 +1809,23 @@ snapshots:
|
||||
package-manager-detector: 1.8.0
|
||||
tinyexec: 1.3.0
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.8':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
@@ -2224,6 +2308,20 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
|
||||
|
||||
'@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.10
|
||||
ast-v8-to-istanbul: 1.0.5
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.4
|
||||
obug: 2.1.4
|
||||
std-env: 4.2.0
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
|
||||
'@vitest/expect@4.1.10':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -2267,6 +2365,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.5:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
@@ -2375,10 +2479,14 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hls.js@1.6.17: {}
|
||||
|
||||
hono@4.13.1: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
@@ -2389,6 +2497,19 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
its-fine@2.0.0(@types/react@19.2.18)(react@19.2.8):
|
||||
dependencies:
|
||||
'@types/react-reconciler': 0.28.9(@types/react@19.2.18)
|
||||
@@ -2398,6 +2519,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
@@ -2514,6 +2637,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.4:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
meshline@3.3.1(three@0.185.1):
|
||||
@@ -2669,6 +2802,10 @@ snapshots:
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
suspend-react@0.1.3(react@19.2.8):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -2768,7 +2905,7 @@ snapshots:
|
||||
tsx: 4.23.11
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||
@@ -2792,6 +2929,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
||||
Reference in New Issue
Block a user