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:
hypercross 2026-07-09 10:26:54 +08:00
parent 19c66640b5
commit 1c69bf394c
10 changed files with 384 additions and 15 deletions

View File

@ -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}`,
);
}

View File

@ -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,
];
/**

View File

@ -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;
},
};

View File

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

View File

@ -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[];
}
/**

View File

@ -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. */

View File

@ -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">

View File

@ -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: [],
},
};
}

View File

@ -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;
}

View File

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