From c0967ab71fa0d1a425740318d72a11e85fa2c034 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 18:59:14 +0800 Subject: [PATCH] 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. --- packages/bgm/package.json | 34 +++ .../bgm/src/__fixtures__/harbor/harbor.md | 75 +++++ packages/bgm/src/collect.test.ts | 61 ++++ packages/bgm/src/collect.ts | 270 ++++++++++++++++++ packages/bgm/src/index.ts | 6 + packages/bgm/src/markdown.test.ts | 74 +++++ packages/bgm/src/markdown.ts | 137 +++++++++ packages/bgm/src/parse.ts | 109 +++++++ packages/bgm/src/schemas.ts | 86 ++++++ packages/bgm/src/types.ts | 200 +++++++++++++ packages/bgm/src/variants.test.ts | 75 +++++ packages/bgm/src/variants.ts | 99 +++++++ packages/bgm/tsconfig.json | 9 + packages/bgm/vitest.config.ts | 7 + 14 files changed, 1242 insertions(+) create mode 100644 packages/bgm/package.json create mode 100644 packages/bgm/src/__fixtures__/harbor/harbor.md create mode 100644 packages/bgm/src/collect.test.ts create mode 100644 packages/bgm/src/collect.ts create mode 100644 packages/bgm/src/index.ts create mode 100644 packages/bgm/src/markdown.test.ts create mode 100644 packages/bgm/src/markdown.ts create mode 100644 packages/bgm/src/parse.ts create mode 100644 packages/bgm/src/schemas.ts create mode 100644 packages/bgm/src/types.ts create mode 100644 packages/bgm/src/variants.test.ts create mode 100644 packages/bgm/src/variants.ts create mode 100644 packages/bgm/tsconfig.json create mode 100644 packages/bgm/vitest.config.ts diff --git a/packages/bgm/package.json b/packages/bgm/package.json new file mode 100644 index 0000000..7f80927 --- /dev/null +++ b/packages/bgm/package.json @@ -0,0 +1,34 @@ +{ + "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": { + "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" + } +} diff --git a/packages/bgm/src/__fixtures__/harbor/harbor.md b/packages/bgm/src/__fixtures__/harbor/harbor.md new file mode 100644 index 0000000..a9bfafa --- /dev/null +++ b/packages/bgm/src/__fixtures__/harbor/harbor.md @@ -0,0 +1,75 @@ +# 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] +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 +``` + +## Setup + +```yaml file=setup/main.yaml +role: setup +type: game +id: main +setup: + /dock/0: harbor:token#wood + /deck: harbor:token#grain +``` \ No newline at end of file diff --git a/packages/bgm/src/collect.test.ts b/packages/bgm/src/collect.test.ts new file mode 100644 index 0000000..5a5b160 --- /dev/null +++ b/packages/bgm/src/collect.test.ts @@ -0,0 +1,61 @@ +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__'); + +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]); + + // One surface with a $variants-expanded candidates list. + expect([...harbor.surfaces.keys()]).toEqual(['board#harbor']); + const board = harbor.surfaces.get('board#harbor')!; + expect(board.size).toEqual([300, 200]); + 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 }); + + // One setup. + expect([...harbor.setups.keys()]).toEqual(['game#main']); + const setup = harbor.setups.get('game#main')!; + 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/); + }); +}); \ No newline at end of file diff --git a/packages/bgm/src/collect.ts b/packages/bgm/src/collect.ts new file mode 100644 index 0000000..ef1b2ed --- /dev/null +++ b/packages/bgm/src/collect.ts @@ -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(['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; + /** All parsed definitions, keyed by file name. */ + defs: Map; +} + +/** + * 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(); + 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(); + // 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(); + 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(); + const byRole = new Map(); + + // 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(); + readonly surfaces = new Map(); + readonly setups = new Map(); + readonly byRole = new Map(); + + 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, baseName: string, source: string): Record[] { + 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(); + 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, source: string): Part { + try { + return validatePart(obj) as unknown as Part; + } catch (err) { + throw wrapZod(err, source); + } +} + +function asSurface( + obj: Record, + source: string, + defs: Map, +): Surface { + const value: Record = { ...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; + 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; + 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, source: string): Setup { + const value: Record = { ...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); +} diff --git a/packages/bgm/src/index.ts b/packages/bgm/src/index.ts new file mode 100644 index 0000000..60860d5 --- /dev/null +++ b/packages/bgm/src/index.ts @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './schemas.js'; +export * from './markdown.js'; +export * from './parse.js'; +export * from './variants.js'; +export * from './collect.js'; \ No newline at end of file diff --git a/packages/bgm/src/markdown.test.ts b/packages/bgm/src/markdown.test.ts new file mode 100644 index 0000000..3616404 --- /dev/null +++ b/packages/bgm/src/markdown.test.ts @@ -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]); + }); +}); \ No newline at end of file diff --git a/packages/bgm/src/markdown.ts b/packages/bgm/src/markdown.ts new file mode 100644 index 0000000..36e8682 --- /dev/null +++ b/packages/bgm/src/markdown.ts @@ -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 `./.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; + +/** + * 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): VirtualFiles { + const files = new Map(); + 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; +} \ No newline at end of file diff --git a/packages/bgm/src/parse.ts b/packages/bgm/src/parse.ts new file mode 100644 index 0000000..bf786bc --- /dev/null +++ b/packages/bgm/src/parse.ts @@ -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, 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'; +} diff --git a/packages/bgm/src/schemas.ts b/packages/bgm/src/schemas.ts new file mode 100644 index 0000000..0f72bb7 --- /dev/null +++ b/packages/bgm/src/schemas.ts @@ -0,0 +1,86 @@ +/** + * 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 surfaceSchema = z.object({ + type: z.string().min(1), + id: z.string().min(1), + size: surfaceSize.optional(), + layout: z.array(route), +}); + +const setupSchema = z.object({ + type: z.string().min(1), + id: z.string().min(1), + 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): Part { + return partSchema.parse(value) as unknown as Part; +} + +/** Validate a raw surface definition. */ +export function validateSurface(value: Record): Surface { + return surfaceSchema.parse(value) as unknown as Surface; +} + +/** Validate a raw setup definition. */ +export function validateSetup(value: Record): Setup { + return setupSchema.parse(value) as unknown as Setup; +} + +/** Validate a raw package definition. */ +export function validatePackage(value: Record): PackageDef { + return packageSchema.parse(value) as unknown as PackageDef; +} diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts new file mode 100644 index 0000000..b3c2e2b --- /dev/null +++ b/packages/bgm/src/types.ts @@ -0,0 +1,200 @@ +/** + * 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; +} + +/** 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; + layout: Route[]; +} + +export type SetupValue = string | string[]; + +/** Seeds the state store: a map from path to a stack of parts. */ +export interface Setup { + type: string; + id: string; + setup: Record; +} + +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; + /** 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; + /** All surfaces by `type#id`. */ + surfaces: Map; + /** All setups by `type#id`. */ + setups: Map; +} + +/** 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'; + } +} diff --git a/packages/bgm/src/variants.test.ts b/packages/bgm/src/variants.test.ts new file mode 100644 index 0000000..8954f47 --- /dev/null +++ b/packages/bgm/src/variants.test.ts @@ -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([ + ['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/, + ); + }); +}); \ No newline at end of file diff --git a/packages/bgm/src/variants.ts b/packages/bgm/src/variants.ts new file mode 100644 index 0000000..466e471 --- /dev/null +++ b/packages/bgm/src/variants.ts @@ -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[]; +} + +/** + * 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, + 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, + source: string, +): Record[] { + 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; +} \ No newline at end of file diff --git a/packages/bgm/tsconfig.json b/packages/bgm/tsconfig.json new file mode 100644 index 0000000..8eed416 --- /dev/null +++ b/packages/bgm/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/bgm/vitest.config.ts b/packages/bgm/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/bgm/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +});