feat: add support for stat templates

Introduces a new `template` stat type that allows for table-based
lookups via CSV blocks in markdown files. When a template stat is
rolled, it uses a dice expression defined in the template to select
an entry, applies a label, and automatically updates associated
modifier stats using relative values.
This commit is contained in:
2026-07-09 10:26:54 +08:00
parent 19c66640b5
commit 1c69bf394c
10 changed files with 384 additions and 15 deletions
+2 -1
View File
@@ -272,13 +272,14 @@ export function createContentServer(
links: [],
sparkTables: [],
stats: [],
statTemplates: [],
};
/** 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} stats=${completionsIndex.stats.length}`,
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
);
}
+3
View File
@@ -6,6 +6,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 { templatesSource } from "./sources/templates.js";
import type { CompletionSource, CompletionsPayload } from "./types.js";
export type {
@@ -14,6 +15,7 @@ export type {
LinkCompletion,
SparkTableCompletion,
StatDef,
StatTemplate,
} from "./types.js";
/** Registered sources — open for extension */
@@ -22,6 +24,7 @@ const sources: CompletionSource[] = [
linksSource,
sparkTablesSource,
statsSource,
templatesSource,
];
/**
+31
View File
@@ -0,0 +1,31 @@
/**
* Stat template completion source — extracts ```csv role=stat-template file=xxx
* blocks from markdown files.
*/
import type { CompletionSource } from "../types.js";
import { parseTemplateCsv } from "../stat-parser.js";
const templateBlockRegex = /```csv\s+role=stat-template\s+file=(\S+)\s*\n([\s\S]*?)```/gi;
export const templatesSource: CompletionSource = {
key: "statTemplates",
scan(index) {
const items: ReturnType<typeof parseTemplateCsv>[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
templateBlockRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = templateBlockRegex.exec(content)) !== null) {
const name = match[1];
const csv = match[2];
items.push(parseTemplateCsv(csv, filePath, name));
}
}
return items;
},
};
+77 -2
View File
@@ -8,13 +8,31 @@
export interface StatDef {
key: string;
label: string;
type: "number" | "string" | "enum" | "modifier" | "derived";
type: "number" | "string" | "enum" | "modifier" | "derived" | "template";
scope: "player" | "global";
default?: string;
target?: string;
options?: string[];
roll?: string;
formula?: string;
/** Name of a StatTemplate to use for template-type stats */
template?: string;
source: string;
}
/** A single row in a stat template table */
export interface TemplateEntry {
range: string;
label: string;
modifiers: Record<string, string>;
}
/** A named stat template (from ```csv role=stat-template file=xxx) */
export interface StatTemplate {
name: string;
/** Dice notation from the first header, e.g. "1d10" */
notation: string;
entries: TemplateEntry[];
source: string;
}
@@ -41,6 +59,7 @@ export function parseStatYaml(yaml: string, source: string): StatDef[] {
scope,
default: current.default,
target: current.target,
template: current.template,
options:
type === "enum" && options.length > 0
? [...options]
@@ -142,7 +161,10 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
let options: string[] | undefined;
const optRaw = get("options");
if (optRaw) {
options = optRaw.split("|").map((s) => s.trim()).filter(Boolean);
options = optRaw
.split("|")
.map((s) => s.trim())
.filter(Boolean);
}
defs.push({
@@ -153,6 +175,7 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
default: get("default"),
roll: get("roll"),
target: get("target"),
template: get("template"),
formula: get("formula"),
options,
source,
@@ -166,6 +189,58 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
// Helpers
// ---------------------------------------------------------------------------
/**
* Parse a ```csv role=stat-template file=xxx block.
*
* The first header is the dice notation (e.g. "1d10").
* The second header is "label".
* Remaining headers are modifier keys.
*/
export function parseTemplateCsv(
csv: string,
source: string,
name: string,
): StatTemplate {
const lines = csv.trim().split(/\r?\n/);
if (lines.length === 0) return { name, notation: "", entries: [], source };
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
if (headers.length < 2) return { name, notation: "", entries: [], source };
const notation = headers[0]; // e.g. "1d10"
const labelIdx = headers.indexOf("label");
// All columns after the notation & label are modifier keys
const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx);
const entries: TemplateEntry[] = [];
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 range = cols[0]?.trim();
if (!range) continue;
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
const modifiers: Record<string, string> = {};
for (const mk of modKeys) {
const mkIdx = headers.indexOf(mk);
if (mkIdx !== -1 && mkIdx < cols.length) {
const val = cols[mkIdx].trim();
if (val) modifiers[mk] = val;
}
}
entries.push({ range, label, modifiers });
}
return { name, notation, entries, source };
}
export function splitCsvRow(row: string): string[] {
const cols: string[] = [];
let current = "";
+3 -2
View File
@@ -2,9 +2,9 @@
* Completion source types — shared between CLI scanner and frontend consumer.
*/
import type { StatDef } from "./stat-parser.js";
import type { StatDef, StatTemplate } from "./stat-parser.js";
export type { StatDef };
export type { StatDef, StatTemplate };
/** A single dice expression found in a markdown file */
export interface DiceCompletion {
@@ -45,6 +45,7 @@ export interface CompletionsPayload {
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
}
/**