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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user