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:
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user