The default include matched every yaml across the games root, so a package could absorb a sibling game's defs (e.g. two game#main setups). Resolve patterns against the package declaration's directory instead; a leading slash marks a pattern as root-relative.
288 lines
9.5 KiB
TypeScript
288 lines
9.5 KiB
TypeScript
/**
|
|
* 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 * as path from 'node:path';
|
|
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);
|
|
const baseDir = path.posix.dirname(file).replace(/^\/+/, '');
|
|
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
/** Path-style directory of the package declaration, e.g. `carcassonne`. */
|
|
private readonly baseDir: 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 package declaration's own directory (e.g. `./**/*.yaml` means
|
|
// this package's folder and below), so a package never absorbs defs from
|
|
// a sibling game. A leading `/` marks a pattern as root-relative.
|
|
// Def names carry a leading `/` (from the empty games root), so resolved
|
|
// patterns are prefixed to match.
|
|
const names = new Set<string>();
|
|
for (const pattern of patterns) {
|
|
const resolved = pattern.startsWith('/')
|
|
? pattern
|
|
: `/${path.posix.join(this.baseDir, pattern)}`;
|
|
const matcher = picomatch(resolved, { 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 {
|
|
const part = validatePart(obj) as unknown as Part;
|
|
// Resolve relative asset paths against the directory of the source file
|
|
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
|
// Real files may carry a leading slash from an empty root; strip it.
|
|
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
|
part.baseUrl = dir ? `${dir}/` : '';
|
|
return 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);
|
|
}
|