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