ttrpg-tools/src/components/journal/StatsView.tsx

235 lines
8.2 KiB
TypeScript

/**
* StatsView — table view of all stat key-value pairs.
*
* Groups stats by scope (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 { fullKey, modifierLabel } from "./stat-helpers";
import type { StatDef } from "./completions";
export const StatsView: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
/** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
const fk = fullKey(def, stream.myName);
map.set(fk, 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 (fullKey matching)
const modKeys: string[] = [];
for (const [fk, d] of defMap()) {
if (
d.type === "modifier" &&
d.target &&
fullKeyTarget(d.target, d, def)
) {
modKeys.push(fk);
}
}
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 || "—";
}
/** Check if a modifier's target matches a given def (scoped correctly). */
function fullKeyTarget(
target: string,
modDef: StatDef,
targetDef: StatDef,
): boolean {
if (modDef.scope === targetDef.scope) {
return (
fullKey({ ...modDef, key: target }, stream.myName) ===
fullKey(targetDef, stream.myName)
);
}
return false;
}
/** Group stats by scope */
const groups = createMemo(() => {
const result: {
scope: string;
label: string;
entries: { fullKey: string; def: StatDef }[];
}[] = [];
const globalEntries: { fullKey: string; def: StatDef }[] = [];
const playerEntries: { fullKey: string; def: StatDef }[] = [];
for (const def of statDefs()) {
const fk = fullKey(def, stream.myName);
if (def.scope === "global") {
globalEntries.push({ fullKey: fk, def });
} else {
playerEntries.push({ fullKey: fk, def });
}
}
if (globalEntries.length > 0) {
result.push({ scope: "global", label: "全局", entries: globalEntries });
}
if (playerEntries.length > 0) {
result.push({
scope: "player",
label: `玩家 (${stream.myName})`,
entries: playerEntries,
});
}
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.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
return (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-sm text-gray-800 truncate">
{modifierLabel(def, statDefs())}
</span>
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
{def.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>
<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>
<Show
when={
hasModifiers() &&
comp() !== (val() ?? def.default ?? "")
}
>
<span class="text-xs text-gray-400">
= {comp()}
</span>
</Show>
<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={() => {
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
);
};