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
+99
View File
@@ -0,0 +1,99 @@
/**
* The `$variants` directive: parse a CSV into a typed object array and
* extend the original object with each row.
*
* Per docs/bgm-format.md §1:
* - The CSV's first row is the header, the second row is the type declaration
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
* the remaining rows are data.
* - Rows are validated against a schema derived from the type row.
* - A cell for an array/tuple type uses `;` as the element separator
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
* containing a newline is inline CSV, otherwise it is a path.
*
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
* this header/schema/data layout and validates each row against a schema
* derived from the type row.
*
* Paths resolve against the virtual def map — the same names `include` and
* `file=` resolve against — so a CSV can be a real file or a markdown code
* block (` ```csv file=parts/cargo.csv `).
*/
import * as path from 'node:path';
import { parseCsv } from 'typed-csv/csv-loader';
import { BgmError, type DefFile } from './types.js';
/** The parsed rows of a CSV, converted to typed values. */
export interface CsvData {
/** Column names from the header row. */
header: string[];
/** One object per data row. */
rows: Record<string, unknown>[];
}
/**
* Parse CSV text into typed row objects using `typed-csv`.
*
* @param text the CSV source (header + schema + data rows)
* @param source the source location, for error messages
*/
export function parseCsvData(text: string, source: string): CsvData {
try {
const result = parseCsv(text, { resolveReferences: false });
return { header: result.propertyConfigs.map((p) => p.name), rows: result.data };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new BgmError(`Invalid CSV: ${message}`, source);
}
}
/**
* Look up a CSV in the virtual def map and parse it.
*
* @param name the CSV's path-style name (relative to the games root)
* @param defs the virtual def map
* @param source the referencing def file's source, for error messages
*/
export function parseCsvByName(
name: string,
defs: Map<string, DefFile[]>,
source: string,
): CsvData {
const list = defs.get(name);
const file = list?.[0];
if (!file) {
throw new BgmError(`CSV not found: "${name}"`, source);
}
if (file.kind !== 'csv') {
throw new BgmError(`Expected a CSV file, got "${file.kind}" for "${name}"`, source);
}
return parseCsvData(file.text, file.source);
}
/**
* Expand a `$variants` value into rows.
*
* @param value the `$variants` value: a path or inline CSV
* @param baseName the path-style name of the referencing def file; a path
* value resolves relative to its directory
* @param defs the virtual def map, for resolving the path
* @param source the def file's source location, for error messages
*/
export function expandVariants(
value: unknown,
baseName: string,
defs: Map<string, DefFile[]>,
source: string,
): Record<string, unknown>[] {
if (typeof value !== 'string') {
throw new BgmError('`$variants` must be a path or inline CSV string', source);
}
if (value.includes('\n')) {
return parseCsvData(value, source).rows;
}
const name = path.posix.join(path.posix.dirname(baseName), value);
return parseCsvByName(name, defs, source).rows;
}