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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user