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:
hypercross 2026-07-09 09:22:14 +08:00
parent 4faff9a391
commit 9747409a1f
9 changed files with 991 additions and 18 deletions

View File

@ -25,6 +25,7 @@ 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 } from "./stat-helpers";
// ---- Helpers ---- // ---- Helpers ----
@ -35,7 +36,7 @@ interface CompletionItem {
} }
interface ParsedInput { interface ParsedInput {
type: "chat" | "roll" | "spark" | "link"; type: "chat" | "roll" | "spark" | "link" | "stat";
payload: Record<string, unknown>; payload: Record<string, unknown>;
error?: string; error?: string;
} }
@ -64,8 +65,62 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } }; return { type: "link", payload: { path, section } };
} }
// /roll, /spark, or /link with no space — need to complete, don't send if (raw.startsWith("/stat ")) {
if (raw === "/roll" || raw === "/spark" || raw === "/link") { const arg = raw.slice("/stat ".length).trim();
if (!arg)
return {
type: "stat",
payload: {},
error: "需要 stat 命令 (set/del/roll)",
};
// /stat set key=value
if (arg.startsWith("set ")) {
const rest = arg.slice("set ".length).trim();
const eqIdx = rest.indexOf("=");
if (eqIdx === -1)
return {
type: "stat",
payload: {},
error: "格式: /stat set key=value",
};
const key = rest.slice(0, eqIdx).trim();
const value = rest.slice(eqIdx + 1).trim();
if (!key || !value)
return { type: "stat", payload: {}, error: "key 和 value 不能为空" };
return { type: "stat", payload: { action: "set", key, value } };
}
// /stat del key
if (arg.startsWith("del ")) {
const key = arg.slice("del ".length).trim();
if (!key)
return { type: "stat", payload: {}, error: "格式: /stat del key" };
return { type: "stat", payload: { action: "del", key } };
}
// /stat roll key
if (arg.startsWith("roll ")) {
const key = arg.slice("roll ".length).trim();
if (!key)
return { type: "stat", payload: {}, error: "格式: /stat roll key" };
return { type: "stat", payload: { action: "roll", key } };
}
return {
type: "stat",
payload: {},
error: "未知 stat 子命令: set/del/roll",
};
}
// /roll, /spark, /link, or /stat with no space — need to complete, don't send
if (
raw === "/roll" ||
raw === "/spark" ||
raw === "/link" ||
raw === "/stat"
) {
return { type: "chat", payload: {}, error: "请补全命令" }; return { type: "chat", payload: {}, error: "请补全命令" };
} }
@ -112,8 +167,8 @@ export const JournalInput: Component = () => {
const raw = text().trim(); const raw = text().trim();
if (!raw) return; if (!raw) return;
// Players / observers: everything is plain chat // Observers: everything is plain chat
if (isPlayer() || isObserver()) { if (isObserver()) {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
@ -131,6 +186,73 @@ export const JournalInput: Component = () => {
} }
setError(null); setError(null);
// Players: chat + stat commands only
if (isPlayer()) {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
if (!result.success) {
setError(result.error);
} else {
setText("");
}
textareaRef?.focus();
return;
}
if (parsed.type === "stat") {
const p = parsed.payload as {
action?: string;
key?: string;
value?: string;
};
if (p.action === "roll" && p.key) {
const resolved = resolveStatRoll(
p.key,
comp.data.stats,
stream.stats,
);
if (resolved.error) {
setError(resolved.error);
} else {
const result = sendMessage("stat", {
action: "set",
key: p.key,
value: resolved.value,
});
if (!result.success) {
setError(result.error);
} else {
setText("");
}
}
textareaRef?.focus();
return;
}
if (!p.action || !p.key) {
setError("无效的 stat 命令");
return;
}
if (!canModifyStat(stream.myRole, stream.myName, p.key)) {
setError(`无权修改属性: ${p.key}`);
return;
}
const result = sendMessage("stat", parsed.payload);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
textareaRef?.focus();
return;
}
// Players can't use other commands
setError("玩家只能发送聊天消息或使用 /stat 命令");
return;
}
// GM: all commands
setSending(true); setSending(true);
// GM roll: resolve the dice result locally // GM roll: resolve the dice result locally
@ -173,6 +295,44 @@ export const JournalInput: Component = () => {
} }
} }
// GM stat: roll resolves client-side, others go straight through
if (parsed.type === "stat") {
const p = parsed.payload as {
action?: string;
key?: string;
value?: string;
};
if (p.action === "roll" && p.key) {
const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats);
if (resolved.error) {
setError(resolved.error);
} else {
const result = sendMessage("stat", {
action: "set",
key: p.key,
value: resolved.value,
});
if (!result.success) {
setError(result.error);
} else {
setText("");
}
}
setSending(false);
textareaRef?.focus();
return;
}
const result = sendMessage("stat", parsed.payload);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
}
const result = sendMessage(parsed.type, parsed.payload); const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
@ -198,21 +358,24 @@ export const JournalInput: Component = () => {
function buildCompletions(): CompletionItem[] { function buildCompletions(): CompletionItem[] {
const raw = text().trim(); const raw = text().trim();
// Only GM gets completions
if (!isGm()) return [];
const data = comp.data; const data = comp.data;
const commands = [ const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " }, { label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " }, { label: "/link", kind: "command" as const, insertText: "/link " },
{ label: "/stat", kind: "command" as const, insertText: "/stat " },
]; ];
// Show commands when user types / or starts typing a command name // Show commands when user types / or starts typing a command name (GM only, except /stat)
if (raw.startsWith("/") && !raw.includes(" ")) { if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase(); const prefix = raw.toLowerCase();
const matches = commands.filter((c) => let matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix), c.label.toLowerCase().startsWith(prefix),
); );
// Non-GM only gets /stat
if (!isGm()) {
matches = matches.filter((c) => c.label === "/stat");
}
return matches.length > 0 return matches.length > 0
? matches ? matches
: [{ label: "未知命令", kind: "no-results", insertText: "" }]; : [{ label: "未知命令", kind: "no-results", insertText: "" }];
@ -289,6 +452,57 @@ export const JournalInput: Component = () => {
}); });
} }
// After /stat — show subcommands or stat keys
if (raw.startsWith("/stat ")) {
const rest = raw.slice("/stat ".length).trim();
// Show subcommands: set, del, roll
if (!rest || rest.length < 3) {
const subs = ["set", "del", "roll"];
const matches = subs.filter((s) => s.startsWith(rest.toLowerCase()));
if (matches.length === 0)
return [
{ label: "set/del/roll", kind: "no-results", insertText: "" },
];
return matches.map((s) => ({
label: `/stat ${s}`,
kind: "value" as const,
insertText: `/stat ${s} `,
}));
}
// After /stat set — show stat keys
if (rest.startsWith("set ")) {
const prefix = rest.slice("set ".length).toLowerCase();
const matches = data.stats
.filter((s) => s.key.toLowerCase().includes(prefix))
.slice(0, 8);
if (matches.length === 0)
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
return matches.map((s) => ({
label: `${s.key} (${s.label})`,
kind: "value" as const,
insertText: `/stat set ${s.key}=`,
}));
}
// After /stat del or /stat roll — show stat keys
if (rest.startsWith("del ") || rest.startsWith("roll ")) {
const [cmd, ...restParts] = rest.split(" ");
const prefix = restParts.join(" ").toLowerCase();
const matches = data.stats
.filter((s) => s.key.toLowerCase().startsWith(prefix))
.slice(0, 8);
if (matches.length === 0)
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
return matches.map((s) => ({
label: `${s.key} (${s.label})`,
kind: "value" as const,
insertText: `/stat ${cmd} ${s.key}`,
}));
}
}
return []; return [];
} }
@ -328,9 +542,9 @@ export const JournalInput: Component = () => {
} }
return; return;
} }
// If not open and starts with /, open completions (GM only) // If not open and starts with /, open completions (GM or /stat for players)
const raw = text(); const raw = text();
if (isGm() && raw.startsWith("/")) { if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
e.preventDefault(); e.preventDefault();
setShowCompletions(true); setShowCompletions(true);
setSelectedIdx(0); setSelectedIdx(0);
@ -373,8 +587,8 @@ export const JournalInput: Component = () => {
const raw = input.value; const raw = input.value;
setText(raw); setText(raw);
// Completions visibility — keep open while user types any / (GM only) // Completions visibility — keep open while user types any / (GM or /stat for players)
if (isGm() && raw.startsWith("/")) { if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
setShowCompletions(true); setShowCompletions(true);
setSelectedIdx(0); setSelectedIdx(0);
} else { } else {
@ -489,13 +703,14 @@ export const JournalInput: Component = () => {
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}
id="journal-input-textarea"
value={text()} value={text()}
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "输入消息,或使用 /roll、/spark、/link 命令..." ? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
: "输入消息..." : "输入消息,或使用 /stat 命令..."
} }
rows={2} rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm

View File

@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
import { ConnectDialog } from "./ConnectDialog"; import { ConnectDialog } from "./ConnectDialog";
import { InviteDialog } from "./InviteDialog"; import { InviteDialog } from "./InviteDialog";
import { CreateSessionDialog } from "./CreateSessionDialog"; import { CreateSessionDialog } from "./CreateSessionDialog";
import { StatsView } from "./StatsView";
export interface JournalPanelProps { export interface JournalPanelProps {
open: boolean; open: boolean;
@ -25,6 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream(); const stream = useJournalStream();
const [showInvite, setShowInvite] = createSignal(false); const [showInvite, setShowInvite] = createSignal(false);
const [showCreateSession, setShowCreateSession] = createSignal(false); const [showCreateSession, setShowCreateSession] = createSignal(false);
const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream");
// Player list (exclude gm and observer) // Player list (exclude gm and observer)
const playerEntries = () => const playerEntries = () =>
@ -84,8 +86,33 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</div> </div>
} }
> >
{/* View toggle */}
<div class="flex items-center border-b border-gray-200 bg-gray-50 shrink-0">
<button
onClick={() => setViewMode("stream")}
class={`flex-1 text-xs py-1.5 transition-colors ${
viewMode() === "stream"
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
<button
onClick={() => setViewMode("stats")}
class={`flex-1 text-xs py-1.5 transition-colors ${
viewMode() === "stats"
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
</div>
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
<StreamView /> <StreamView />
</Show>
</div> </div>
<JournalInput /> <JournalInput />
</Show> </Show>

View File

@ -0,0 +1,202 @@
/**
* StatsView table view of all stat key-value pairs
*
* Groups stats by owner (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
*/
import { Component, For, createMemo, Show } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import type { StatDef } from "./completions";
export const StatsView: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
/** All stat definitions from YAML */
const statDefs = createMemo(() => comp.data.stats);
/** Current runtime values */
const values = createMemo(() => stream.stats);
/** Build a lookup: key -> StatDef */
const defMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
map.set(def.key, def);
}
return map;
});
/** Compute the effective value of a stat (base + modifiers) */
function computedValue(key: string): string {
const def = defMap().get(key);
if (!def) return values()[key] ?? "";
const base = values()[key] ?? def.default ?? "";
const baseNum = parseFloat(base);
if (def.type === "number" || def.type === "derived") {
// Sum modifiers that target this key
const modKeys = statDefs()
.filter((d) => d.type === "modifier" && d.target === key)
.map((d) => d.key);
let total = isNaN(baseNum) ? 0 : baseNum;
for (const mk of modKeys) {
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
if (!isNaN(mv)) total += mv;
}
return String(total);
}
return base || "—";
}
/** Group stats by owner */
const groups = createMemo(() => {
const result: { owner: string; label: string; keys: string[] }[] = [];
const globalKeys: string[] = [];
const playerMap = new Map<string, string[]>();
for (const def of statDefs()) {
const colonIdx = def.key.indexOf(":");
if (colonIdx === -1) {
globalKeys.push(def.key);
} else {
const owner = def.key.slice(0, colonIdx);
if (!playerMap.has(owner)) playerMap.set(owner, []);
playerMap.get(owner)!.push(def.key);
}
}
if (globalKeys.length > 0) {
result.push({ owner: "", label: "全局", keys: globalKeys });
}
for (const [owner, keys] of playerMap) {
result.push({ owner, label: owner, keys });
}
return result;
});
return (
<div class="h-full overflow-y-auto bg-gray-50">
<Show
when={statDefs().length > 0}
fallback={
<p class="text-center text-gray-400 text-xs py-8">
使 ```yaml role=stat 代码块定义属性。
</p>
}
>
<For each={groups()}>
{(group) => (
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.keys}>
{(key) => {
const def = () => defMap().get(key);
const val = () => values()[key];
const comp = () => computedValue(key);
const hasModifiers = () =>
statDefs().some(
(d) => d.type === "modifier" && d.target === key,
);
const canRoll = () =>
def()?.roll || def()?.formula || def()?.type === "enum";
return (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
{/* Label + type badge */}
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-sm text-gray-800 truncate">
{def()?.label ?? key}
</span>
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
{key}
</span>
<Show when={def()?.type === "modifier"}>
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
{def()?.target}
</span>
</Show>
</div>
{/* Value */}
<div class="flex items-center gap-2 shrink-0">
<Show
when={val() !== undefined}
fallback={
<span class="text-sm text-gray-300">
{def()?.default ?? "—"}
</span>
}
>
<span class="text-sm font-mono text-gray-700">
{val()}
</span>
</Show>
{/* Computed value (shown when different from base) */}
<Show
when={
hasModifiers() &&
comp() !== (val() ?? def()?.default ?? "")
}
>
<span class="text-xs text-gray-400">
= {comp()}
</span>
</Show>
{/* Roll button */}
<Show when={canRoll()}>
<button
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
title="掷骰"
onClick={() => {
// Prefill the input with /stat roll key
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
);
};

View File

@ -38,10 +38,24 @@ export interface SparkTableCompletion {
headers: string[]; 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 { export interface JournalCompletions {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[];
} }
export type CompletionsState = export type CompletionsState =
@ -67,6 +81,7 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
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 : [],
}; };
} catch { } catch {
return null; return null;
@ -80,7 +95,9 @@ async function scanClientSide(): Promise<JournalCompletions> {
const dice: DiceCompletion[] = []; const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = []; const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = []; const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; 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) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
@ -145,9 +162,18 @@ async function scanClientSide(): Promise<JournalCompletions> {
i = j - 1; 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 { function splitTableRow(line: string): string[] | null {
@ -159,6 +185,102 @@ function splitTableRow(line: string): string[] | null {
return inner.split("|").map((c) => c.trim()); 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) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately // Using a top-level IIFE so the promise starts immediately
@ -216,5 +338,8 @@ export function useJournalCompletions(): {
if (s.status === "loaded") { if (s.status === "loaded") {
return { state: s, data: s.data }; return { state: s, data: s.data };
} }
return { state: s, data: { dice: [], links: [], sparkTables: [] } }; return {
state: s,
data: { dice: [], links: [], sparkTables: [], stats: [] },
};
} }

View File

@ -0,0 +1,216 @@
/**
* Minimal expression evaluator for derived stat formulas.
*
* Supports:
* - Stat references: any identifier resolves to a number from the lookup
* - Arithmetic: + - * /
* - Functions: floor(x), ceil(x), round(x)
* - Parentheses for grouping
*/
export type StatLookup = (key: string) => number;
/** Evaluate a formula string against a stat lookup function. */
export function evaluateFormula(formula: string, lookup: StatLookup): number {
const tokens = tokenize(formula);
const result = parseExpression(tokens, 0, lookup);
if (result.next < tokens.length) {
throw new Error(`Unexpected token at position ${result.next}: "${tokens[result.next]}"`);
}
return result.value;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
type Token =
| { kind: "number"; value: number }
| { kind: "ident"; value: string }
| { kind: "op"; value: string }
| { kind: "lparen" }
| { kind: "rparen" }
| { kind: "comma" };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const ch = input[i];
// Whitespace
if (/\s/.test(ch)) {
i++;
continue;
}
// Number (integer or decimal)
if (/[0-9]/.test(ch)) {
let num = "";
while (i < input.length && /[0-9.]/.test(input[i])) {
num += input[i];
i++;
}
tokens.push({ kind: "number", value: parseFloat(num) });
continue;
}
// Identifier or function name
if (/[a-zA-Z_]/.test(ch)) {
let ident = "";
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
ident += input[i];
i++;
}
tokens.push({ kind: "ident", value: ident });
continue;
}
// Operators and punctuation
switch (ch) {
case "+":
case "-":
case "*":
case "/":
tokens.push({ kind: "op", value: ch });
i++;
break;
case "(":
tokens.push({ kind: "lparen" });
i++;
break;
case ")":
tokens.push({ kind: "rparen" });
i++;
break;
case ",":
tokens.push({ kind: "comma" });
i++;
break;
default:
throw new Error(`Unexpected character: "${ch}"`);
}
}
return tokens;
}
// ---------------------------------------------------------------------------
// Recursive descent parser
// ---------------------------------------------------------------------------
interface ParseResult {
value: number;
next: number; // index of next unconsumed token
}
/** expression := term (("+" | "-") term)* */
function parseExpression(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
let result = parseTerm(tokens, pos, lookup);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, lookup);
if (tok.value === "+") {
result = { value: result.value + right.value, next: right.next };
} else {
result = { value: result.value - right.value, next: right.next };
}
pos = result.next;
} else {
break;
}
}
return result;
}
/** term := factor (("*" | "/") factor)* */
function parseTerm(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
let result = parseFactor(tokens, pos, lookup);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, lookup);
if (tok.value === "*") {
result = { value: result.value * right.value, next: right.next };
} else {
if (right.value === 0) throw new Error("Division by zero");
result = { value: result.value / right.value, next: right.next };
}
pos = result.next;
} else {
break;
}
}
return result;
}
/** factor := number | ident ["(" expression ")"] | "(" expression ")" | "-" factor */
function parseFactor(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
if (pos >= tokens.length) {
throw new Error("Unexpected end of formula");
}
const tok = tokens[pos];
// Unary minus
if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, lookup);
return { value: -inner.value, next: inner.next };
}
// Number literal
if (tok.kind === "number") {
return { value: tok.value, next: pos + 1 };
}
// Parenthesized expression
if (tok.kind === "lparen") {
const inner = parseExpression(tokens, pos + 1, lookup);
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
throw new Error("Missing closing parenthesis");
}
return { value: inner.value, next: inner.next + 1 };
}
// Identifier (stat reference or function call)
if (tok.kind === "ident") {
const name = tok.value;
// Check for function call: ident "(" ...
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
const arg = parseExpression(tokens, pos + 2, lookup);
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
throw new Error(`Missing closing parenthesis after ${name}(...)`);
}
const value = applyFunction(name, arg.value);
return { value, next: arg.next + 1 };
}
// Plain stat reference
const value = lookup(name);
return { value, next: pos + 1 };
}
throw new Error(`Unexpected token: "${JSON.stringify(tok)}"`);
}
function applyFunction(name: string, arg: number): number {
switch (name.toLowerCase()) {
case "floor":
return Math.floor(arg);
case "ceil":
return Math.ceil(arg);
case "round":
return Math.round(arg);
default:
throw new Error(`Unknown function: ${name}`);
}
}

View File

@ -0,0 +1,118 @@
/**
* Stat command helpers stat resolution, permission checking, and
* dice-formula stat-reference expansion.
*
* Extracted from JournalInput to keep that component focused on UI.
*/
import { rollFormula } from "../md-commander/hooks";
import { evaluateFormula } from "./stat-formula";
import type { StatDef } from "./completions";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
* with their numeric values, so the result can be passed to rollFormula.
*/
export function resolveStatRefs(
formula: string,
lookup: (key: string) => number,
): string {
return formula.replace(/[a-zA-Z_]\w*/g, (match) => {
// Don't replace dice notation tokens like "d20", "kh1", etc.
if (/^d\d/i.test(match)) return match;
if (/^[kdh]\d/i.test(match)) return match;
const val = lookup(match);
return String(val);
});
}
/** Build a stat lookup function that resolves keys to numbers. */
export function makeStatLookup(
runtimeStats: Record<string, string>,
statDefs: StatDef[],
): (key: string) => number {
return (k: string): number => {
const val = runtimeStats[k];
if (val !== undefined) {
const n = parseFloat(val);
return isNaN(n) ? 0 : n;
}
const sdef = statDefs.find((s) => s.key === k);
if (sdef?.default !== undefined) {
const n = parseFloat(sdef.default);
return isNaN(n) ? 0 : n;
}
return 0;
};
}
// ---------------------------------------------------------------------------
// Permission
// ---------------------------------------------------------------------------
/** Check whether a role can modify a given stat key. */
export function canModifyStat(
role: string,
myName: string,
key: string,
): boolean {
if (role === "gm") return true;
if (role === "observer") return false;
return key.startsWith(myName + ":");
}
// ---------------------------------------------------------------------------
// Roll resolution
// ---------------------------------------------------------------------------
export interface StatRollResult {
value: string;
error?: string;
}
/**
* Resolve a /stat roll command: look up the stat definition, evaluate the
* appropriate resolution strategy (enum random, derived formula, dice roll),
* and return the string value to publish.
*/
export function resolveStatRoll(
key: string,
statDefs: StatDef[],
runtimeStats: Record<string, string>,
): StatRollResult {
const def = statDefs.find((s) => s.key === key);
if (!def) {
return { value: "", error: `未知属性: ${key}` };
}
const lookup = makeStatLookup(runtimeStats, statDefs);
if (def.type === "enum" && def.options && def.options.length > 0) {
const idx = Math.floor(Math.random() * def.options.length);
return { value: def.options[idx] };
}
if (def.type === "derived" && def.formula) {
try {
const result = evaluateFormula(def.formula, lookup);
return { value: String(result) };
} catch (e) {
return {
value: "",
error: e instanceof Error ? e.message : "公式计算失败",
};
}
}
if (def.roll) {
const resolvedFormula = resolveStatRefs(def.roll, lookup);
const roll = rollFormula(resolvedFormula);
return { value: String(roll.result.total) };
}
return { value: "", error: `属性 "${key}" 不支持掷骰` };
}

View File

@ -10,3 +10,4 @@ import "./roll";
import "./spark"; import "./spark";
import "./link"; import "./link";
import "./intent"; import "./intent";
import "./stat";

View File

@ -0,0 +1,62 @@
/**
* Built-in message type: stat
*
* Emitters: gm, player
* Command: /stat set key=value | /stat del key | /stat roll key
*
* Stat definitions are discovered from ```stat YAML blocks in markdown
* documents. The stream carries set/del mutations that build up the
* runtime stat store additively.
*/
import { z } from "zod";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
const schema = z.object({
action: z.enum(["set", "del"]),
key: z.string().min(1),
value: z.string().optional(),
});
export type StatPayload = z.infer<typeof schema>;
registerMessageType<StatPayload>({
type: "stat",
label: "属性",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ action: "set", key: "", value: "" }),
render: (p) => {
if (p.action === "del") {
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">📊</span>
<span class="font-mono text-sm text-gray-500 line-through">
{p.key}
</span>
</div>
);
}
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">📊</span>
<span class="font-mono text-sm">
{p.key} <span class="font-bold">{p.value}</span>
</span>
</div>
);
},
reducer: (p) => {
journalSetState(
produce((s) => {
if (p.action === "del") {
delete s.stats[p.key];
} else if (p.value !== undefined) {
s.stats[p.key] = p.value;
}
}),
);
},
});

View File

@ -59,6 +59,12 @@ export interface JournalStreamState {
brokerUrl: string | null; brokerUrl: string | null;
/** Active player list (keyed by player name) */ /** Active player list (keyed by player name) */
players: Record<string, { role: string }>; players: Record<string, { role: string }>;
/**
* Stat values set via /stat set/del/roll commands.
* Key: stat key (e.g. "strength", "alice:hp"). Value: string.
* Populated during hydration and live receipt via the stat type's reducer.
*/
stats: Record<string, string>;
} }
export interface SessionMeta { export interface SessionMeta {
@ -95,6 +101,7 @@ const [state, setState] = createStore<JournalStreamState>({
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm", myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl, brokerUrl: persisted.brokerUrl,
players: {}, players: {},
stats: {},
}); });
// Sync initial URL params if they came from localStorage (not URL) // Sync initial URL params if they came from localStorage (not URL)