Compare commits

..
11 Commits
Author SHA1 Message Date
hypercross 43b69133cc docs: add bgm-tabletop implementation plan 2026-08-09 21:08:01 +08:00
hypercross 599fb6a76d feat(bgm): add surface mounting and setup surfaces 2026-08-09 21:03:20 +08:00
hypercross 4e18ab5bc8 feat(web): add bgm viewer navigation 2026-08-09 20:02:03 +08:00
hypercross 64c8bb60b3 feat(web): add bgm package browser 2026-08-09 19:44:27 +08:00
hypercross c86d7444b8 docs: add poker game document 2026-08-09 19:44:05 +08:00
hypercross 7f3f758c52 test(bgm): cover emitted shape and multi-package collection
Add a SerializedPackage type for the JSON the plugin emits, and tests
that assert the maps serialize to plain objects, that the packages module
returns every package, and that buildStart watches def files. Add a
second fixture package to exercise multi-package collection.
2026-08-09 19:23:50 +08:00
hypercross 845d510948 refactor(bgm): use virtual: module specifiers
Rename the plugin's virtual modules to virtual:bgm/packages and
virtual:bgm/package/<id> so they read as plugin-provided modules rather
than real packages. Update the unit and build integration tests.
2026-08-09 19:16:17 +08:00
hypercross ba16f17d8a feat(bgm): add vite plugin and build integration test
Move the bgm vite plugin into the package so it can be tested in
isolation from the web app. Serve each package as a JSON module,
serializing its maps, and verify resolution through a real vite build
against a self-contained fixture.
2026-08-09 19:07:14 +08:00
hypercross c0967ab71f feat(bgm): add board game manifest loader
Parse yaml/json/toml and markdown code blocks into packages, expanding
$variants via typed-csv and collecting parts, surfaces, and setups by
include patterns. Ships zod validation, a vitest config, and 16 tests.
2026-08-09 18:59:14 +08:00
hypercross 1cfec40c99 docs: simplify BGM format description 2026-08-09 18:17:47 +08:00
hypercross 664079528c docs: add board game manifest specification 2026-08-09 18:14:43 +08:00
42 changed files with 3124 additions and 17 deletions
+1
View File
@@ -21,6 +21,7 @@
"@tts/extract": "workspace:*",
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+19
View File
@@ -2,6 +2,14 @@ import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage';
import FullSetupPage from './pages/FullSetupPage';
import BgmPage from './pages/BgmPage';
import BgmPackagePage from './pages/BgmPackagePage';
import PartsPage from './pages/PartsPage';
import PartPage from './pages/PartPage';
import SurfacesPage from './pages/SurfacesPage';
import SurfacePage from './pages/SurfacePage';
import SetupsPage from './pages/SetupsPage';
import SetupPage from './pages/SetupPage';
export default function App() {
return (
@@ -15,6 +23,9 @@ export default function App() {
<Link to="/" className="hover:text-zinc-100">
Search
</Link>
<Link to="/bgm" className="hover:text-zinc-100">
BGM
</Link>
</nav>
</div>
</header>
@@ -23,6 +34,14 @@ export default function App() {
<Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} />
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
<Route path="/bgm" element={<BgmPage />} />
<Route path="/bgm/:id" element={<BgmPackagePage />} />
<Route path="/bgm/:id/parts" element={<PartsPage />} />
<Route path="/bgm/:id/parts/:type/:part" element={<PartPage />} />
<Route path="/bgm/:id/surfaces" element={<SurfacesPage />} />
<Route path="/bgm/:id/surfaces/:type/:surface" element={<SurfacePage />} />
<Route path="/bgm/:id/setups" element={<SetupsPage />} />
<Route path="/bgm/:id/setups/:type/:setup" element={<SetupPage />} />
</Routes>
</main>
</div>
+33
View File
@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom';
export interface Crumb {
label: string;
/** Where the crumb links; omit for the current (last) crumb. */
to?: string;
}
/**
* A breadcrumb navigation trail. The last crumb is the current page and is
* rendered as plain text; earlier crumbs link to their routes.
*/
export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
return (
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400">
{crumbs.map((crumb, i) => {
const last = i === crumbs.length - 1;
return (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-zinc-600">/</span>}
{crumb.to && !last ? (
<Link to={crumb.to} className="hover:text-zinc-100">
{crumb.label}
</Link>
) : (
<span className={last ? 'text-zinc-100' : ''}>{crumb.label}</span>
)}
</span>
);
})}
</nav>
);
}
@@ -0,0 +1,15 @@
import { Link } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
/** Shown when a bgm package id doesn't resolve to a known package. */
export default function PackageMissing({ id }: { id: string }) {
return (
<div className="space-y-4">
<Breadcrumbs crumbs={[{ label: 'Board games', to: '/bgm' }, { label: id }]} />
<p className="text-sm text-zinc-400">No package {id}.</p>
<Link to="/bgm" className="text-sm text-zinc-300 underline">
Back to all packages
</Link>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* Summary view for a single bgm package: its metadata and counts of parts,
* surfaces, and setups, each linking to its collection page.
*/
export default function BgmPackagePage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const partCount = Object.keys(pkg.parts).length;
const surfaceCount = Object.keys(pkg.surfaces).length;
const setupCount = Object.keys(pkg.setups).length;
const sections = [
{
label: 'Parts',
count: partCount,
to: `/bgm/${pkg.meta.id}/parts`,
},
{
label: 'Surfaces',
count: surfaceCount,
to: `/bgm/${pkg.meta.id}/surfaces`,
},
{
label: 'Setups',
count: setupCount,
to: `/bgm/${pkg.meta.id}/setups`,
},
];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[{ label: 'Board games', to: '/bgm' }, { label: pkg.meta.title ?? pkg.meta.id }]}
/>
<div>
<h1 className="text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
<p className="mt-1 text-sm text-zinc-400">
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
{pkg.meta.language}
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-3">
{sections.map((section) => (
<li key={section.label}>
<Link
to={section.to}
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
>
<div className="font-medium">{section.label}</div>
<div className="mt-1 text-sm text-zinc-400">{section.count}</div>
</Link>
</li>
))}
</ul>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Link } from 'react-router-dom';
import packages from 'virtual:bgm/packages';
import Breadcrumbs from '../components/Breadcrumbs';
/**
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
* default export is an array of all packages the loader found in the games
* root.
*/
export default function BgmPage() {
return (
<div className="space-y-6">
<Breadcrumbs crumbs={[{ label: 'Board games' }]} />
<div>
<h1 className="text-2xl font-semibold">Board games</h1>
<p className="mt-1 text-sm text-zinc-400">
{packages.length} package{packages.length === 1 ? '' : 's'} discovered.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{packages.map((pkg) => (
<li key={pkg.meta.id}>
<Link
to={`/bgm/${pkg.meta.id}`}
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
>
<div className="font-medium">{pkg.meta.title ?? pkg.meta.id}</div>
<div className="mt-1 text-xs text-zinc-400">
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
{pkg.meta.language}
</div>
<div className="mt-1 text-xs text-zinc-500">
{Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '}
{Object.keys(pkg.setups).length} setups
</div>
</Link>
</li>
))}
</ul>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/** Detail view for a single part within a package. */
export default function PartPage() {
const { id, type, part } = useParams<{ id: string; type: string; part: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.parts[`${type}#${part}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Parts', to: `/bgm/${pkg.meta.id}/parts` },
{ label: `${type}#${part}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No part {type}#{part}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
<p className="mt-1 text-sm text-zinc-400">
{found.size ? `size ${found.size.join('×')}mm` : ''}
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
</p>
</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
</pre>
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All parts of a package, grouped by part type. Each part links to its
* `/bgm/:id/parts/:type/:part` detail page.
*/
export default function PartsPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const parts = Object.values(pkg.parts);
const byType = new Map<string, typeof parts>();
for (const part of parts) {
const list = byType.get(part.type) ?? [];
list.push(part);
byType.set(part.type, list);
}
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Parts' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Parts</h1>
<p className="mt-1 text-sm text-zinc-400">{parts.length} part{parts.length === 1 ? '' : 's'}.</p>
</div>
{[...byType.entries()].map(([type, list]) => (
<section key={type} className="space-y-2">
<h2 className="text-lg font-semibold">{type}</h2>
<ul className="grid gap-2 sm:grid-cols-2">
{list.map((part) => (
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/parts/${part.type}/${part.id}`}
className="font-medium hover:text-zinc-100"
>
{part.id}
</Link>
<div className="mt-1 text-xs text-zinc-400">
{part.size ? `size ${part.size.join('×')}mm` : ''}
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
</div>
</li>
))}
</ul>
</section>
))}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/** Detail view for a single setup within a package. */
export default function SetupPage() {
const { id, type, setup } = useParams<{ id: string; type: string; setup: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.setups[`${type}#${setup}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Setups', to: `/bgm/${pkg.meta.id}/setups` },
{ label: `${type}#${setup}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No setup {type}#{setup}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found.setup, null, 2)}
</pre>
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All setups of a package. Each setup links to its
* `/bgm/:id/setups/:type/:setup` detail page.
*/
export default function SetupsPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const setups = Object.values(pkg.setups);
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Setups' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Setups</h1>
<p className="mt-1 text-sm text-zinc-400">
{setups.length} setup{setups.length === 1 ? '' : 's'}.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{setups.map((setup) => (
<li key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/setups/${setup.type}/${setup.id}`}
className="font-medium hover:text-zinc-100"
>
{setup.id}
</Link>
</li>
))}
</ul>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/** Detail view for a single surface within a package. */
export default function SurfacePage() {
const { id, type, surface } = useParams<{ id: string; type: string; surface: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.surfaces[`${type}#${surface}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Surfaces', to: `/bgm/${pkg.meta.id}/surfaces` },
{ label: `${type}#${surface}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No surface {type}#{surface}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
<p className="mt-1 text-sm text-zinc-400">
{found.size ? `size ${found.size.join('×')}mm` : ''} · {found.layout.length} routes
</p>
</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
</pre>
</div>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All surfaces of a package. Each surface links to its
* `/bgm/:id/surfaces/:type/:surface` detail page.
*/
export default function SurfacesPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const surfaces = Object.values(pkg.surfaces);
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Surfaces' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Surfaces</h1>
<p className="mt-1 text-sm text-zinc-400">
{surfaces.length} surface{surfaces.length === 1 ? '' : 's'}.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{surfaces.map((surface) => (
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/surfaces/${surface.type}/${surface.id}`}
className="font-medium hover:text-zinc-100"
>
{surface.id}
</Link>
<div className="mt-1 text-xs text-zinc-400">
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
</div>
</li>
))}
</ul>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import packages from 'virtual:bgm/packages';
import type { SerializedPackage } from '@tts/bgm';
/** Look up a bgm package by id from the `virtual:bgm/packages` module. */
export function findPackage(id: string | undefined): SerializedPackage | undefined {
return packages.find((p) => p.meta.id === id);
}
+18
View File
@@ -1 +1,19 @@
/// <reference types="vite/client" />
/**
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin.
*
* - `virtual:bgm/packages` — every discovered package.
* - `virtual:bgm/package/<id>` — a single package's assembled JSON.
*/
declare module 'virtual:bgm/packages' {
import type { SerializedPackage } from '@tts/bgm';
const packages: SerializedPackage[];
export default packages;
}
declare module 'virtual:bgm/package/*' {
import type { SerializedPackage } from '@tts/bgm';
const pkg: SerializedPackage;
export default pkg;
}
+7 -1
View File
@@ -1,9 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'node:url';
import { bgm } from '@tts/bgm';
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
],
server: {
// Proxy API calls to the Hono backend during development.
proxy: {
+391
View File
@@ -0,0 +1,391 @@
# Board Game Manifest — Technical Reference
> The concrete behavior of the board game manifest (bgm) format.
>
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
> codeblock mode, each code block is a virtual definition file, named relative
> to the current markdown file.
---
## 1. json features
### The `$variants` directive
For objects with a `$variants` key, the value is a CSV. Parse it into an object
array with `typed-csv`, extend the original object with each row, and return
the array.
```yaml
job: 'hero'
$variants: ./heroes.csv
```
```csv
name,parents
string,string[]
clark,[jonathan;martha]
bruce,[]
```
```json
[
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
{ "job": "hero", "name": "bruce", "parents": [] }
]
```
### Inline vs file
`$variants` can be a file/URL path *or* an inline CSV string. If the value
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
```yaml
$variants: |
id,name,faceCrop
string,string,[number;number;number;number]
fish,Fish,[0;0;5;2]
grain,Grain,[1;0;5;2]
wood,Wood,[2;0;5;2]
```
### CSV conventions
CSV is parsed with `typed-csv`:
- The first row is the header, the second row is the type declaration
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
- Rows are validated against a zod schema derived from the type row.
- **`crop` inside a CSV cell** uses `;` as the element separator
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
an array with value `[0,0,5,2]`.
---
## 2. Definition discovery
Definitions are organized in **packages**. A loader loads a package
declaration, then uses its `include` paths to find the definitions.
### Code blocks as virtual files
A code block is a virtual definition file. To give it a name — so `include:`
and `$variants` paths can resolve against it — add a `file=` segment to the
code block's info string. The name is relative to the current markdown file:
````md
```yaml file=parts/cargo.yaml
...
```
```csv file=parts/cargo.csv
...
```
````
- A block with `file=` is addressable by that path.
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
is derived from its content. This makes every yaml block naturally
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
to the same hash.
- The `file=` name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
resolve against. When there is a real file in that path, the codeblock wins.
- `file=` implies the file type from its extension; the language tag is
optional and only for editor highlighting.
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
referencing. To point at a specific yaml block by name, give it an explicit
`file=`; otherwise its name is content-derived and unstable.
### include
`include` is a list of git-style path patterns — the defs that make up the
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
folders is discovered with no configuration. This also matches the package
declaration itself, which is fine — it's the package, not a part.
---
## 3. Roles
json objects in yaml blocks are handled if they have a `role:` field for either
- `package`
- `part`
- `surface`
- `setup`
a valid object can either be the root or in the list of the yaml block.
for all roles except package, `type` and `id` are needed.
`type#id` is used for identification so that combo must be unique in the package.
### package
The package is the container for a game's definitions. It is declared with a
`role: package` object:
```yaml
role: package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
```
- `role`: for block discovery.
- `id`: package identification.
- `title` — game name.
- `include` — the defs that make up the package (see §2).
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
`players` (player count), `language`.
### part
A part is a game component. It is identified by a `package:type#id` string,
placed on the board via `setup`, and visualized by routes.
#### part value types
- `image` — a url to an image.
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
Negative `cols` flips the rendered image.
- `size` — a tuple `[width, height, depth]` in mm units.
#### part props
- `face` — `sprite`. Used for texture.
- `faceCrop` — `crop` for `face`.
- `back` — `sprite`. Used for texture. Defaults to `face`.
- `backCrop` — `crop` for `back`.
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
Defaults to the full rect of the back image.
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
#### example
```yaml
role: part
type: token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
A `wood` token: the `face` and `back` sprites come from the same sheet,
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
### surface
A `surface` is a **view** over the state store, purely for **visual rendering**.
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
routes. The size is a reference — it may be scaled to fit larger or smaller
tables. It does not affect part placement; placement lives in the state store
(see §4). A surface need not cover every part — parts with no matching route on
this surface are simply not shown.
A surface also declares how it is **mounted**: as the root table surface, on a
HUD area, or as a child of another surface. `mount` is always an object, with
`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The
`kind` selects the mount type:
- `table` — the root table surface (default).
- `hud` — mounted to a HUD area, e.g. a player's hand.
- `child` — mounted relative to a parent surface. A surface lists its
`children` (`type#id` refs) so a surface can be repeated, like a player
board; each child is mounted relative to its parent's anchor.
```yaml
type: board
id: harbor
role: surface
size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```yaml
type: hud
id: hand
role: surface
size: [200, 100]
mount:
kind: hud
area: bottom-left
```
```yaml
type: board
id: player
role: surface
size: [200, 200]
mount:
kind: child
x: 100
y: 50
rotation: 0
```
### setup
`setup` seeds the state store: the enabled surfaces and the part placement.
Each valid game state is a valid setup.
```yaml
role: setup
type: game
id: main
surfaces:
- board#harbor
- hud#hand
setup:
/dock/0: harbor:boat#fleet
/deck: harbor:card
```
`surfaces` lists the surfaces enabled at the start. A surface not listed is
disabled and not rendered. When `surfaces` is omitted, all surfaces are
enabled.
The value on a setup path can be either a string, or a string list.
The string can either be a one part string, or a type without an id.
When id is omitted, it expands to all parts in that type during game state initialization.
---
## 4. Concepts
### Game state
The board's state is a **state store**: the set of **enabled surfaces** and a
map from path to a **stack** of parts. It is the authoritative record of which
surfaces are in play and where every part is placed.
A path is a URL path with named params, like `/dock/1`.
A part is identified by a `package:type#id` string.
A surface is enabled or disabled; a disabled surface is not rendered. Setup
seeds the enabled set (see §3), and it changes at runtime as the game
progresses (e.g. enabling the main board after an expansion-chooser scene).
### Routing
A route is a **visualization route**: it maps a part to a location on a
surface. Routes match the keys of the state store, but they are defined by a
surface and need not cover every placed part — a part with no matching route on
a given surface is simply not shown there. Routes exist only for game parts; a
surface is not a part and never appears on a route.
A route matches all parts on the path; the placement of each individual part on
the stack is a separate concern.
A route is an express-style URL path with named params, plus the `x`, `y`, and
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once:
```yaml
layout:
- route: /dock/:seat
x: 40
y: 0
rotation: 0
- route: /deck
x: -100
y: 0
rotation: 0
```
### Candidates
To match a class of routes against a list of positions, keep a single route with its param and give it a
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
```yaml
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
```
```csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
When no candidates match, the whole route fails to match.
### Stacking
When multiple parts live on a path, only the top (last) one shows by default.
To override this, add stacking strategies:
```yaml
layout:
- route: /deck
x: -100
y: 0
rotation: 0
stacking:
curve: M 0 0 C 20 -20 40 -20 60 0
limit: 5
align: center
steps: 4
```
- `curve` — an SVG path string to spread the content along, relative to the
anchor `x`, `y`, `rotation`.
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
`-3` shows the last 3.
- `align` — `start`, `end`, or `center` of the curve.
- `steps` — the maximum number of parts per curve length unit. Defaults to
`1`. See the positioning process below.
#### positioning process
1. **Determine the step length.** It is `curve length / max(steps, # of
parts on path 1)`.
2. **Determine the alignment.** It places the span of
`step length × (# of parts 1)` on the curve.
3. **Place each part.** Part `#0` is at the start, the last part at the end,
each `step length` apart.
### Edge cases
- Object with no matching route → **not placed on this surface**. The game
state is still valid — the part simply isn't visualized. A surface is a view
over the state store, not a mirror of it, and may show only a subset (e.g. a
player's hand on the HUD).
- Route with no matching object → empty, fine.
- Multiple routes match one path -> first route wins.
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
for all parts on a path, and the stacking strategy decides what's shown
(it may drop parts that are not dropped on other matching routes).
+58
View File
@@ -0,0 +1,58 @@
# bgm Loader — Status
> WIP. What's built, what works, what's missing, and the known issues.
> Spec: [`bgm-format.md`](./bgm-format.md).
## What's built
### `packages/bgm` — the loader core (new)
| File | Purpose |
| --- | --- |
| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, `SurfaceMount`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` |
| `src/schemas.ts` | zod schemas + `validate*` for each role |
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates) |
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
### `games/harbor/harbor.md` — example game (new)
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
### `apps/web` — consumer (new)
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
- `src/vite-env.d.ts` — ambient `declare module 'virtual:bgm/packages'` (all packages) and `'virtual:bgm/package/*'` (one package).
- `src/pages/BgmPage.tsx` — list page: imports `virtual:bgm/packages` and shows every discovered package. Routed at `/bgm`.
- `src/pages/BgmPackagePage.tsx` — detail page: looks up a package by id from `bgm` and renders its parts/surfaces/setups. Routed at `/bgm/:id`.
The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/bgm`), not in the web app — so the loader's plugin is tested in isolation from the web project.
## Works
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
- Root `pnpm test`: **177 pass**.
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
## Known issues
1. ~~Plugin emits empty maps~~ — fixed: `load` serializes `Map`s via `Object.fromEntries`.
2. ~~Plugin `load` uses `this.error`~~ — fixed: throws instead.
3. ~~HMR cache not invalidated~~ — fixed: dropped the closure cache; re-collects per `load`.
4. ~~`tinyglobby` leftover dep~~ — removed.
5. ~~Package-level test script finds no files~~ — added `packages/bgm/vitest.config.ts`.
## Not yet done
- ~~No consumer yet~~ — `apps/web/src/pages/BgmPage.tsx` lists packages from `virtual:bgm/packages`; `BgmPackagePage.tsx` shows one at `/bgm/:id`.
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `docs/bgm-tabletop.md`).
- Docs for the loader itself (this file is the start).
+172
View File
@@ -0,0 +1,172 @@
# bgm-tabletop — Implementation Plan / Status
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
> board games: a state store, surface mounting, part placement with stacking,
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
> **Status:** planning — no code yet.
## Goal
A library (new `packages/tabletop`) that takes a bgm package and renders it as
an interactive 3D table: enabled surfaces mounted in world/HUD space, parts
placed on their routes, stacked per the format's stacking strategy. The web
app's bgm inspector routes are one consumer; the library must not depend on the
web app.
## Stack
`react`, `react-router` (types only), `tailwind` (styles only), `r3f`
(`@react-three/fiber`), `drei`, `postprocessing`, `zustand`, `three`,
`@tts/bgm` (types), `@tts/mesh` (geometry).
## Package layout
```
packages/tabletop/
package.json # @tts/tabletop
tsconfig.json
vitest.config.ts
src/
index.ts # public exports
state.ts # zustand store + derived render state
setup.ts # SetupLoader: seed state from a setup
mount.ts # resolve surface mount tree (table/hud/child)
stacking.ts # useStacking hook
placement.ts # PartPlacement
partView.tsx # PartView: mesh from a part definition
surfaces/
WorldSurfaceView.tsx
HudSurfaceView.tsx
*.test.ts # colocated unit tests
```
## Work items
### 1. Package scaffold
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
globs `packages/*`).
- Deps: `@tts/bgm`, `@tts/mesh`, `three`, `@react-three/fiber`, `@react-three/drei`,
`@react-three/postprocessing`, `zustand`. Dev: `vitest`, `typescript`,
`@types/three`.
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
### 2. Part meshes + export + web integration
First deliverable: `PartView` renders a single part's mesh from its definition,
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
useful slice and unblocks the web app's part inspection route immediately.
- `PartView` (`partView.tsx`): creates a mesh from a `Part` definition:
- `size` → world dimensions; `fillet` → corner radius.
- `face`/`faceCrop`/`back`/`backCrop` → textures (drei `useTexture`), sprite
UVs from `faceCrop`/`backCrop` (a `[col,row,cols,rows]` grid cell).
- `shape` → traced silhouette (via the proxy `/trace`, like the web token
viewer) or a fallback rect/rounded-rect.
- `extrudeShapeParts` from `@tts/mesh` for the mesh.
- Shared geometry/material caching (module-level `Map`s) so repeated parts
reuse buffers, mirroring the web viewers' `sharedResources`.
- Export `PartView` from `index.ts`.
- **Web integration**: replace the web app's part inspection route
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
it end-to-end.
### 3. State store (`state.ts`)
Source-of-truth game state per `bgm-tabletop.md` §2:
```ts
interface GameState {
surfaces: Record<string, boolean>; // enabled per surface id
paths: Record<string, string[]>; // path -> part list
}
```
- A zustand store holding `GameState`.
- **Derived render state**: `game state + surface routes => map of piece id to
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
Computed with a selector/memo so the render list is stable.
- **Assumption**: each piece id is unique within a path (documented in
`bgm-tabletop.md`); the render map is keyed by piece id.
### 4. Setup seeding (`setup.ts`)
- `SetupLoader`: side-effect-only component that seeds the store from a
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
`setup` paths.
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
game-state init concern, so it lives here).
### 5. Surface mounting (`mount.ts`)
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
- `kind: table` — root, world space.
- `kind: hud` — HUD area (`mount.area`).
- `kind: child` — mounted relative to a parent that lists it in `children`.
- `WorldSurfaceView` / `HudSurfaceView` mount an enabled surface; a disabled
surface isn't rendered. Child surfaces mount relative to their parent's
anchor (`x`/`y`/`rotation`).
### 6. Part placement (`placement.ts`)
- `PartPlacement`: stable per-part component that positions a part on a surface
location from the derived render state (route anchor + candidate anchor).
- Applies the route's stacking strategy via `useStacking`.
### 7. Stacking (`stacking.ts`)
- `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`.
- Implements the format's positioning process (`bgm-format.md` §4): step
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
- Curve length from an SVG path string (small helper; no new dep).
### 8. Public API (`index.ts`)
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
library never imports from `apps/*`.
## Reuse from `@tts/mesh`
- `extrudeShapeParts` / `extrudeShape` — front/back/walls geometry.
- `rectShape`, `roundedRectShape`, `circleShape`, `polygonShape`, `hexShape`,
`frameShape`, `scaleShape` — shape generators for parts without a `shape`
sprite.
- `shapeFromThree` — author shapes with the three.js path API.
- `ExtrudedGeometry` / `UVBounds` — raw typed arrays + UV framing.
The web viewers (`TokenViewer`/`CardViewer`) contain logic we'll mirror rather
than import: trace-to-shape conversion, sprite UV math, texture flipping. These
are candidates to lift into `@tts/mesh` or `@tts/tabletop` later so both
consumers share them (see Open decisions).
## Testing
- `state.ts` — derived render state: enabled surfaces, route matching,
candidate selection, stacking index/stackSize.
- `stacking.ts` — positioning process: step length, alignment, limit.
- `setup.ts` — seeding + bare-type expansion.
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
- A real `vite build` integration test (mirroring `packages/bgm/src/vite.test.ts`)
proving the library bundles against a fixture package.
## Validation
- `pnpm --filter @tts/tabletop build` / `typecheck` / `test`.
- Root `pnpm test` stays green.
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
from the library (work item 2), proving it end-to-end.
## Open decisions (defaults in bold)
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
by web viewers + tabletop) vs duplicate in `@tts/tabletop`. Lifting is
cleaner but touches the web viewers; decide when `PartView` (work item 2)
needs them.
- **HUD rendering** — **drei `Html`/orthographic overlay** vs a second
`Canvas`. Default to an overlay so world + HUD share one scene.
- **Curve length** — **small internal SVG-path length helper** vs a dependency
(e.g. `svg-path-properties`). Prefer the helper to avoid a dep.
+49
View File
@@ -0,0 +1,49 @@
# bgm-tabletop
a r3f based interactive component library to work with [bgm](./bgm-format.md) board games. will be used somewhere in the `web` app's bgm inspector routes.
## 1. stack
`react` - react, react router, tailwindv4
`r3f` - r3f, drei, postprocessing
`zustand` - for state management
## 2. states
source-of-truth game state:
```ts
{
surfaces: Record<string, boolean>, // enabled per surface id
paths: Record<string, string[]>, // path -> part list
}
```
**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous.
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list.
- `route` - the matched route.
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
- `index` - the piece's position in its path's stack.
- `stackSize` - the number of pieces on the path.
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
## 3. components
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
- `WorldSurfaceView` mounts a surface to world space.
- `HudSurfaceView` mounts a surface to hud space.
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
## 4. stacking
the format's stacking strategy (curve / limit / align / steps, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece. `PartPlacement` consumes it.
## 5. usage
- we will inspect individual parts with `PartView` in the web app's part inspection route.
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
- a surface is mounted only when enabled; a disabled surface is not rendered.
+137
View File
@@ -0,0 +1,137 @@
# Poker
A standard 52-card poker deck, laid out on a table with a draw pile and
community-card slots. Exercises the bgm loader's `$variants` expansion to
generate a full deck from a single part definition.
```yaml file=poker.yaml
role: package
id: poker
title: Poker
designer: Public Domain
players: 9
language: en
```
## Parts
A single `card` part expanded into 52 cards by the `$variants` CSV. Each row
picks a cell from a `13×5` sprite sheet — 13 ranks across, 4 suits down, and
a shared card-back row at index 4.
```yaml file=parts/cards.yaml
role: part
type: card
face: ./assets/cards.png
back: ./assets/cards.png
size: [63, 88, 3]
fillet: 2
$variants: ./cards.csv
```
```csv file=parts/cards.csv
id,rank,suit,faceCrop,backCrop
string,string,string,[number;number;number;number],[number;number;number;number]
2s,2,spades,[0;0;13;5],[0;4;13;5]
3s,3,spades,[1;0;13;5],[0;4;13;5]
4s,4,spades,[2;0;13;5],[0;4;13;5]
5s,5,spades,[3;0;13;5],[0;4;13;5]
6s,6,spades,[4;0;13;5],[0;4;13;5]
7s,7,spades,[5;0;13;5],[0;4;13;5]
8s,8,spades,[6;0;13;5],[0;4;13;5]
9s,9,spades,[7;0;13;5],[0;4;13;5]
10s,10,spades,[8;0;13;5],[0;4;13;5]
js,J,spades,[9;0;13;5],[0;4;13;5]
qs,Q,spades,[10;0;13;5],[0;4;13;5]
ks,K,spades,[11;0;13;5],[0;4;13;5]
as,A,spades,[12;0;13;5],[0;4;13;5]
2h,2,hearts,[0;1;13;5],[0;4;13;5]
3h,3,hearts,[1;1;13;5],[0;4;13;5]
4h,4,hearts,[2;1;13;5],[0;4;13;5]
5h,5,hearts,[3;1;13;5],[0;4;13;5]
6h,6,hearts,[4;1;13;5],[0;4;13;5]
7h,7,hearts,[5;1;13;5],[0;4;13;5]
8h,8,hearts,[6;1;13;5],[0;4;13;5]
9h,9,hearts,[7;1;13;5],[0;4;13;5]
10h,10,hearts,[8;1;13;5],[0;4;13;5]
jh,J,hearts,[9;1;13;5],[0;4;13;5]
qh,Q,hearts,[10;1;13;5],[0;4;13;5]
kh,K,hearts,[11;1;13;5],[0;4;13;5]
ah,A,hearts,[12;1;13;5],[0;4;13;5]
2d,2,diamonds,[0;2;13;5],[0;4;13;5]
3d,3,diamonds,[1;2;13;5],[0;4;13;5]
4d,4,diamonds,[2;2;13;5],[0;4;13;5]
5d,5,diamonds,[3;2;13;5],[0;4;13;5]
6d,6,diamonds,[4;2;13;5],[0;4;13;5]
7d,7,diamonds,[5;2;13;5],[0;4;13;5]
8d,8,diamonds,[6;2;13;5],[0;4;13;5]
9d,9,diamonds,[7;2;13;5],[0;4;13;5]
10d,10,diamonds,[8;2;13;5],[0;4;13;5]
jd,J,diamonds,[9;2;13;5],[0;4;13;5]
qd,Q,diamonds,[10;2;13;5],[0;4;13;5]
kd,K,diamonds,[11;2;13;5],[0;4;13;5]
ad,A,diamonds,[12;2;13;5],[0;4;13;5]
2c,2,clubs,[0;3;13;5],[0;4;13;5]
3c,3,clubs,[1;3;13;5],[0;4;13;5]
4c,4,clubs,[2;3;13;5],[0;4;13;5]
5c,5,clubs,[3;3;13;5],[0;4;13;5]
6c,6,clubs,[4;3;13;5],[0;4;13;5]
7c,7,clubs,[5;3;13;5],[0;4;13;5]
8c,8,clubs,[6;3;13;5],[0;4;13;5]
9c,9,clubs,[7;3;13;5],[0;4;13;5]
10c,10,clubs,[8;3;13;5],[0;4;13;5]
jc,J,clubs,[9;3;13;5],[0;4;13;5]
qc,Q,clubs,[10;3;13;5],[0;4;13;5]
kc,K,clubs,[11;3;13;5],[0;4;13;5]
ac,A,clubs,[12;3;13;5],[0;4;13;5]
```
## Board
A table with a draw pile on the left and five community-card slots across the
middle. The deck pile fans its stacked cards along a curve.
```yaml file=parts/board.yaml
type: board
id: poker
role: surface
size: [600, 400]
layout:
- route: /deck
x: -250
y: 0
rotation: 0
stacking:
curve: M 0 0 C 20 -20 40 -20 60 0
limit: 0
align: center
- route: /community/:slot
candidates:
$variants: ./community.csv
```
```csv file=parts/community.csv
slot,x,y,rotation
string,number,number,number
0,-100,0,0
1,-50,0,0
2,0,0,0
3,50,0,0
4,100,0,0
```
## Setup
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
that type), then flip a flop onto the community slots.
```yaml file=setup/main.yaml
role: setup
type: game
id: main
setup:
/deck: poker:card
/community/0: poker:card#as
/community/1: poker:card#kh
/community/2: poker:card#7d
```
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@tts/bgm",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"dependencies": {
"vite": "^8.2.1",
"marked": "^16.0.0",
"picomatch": "^4.0.5",
"smol-toml": "^1.4.0",
"typed-csv": "^2.0.0",
"yaml": "^2.4.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.12.0",
"@types/picomatch": "^4.0.0",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,108 @@
# Harbor
A tiny example game used to exercise the bgm loader.
```yaml file=harbor.yaml
role: package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
```
## Tokens
```yaml file=parts/tokens.yaml
role: part
type: token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
```yaml file=parts/tokens.yaml
role: part
type: token
id: grain
face: ./assets/tokens.png
faceCrop: [0, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [2, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
## Board
```yaml file=parts/board.yaml
type: board
id: harbor
role: surface
size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```yaml file=parts/player.yaml
type: board
id: player
role: surface
size: [200, 200]
mount:
kind: child
x: 100
y: 50
rotation: 0
layout:
- route: /hand/:slot
candidates:
$variants: ./hand.csv
```
```csv file=parts/hand.csv
slot,x,y,rotation
string,number,number,number
0,0,0,0
1,0,20,0
```
```csv file=parts/seats.csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
## Setup
```yaml file=setup/main.yaml
role: setup
type: game
id: main
surfaces:
- board#harbor
- board#player
setup:
/dock/0: harbor:token#wood
/deck: harbor:token#grain
```
@@ -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
```
@@ -0,0 +1,58 @@
# Harbor
A tiny example game used to exercise the bgm loader end-to-end through a real
vite build.
```yaml file=harbor.yaml
role: package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
include: ['**/harbor/**/*.yaml']
```
```yaml file=parts/tokens.yaml
role: part
type: token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
```yaml file=parts/board.yaml
type: board
id: harbor
role: surface
size: [300, 200]
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```csv file=parts/seats.csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
```yaml file=setup/main.yaml
role: setup
type: game
id: main
setup:
/dock/0: harbor:token#wood
/deck: harbor:token#grain
```
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>bgm build fixture</title>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +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 = 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, azulTitle);
+68
View File
@@ -0,0 +1,68 @@
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');
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' });
// Two tokens from two yaml blocks sharing a `file=` name.
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
const wood = harbor.parts.get('token#wood')!;
expect(wood).toMatchObject({
type: 'token',
id: 'wood',
size: [20, 20, 3],
fillet: 2,
});
expect(wood.face).toBe('./assets/tokens.png');
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
// 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(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.layout).toHaveLength(2);
const dock = board.layout[0]!;
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 },
]);
const deck = board.layout[1]!;
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 });
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(setup.setup).toEqual({
'/dock/0': 'harbor:token#wood',
'/deck': 'harbor:token#grain',
});
});
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('parts/tokens.yaml'))!;
const tokens = defMap.defs.get(tokensKey)!;
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* Collect packages from a games root directory.
*
* A games root contains yaml/json/toml files and markdown files with
* definition code blocks. The loader:
*
* 1. Reads real files and extracts virtual files from markdown code blocks
* (virtual wins over real files with the same name).
* 2. Parses each def file into JSON objects.
* 3. Recognizes `role: package` objects, expands their `$variants`, follows
* their `include` patterns, and assembles the package's parts, surfaces,
* and setups.
*
* See docs/bgm-format.md for the format's concrete behavior.
*/
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 {
BgmError,
type DefFile,
type ParsedDef,
type Package,
type PackageDef,
type Part,
type Role,
type Setup,
type Surface,
} from './types.js';
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
/** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap {
/** All def files (real + virtual), keyed by name. */
files: Map<string, DefFile[]>;
/** All parsed definitions, keyed by file name. */
defs: Map<string, ParsedDef[]>;
}
/**
* Load a games root into a def map.
*
* @param root the path-style name of the root, e.g. `harbor`
* @param rootDir the absolute path of the games root
*/
export function loadDefs(root: string, rootDir: string): DefMap {
const realFiles = readDefFiles(rootDir, root);
const markdownFiles = new Map<string, string>();
const others: DefFile[] = [];
for (const file of realFiles) {
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
else others.push(file);
}
const virtualFiles = collectVirtualFiles(markdownFiles);
const files = new Map<string, DefFile[]>();
// Real files first, virtual files override (virtual wins per the format).
for (const file of others) files.set(file.name, [file]);
for (const [name, list] of virtualFiles) files.set(name, list);
const defs = new Map<string, ParsedDef[]>();
for (const [name, list] of files) {
const parsed: ParsedDef[] = [];
for (const file of list) parsed.push(...parseDefText(file));
defs.set(name, parsed);
}
return { files, defs };
}
/**
* Collect all packages from the given def map.
*
* @param rootDir the absolute path of the games root; `$variants` file paths
* resolve relative to their def file's directory within the root
*/
export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
const packages = new Map<string, PackageAcc>();
const byRole = new Map<string, ParsedDef[]>();
// Group parsed defs by role.
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)) {
list.push(def);
byRole.set(file, list);
}
}
}
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 pkg = asPackage(def, file);
accs.push(new PackageAcc(pkg, defMap, rootDir));
}
}
}
const result: Package[] = [];
for (const acc of accs) {
acc.collect();
result.push(acc.toPackage());
}
return result;
}
/** Identity validation: `type#id` must be unique within a package. */
class PackageAcc {
readonly parts = new Map<string, Part>();
readonly surfaces = new Map<string, Surface>();
readonly setups = new Map<string, Setup>();
readonly byRole = new Map<string, string[]>();
constructor(
readonly pkg: PackageDef,
private readonly defs: DefMap,
private readonly rootDir: string,
) {}
collect() {
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;
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>, baseName: string, source: string): Record<string, unknown>[] {
if (!('$variants' in obj)) return [obj];
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
const { $variants: _v, ...base } = obj;
return rows.map((row) => ({ ...base, ...row }));
}
private expandIncludes(patterns: string[]): string[] {
// Match include patterns against the parsed definitions' names, which
// cover both real files and markdown code blocks. Patterns are relative
// to the games root (e.g. `./**/*.yaml`).
const names = new Set<string>();
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true });
for (const name of this.defs.defs.keys()) {
if (matcher(name)) names.add(name);
}
}
return [...names];
}
private add(role: Role, def: ParsedDef, fileName: string) {
const expanded = this.expand(def.value, def.file, def.source);
for (const obj of expanded) {
switch (role) {
case 'part': {
const part = asPart(obj, fileName);
const key = `${part.type}#${part.id}`;
if (this.parts.has(key)) {
throw new BgmError(`Duplicate part "${key}"`, fileName);
}
this.parts.set(key, part);
break;
}
case 'surface': {
const surface = asSurface(obj, fileName, this.defs.files);
const key = `${surface.type}#${surface.id}`;
if (this.surfaces.has(key)) {
throw new BgmError(`Duplicate surface "${key}"`, fileName);
}
this.surfaces.set(key, surface);
break;
}
case 'setup': {
const setup = asSetup(obj, fileName);
const key = `${setup.type}#${setup.id}`;
if (this.setups.has(key)) {
throw new BgmError(`Duplicate setup "${key}"`, fileName);
}
this.setups.set(key, setup);
break;
}
}
}
}
toPackage(): Package {
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
}
}
function metaOf(pkg: PackageDef) {
const { role: _role, include: _include, ...meta } = pkg;
return meta;
}
function asPackage(def: ParsedDef, source: string): PackageDef {
const obj = def.value;
try {
return validatePackage(obj) as unknown as PackageDef;
} catch (err) {
throw wrapZod(err, source);
}
}
function asPart(obj: Record<string, unknown>, source: string): Part {
try {
return validatePart(obj) as unknown as Part;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSurface(
obj: Record<string, unknown>,
source: string,
defs: Map<string, DefFile[]>,
): Surface {
const value: Record<string, unknown> = { ...obj };
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;
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'], source, defs, source);
const { $variants: _v, ...base } = cand as Record<string, unknown>;
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
}
return route;
});
}
try {
return validateSurface(value) as unknown as Surface;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSetup(obj: Record<string, unknown>, source: string): Setup {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
try {
return validateSetup(value) as unknown as Setup;
} 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);
return new BgmError(`Invalid definition: ${message}`, source);
}
+7
View File
@@ -0,0 +1,7 @@
export * from './types.js';
export * from './schemas.js';
export * from './markdown.js';
export * from './parse.js';
export * from './variants.js';
export * from './collect.js';
export * from './vite.js';
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { scanMarkdown } from './markdown.js';
describe('scanMarkdown', () => {
it('extracts a fenced code block with a file= name', () => {
const md = [
'# Title',
'',
'```yaml file=parts/cargo.yaml',
'role: part',
'```',
'',
'text after',
].join('\n');
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
expect(fences).toHaveLength(1);
expect(fences[0]).toMatchObject({
info: 'yaml file=parts/cargo.yaml',
content: 'role: part',
startLine: 3,
endLine: 5,
});
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({
name: 'harbor/parts/cargo.yaml',
kind: 'yaml',
text: 'role: part',
source: 'harbor/harbor.md:3-5',
});
});
it('auto-names a block without file= from its content hash', () => {
const md = '```yaml\nrole: part\n```';
const { files } = scanMarkdown(md, 'harbor/harbor.md');
expect(files).toHaveLength(1);
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
expect(files[0]!.kind).toBe('yaml');
});
it('ignores non-definition languages', () => {
const md = '```js\nconst x = 1;\n```';
const { files, fences } = scanMarkdown(md, 'harbor/harbor.md');
expect(files).toHaveLength(0);
expect(fences).toHaveLength(1);
});
it('ignores indented code blocks', () => {
const md = ' role: part\n';
const { files } = scanMarkdown(md, 'harbor/harbor.md');
expect(files).toHaveLength(0);
});
it('names a csv block with file= as csv', () => {
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
const { files } = scanMarkdown(md, 'harbor/harbor.md');
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
});
it('tracks line numbers across multiple blocks', () => {
const md = [
'```yaml file=a.yaml',
'role: part',
'```',
'',
'```yaml file=b.yaml',
'role: part',
'```',
].join('\n');
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Extract virtual definition files from markdown code blocks.
*
* Each fenced code block is a virtual definition file:
* - With a `file=` segment in its info string, named relative to the
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
* block is discoverable by the default include pattern (all yaml in the
* same and sub folders). Identical blocks dedupe to the same hash.
*
* Markdown is tokenized with `marked`; each `code` token is a candidate
* virtual file.
*/
import * as crypto from 'node:crypto';
import { posix } from 'node:path';
import { marked } from 'marked';
import { BgmError, type DefFile } from './types.js';
/** The languages that count as definition files; others are ignored. */
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
/** A single fenced code block. */
export interface Fence {
/** Line number (1-based) of the opening fence. */
startLine: number;
/** Line number (1-based) of the closing fence. */
endLine: number;
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
info: string;
/** The code block's content (without the fences). */
content: string;
}
/** Result of scanning a markdown file. */
export interface MarkdownResult {
/** All fenced code blocks found, in order. */
fences: Fence[];
/** Virtual def files extracted from the definition-language blocks. */
files: DefFile[];
}
/**
* Scan `text` for fenced code blocks.
*
* @param text the markdown source
* @param sourcePath the markdown file's path-style name, for error messages
* and for resolving `file=` names relative to the markdown file
* @returns the fences and the virtual def files derived from them
*/
export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
const fences: Fence[] = [];
const files: DefFile[] = [];
const tokens = marked.lexer(text);
for (const token of tokens) {
if (token.type !== 'code' || token.codeBlockStyle === 'indented') continue;
const info = token.lang ?? '';
const startLine = lineOf(text, token.raw);
const endLine = startLine + token.raw.split(/\r?\n/).length - 1;
fences.push({ startLine, endLine, info, content: token.text });
const name = parseInfo(info);
if (name) {
files.push({
name: posix.join(posix.dirname(sourcePath), name),
text: token.text,
source: `${sourcePath}:${startLine}-${endLine}`,
kind: kindOf(name),
});
}
}
return { fences, files };
}
/**
* Parse a fence's info string for a `file=` segment and derive the virtual
* file name. Blocks without `file=` are auto-named from their content hash.
*/
function parseInfo(info: string): string | null {
const fileMatch = /file=(\S+)/.exec(info);
if (fileMatch) return fileMatch[1]!;
const lang = info.split(/\s+/)[0];
if (!lang || !DEF_LANGS.has(lang)) return null;
return `./${hash(info)}.yaml`;
}
/** Derive the def file type from its name's extension. */
function kindOf(name: string): DefFile['kind'] {
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.toml')) return 'toml';
if (name.endsWith('.md') || name.endsWith('.markdown')) return 'markdown';
if (name.endsWith('.csv')) return 'csv';
return 'yaml';
}
/** A stable content hash for auto-named blocks. */
function hash(text: string): string {
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
}
/** The 1-based line number where `raw` starts within `text`. */
function lineOf(text: string, raw: string): number {
const idx = text.indexOf(raw);
if (idx < 0) return 1;
return text.slice(0, idx).split(/\r?\n/).length;
}
/**
* Virtual files gathered from markdown code blocks, keyed by path-style name.
* Multiple blocks may share a name (e.g. several `file=parts/tokens.yaml`
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
* same hash name.
*/
export type VirtualFiles = Map<string, DefFile[]>;
/**
* Collect virtual def files from a set of markdown sources.
*
* @param markdownFiles real markdown files, keyed by their path-style name
* relative to the games root, e.g. `harbor/harbor.md`
* @returns the virtual files, keyed by name
*/
export function collectVirtualFiles(markdownFiles: Map<string, string>): VirtualFiles {
const files = new Map<string, DefFile[]>();
for (const [name, text] of markdownFiles) {
const result = scanMarkdown(text, name);
for (const file of result.files) {
const list = files.get(file.name) ?? [];
list.push(file);
files.set(file.name, list);
}
}
return files;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Parse raw definition files (yaml/json/toml text) into JSON objects.
*
* A def file's document can be either a single JSON object (the root) or a
* list of objects; both are handled per docs/bgm-format.md §3. In list mode,
* each object is a separate definition.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { parse as parseToml } from 'smol-toml';
import { BgmError, type DefFile, type ParsedDef } from './types.js';
/**
* Parse a def file's text into a list of definition objects.
*
* @returns the parsed objects; the root object (index `-1`) or the list
* items (index `0..n`)
*/
export function parseDefText(file: DefFile): ParsedDef[] {
if (file.kind === 'csv') return [];
const text = file.text.trim();
if (!text) return [];
const out: ParsedDef[] = [];
let doc: unknown;
try {
doc = parseText(file.kind, text);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new BgmError(`Failed to parse ${file.kind}: ${message}`, file.source);
}
const push = (value: unknown, index: number) => {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
} else if (value !== null) {
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
}
};
if (Array.isArray(doc)) {
doc.forEach((item, index) => push(item, index));
} else {
push(doc, -1);
}
return out;
}
function parseText(kind: DefFile['kind'], text: string): unknown {
switch (kind) {
case 'json':
return JSON.parse(text);
case 'yaml':
return parseYaml(text);
case 'toml':
return parseToml(text);
case 'markdown':
// Markdown-only blocks contain no definitions; handled by the caller.
return null;
}
}
/**
* Parse a directory of real files (yaml/json/toml/md) into def files.
* Markdown files are also returned here as-is; code-block extraction happens
* in `collect.ts` via `scanMarkdown`.
*
* @param dir absolute directory to scan
* @param root the path-style root the file names are relative to (for
* consistent naming with virtual files), e.g. `harbor`
*/
export function readDefFiles(dir: string, root: string): DefFile[] {
const out: DefFile[] = [];
const walk = (current: string, rel: string) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const abs = path.join(current, entry.name);
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, relPath);
} else if (/csv$/i.test(entry.name)) {
out.push({
name: `${root}/${relPath}`,
text: fs.readFileSync(abs, 'utf8'),
source: abs,
kind: 'csv',
});
} else if (/\.(ya?ml|json|toml|md|markdown)$/i.test(entry.name)) {
const kind = kindOf(entry.name);
out.push({
name: `${root}/${relPath}`,
text: fs.readFileSync(abs, 'utf8'),
source: abs,
kind,
});
}
}
};
walk(dir, '');
return out;
}
function kindOf(name: string): DefFile['kind'] {
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.toml')) return 'toml';
if (name.endsWith('.md') || name.endsWith('.markdown')) return 'markdown';
if (name.endsWith('.csv')) return 'csv';
return 'yaml';
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Zod schemas for the bgm definition roles.
*
* These validate the raw definition objects (after `$variants` expansion)
* 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';
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
const size = z.tuple([z.number(), z.number(), z.number()]);
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(),
steps: z.number().optional(),
});
const route = z.object({
route: z.string(),
x: z.number().optional(),
y: z.number().optional(),
rotation: z.number().optional(),
candidates: z.array(z.record(z.string(), z.unknown())).optional(),
stacking: stacking.optional(),
});
const partSchema = z.object({
type: z.string().min(1),
id: z.string().min(1),
face: z.string().optional(),
faceCrop: crop.optional(),
back: z.string().optional(),
backCrop: crop.optional(),
shape: z.string().optional(),
size: size.optional(),
fillet: z.number().optional(),
});
const surfaceMount = z.object({
kind: z.enum(['table', 'hud', 'child']),
x: z.number().optional(),
y: z.number().optional(),
rotation: z.number().optional(),
area: z.string().optional(),
});
const surfaceSchema = z.object({
type: z.string().min(1),
id: z.string().min(1),
size: surfaceSize.optional(),
mount: surfaceMount.optional(),
children: z.array(z.string()).optional(),
layout: z.array(route),
});
const setupSchema = z.object({
type: z.string().min(1),
id: z.string().min(1),
surfaces: z.array(z.string()).optional(),
setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
});
const packageSchema = z.object({
role: z.literal('package'),
id: z.string().min(1),
title: z.string().optional(),
designer: z.string().optional(),
development: z.string().optional(),
publisher: z.string().optional(),
players: z.number().optional(),
language: z.string().optional(),
include: z.array(z.string()).optional(),
});
/** Validate a raw part definition. */
export function validatePart(value: Record<string, unknown>): Part {
return partSchema.parse(value) as unknown as Part;
}
/** Validate a raw surface definition. */
export function validateSurface(value: Record<string, unknown>): Surface {
return surfaceSchema.parse(value) as unknown as Surface;
}
/** Validate a raw setup definition. */
export function validateSetup(value: Record<string, unknown>): Setup {
return setupSchema.parse(value) as unknown as Setup;
}
/** Validate a raw package definition. */
export function validatePackage(value: Record<string, unknown>): PackageDef {
return packageSchema.parse(value) as unknown as PackageDef;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Core types for the board game manifest (bgm) format.
*
* A package is the container for a game's definitions. Raw definitions are
* discovered as JSON objects from yaml/json/toml files and from markdown
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
*
* The concrete behavior of the format is described in `docs/bgm-format.md`.
*/
/** Part value types. */
export type PartValueType = 'image' | 'crop' | 'size' | 'sprite';
/**
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
* `cols` x `rows` grid and picks the cell at `[col, row]`.
*/
export type Crop = [col: number, row: number, cols: number, rows: number];
/** A size tuple `[width, height, depth]` in mm units. */
export type Size = [width: number, height: number, depth: number];
/** A surface size `[width, height]` in mm units. */
export type SurfaceSize = [width: number, height: number];
/** `type#id` identification used across roles (e.g. `harbor:token#wood`). */
export type PartRef = string;
/**
* The identification of a part. `package:type#id` is the full, package-qualified
* string placed on the board via setup and referenced by routes.
*/
export interface PartId {
package: string;
type: string;
id: string;
}
export interface PackageMeta {
/** Package id (also used as the module name, e.g. `bgm/harbor`). */
id: string;
/** Game name. */
title?: string;
designer?: string;
/** Artist / developer credit. */
development?: string;
publisher?: string;
/** Player count. */
players?: number;
/** Language code, e.g. `en`. */
language?: string;
}
/** A game component: identified by `package:type#id`, placed via setup. */
export interface Part {
type: string;
id: string;
/** Face sprite url (texture). */
face?: string;
/** Crop for `face`. */
faceCrop?: Crop;
/** Back sprite url; defaults to the face sprite. */
back?: string;
/** Crop for `back`. */
backCrop?: Crop;
/** Shape sprite url; traced for its profile to create the mesh. */
shape?: string;
/** `[width, height, depth]` in mm; the token is scaled to fit the box. */
size?: Size;
/** Fillet radius in mm; defaults to `0`. */
fillet?: number;
/** Extra fields from the source definition, kept for forwards compatibility. */
[key: string]: unknown;
}
/** A candidate for a route's `:param`, carrying its own anchor. */
export interface Candidate {
[param: string]: unknown;
x?: number;
y?: number;
rotation?: number;
}
export interface Route {
/** Express-style url path with named params, e.g. `/dock/:seat`. */
route: string;
x: number;
y: number;
rotation: number;
/** Candidates to match `:param` against; each carries its own anchor. */
candidates?: Candidate[];
/** Stacking strategy for multiple parts on the path. */
stacking?: Stacking;
}
export interface Stacking {
/** SVG path string to spread stacked parts along, relative to the anchor. */
curve?: string;
/** 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';
/** Maximum parts per curve length unit; defaults to `1`. */
steps?: number;
}
/** How a surface is mounted. `kind` selects the mount type. */
export type SurfaceMountKind = 'table' | 'hud' | 'child';
/**
* How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation`
* like a route. `table` is the root table surface; `hud` mounts to a HUD
* area; `child` mounts relative to a parent surface (see `children`).
*/
export interface SurfaceMount {
kind: SurfaceMountKind;
x?: number;
y?: number;
rotation?: number;
/** HUD area, for `kind: hud`. */
area?: string;
}
/** A view over the state store, purely for visual rendering. */
export interface Surface {
type: string;
id: string;
/** Reference `[width, height]` in mm; may be scaled to fit the table. */
size?: SurfaceSize;
/** How this surface is mounted; defaults to `{ kind: 'table' }`. */
mount?: SurfaceMount;
/** Child surfaces (`type#id` refs), mounted relative to this surface. */
children?: string[];
layout: Route[];
}
export type SetupValue = string | string[];
/** Seeds the state store: the enabled surfaces and a map from path to parts. */
export interface Setup {
type: string;
id: string;
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
surfaces?: string[];
setup: Record<string, SetupValue>;
}
export type Role = 'package' | 'part' | 'surface' | 'setup';
/**
* A raw definition object as written by the author. Definitions can be the
* root of a file/block or an item in the file's list.
*/
export interface RawDef {
role?: Role;
[key: string]: unknown;
}
/** The four definition roles. */
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
export interface PackageDef extends PackageMeta {
role: 'package';
/** Git-style path patterns of the defs that make up the package. */
include?: string[];
}
export interface PartDef extends Part {
role: 'part';
}
export interface SurfaceDef extends Surface {
role: 'surface';
}
export interface SetupDef extends Setup {
role: 'setup';
}
/** A virtual definition file: a real file or a markdown code block. */
export interface DefFile {
/** Path-style name; for code blocks, relative to their markdown file. */
name: string;
/** Raw text content. */
text: string;
/** 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';
}
/** A single parsed definition (one JSON object from a def file). */
export interface ParsedDef {
file: string;
/** Index into the file's parsed object list; `-1` for the root object. */
index: number;
/** The raw definition object. */
value: Record<string, unknown>;
/** Source location for error messages (real path or `file.md:12-19`). */
source: string;
}
/**
* A fully collected package: the parts, surfaces, and setups reachable from
* the package declaration's `include` patterns.
*/
export interface Package {
meta: PackageMeta;
/** All parts by `type#id`. */
parts: Map<string, Part>;
/** All surfaces by `type#id`. */
surfaces: Map<string, Surface>;
/** All setups by `type#id`. */
setups: Map<string, Setup>;
}
/**
* 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<string, Part>;
/** All surfaces by `type#id`. */
surfaces: Record<string, Surface>;
/** All setups by `type#id`. */
setups: Record<string, Setup>;
}
/** Errors during loading, carrying the source location when available. */
export class BgmError extends Error {
constructor(message: string, readonly location?: string) {
super(location ? `${location}: ${message}` : message);
this.name = 'BgmError';
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { parseCsvData, expandVariants } from './variants.js';
import type { DefFile } from './types.js';
function defFile(name: string, text: string): DefFile {
return { name, text, source: name, kind: 'csv' };
}
describe('parseCsvData', () => {
it('parses the spec example with an empty array', () => {
const csv = [
'name,parents',
'string,string[]',
'clark,[jonathan;martha]',
'bruce,[]',
].join('\n');
const { rows, header } = parseCsvData(csv, 'test');
expect(header).toEqual(['name', 'parents']);
expect(rows).toEqual([
{ name: 'clark', parents: ['jonathan', 'martha'] },
{ name: 'bruce', parents: [] },
]);
});
it('parses a crop tuple', () => {
const csv = [
'id,faceCrop',
'string,[number;number;number;number]',
'fish,[0;0;5;2]',
'grain,[1;0;5;2]',
].join('\n');
const { rows } = parseCsvData(csv, 'test');
expect(rows).toEqual([
{ id: 'fish', faceCrop: [0, 0, 5, 2] },
{ id: 'grain', faceCrop: [1, 0, 5, 2] },
]);
});
it('throws a BgmError on a type mismatch', () => {
const csv = ['n', 'number', 'not-a-number'].join('\n');
expect(() => parseCsvData(csv, 'test')).toThrow(/Invalid CSV/);
});
it('allows a header and schema with no data rows', () => {
const { rows } = parseCsvData('a\nstring', 'test');
expect(rows).toEqual([]);
});
});
describe('expandVariants', () => {
it('parses inline CSV when the value contains a newline', () => {
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
expect(rows).toEqual([{ a: 'x', b: 1 }]);
});
it('resolves a path against the def file directory', () => {
const defs = new Map<string, DefFile[]>([
['pkg/parts/seats.csv', [defFile('pkg/parts/seats.csv', 'seat\nnumber\n0\n1')]],
]);
const rows = expandVariants('./seats.csv', 'pkg/parts/board.yaml', defs, 'src');
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
});
it('throws when the referenced csv is missing', () => {
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
/CSV not found/,
);
});
it('throws when $variants is not a string', () => {
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
/must be a path or inline CSV/,
);
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* The `$variants` directive: parse a CSV into a typed object array and
* extend the original object with each row.
*
* Per docs/bgm-format.md §1:
* - The CSV's first row is the header, the second row is the type declaration
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
* the remaining rows are data.
* - Rows are validated against a schema derived from the type row.
* - A cell for an array/tuple type uses `;` as the element separator
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
* containing a newline is inline CSV, otherwise it is a path.
*
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
* this header/schema/data layout and validates each row against a schema
* derived from the type row.
*
* Paths resolve against the virtual def map — the same names `include` and
* `file=` resolve against — so a CSV can be a real file or a markdown code
* block (` ```csv file=parts/cargo.csv `).
*/
import * as path from 'node:path';
import { parseCsv } from 'typed-csv/csv-loader';
import { BgmError, type DefFile } from './types.js';
/** The parsed rows of a CSV, converted to typed values. */
export interface CsvData {
/** Column names from the header row. */
header: string[];
/** One object per data row. */
rows: Record<string, unknown>[];
}
/**
* Parse CSV text into typed row objects using `typed-csv`.
*
* @param text the CSV source (header + schema + data rows)
* @param source the source location, for error messages
*/
export function parseCsvData(text: string, source: string): CsvData {
try {
const result = parseCsv(text, { resolveReferences: false });
return { header: result.propertyConfigs.map((p) => p.name), rows: result.data };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new BgmError(`Invalid CSV: ${message}`, source);
}
}
/**
* Look up a CSV in the virtual def map and parse it.
*
* @param name the CSV's path-style name (relative to the games root)
* @param defs the virtual def map
* @param source the referencing def file's source, for error messages
*/
export function parseCsvByName(
name: string,
defs: Map<string, DefFile[]>,
source: string,
): CsvData {
const list = defs.get(name);
const file = list?.[0];
if (!file) {
throw new BgmError(`CSV not found: "${name}"`, source);
}
if (file.kind !== 'csv') {
throw new BgmError(`Expected a CSV file, got "${file.kind}" for "${name}"`, source);
}
return parseCsvData(file.text, file.source);
}
/**
* Expand a `$variants` value into rows.
*
* @param value the `$variants` value: a path or inline CSV
* @param baseName the path-style name of the referencing def file; a path
* value resolves relative to its directory
* @param defs the virtual def map, for resolving the path
* @param source the def file's source location, for error messages
*/
export function expandVariants(
value: unknown,
baseName: string,
defs: Map<string, DefFile[]>,
source: string,
): Record<string, unknown>[] {
if (typeof value !== 'string') {
throw new BgmError('`$variants` must be a path or inline CSV string', source);
}
if (value.includes('\n')) {
return parseCsvData(value, source).rows;
}
const name = path.posix.join(path.posix.dirname(baseName), value);
return parseCsvByName(name, defs, source).rows;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { bgm } from './vite.js';
const fixtureRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'__fixtures__',
'vite-build',
);
const gamesRoot = path.join(fixtureRoot, 'games');
describe('bgm vite plugin (integration)', () => {
it('resolves bgm/ imports through a real vite build', async () => {
const outDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bgm-build-'));
try {
await build({
root: fixtureRoot,
logLevel: 'silent',
build: {
outDir,
write: true,
emptyOutDir: true,
// Keep identifiers readable so the test can assert on them.
minify: false,
},
plugins: [bgm({ root: gamesRoot })],
});
// The fixture's entry re-exports the package data; find the bundle chunk.
const chunk = walk(outDir).find((f) => f.endsWith('.js'))!;
const code = fs.readFileSync(path.join(outDir, chunk), 'utf8');
// The plugin serialized the package's maps into the emitted module.
expect(code).toContain('token#wood');
expect(code).toContain('board#harbor');
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 });
}
});
});
/** Recursively list files under a directory, as paths relative to it. */
function walk(dir: string): string[] {
const out: string[] = [];
const visit = (current: string) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) visit(full);
else out.push(path.relative(dir, full));
}
};
visit(dir);
return out;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Vite plugin: resolve `virtual:bgm/packages` and `virtual:bgm/package/<id>`
* imports to JSON.
*
* The loader reads a games root (markdown code blocks + real
* yaml/json/toml/csv files), collects packages, and this plugin serves them
* as modules:
*
* - `virtual:bgm/packages` — the default export is an array of every
* discovered package.
* - `virtual:bgm/package/<id>` — the default export is that single package's
* assembled JSON.
*
* The `virtual:` prefix marks these as plugin-provided modules, so they can't
* be mistaken for real installed packages. Editing a game definition
* hot-reloads the app via `addWatchFile`.
*/
import * as path from 'node:path';
import type { Plugin } from 'vite';
import { collectPackages, loadDefs } from './collect.js';
import type { Package, SerializedPackage } from './types.js';
const VIRTUAL_PREFIX = '\0bgm:';
/** Public specifier for the module that lists every package. */
const PACKAGES = 'virtual:bgm/packages';
/** Public specifier prefix for a single package module. */
const PACKAGE = 'virtual:bgm/package/';
export interface BgmOptions {
/** Absolute path to the games root (e.g. `<repo>/games`). */
root: string;
}
export function bgm(options: BgmOptions): Plugin {
const root = options.root;
const collect = (): Package[] => {
const defMap = loadDefs('', root);
return collectPackages(defMap, root);
};
return {
name: 'bgm',
buildStart() {
// Watch every source file so edits trigger a reload/re-collect.
const defMap = loadDefs('', root);
for (const name of defMap.files.keys()) {
this.addWatchFile(path.join(root, name));
}
},
resolveId(id) {
if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES;
if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id;
},
load(id) {
if (!id.startsWith(VIRTUAL_PREFIX)) return;
const virtual = id.slice(VIRTUAL_PREFIX.length);
if (virtual === PACKAGES) {
const packages = collect().map(toJson);
return `export default ${JSON.stringify(packages)}`;
}
const name = virtual.slice(PACKAGE.length);
const pkg = collect().find((p) => p.meta.id === name);
if (!pkg) {
throw new Error(`bgm package "${name}" not found`);
}
return `export default ${JSON.stringify(toJson(pkg))}`;
},
};
}
/**
* Serialize a `Package` for JSON emission. The parts/surfaces/setups are
* `Map`s, which `JSON.stringify` would otherwise turn into `{}`.
*/
function toJson(pkg: Package): SerializedPackage {
return {
meta: pkg.meta,
parts: Object.fromEntries(pkg.parts),
surfaces: Object.fromEntries(pkg.surfaces),
setups: Object.fromEntries(pkg.setups),
};
}
+92
View File
@@ -0,0 +1,92 @@
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');
/**
* 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;
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
isEntry: false,
});
}
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.load as Callable<typeof plugin.load>)(id, { ssr: false });
}
/** 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));
}
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();
});
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>>;
expect(packages).toHaveLength(1);
expect(packages[0].meta.id).toBe('harbor');
expect(packages[0].parts).toHaveProperty('token#wood');
});
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');
});
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>;
// 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', 'board#player']);
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<typeof plugin.buildStart>).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);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/__fixtures__", "src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+103 -15
View File
@@ -13,7 +13,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))
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))
apps/proxy:
dependencies:
@@ -81,6 +81,9 @@ importers:
'@react-three/postprocessing':
specifier: ^3.0.4
version: 3.0.4(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1))(@types/three@0.185.4)(react@19.2.8)(three@0.185.1)
'@tts/bgm':
specifier: workspace:*
version: link:../../packages/bgm
'@tts/extract':
specifier: workspace:*
version: link:../../packages/extract
@@ -111,7 +114,7 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))
version: 4.3.3(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))
'@types/react':
specifier: ^19.2.18
version: 19.2.18
@@ -123,7 +126,7 @@ importers:
version: 0.185.4
'@vitejs/plugin-react':
specifier: ^6.0.5
version: 6.0.5(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))
version: 6.0.5(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))
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
@@ -132,10 +135,47 @@ importers:
version: 5.9.3
vite:
specifier: ^8.2.1
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)
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))
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))
packages/bgm:
dependencies:
marked:
specifier: ^16.0.0
version: 16.4.2
picomatch:
specifier: ^4.0.5
version: 4.0.5
smol-toml:
specifier: ^1.4.0
version: 1.7.1
typed-csv:
specifier: ^2.0.0
version: 2.0.0(esbuild@0.28.1)
vite:
specifier: ^8.2.1
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)
yaml:
specifier: ^2.4.2
version: 2.9.0
zod:
specifier: ^3.24.1
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^22.12.0
version: 22.20.1
'@types/picomatch':
specifier: ^4.0.0
version: 4.0.3
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))
packages/extract:
dependencies:
@@ -829,6 +869,9 @@ packages:
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/picomatch@4.0.3':
resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
'@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
@@ -958,6 +1001,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
csv-parse@5.6.0:
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
detect-gpu@5.0.70:
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
@@ -1213,6 +1259,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
hasBin: true
meshline@3.3.1:
resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
peerDependencies:
@@ -1343,6 +1394,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
smol-toml@1.7.1:
resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==}
engines: {node: '>= 18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1429,6 +1484,17 @@ packages:
tunnel-rat@0.1.2:
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
typed-csv@2.0.0:
resolution: {integrity: sha512-iwDT4D2SjLJswiwbKUl21ikzRWfXxy6R7pylfKk418ylMPJm3kJk4n87eJN/1bMYszLZm7ISo99IoyE2DhqWFA==}
peerDependencies:
'@rspack/core': ^1.x
esbuild: '*'
peerDependenciesMeta:
'@rspack/core':
optional: true
esbuild:
optional: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1546,6 +1612,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -2002,12 +2073,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))':
'@tailwindcss/vite@4.3.3(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:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)
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)
'@tweenjs/tween.js@23.1.3': {}
@@ -2032,6 +2103,8 @@ snapshots:
'@types/offscreencanvas@2019.7.3': {}
'@types/picomatch@4.0.3': {}
'@types/react-dom@19.2.4(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -2066,10 +2139,10 @@ snapshots:
'@visioncortex/vtracer@1.0.0-alpha.3': {}
'@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))':
'@vitejs/plugin-react@6.0.5(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:
'@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)
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:
@@ -2080,13 +2153,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@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))':
'@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))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)
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/pretty-format@4.1.10':
dependencies:
@@ -2153,6 +2226,8 @@ snapshots:
csstype@3.2.3: {}
csv-parse@5.6.0: {}
detect-gpu@5.0.70:
dependencies:
webgl-constants: 1.1.1
@@ -2359,6 +2434,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@16.4.2: {}
meshline@3.3.1(three@0.185.1):
dependencies:
three: 0.185.1
@@ -2497,6 +2574,8 @@ snapshots:
siginfo@2.0.0: {}
smol-toml@1.7.1: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@@ -2578,6 +2657,12 @@ snapshots:
- immer
- react
typed-csv@2.0.0(esbuild@0.28.1):
dependencies:
csv-parse: 5.6.0
optionalDependencies:
esbuild: 0.28.1
typescript@5.9.3: {}
undici-types@6.21.0: {}
@@ -2588,7 +2673,7 @@ snapshots:
utility-types@3.11.0: {}
vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11):
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:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -2601,11 +2686,12 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
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)):
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)):
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))
'@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))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -2622,7 +2708,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)
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)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
@@ -2642,6 +2728,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
yaml@2.9.0: {}
zod@3.25.76: {}
zustand@4.5.7(@types/react@19.2.18)(react@19.2.8):