diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index 014c32e..c9d631a 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -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); diff --git a/src/cli/completions/stat-parser.ts b/src/cli/completions/stat-parser.ts index c7f30fa..fb370a1 100644 --- a/src/cli/completions/stat-parser.ts +++ b/src/cli/completions/stat-parser.ts @@ -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 = {}; + 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 = ""; diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 4828670..1d2cdcb 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -19,6 +19,7 @@ import { parseStatYaml, parseStatCsv, parseTemplateCsv, + parseStatModifiers, } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; import { @@ -153,6 +154,14 @@ async function scanClientSide(): Promise { statTemplates.push(parseTemplateCsv(body, filePath, name)); } + if (attrs.role === "stat-modifiers") { + const id = attrs.id || `_mod_${filePath}_${stats.length}`; + const result = parseStatModifiers(body, filePath, id); + stats.push(result.statDef); + stats.push(...result.modifierDefs); + statTemplates.push(result.template); + } + if (attrs.role === "spark-table") { const parsed = parseSparkTableCsv(body, filePath, slugger); if (parsed) sparkTables.push(parsed); diff --git a/src/doc-entries/journal-stat.md b/src/doc-entries/journal-stat.md index 5d365d5..b9ca0e2 100644 --- a/src/doc-entries/journal-stat.md +++ b/src/doc-entries/journal-stat.md @@ -239,42 +239,71 @@ CSV 列说明: 模板属性用于查表掷骰(年龄表、职业表等)。 -**第一步:定义模板表** +### 方式一:stat-modifiers(推荐) -```csv id=年龄 role=stat-template -1d10,label,heart,mind,strength,speed -1-3,青少年,+20,-10,, +一个代码块同时生成模板 stat 和修饰符 stat defs: + +```csv id=年龄 role=stat-modifiers +1d10,label,mind,heart,strength,speed +1-3,青少年,-10,+20,, 4-7,成年,,-10,,+20 8-9,老年,,+20,-10, 10,换躯者,,,+30,-10 ``` -- **第一列(header)**:骰子表达式,如 `1d10` -- **第一列(rows)**:匹配范围,`1-3`(区间)、`4`(精确)、`1-3,5`(多个) -- **`label`**:显示名称 -- **其余列**:修饰符键名,值为变化量(`+`加、`-`减、空不修改) +这会自动生成: +- `age`(type: template, template: 年龄) +- `age_mind`(type: modifier, target: mind) +- `age_heart`(type: modifier, target: heart) +- `age_strength`(type: modifier, target: strength) +- `age_speed`(type: modifier, target: speed) -**第二步:引用模板** +修饰符键名规则:`{id}_{列名}`,目标为列名本身。 + +### 方式二:手动定义 + +分别定义模板表和修饰符 stat: + +```csv id=年龄 role=stat-template +1d10,label,age_mind,age_heart,age_strength,age_speed +1-3,青少年,-10,+20,, +4-7,成年,,-10,,+20 +8-9,老年,,+20,-10, +10,换躯者,,,+30,-10 +``` ```yaml role=stat +- key: age_mind + type: modifier + target: mind +- key: age_heart + type: modifier + target: heart - key: age - scope: player - label: 年龄 type: template template: 年龄 ``` -**第三步:掷骰或直接设置** +### 使用 + +### 使用 - `/stat roll age` — 掷 `1d10`,匹配范围,应用修饰符 - `/stat set age=换躯者` — 直接设置,同样应用修饰符 `/stat roll age` 或 `/stat set age=青少年` 时: 1. 发布 `set age=青少年` -2. 同时发布 `set alice:heart=52`(原始值 +20)、`set alice:mind=22`(-10) +2. 同时发布 `set alice:age_mind=-10`、`set alice:age_heart=+20` 等 修饰符值中的 `+`/`-` 前缀表示相对调整,基于当前值计算。 +### 模板表语法 + +- **第一列(header)**:骰子表达式,如 `1d10` +- **第一列(rows)**:匹配范围,`1-3`(区间)、`4`(精确)、`1-3,5`(多个) +- **`label`**:显示名称 +- **其余列**:修饰符键名,值为变化量(`+`加、`-`减、空不修改) + ## 掷骰公式中的属性引用 `roll` 字段中的标识符自动替换为当前属性值: