feat: add stat completion source

Implement a new completion source that extracts stat definitions
from markdown files using `yaml` or `csv` code blocks with
`role=stat`.
This commit is contained in:
hypercross 2026-07-09 09:52:34 +08:00
parent 45cd9a01ac
commit ef8b56e5b6
5 changed files with 224 additions and 2 deletions

View File

@ -271,13 +271,14 @@ export function createContentServer(
dice: [], dice: [],
links: [], links: [],
sparkTables: [], sparkTables: [],
stats: [],
}; };
/** Re-scan completions from current content index (cached) */ /** Re-scan completions from current content index (cached) */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex); completionsIndex = scanCompletions(contentIndex);
console.log( 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}`,
); );
} }

View File

@ -5,6 +5,7 @@
import { diceSource } from "./sources/dice.js"; import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js"; import { linksSource } from "./sources/links.js";
import { sparkTablesSource } from "./sources/spark-tables.js"; import { sparkTablesSource } from "./sources/spark-tables.js";
import { statsSource } from "./sources/stats.js";
import type { CompletionSource, CompletionsPayload } from "./types.js"; import type { CompletionSource, CompletionsPayload } from "./types.js";
export type { export type {
@ -12,6 +13,7 @@ export type {
DiceCompletion, DiceCompletion,
LinkCompletion, LinkCompletion,
SparkTableCompletion, SparkTableCompletion,
StatDef,
} from "./types.js"; } from "./types.js";
/** Registered sources — open for extension */ /** Registered sources — open for extension */
@ -19,6 +21,7 @@ const sources: CompletionSource[] = [
diceSource, diceSource,
linksSource, linksSource,
sparkTablesSource, sparkTablesSource,
statsSource,
]; ];
/** /**

View File

@ -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<string, string> | 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;
}

View File

@ -41,6 +41,21 @@ export interface CompletionsPayload {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[]; 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;
} }
/** /**

View File

@ -43,8 +43,9 @@ function parseEntry(raw: string): JournalDocEntry | null {
import journalGmRaw from "../doc-entries/journal-gm.md"; import journalGmRaw from "../doc-entries/journal-gm.md";
import journalPlayerRaw from "../doc-entries/journal-player.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; let _entries: JournalDocEntry[] | null = null;