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: [],
|
links: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
stats: [],
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Re-scan completions from current content index (cached) */
|
/** Re-scan completions from current content index (cached) */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex);
|
completionsIndex = scanCompletions(contentIndex);
|
||||||
console.log(
|
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 { linksSource } from "./sources/links.js";
|
||||||
import { sparkTablesSource } from "./sources/spark-tables.js";
|
import { sparkTablesSource } from "./sources/spark-tables.js";
|
||||||
import { statsSource } from "./sources/stats.js";
|
import { statsSource } from "./sources/stats.js";
|
||||||
|
import { templatesSource } from "./sources/templates.js";
|
||||||
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -14,6 +15,7 @@ export type {
|
||||||
LinkCompletion,
|
LinkCompletion,
|
||||||
SparkTableCompletion,
|
SparkTableCompletion,
|
||||||
StatDef,
|
StatDef,
|
||||||
|
StatTemplate,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/** Registered sources — open for extension */
|
/** Registered sources — open for extension */
|
||||||
|
|
@ -22,6 +24,7 @@ const sources: CompletionSource[] = [
|
||||||
linksSource,
|
linksSource,
|
||||||
sparkTablesSource,
|
sparkTablesSource,
|
||||||
statsSource,
|
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 {
|
export interface StatDef {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "number" | "string" | "enum" | "modifier" | "derived";
|
type: "number" | "string" | "enum" | "modifier" | "derived" | "template";
|
||||||
scope: "player" | "global";
|
scope: "player" | "global";
|
||||||
default?: string;
|
default?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
roll?: string;
|
roll?: string;
|
||||||
formula?: 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;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +59,7 @@ export function parseStatYaml(yaml: string, source: string): StatDef[] {
|
||||||
scope,
|
scope,
|
||||||
default: current.default,
|
default: current.default,
|
||||||
target: current.target,
|
target: current.target,
|
||||||
|
template: current.template,
|
||||||
options:
|
options:
|
||||||
type === "enum" && options.length > 0
|
type === "enum" && options.length > 0
|
||||||
? [...options]
|
? [...options]
|
||||||
|
|
@ -142,7 +161,10 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||||
let options: string[] | undefined;
|
let options: string[] | undefined;
|
||||||
const optRaw = get("options");
|
const optRaw = get("options");
|
||||||
if (optRaw) {
|
if (optRaw) {
|
||||||
options = optRaw.split("|").map((s) => s.trim()).filter(Boolean);
|
options = optRaw
|
||||||
|
.split("|")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
defs.push({
|
defs.push({
|
||||||
|
|
@ -153,6 +175,7 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||||
default: get("default"),
|
default: get("default"),
|
||||||
roll: get("roll"),
|
roll: get("roll"),
|
||||||
target: get("target"),
|
target: get("target"),
|
||||||
|
template: get("template"),
|
||||||
formula: get("formula"),
|
formula: get("formula"),
|
||||||
options,
|
options,
|
||||||
source,
|
source,
|
||||||
|
|
@ -166,6 +189,58 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||||
// Helpers
|
// 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[] {
|
export function splitCsvRow(row: string): string[] {
|
||||||
const cols: string[] = [];
|
const cols: string[] = [];
|
||||||
let current = "";
|
let current = "";
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* Completion source types — shared between CLI scanner and frontend consumer.
|
* 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 */
|
/** A single dice expression found in a markdown file */
|
||||||
export interface DiceCompletion {
|
export interface DiceCompletion {
|
||||||
|
|
@ -45,6 +45,7 @@ export interface CompletionsPayload {
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
|
statTemplates: StatTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
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 { parseInput } from "./command-parser";
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import { buildCompletions } from "./command-completions";
|
import { buildCompletions } from "./command-completions";
|
||||||
|
|
@ -152,17 +158,35 @@ export const JournalInput: Component = () => {
|
||||||
comp.data.stats,
|
comp.data.stats,
|
||||||
stream.stats,
|
stream.stats,
|
||||||
stream.myName,
|
stream.myName,
|
||||||
|
comp.data.statTemplates,
|
||||||
);
|
);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
setError(resolved.error);
|
setError(resolved.error);
|
||||||
} else {
|
} else {
|
||||||
|
// Send the primary stat value
|
||||||
const result = sendMessage("stat", {
|
const result = sendMessage("stat", {
|
||||||
action: "set",
|
action: "set",
|
||||||
key: resolved.fullKey,
|
key: resolved.fullKey,
|
||||||
value: resolved.value,
|
value: resolved.value,
|
||||||
});
|
});
|
||||||
const r = unwrap(result);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +210,32 @@ export const JournalInput: Component = () => {
|
||||||
value: p.value,
|
value: p.value,
|
||||||
});
|
});
|
||||||
const r = unwrap(result);
|
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. */
|
/** Resolve a bare or full key to the actual runtime key. */
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,10 @@ export const StatsView: Component = () => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const canRoll = () =>
|
const canRoll = () =>
|
||||||
def.roll || def.formula || def.type === "enum";
|
def.roll ||
|
||||||
|
def.formula ||
|
||||||
|
def.type === "enum" ||
|
||||||
|
def.type === "template";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,14 @@ import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
} from "../../data-loader/file-index";
|
} from "../../data-loader/file-index";
|
||||||
import { parseStatYaml, parseStatCsv } from "../../cli/completions/stat-parser";
|
import {
|
||||||
import type { StatDef } from "../../cli/completions/stat-parser";
|
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) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -47,6 +51,7 @@ export interface JournalCompletions {
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
|
statTemplates: StatTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -73,6 +78,9 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
links: Array.isArray(data.links) ? data.links : [],
|
links: Array.isArray(data.links) ? data.links : [],
|
||||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||||
|
statTemplates: Array.isArray(data.statTemplates)
|
||||||
|
? data.statTemplates
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 {
|
function splitTableRow(line: string): string[] | null {
|
||||||
|
|
@ -244,6 +269,12 @@ export function useJournalCompletions(): {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
state: s,
|
state: s,
|
||||||
data: { dice: [], links: [], sparkTables: [], stats: [] },
|
data: {
|
||||||
|
dice: [],
|
||||||
|
links: [],
|
||||||
|
sparkTables: [],
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../md-commander/hooks";
|
||||||
import { evaluateFormula } from "./stat-formula";
|
import { evaluateFormula } from "./stat-formula";
|
||||||
import type { StatDef } from "./completions";
|
import type { StatDef, StatTemplate } from "./completions";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Key resolution
|
// Key resolution
|
||||||
|
|
@ -115,6 +115,48 @@ export interface StatRollResult {
|
||||||
fullKey: string;
|
fullKey: string;
|
||||||
value: string;
|
value: string;
|
||||||
error?: 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[],
|
statDefs: StatDef[],
|
||||||
runtimeStats: Record<string, string>,
|
runtimeStats: Record<string, string>,
|
||||||
playerName: string,
|
playerName: string,
|
||||||
|
templates?: StatTemplate[],
|
||||||
): StatRollResult {
|
): StatRollResult {
|
||||||
// Try exact match first, then resolve bare key → full key
|
// Try exact match first, then resolve bare key → full key
|
||||||
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
||||||
|
|
@ -148,6 +191,58 @@ export function resolveStatRoll(
|
||||||
const fk = fullKey(def, playerName);
|
const fk = fullKey(def, playerName);
|
||||||
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
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) {
|
if (def.type === "enum" && def.options && def.options.length > 0) {
|
||||||
const idx = Math.floor(Math.random() * def.options.length);
|
const idx = Math.floor(Math.random() * def.options.length);
|
||||||
return { fullKey: fk, value: def.options[idx] };
|
return { fullKey: fk, value: def.options[idx] };
|
||||||
|
|
@ -174,3 +269,44 @@ export function resolveStatRoll(
|
||||||
|
|
||||||
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
|
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` | 枚举选项,掷骰随机选择 | ✅ |
|
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||||
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||||
| `derived` | 通过公式从其他属性计算 | ✅ |
|
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||||
|
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
|
||||||
|
|
||||||
## 属性定义语法
|
## 属性定义语法
|
||||||
|
|
||||||
|
|
@ -229,6 +230,44 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
公式中引用其他属性时,会自动使用其计算值(包含修饰符)。
|
公式中引用其他属性时,会自动使用其计算值(包含修饰符)。
|
||||||
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
支持的函数:`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` 字段中的标识符会自动替换为当前属性值:
|
`roll` 字段中的标识符会自动替换为当前属性值:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue