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:
2026-07-09 10:26:54 +08:00
parent 19c66640b5
commit 1c69bf394c
10 changed files with 384 additions and 15 deletions
+52 -3
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. */
+4 -1
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">
+36 -5
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: [],
},
};
}
+137 -1
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;
}