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:
hypercross 2026-07-09 11:16:39 +08:00
parent d83256e049
commit ef71a43f0a
4 changed files with 183 additions and 19 deletions

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);

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 = "";

View File

@ -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<JournalCompletions> {
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);

View File

@ -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` 字段中的标识符自动替换为当前属性值: