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
+230 -15
View File
@@ -25,6 +25,7 @@ import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { resolveStatRoll, canModifyStat } from "./stat-helpers";
// ---- Helpers ----
@@ -35,7 +36,7 @@ interface CompletionItem {
}
interface ParsedInput {
type: "chat" | "roll" | "spark" | "link";
type: "chat" | "roll" | "spark" | "link" | "stat";
payload: Record<string, unknown>;
error?: string;
}
@@ -64,8 +65,62 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } };
}
// /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
if (raw.startsWith("/stat ")) {
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: "请补全命令" };
}
@@ -112,8 +167,8 @@ export const JournalInput: Component = () => {
const raw = text().trim();
if (!raw) return;
// Players / observers: everything is plain chat
if (isPlayer() || isObserver()) {
// Observers: everything is plain chat
if (isObserver()) {
const result = sendMessage("chat", { text: raw });
if (!result.success) {
setError(result.error);
@@ -131,6 +186,73 @@ export const JournalInput: Component = () => {
}
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);
// 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);
if (!result.success) {
setError(result.error);
@@ -198,21 +358,24 @@ export const JournalInput: Component = () => {
function buildCompletions(): CompletionItem[] {
const raw = text().trim();
// Only GM gets completions
if (!isGm()) return [];
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ 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(" ")) {
const prefix = raw.toLowerCase();
const matches = commands.filter((c) =>
let matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
// Non-GM only gets /stat
if (!isGm()) {
matches = matches.filter((c) => c.label === "/stat");
}
return matches.length > 0
? matches
: [{ 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 [];
}
@@ -328,9 +542,9 @@ export const JournalInput: Component = () => {
}
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();
if (isGm() && raw.startsWith("/")) {
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
e.preventDefault();
setShowCompletions(true);
setSelectedIdx(0);
@@ -373,8 +587,8 @@ export const JournalInput: Component = () => {
const raw = input.value;
setText(raw);
// Completions visibility — keep open while user types any / (GM only)
if (isGm() && raw.startsWith("/")) {
// Completions visibility — keep open while user types any / (GM or /stat for players)
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
setShowCompletions(true);
setSelectedIdx(0);
} else {
@@ -489,13 +703,14 @@ export const JournalInput: Component = () => {
>
<textarea
ref={textareaRef}
id="journal-input-textarea"
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "输入消息,或使用 /roll、/spark、/link 命令..."
: "输入消息..."
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
: "输入消息,或使用 /stat 命令..."
}
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm