feat: implement stat system and StatsView

Introduces a comprehensive stat management system including:
- `/stat` command support (set, del, roll)
- `StatsView` component for displaying grouped stat tables
- Formula evaluation for derived stats (supporting arithmetic and
  functions)
- Stat definition parsing from YAML blocks
- Permission checking for stat modification based on roles
This commit is contained in:
2026-07-09 09:22:14 +08:00
parent 4faff9a391
commit 9747409a1f
9 changed files with 991 additions and 18 deletions
+127 -2
View File
@@ -38,10 +38,24 @@ export interface SparkTableCompletion {
headers: string[];
}
/** A single stat definition parsed from a ```stat YAML block */
export interface StatDef {
key: string;
label: string;
type: "number" | "string" | "enum" | "modifier" | "derived";
default?: string;
target?: string;
options?: string[];
roll?: string;
formula?: string;
source: string;
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
stats: StatDef[];
}
export type CompletionsState =
@@ -67,6 +81,7 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
stats: Array.isArray(data.stats) ? data.stats : [],
};
} catch {
return null;
@@ -80,7 +95,9 @@ async function scanClientSide(): Promise<JournalCompletions> {
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi;
for (const filePath of paths) {
const content = await getIndexedData(filePath);
@@ -145,9 +162,18 @@ async function scanClientSide(): Promise<JournalCompletions> {
i = j - 1;
}
// Stat block scan
statBlockRegex.lastIndex = 0;
let statMatch: RegExpExecArray | null;
while ((statMatch = statBlockRegex.exec(content)) !== null) {
const yaml = statMatch[1];
const parsed = parseStatYaml(yaml, filePath);
stats.push(...parsed);
}
}
return { dice, links, sparkTables };
return { dice, links, sparkTables, stats };
}
function splitTableRow(line: string): string[] | null {
@@ -159,6 +185,102 @@ function splitTableRow(line: string): string[] | null {
return inner.split("|").map((c) => c.trim());
}
// ---------------------------------------------------------------------------
// Stat YAML parser
// ---------------------------------------------------------------------------
/**
* Parse a ```yaml role=stat block into StatDef entries.
* Handles a minimal YAML subset: list of objects with key/value pairs.
*/
function parseStatYaml(yaml: string, source: string): StatDef[] {
const defs: StatDef[] = [];
const lines = yaml.split(/\r?\n/);
let current: Record<string, string> | null = null;
let collectingOptions = false;
let options: string[] = [];
function flushCurrent() {
if (!current || !current.key) return;
const type = (current.type || "number") as StatDef["type"];
defs.push({
key: current.key,
label: current.label || current.key,
type,
default: current.default,
target: current.target,
options:
type === "enum" && options.length > 0
? [...options]
: current.options
? current.options
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: undefined,
roll: current.roll,
formula: current.formula,
source,
});
current = null;
options = [];
collectingOptions = false;
}
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) continue;
// New entry: "- key: value"
if (trimmed.startsWith("- ")) {
flushCurrent();
current = {};
collectingOptions = false;
const rest = trimmed.slice(2);
const colonIdx = rest.indexOf(":");
if (colonIdx === -1) continue;
const k = rest.slice(0, colonIdx).trim();
const v = rest.slice(colonIdx + 1).trim();
current[k] = v;
continue;
}
// Continuation of current entry: " key: value"
if (current && trimmed.match(/^\w/)) {
const colonIdx = trimmed.indexOf(":");
if (colonIdx === -1) continue;
const k = trimmed.slice(0, colonIdx).trim();
const v = trimmed.slice(colonIdx + 1).trim();
if (k === "options") {
// Inline options: options: [a, b, c]
if (v.startsWith("[") && v.endsWith("]")) {
current[k] = v.slice(1, -1);
} else {
// Multi-line options list
collectingOptions = true;
options = [];
}
} else {
current[k] = v;
}
continue;
}
// Options list item: " - value"
if (collectingOptions && trimmed.startsWith("- ")) {
options.push(trimmed.slice(2).trim());
continue;
}
}
flushCurrent();
return defs;
}
// ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately
@@ -216,5 +338,8 @@ export function useJournalCompletions(): {
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return { state: s, data: { dice: [], links: [], sparkTables: [] } };
return {
state: s,
data: { dice: [], links: [], sparkTables: [], stats: [] },
};
}