feat(bgm): add board game manifest loader

Parse yaml/json/toml and markdown code blocks into packages, expanding
$variants via typed-csv and collecting parts, surfaces, and setups by
include patterns. Ships zod validation, a vitest config, and 16 tests.
This commit is contained in:
2026-08-09 18:59:14 +08:00
parent 1cfec40c99
commit c0967ab71f
14 changed files with 1242 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
/**
* Collect packages from a games root directory.
*
* A games root contains yaml/json/toml files and markdown files with
* definition code blocks. The loader:
*
* 1. Reads real files and extracts virtual files from markdown code blocks
* (virtual wins over real files with the same name).
* 2. Parses each def file into JSON objects.
* 3. Recognizes `role: package` objects, expands their `$variants`, follows
* their `include` patterns, and assembles the package's parts, surfaces,
* and setups.
*
* See docs/bgm-format.md for the format's concrete behavior.
*/
import picomatch from 'picomatch';
import { collectVirtualFiles } from './markdown.js';
import { parseDefText, readDefFiles } from './parse.js';
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
import { expandVariants } from './variants.js';
import {
BgmError,
type DefFile,
type ParsedDef,
type Package,
type PackageDef,
type Part,
type Role,
type Setup,
type Surface,
} from './types.js';
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
/** Every definition parsed from a def file, keyed by its path-style name. */
export interface DefMap {
/** All def files (real + virtual), keyed by name. */
files: Map<string, DefFile[]>;
/** All parsed definitions, keyed by file name. */
defs: Map<string, ParsedDef[]>;
}
/**
* Load a games root into a def map.
*
* @param root the path-style name of the root, e.g. `harbor`
* @param rootDir the absolute path of the games root
*/
export function loadDefs(root: string, rootDir: string): DefMap {
const realFiles = readDefFiles(rootDir, root);
const markdownFiles = new Map<string, string>();
const others: DefFile[] = [];
for (const file of realFiles) {
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
else others.push(file);
}
const virtualFiles = collectVirtualFiles(markdownFiles);
const files = new Map<string, DefFile[]>();
// Real files first, virtual files override (virtual wins per the format).
for (const file of others) files.set(file.name, [file]);
for (const [name, list] of virtualFiles) files.set(name, list);
const defs = new Map<string, ParsedDef[]>();
for (const [name, list] of files) {
const parsed: ParsedDef[] = [];
for (const file of list) parsed.push(...parseDefText(file));
defs.set(name, parsed);
}
return { files, defs };
}
/**
* Collect all packages from the given def map.
*
* @param rootDir the absolute path of the games root; `$variants` file paths
* resolve relative to their def file's directory within the root
*/
export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
const packages = new Map<string, PackageAcc>();
const byRole = new Map<string, ParsedDef[]>();
// Group parsed defs by role.
for (const [file, defs] of defMap.defs) {
const list: ParsedDef[] = [];
for (const def of defs) {
const role = def.value['role'];
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
list.push(def);
byRole.set(file, list);
}
}
}
const accs: PackageAcc[] = [];
for (const [file, defs] of byRole) {
for (const def of defs) {
const role = def.value['role'] as Role;
if (role === 'package') {
const pkg = asPackage(def, file);
accs.push(new PackageAcc(pkg, defMap, rootDir));
}
}
}
const result: Package[] = [];
for (const acc of accs) {
acc.collect();
result.push(acc.toPackage());
}
return result;
}
/** Identity validation: `type#id` must be unique within a package. */
class PackageAcc {
readonly parts = new Map<string, Part>();
readonly surfaces = new Map<string, Surface>();
readonly setups = new Map<string, Setup>();
readonly byRole = new Map<string, string[]>();
constructor(
readonly pkg: PackageDef,
private readonly defs: DefMap,
private readonly rootDir: string,
) {}
collect() {
const include = this.pkg.include ?? ['./**/*.yaml'];
const names = this.expandIncludes(include);
for (const name of names) {
const fileDefs = this.defs.defs.get(name);
if (!fileDefs) continue;
for (const def of fileDefs) {
const role = def.value['role'];
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
this.add(role as Role, def, name);
}
}
}
/** Expand `$variants` on a def object into a list of concrete objects. */
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
if (!('$variants' in obj)) return [obj];
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
const { $variants: _v, ...base } = obj;
return rows.map((row) => ({ ...base, ...row }));
}
private expandIncludes(patterns: string[]): string[] {
// Match include patterns against the parsed definitions' names, which
// cover both real files and markdown code blocks. Patterns are relative
// to the games root (e.g. `./**/*.yaml`).
const names = new Set<string>();
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true });
for (const name of this.defs.defs.keys()) {
if (matcher(name)) names.add(name);
}
}
return [...names];
}
private add(role: Role, def: ParsedDef, fileName: string) {
const expanded = this.expand(def.value, def.file, def.source);
for (const obj of expanded) {
switch (role) {
case 'part': {
const part = asPart(obj, fileName);
const key = `${part.type}#${part.id}`;
if (this.parts.has(key)) {
throw new BgmError(`Duplicate part "${key}"`, fileName);
}
this.parts.set(key, part);
break;
}
case 'surface': {
const surface = asSurface(obj, fileName, this.defs.files);
const key = `${surface.type}#${surface.id}`;
if (this.surfaces.has(key)) {
throw new BgmError(`Duplicate surface "${key}"`, fileName);
}
this.surfaces.set(key, surface);
break;
}
case 'setup': {
const setup = asSetup(obj, fileName);
const key = `${setup.type}#${setup.id}`;
if (this.setups.has(key)) {
throw new BgmError(`Duplicate setup "${key}"`, fileName);
}
this.setups.set(key, setup);
break;
}
}
}
}
toPackage(): Package {
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
}
}
function metaOf(pkg: PackageDef) {
const { role: _role, include: _include, ...meta } = pkg;
return meta;
}
function asPackage(def: ParsedDef, source: string): PackageDef {
const obj = def.value;
try {
return validatePackage(obj) as unknown as PackageDef;
} catch (err) {
throw wrapZod(err, source);
}
}
function asPart(obj: Record<string, unknown>, source: string): Part {
try {
return validatePart(obj) as unknown as Part;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSurface(
obj: Record<string, unknown>,
source: string,
defs: Map<string, DefFile[]>,
): Surface {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
// Expand `candidates.$variants` on each route into a concrete array.
if (Array.isArray(value['layout'])) {
value['layout'] = value['layout'].map((route) => {
if (typeof route !== 'object' || route === null) return route;
const r = route as Record<string, unknown>;
const cand = r['candidates'];
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
const rows = expandVariants(cand['$variants'], source, defs, source);
const { $variants: _v, ...base } = cand as Record<string, unknown>;
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
}
return route;
});
}
try {
return validateSurface(value) as unknown as Surface;
} catch (err) {
throw wrapZod(err, source);
}
}
function asSetup(obj: Record<string, unknown>, source: string): Setup {
const value: Record<string, unknown> = { ...obj };
delete value['role'];
try {
return validateSetup(value) as unknown as Setup;
} catch (err) {
throw wrapZod(err, source);
}
}
/** Wrap a zod error with the source location. */
function wrapZod(err: unknown, source: string): BgmError {
const message = err instanceof Error ? err.message : String(err);
return new BgmError(`Invalid definition: ${message}`, source);
}