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:
parent
19c66640b5
commit
1c69bf394c
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
|||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import { resolveStatRoll, canModifyStat, fullKey } from "./stat-helpers";
|
||||
import {
|
||||
resolveStatRoll,
|
||||
resolveTemplateSet,
|
||||
canModifyStat,
|
||||
fullKey,
|
||||
findStatDef,
|
||||
} from "./stat-helpers";
|
||||
import { parseInput } from "./command-parser";
|
||||
import type { CompletionItem } from "./command-parser";
|
||||
import { buildCompletions } from "./command-completions";
|
||||
|
|
@ -152,17 +158,35 @@ export const JournalInput: Component = () => {
|
|||
comp.data.stats,
|
||||
stream.stats,
|
||||
stream.myName,
|
||||
comp.data.statTemplates,
|
||||
);
|
||||
if (resolved.error) {
|
||||
setError(resolved.error);
|
||||
} else {
|
||||
// Send the primary stat value
|
||||
const result = sendMessage("stat", {
|
||||
action: "set",
|
||||
key: resolved.fullKey,
|
||||
value: resolved.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
if (!r.ok) {
|
||||
finish(false, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send any modifier overrides from template
|
||||
if (resolved.modifiers) {
|
||||
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||
sendMessage("stat", {
|
||||
action: "set",
|
||||
key: mk,
|
||||
value: mv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
finish(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -186,7 +210,32 @@ export const JournalInput: Component = () => {
|
|||
value: p.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
if (!r.ok) {
|
||||
finish(false, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
// If setting a template-type stat, apply its modifier overrides
|
||||
if (p.action === "set" && p.value) {
|
||||
const def = findStatDef(fk, comp.data.stats, stream.myName);
|
||||
if (def) {
|
||||
const modifiers = resolveTemplateSet(
|
||||
def,
|
||||
p.value,
|
||||
comp.data.stats,
|
||||
stream.stats,
|
||||
stream.myName,
|
||||
comp.data.statTemplates,
|
||||
);
|
||||
if (modifiers) {
|
||||
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finish(true);
|
||||
}
|
||||
|
||||
/** Resolve a bare or full key to the actual runtime key. */
|
||||
|
|
|
|||
|
|
@ -144,7 +144,10 @@ export const StatsView: Component = () => {
|
|||
return false;
|
||||
};
|
||||
const canRoll = () =>
|
||||
def.roll || def.formula || def.type === "enum";
|
||||
def.roll ||
|
||||
def.formula ||
|
||||
def.type === "enum" ||
|
||||
def.type === "template";
|
||||
|
||||
return (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
|
|
|
|||
|
|
@ -15,10 +15,14 @@ import {
|
|||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
} from "../../data-loader/file-index";
|
||||
import { parseStatYaml, parseStatCsv } from "../../cli/completions/stat-parser";
|
||||
import type { StatDef } from "../../cli/completions/stat-parser";
|
||||
import {
|
||||
parseStatYaml,
|
||||
parseStatCsv,
|
||||
parseTemplateCsv,
|
||||
} from "../../cli/completions/stat-parser";
|
||||
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
||||
|
||||
export type { StatDef };
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
|
|
@ -47,6 +51,7 @@ export interface JournalCompletions {
|
|||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
export type CompletionsState =
|
||||
|
|
@ -73,6 +78,9 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
|||
links: Array.isArray(data.links) ? data.links : [],
|
||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||
statTemplates: Array.isArray(data.statTemplates)
|
||||
? data.statTemplates
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -173,7 +181,24 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
}
|
||||
}
|
||||
|
||||
return { dice, links, sparkTables, stats };
|
||||
// Stat template scan
|
||||
const statTemplates: StatTemplate[] = [];
|
||||
const templateBlockRegex =
|
||||
/```csv\s+role=stat-template\s+file=(\S+)\s*\n([\s\S]*?)```/gi;
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
templateBlockRegex.lastIndex = 0;
|
||||
let tMatch: RegExpExecArray | null;
|
||||
while ((tMatch = templateBlockRegex.exec(content)) !== null) {
|
||||
const name = tMatch[1];
|
||||
const csv = tMatch[2];
|
||||
statTemplates.push(parseTemplateCsv(csv, filePath, name));
|
||||
}
|
||||
}
|
||||
|
||||
return { dice, links, sparkTables, stats, statTemplates };
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] | null {
|
||||
|
|
@ -244,6 +269,12 @@ export function useJournalCompletions(): {
|
|||
}
|
||||
return {
|
||||
state: s,
|
||||
data: { dice: [], links: [], sparkTables: [], stats: [] },
|
||||
data: {
|
||||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
import { evaluateFormula } from "./stat-formula";
|
||||
import type { StatDef } from "./completions";
|
||||
import type { StatDef, StatTemplate } from "./completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key resolution
|
||||
|
|
@ -115,6 +115,48 @@ export interface StatRollResult {
|
|||
fullKey: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
/** For template rolls: additional modifier keys → values to apply */
|
||||
modifiers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a template-type stat is set explicitly (not rolled), look up the
|
||||
* template entry by label and return the modifier overrides to apply.
|
||||
* Returns null if the stat is not a template type or the label doesn't match.
|
||||
*/
|
||||
export function resolveTemplateSet(
|
||||
def: StatDef,
|
||||
value: string,
|
||||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): Record<string, string> | null {
|
||||
if (def.type !== "template" || !def.template) return null;
|
||||
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl) return null;
|
||||
|
||||
const entry = tpl.entries.find((e) => e.label === value);
|
||||
if (!entry || Object.keys(entry.modifiers).length === 0) return null;
|
||||
|
||||
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
const currentVal = runtimeStats[modFullKey];
|
||||
const currentNum =
|
||||
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
|
||||
const delta = parseFloat(mv);
|
||||
if (!isNaN(currentNum) && !isNaN(delta)) {
|
||||
resolved[modFullKey] = String(currentNum + delta);
|
||||
} else {
|
||||
resolved[modFullKey] = mv;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,6 +169,7 @@ export function resolveStatRoll(
|
|||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): StatRollResult {
|
||||
// Try exact match first, then resolve bare key → full key
|
||||
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
||||
|
|
@ -148,6 +191,58 @@ export function resolveStatRoll(
|
|||
const fk = fullKey(def, playerName);
|
||||
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||
|
||||
if (def.type === "template" && def.template) {
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl || tpl.entries.length === 0) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `未找到模板: ${def.template}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Roll the dice to pick an entry (use template's notation)
|
||||
const formula = tpl.notation || "1d" + String(tpl.entries.length);
|
||||
const resolvedFormula = resolveStatRefs(formula, lookup);
|
||||
const roll = rollFormula(resolvedFormula);
|
||||
const rolled = roll.result.total;
|
||||
|
||||
// Find matching entry by range
|
||||
const entry = matchTemplateRange(rolled, tpl.entries);
|
||||
if (!entry) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `模板 "${def.template}" 中未找到匹配 ${rolled} 的条目`,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve modifier keys to full keys (same scope as def)
|
||||
// Modifier values like "+20" / "-10" are applied relative to current value
|
||||
const resolvedModifiers: Record<string, string> = {};
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
|
||||
// Compute absolute value: current + delta
|
||||
const currentVal = runtimeStats[modFullKey];
|
||||
const currentNum =
|
||||
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
|
||||
const delta = parseFloat(mv);
|
||||
if (!isNaN(currentNum) && !isNaN(delta)) {
|
||||
resolvedModifiers[modFullKey] = String(currentNum + delta);
|
||||
} else {
|
||||
// If we can't compute relative, fall back to raw value
|
||||
resolvedModifiers[modFullKey] = mv;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: entry.label,
|
||||
modifiers: resolvedModifiers,
|
||||
};
|
||||
}
|
||||
|
||||
if (def.type === "enum" && def.options && def.options.length > 0) {
|
||||
const idx = Math.floor(Math.random() * def.options.length);
|
||||
return { fullKey: fk, value: def.options[idx] };
|
||||
|
|
@ -174,3 +269,44 @@ export function resolveStatRoll(
|
|||
|
||||
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template range matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Match a rolled number against template entries with range strings.
|
||||
*
|
||||
* Range formats:
|
||||
* "1-3" → inclusive range
|
||||
* "4" → exact match
|
||||
* "1-3,5" → multiple ranges
|
||||
*
|
||||
* Returns the first matching entry, or undefined.
|
||||
*/
|
||||
function matchTemplateRange(
|
||||
rolled: number,
|
||||
entries: {
|
||||
range: string;
|
||||
label: string;
|
||||
modifiers: Record<string, string>;
|
||||
}[],
|
||||
): (typeof entries)[number] | undefined {
|
||||
for (const entry of entries) {
|
||||
const parts = entry.range.split(",").map((s) => s.trim());
|
||||
for (const part of parts) {
|
||||
if (part.includes("-")) {
|
||||
const [lo, hi] = part.split("-").map(Number);
|
||||
if (!isNaN(lo) && !isNaN(hi) && rolled >= lo && rolled <= hi) {
|
||||
return entry;
|
||||
}
|
||||
} else {
|
||||
const n = Number(part);
|
||||
if (!isNaN(n) && rolled === n) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ props:
|
|||
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
|
||||
|
||||
## 属性定义语法
|
||||
|
||||
|
|
@ -229,6 +230,44 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
|||
公式中引用其他属性时,会自动使用其计算值(包含修饰符)。
|
||||
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
||||
|
||||
## 模板属性(template)详解
|
||||
|
||||
模板属性用于查表掷骰,例如年龄表、职业表等。
|
||||
|
||||
模板在独立的 CSV 代码块中定义,使用 `role=stat-template` 和 `file=模板名`。
|
||||
**第一列是骰子表达式**(如 `1d10`),第二列是 `label`,其余列为修饰符:
|
||||
|
||||
```csv role=stat-template file=年龄
|
||||
1d10,label,heart,mind,strength,speed
|
||||
1-3,青少年,+20,-10,,
|
||||
4-7,成年,,-10,,+20
|
||||
8-9,老年,,+20,-10,
|
||||
10,换躯者,,,+30,-10
|
||||
```
|
||||
|
||||
- **第一列(header)**:骰子表达式,如 `1d10`、`2d6`
|
||||
- **第一列(rows)**:匹配范围,支持 `1-3`(区间)、`4`(精确)、`1-3,5`(多个)
|
||||
- **`label` 列**:显示名称
|
||||
- **其余列**:修饰符键名,值为变化量(正数加、负数减、空表示不修改)
|
||||
|
||||
然后在属性定义中引用模板:
|
||||
|
||||
```yaml role=stat
|
||||
- key: age
|
||||
scope: player
|
||||
label: 年龄
|
||||
type: template
|
||||
template: 年龄
|
||||
```
|
||||
|
||||
`/stat roll age` 时:
|
||||
1. 掷 `1d10`(模板头部的骰子表达式)
|
||||
2. 匹配第一列获取条目
|
||||
3. 发布 `set age=青少年`
|
||||
4. 同时发布 `set alice:heart=52`(原始值 +20)、`set alice:mind=22`(-10)等
|
||||
|
||||
修饰符值中的 `+`/`-` 前缀表示相对调整。如果 modifiers 值是 `+20`,会在当前值基础上加 20;如果是 `-10`,会减 10。
|
||||
|
||||
## 掷骰公式中的属性引用
|
||||
|
||||
`roll` 字段中的标识符会自动替换为当前属性值:
|
||||
|
|
|
|||
Loading…
Reference in New Issue