diff --git a/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md b/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md new file mode 100644 index 0000000..dcd9c20 --- /dev/null +++ b/packages/bgm/src/__fixtures__/vite-build/games/azul/azul.md @@ -0,0 +1,52 @@ +# Azul + +A second tiny example game, used to exercise the loader's collection of +multiple packages. + +```yaml file=azul.yaml +role: package +id: azul +title: Azul +designer: Michael Kiesling +players: 4 +language: en +include: ['**/azul/**/*.yaml'] +``` + +```yaml file=parts/tiles.yaml +role: part +type: tile +id: blue +face: ./assets/tiles.png +faceCrop: [0, 0, 5, 5] +size: [20, 20, 3] +fillet: 1 +``` + +```yaml file=parts/board.yaml +type: board +id: azul +role: surface +size: [400, 300] +layout: + - route: /factory/:n + candidates: + $variants: ./factories.csv +``` + +```csv file=parts/factories.csv +n,x,y,rotation +string,number,number,number +0,-150,0,0 +1,-50,0,0 +2,50,0,0 +3,150,0,0 +``` + +```yaml file=setup/main.yaml +role: setup +type: game +id: main +setup: + /factory/0: azul:tile#blue +``` \ No newline at end of file diff --git a/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md b/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md index 7ed9319..94f851b 100644 --- a/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md +++ b/packages/bgm/src/__fixtures__/vite-build/games/harbor/harbor.md @@ -10,6 +10,7 @@ title: Harbor designer: Jane Doe players: 2 language: en +include: ['**/harbor/**/*.yaml'] ``` ```yaml file=parts/tokens.yaml diff --git a/packages/bgm/src/__fixtures__/vite-build/src/main.ts b/packages/bgm/src/__fixtures__/vite-build/src/main.ts index 64e1b29..32b8b87 100644 --- a/packages/bgm/src/__fixtures__/vite-build/src/main.ts +++ b/packages/bgm/src/__fixtures__/vite-build/src/main.ts @@ -1,11 +1,13 @@ import harbor from 'virtual:bgm/package/harbor'; +import azul from 'virtual:bgm/package/azul'; import packages from 'virtual:bgm/packages'; // Re-export the package data so the test can assert the bundled output. -export const parts = [...harbor.parts.keys()]; -export const surfaces = [...harbor.surfaces.keys()]; -export const setups = [...harbor.setups.keys()]; +export const parts = Object.keys(harbor.parts); +export const surfaces = Object.keys(harbor.surfaces); +export const setups = Object.keys(harbor.setups); export const title = harbor.meta.title; +export const azulTitle = azul.meta.title; export const allIds = packages.map((p) => p.meta.id); -console.log(title, parts, surfaces, setups, allIds); +console.log(title, parts, surfaces, setups, allIds, azulTitle); diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index b3c2e2b..5c51248 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -191,6 +191,21 @@ export interface Package { setups: Map; } +/** + * A package as emitted by the vite plugin: the `Map`s are serialized to + * plain objects keyed by `type#id`, since `JSON.stringify` can't encode a + * `Map`. This is the shape consumers receive from `virtual:bgm/*` modules. + */ +export interface SerializedPackage { + meta: PackageMeta; + /** All parts by `type#id`. */ + parts: Record; + /** All surfaces by `type#id`. */ + surfaces: Record; + /** All setups by `type#id`. */ + setups: Record; +} + /** Errors during loading, carrying the source location when available. */ export class BgmError extends Error { constructor(message: string, readonly location?: string) { diff --git a/packages/bgm/src/vite.test.ts b/packages/bgm/src/vite.test.ts index a53aed3..1e22b3c 100644 --- a/packages/bgm/src/vite.test.ts +++ b/packages/bgm/src/vite.test.ts @@ -41,9 +41,14 @@ describe('bgm vite plugin (integration)', () => { expect(code).toContain('game#main'); expect(code).toContain('Harbor'); + // A second package resolves through the same plugin. + expect(code).toContain('azul'); + expect(code).toContain('tile#blue'); + // The `bgm` module lists every discovered package. expect(code).toContain('allIds'); expect(code).toContain('"harbor"'); + expect(code).toContain('"azul"'); } finally { await fs.promises.rm(outDir, { recursive: true, force: true }); } diff --git a/packages/bgm/src/vite.ts b/packages/bgm/src/vite.ts index 07065d9..ea9b4f0 100644 --- a/packages/bgm/src/vite.ts +++ b/packages/bgm/src/vite.ts @@ -18,7 +18,7 @@ import * as path from 'node:path'; import type { Plugin } from 'vite'; import { collectPackages, loadDefs } from './collect.js'; -import type { Package } from './types.js'; +import type { Package, SerializedPackage } from './types.js'; const VIRTUAL_PREFIX = '\0bgm:'; /** Public specifier for the module that lists every package. */ @@ -73,7 +73,7 @@ export function bgm(options: BgmOptions): Plugin { * Serialize a `Package` for JSON emission. The parts/surfaces/setups are * `Map`s, which `JSON.stringify` would otherwise turn into `{}`. */ -function toJson(pkg: Package): Record { +function toJson(pkg: Package): SerializedPackage { return { meta: pkg.meta, parts: Object.fromEntries(pkg.parts), diff --git a/packages/bgm/src/vite.unit.test.ts b/packages/bgm/src/vite.unit.test.ts index 646c02e..d3ed493 100644 --- a/packages/bgm/src/vite.unit.test.ts +++ b/packages/bgm/src/vite.unit.test.ts @@ -23,6 +23,12 @@ function load(plugin: ReturnType, id: string): unknown { return (plugin.load as Callable)(id, { ssr: false }); } +/** Parse the JSON payload out of an emitted `export default ` module. */ +function parseModule(code: unknown): unknown { + expect(String(code).startsWith('export default ')).toBe(true); + return JSON.parse(String(code).slice('export default '.length)); +} + describe('bgm vite plugin', () => { it('resolves bgm imports to the virtual module', () => { const plugin = bgm({ root: gamesRoot }); @@ -34,10 +40,7 @@ describe('bgm vite plugin', () => { it('loads every package as a JSON module', () => { const plugin = bgm({ root: gamesRoot }); - const code = load(plugin, '\0bgm:virtual:bgm/packages'); - expect(code).toBeDefined(); - expect(String(code).startsWith('export default ')).toBe(true); - const packages = JSON.parse(String(code).slice('export default '.length)); + const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array>; expect(packages).toHaveLength(1); expect(packages[0].meta.id).toBe('harbor'); expect(packages[0].parts).toHaveProperty('token#wood'); @@ -45,17 +48,45 @@ describe('bgm vite plugin', () => { it('loads a package as a JSON module', () => { const plugin = bgm({ root: gamesRoot }); - const code = load(plugin, '\0bgm:virtual:bgm/package/harbor'); - expect(code).toBeDefined(); - expect(String(code).startsWith('export default ')).toBe(true); - const pkg = JSON.parse(String(code).slice('export default '.length)); + const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record; expect(pkg.meta.id).toBe('harbor'); expect(pkg.parts).toHaveProperty('token#wood'); expect(pkg.surfaces).toHaveProperty('board#harbor'); }); + it('serializes maps as plain objects, not Map instances', () => { + const plugin = bgm({ root: gamesRoot }); + const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record; + + // The emitted shape must be JSON-serializable: plain objects keyed by + // `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`). + for (const key of ['parts', 'surfaces', 'setups'] as const) { + 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']); + expect(Object.keys(pkg.setups)).toEqual(['game#main']); + }); + it('errors on an unknown package', () => { const plugin = bgm({ root: gamesRoot }); expect(() => load(plugin, '\0bgm:virtual:bgm/package/unknown')).toThrow(/not found/); }); + + it('watches every source file for reloads', () => { + const plugin = bgm({ root: gamesRoot }); + const watched: string[] = []; + const context = { addWatchFile: (file: string) => watched.push(file) }; + (plugin.buildStart as Callable).call(context); + + // Every def file (real + virtual code blocks) is watched so edits + // trigger a re-collect. + 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.every((f) => path.isAbsolute(f))).toBe(true); + }); });