feat: add stat-modifiers role for automatic stat generation

Introduces a new `stat-modifiers` CSV role that simplifies the
creation of template stats and their associated modifier stats.
A single CSV block now automatically generates:
- A template stat definition.
- Multiple modifier stat definitions (prefixed with the template ID).
- The corresponding template table.

This reduces the need for manual YAML definitions for every
modifier associated with a template.
This commit is contained in:
2026-07-09 11:16:39 +08:00
parent d83256e049
commit ef71a43f0a
4 changed files with 183 additions and 19 deletions
+9
View File
@@ -12,6 +12,7 @@ import {
parseStatYaml,
parseStatCsv,
parseTemplateCsv,
parseStatModifiers,
type StatDef,
type StatTemplate,
} from "./stat-parser.js";
@@ -111,6 +112,14 @@ export function processBlocks(
);
}
if (attrs.role === "stat-modifiers") {
const id = attrs.id || `_mod_${contentHash(body)}`;
const result = parseStatModifiers(body, fileRelativePath, id);
blocks.stats.push(result.statDef);
blocks.stats.push(...result.modifierDefs);
blocks.statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
if (parsed) blocks.sparkTables.push(parsed);
+123 -6
View File
@@ -186,15 +186,15 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
}
// ---------------------------------------------------------------------------
// Helpers
// Template CSV parser
// ---------------------------------------------------------------------------
/**
* Parse a ```csv role=stat-template file=xxx block.
* Parse a ```csv role=stat-template block.
*
* The first header is the dice notation (e.g. "1d10").
* The second header is "label".
* Remaining headers are modifier keys.
* Remaining headers are modifier keys (bare names, no prefix).
*/
export function parseTemplateCsv(
csv: string,
@@ -207,10 +207,8 @@ export function parseTemplateCsv(
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 notation = headers[0];
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[] = [];
@@ -241,6 +239,125 @@ export function parseTemplateCsv(
return { name, notation, entries, source };
}
// ---------------------------------------------------------------------------
// Stat-modifiers parser
// ---------------------------------------------------------------------------
/** Result of parsing a stat-modifiers block. */
export interface StatModifiersResult {
/** The template stat def (type: template) */
statDef: StatDef;
/** Modifier stat defs (type: modifier, one per value column) */
modifierDefs: StatDef[];
/** The template table with prefixed modifier keys */
template: StatTemplate;
}
/**
* Parse a ```csv role=stat-modifiers block.
*
* Auto-generates a template stat + modifier stat defs from a single CSV.
*
* The first header is the dice notation (e.g. "1d10").
* The second header is "label".
* Remaining headers are bare target names (e.g. "mind", "heart").
*
* Generated modifier keys: {id}_{column} (e.g. "age_mind").
* Template entries internally map: column → {id}_{column}.
*/
export function parseStatModifiers(
csv: string,
source: string,
id: string,
scope: "player" | "global" = "player",
): StatModifiersResult {
const lines = csv.trim().split(/\r?\n/);
const emptyTemplate: StatTemplate = {
name: id,
notation: "",
entries: [],
source,
};
if (lines.length === 0) {
return {
statDef: { key: id, label: id, type: "template", scope, template: id, source },
modifierDefs: [],
template: emptyTemplate,
};
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
if (headers.length < 2) {
return {
statDef: { key: id, label: id, type: "template", scope, template: id, source },
modifierDefs: [],
template: emptyTemplate,
};
}
const notation = headers[0];
const labelIdx = headers.indexOf("label");
// Bare column names (e.g. "mind", "heart") — these become targets
const bareColumns = headers.filter((h, i) => i !== 0 && i !== labelIdx);
// Generate modifier stat defs: key = {id}_{column}, target = column
const modifierDefs: StatDef[] = bareColumns.map((col) => ({
key: `${id}_${col}`,
label: col,
type: "modifier" as const,
scope,
target: col,
source,
}));
// Build template entries with prefixed modifier keys
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 col of bareColumns) {
const colIdx = headers.indexOf(col);
if (colIdx !== -1 && colIdx < cols.length) {
const val = cols[colIdx].trim();
if (val) {
modifiers[`${id}_${col}`] = val;
}
}
}
entries.push({ range, label, modifiers });
}
const template: StatTemplate = { name: id, notation, entries, source };
const statDef: StatDef = {
key: id,
label: id,
type: "template",
scope,
template: id,
source,
};
return { statDef, modifierDefs, template };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function splitCsvRow(row: string): string[] {
const cols: string[] = [];
let current = "";