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:
parent
45cd9a01ac
commit
ef8b56e5b6
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue