diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 8c44da8..34cbf65 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -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; 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 = () => { >