diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index d8b6ffc..45b1ee8 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -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}`, ); } diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index 5b0b03d..efd056b 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -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, ]; /** diff --git a/src/cli/completions/sources/templates.ts b/src/cli/completions/sources/templates.ts new file mode 100644 index 0000000..5ae741c --- /dev/null +++ b/src/cli/completions/sources/templates.ts @@ -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[] = []; + + 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; + }, +}; diff --git a/src/cli/completions/stat-parser.ts b/src/cli/completions/stat-parser.ts index 0dcc06c..c7f30fa 100644 --- a/src/cli/completions/stat-parser.ts +++ b/src/cli/completions/stat-parser.ts @@ -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; +} + +/** 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 = {}; + 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 = ""; diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index a1c50d7..b81b661 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -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[]; } /** diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 172243b..835b0e6 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -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. */ diff --git a/src/components/journal/StatsView.tsx b/src/components/journal/StatsView.tsx index ccf88ad..d6a2076 100644 --- a/src/components/journal/StatsView.tsx +++ b/src/components/journal/StatsView.tsx @@ -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 (
diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index b4c2a09..119f440 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -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 { 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 { } } - 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: [], + }, }; } diff --git a/src/components/journal/stat-helpers.ts b/src/components/journal/stat-helpers.ts index eca1031..8f84d1a 100644 --- a/src/components/journal/stat-helpers.ts +++ b/src/components/journal/stat-helpers.ts @@ -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; +} + +/** + * 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, + playerName: string, + templates?: StatTemplate[], +): Record | 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 = {}; + + 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, 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 = {}; + 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; + }[], +): (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; +} diff --git a/src/doc-entries/journal-stat.md b/src/doc-entries/journal-stat.md index cb8d794..b1b48c9 100644 --- a/src/doc-entries/journal-stat.md +++ b/src/doc-entries/journal-stat.md @@ -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` 字段中的标识符会自动替换为当前属性值: