diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index c72fc30..d8b6ffc 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -271,13 +271,14 @@ export function createContentServer( dice: [], links: [], sparkTables: [], + stats: [], }; /** Re-scan completions from current content index (cached) */ function recomputeCompletions(): void { completionsIndex = scanCompletions(contentIndex); console.log( - `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`, + `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length}`, ); } diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index dc5e5c3..5b0b03d 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -5,6 +5,7 @@ import { diceSource } from "./sources/dice.js"; import { linksSource } from "./sources/links.js"; import { sparkTablesSource } from "./sources/spark-tables.js"; +import { statsSource } from "./sources/stats.js"; import type { CompletionSource, CompletionsPayload } from "./types.js"; export type { @@ -12,6 +13,7 @@ export type { DiceCompletion, LinkCompletion, SparkTableCompletion, + StatDef, } from "./types.js"; /** Registered sources — open for extension */ @@ -19,6 +21,7 @@ const sources: CompletionSource[] = [ diceSource, linksSource, sparkTablesSource, + statsSource, ]; /** diff --git a/src/cli/completions/sources/stats.ts b/src/cli/completions/sources/stats.ts new file mode 100644 index 0000000..f892e1c --- /dev/null +++ b/src/cli/completions/sources/stats.ts @@ -0,0 +1,202 @@ +/** + * Stat completion source — extracts ```yaml role=stat and ```csv role=stat + * blocks from markdown files. + */ + +import type { CompletionSource, StatDef } from "../types.js"; + +const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; +const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; + +export const statsSource: CompletionSource = { + key: "stats", + + scan(index) { + const items: StatDef[] = []; + + for (const [filePath, content] of Object.entries(index)) { + if (!filePath.endsWith(".md")) continue; + + // YAML blocks + statBlockRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = statBlockRegex.exec(content)) !== null) { + const parsed = parseStatYaml(match[1], filePath); + items.push(...parsed); + } + + // CSV blocks + statCsvRegex.lastIndex = 0; + while ((match = statCsvRegex.exec(content)) !== null) { + const parsed = parseStatCsv(match[1], filePath); + items.push(...parsed); + } + } + + return items; + }, +}; + +// --------------------------------------------------------------------------- +// YAML parser +// --------------------------------------------------------------------------- + +function parseStatYaml(yaml: string, source: string): StatDef[] { + const defs: StatDef[] = []; + const lines = yaml.split(/\r?\n/); + + let current: Record | null = null; + let collectingOptions = false; + let options: string[] = []; + + function flushCurrent() { + if (!current || !current.key) return; + const type = (current.type || "number") as StatDef["type"]; + const scope = (current.scope || "global") as StatDef["scope"]; + defs.push({ + key: current.key, + label: current.label || current.key, + type, + scope, + default: current.default, + target: current.target, + options: + type === "enum" && options.length > 0 + ? [...options] + : current.options + ? current.options + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + roll: current.roll, + formula: current.formula, + source, + }); + current = null; + options = []; + collectingOptions = false; + } + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed.startsWith("- ")) { + flushCurrent(); + current = {}; + collectingOptions = false; + const rest = trimmed.slice(2); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) continue; + const k = rest.slice(0, colonIdx).trim(); + const v = rest.slice(colonIdx + 1).trim(); + current[k] = v; + continue; + } + + if (current && trimmed.match(/^\w/)) { + const colonIdx = trimmed.indexOf(":"); + if (colonIdx === -1) continue; + const k = trimmed.slice(0, colonIdx).trim(); + const v = trimmed.slice(colonIdx + 1).trim(); + + if (k === "options") { + if (v.startsWith("[") && v.endsWith("]")) { + current[k] = v.slice(1, -1); + } else { + collectingOptions = true; + options = []; + } + } else { + current[k] = v; + } + continue; + } + + if (collectingOptions && trimmed.startsWith("- ")) { + options.push(trimmed.slice(2).trim()); + continue; + } + } + + flushCurrent(); + return defs; +} + +// --------------------------------------------------------------------------- +// CSV parser +// --------------------------------------------------------------------------- + +function parseStatCsv(csv: string, source: string): StatDef[] { + const lines = csv.trim().split(/\r?\n/); + if (lines.length === 0) return []; + + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const idx = (name: string) => { + const i = headers.indexOf(name); + return i === -1 ? null : i; + }; + + const defs: StatDef[] = []; + + for (let i = 1; i < lines.length; i++) { + const row = lines[i].trim(); + if (!row || row.startsWith("#")) continue; + const cols = splitCsvRow(row); + if (cols.length === 0) continue; + + const get = (name: string) => { + const colIdx = idx(name); + if (colIdx === null || colIdx >= cols.length) return undefined; + return cols[colIdx].trim() || undefined; + }; + + const key = get("key"); + if (!key) continue; + + const type = (get("type") || "number") as StatDef["type"]; + const scope = (get("scope") || "player") as StatDef["scope"]; + + let options: string[] | undefined; + const optRaw = get("options"); + if (optRaw) { + options = optRaw.split("|").map((s) => s.trim()).filter(Boolean); + } + + defs.push({ + key, + label: get("label") || key, + type, + scope, + default: get("default"), + roll: get("roll"), + target: get("target"), + formula: get("formula"), + options, + source, + }); + } + + return defs; +} + +function splitCsvRow(row: string): string[] { + const cols: string[] = []; + let current = ""; + let inQuote = false; + + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === "," && !inQuote) { + cols.push(current); + current = ""; + } else { + current += ch; + } + } + cols.push(current); + return cols; +} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index ee43b16..bb1631c 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -41,6 +41,21 @@ export interface CompletionsPayload { dice: DiceCompletion[]; links: LinkCompletion[]; sparkTables: SparkTableCompletion[]; + stats: StatDef[]; +} + +/** A single stat definition parsed from a ```yaml/csv role=stat block */ +export interface StatDef { + key: string; + label: string; + type: "number" | "string" | "enum" | "modifier" | "derived"; + scope: "player" | "global"; + default?: string; + target?: string; + options?: string[]; + roll?: string; + formula?: string; + source: string; } /** diff --git a/src/components/journal-doc-data.ts b/src/components/journal-doc-data.ts index af3751d..4e2126c 100644 --- a/src/components/journal-doc-data.ts +++ b/src/components/journal-doc-data.ts @@ -43,8 +43,9 @@ function parseEntry(raw: string): JournalDocEntry | null { import journalGmRaw from "../doc-entries/journal-gm.md"; import journalPlayerRaw from "../doc-entries/journal-player.md"; +import journalStatRaw from "../doc-entries/journal-stat.md"; -const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw]; +const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw, journalStatRaw]; let _entries: JournalDocEntry[] | null = null;