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:
parent
4faff9a391
commit
9747409a1f
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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: [] },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}" 不支持掷骰` };
|
||||||
|
}
|
||||||
|
|
@ -10,3 +10,4 @@ import "./roll";
|
||||||
import "./spark";
|
import "./spark";
|
||||||
import "./link";
|
import "./link";
|
||||||
import "./intent";
|
import "./intent";
|
||||||
|
import "./stat";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue