Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b69133cc | ||
|
|
599fb6a76d | ||
|
|
4e18ab5bc8 | ||
|
|
64c8bb60b3 | ||
|
|
c86d7444b8 | ||
|
|
7f3f758c52 | ||
|
|
845d510948 | ||
|
|
ba16f17d8a | ||
|
|
c0967ab71f | ||
|
|
1cfec40c99 | ||
|
|
664079528c |
@@ -21,6 +21,7 @@
|
|||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
|
"@tts/bgm": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { Link, Route, Routes } from 'react-router-dom';
|
|||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
import ModPage from './pages/ModPage';
|
import ModPage from './pages/ModPage';
|
||||||
import FullSetupPage from './pages/FullSetupPage';
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +23,9 @@ export default function App() {
|
|||||||
<Link to="/" className="hover:text-zinc-100">
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
Search
|
Search
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -23,6 +34,14 @@ export default function App() {
|
|||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
<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>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+18
@@ -1 +1,19 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bgm } from '@tts/bgm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
// Proxy API calls to the Hono backend during development.
|
// Proxy API calls to the Hono backend during development.
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/__fixtures__", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+103
-15
@@ -13,7 +13,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.10
|
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:
|
apps/proxy:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -81,6 +81,9 @@ importers:
|
|||||||
'@react-three/postprocessing':
|
'@react-three/postprocessing':
|
||||||
specifier: ^3.0.4
|
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)
|
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':
|
'@tts/extract':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/extract
|
version: link:../../packages/extract
|
||||||
@@ -111,7 +114,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.3
|
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':
|
'@types/react':
|
||||||
specifier: ^19.2.18
|
specifier: ^19.2.18
|
||||||
version: 19.2.18
|
version: 19.2.18
|
||||||
@@ -123,7 +126,7 @@ importers:
|
|||||||
version: 0.185.4
|
version: 0.185.4
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.5
|
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:
|
tailwindcss:
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
@@ -132,10 +135,47 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.2.1
|
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:
|
vitest:
|
||||||
specifier: ^4.1.10
|
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:
|
packages/extract:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -829,6 +869,9 @@ packages:
|
|||||||
'@types/offscreencanvas@2019.7.3':
|
'@types/offscreencanvas@2019.7.3':
|
||||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||||
|
|
||||||
|
'@types/picomatch@4.0.3':
|
||||||
|
resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.4':
|
'@types/react-dom@19.2.4':
|
||||||
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -958,6 +1001,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
detect-gpu@5.0.70:
|
||||||
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
@@ -1213,6 +1259,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
meshline@3.3.1:
|
||||||
resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
|
resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1343,6 +1394,10 @@ packages:
|
|||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1429,6 +1484,17 @@ packages:
|
|||||||
tunnel-rat@0.1.2:
|
tunnel-rat@0.1.2:
|
||||||
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
|
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:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1546,6 +1612,11 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yaml@2.9.0:
|
||||||
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
@@ -2002,12 +2073,12 @@ snapshots:
|
|||||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
||||||
'@tailwindcss/oxide-win32-x64-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:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.3.3
|
'@tailwindcss/node': 4.3.3
|
||||||
'@tailwindcss/oxide': 4.3.3
|
'@tailwindcss/oxide': 4.3.3
|
||||||
tailwindcss: 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': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
@@ -2032,6 +2103,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/offscreencanvas@2019.7.3': {}
|
'@types/offscreencanvas@2019.7.3': {}
|
||||||
|
|
||||||
|
'@types/picomatch@4.0.3': {}
|
||||||
|
|
||||||
'@types/react-dom@19.2.4(@types/react@19.2.18)':
|
'@types/react-dom@19.2.4(@types/react@19.2.18)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
@@ -2066,10 +2139,10 @@ snapshots:
|
|||||||
|
|
||||||
'@visioncortex/vtracer@1.0.0-alpha.3': {}
|
'@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:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@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':
|
'@vitest/expect@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2080,13 +2153,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.1
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.10
|
'@vitest/spy': 4.1.10
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2153,6 +2226,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
csv-parse@5.6.0: {}
|
||||||
|
|
||||||
detect-gpu@5.0.70:
|
detect-gpu@5.0.70:
|
||||||
dependencies:
|
dependencies:
|
||||||
webgl-constants: 1.1.1
|
webgl-constants: 1.1.1
|
||||||
@@ -2359,6 +2434,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@16.4.2: {}
|
||||||
|
|
||||||
meshline@3.3.1(three@0.185.1):
|
meshline@3.3.1(three@0.185.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
three: 0.185.1
|
three: 0.185.1
|
||||||
@@ -2497,6 +2574,8 @@ snapshots:
|
|||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
smol-toml@1.7.1: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
@@ -2578,6 +2657,12 @@ snapshots:
|
|||||||
- immer
|
- immer
|
||||||
- react
|
- 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: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
@@ -2588,7 +2673,7 @@ snapshots:
|
|||||||
|
|
||||||
utility-types@3.11.0: {}
|
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:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -2601,11 +2686,12 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.11
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@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/pretty-format': 4.1.10
|
||||||
'@vitest/runner': 4.1.10
|
'@vitest/runner': 4.1.10
|
||||||
'@vitest/snapshot': 4.1.10
|
'@vitest/snapshot': 4.1.10
|
||||||
@@ -2622,7 +2708,7 @@ snapshots:
|
|||||||
tinyexec: 1.3.0
|
tinyexec: 1.3.0
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.1
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
@@ -2642,6 +2728,8 @@ snapshots:
|
|||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zustand@4.5.7(@types/react@19.2.18)(react@19.2.8):
|
zustand@4.5.7(@types/react@19.2.18)(react@19.2.8):
|
||||||
|
|||||||
Reference in New Issue
Block a user