feat: implement scoped stat system with player/global support
Introduces a scoping mechanism for statistics, allowing properties to be defined as either "player" (prefixed with player name) or "global". - Adds `scope` property to StatDef - Implements key resolution to handle bare keys vs full keys - Updates `canModifyStat` to enforce player-specific permissions - Enhances `makeStatLookup` to resolve scoped references in formulas - Adds documentation for the new stat system
This commit is contained in:
parent
44e4e15f3f
commit
138c089514
|
|
@ -15,7 +15,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";
|
import { resolveStatRoll, canModifyStat, fullKey } from "./stat-helpers";
|
||||||
import { parseInput } from "./command-parser";
|
import { parseInput } from "./command-parser";
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import { buildCompletions } from "./command-completions";
|
import { buildCompletions } from "./command-completions";
|
||||||
|
|
@ -147,13 +147,18 @@ export const JournalInput: Component = () => {
|
||||||
const p = payload as { action?: string; key?: string; value?: string };
|
const p = payload as { action?: string; key?: string; value?: string };
|
||||||
|
|
||||||
if (p.action === "roll" && p.key) {
|
if (p.action === "roll" && p.key) {
|
||||||
const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats);
|
const resolved = resolveStatRoll(
|
||||||
|
p.key,
|
||||||
|
comp.data.stats,
|
||||||
|
stream.stats,
|
||||||
|
stream.myName,
|
||||||
|
);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
setError(resolved.error);
|
setError(resolved.error);
|
||||||
} else {
|
} else {
|
||||||
const result = sendMessage("stat", {
|
const result = sendMessage("stat", {
|
||||||
action: "set",
|
action: "set",
|
||||||
key: p.key,
|
key: resolved.fullKey,
|
||||||
value: resolved.value,
|
value: resolved.value,
|
||||||
});
|
});
|
||||||
const r = unwrap(result);
|
const r = unwrap(result);
|
||||||
|
|
@ -167,16 +172,38 @@ export const JournalInput: Component = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canModifyStat(stream.myRole, stream.myName, p.key)) {
|
// Resolve bare key to full key for set/del
|
||||||
|
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
|
||||||
|
|
||||||
|
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
|
||||||
setError(`无权修改属性: ${p.key}`);
|
setError(`无权修改属性: ${p.key}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = sendMessage("stat", payload);
|
const result = sendMessage("stat", {
|
||||||
|
action: p.action,
|
||||||
|
key: fk,
|
||||||
|
value: p.value,
|
||||||
|
});
|
||||||
const r = unwrap(result);
|
const r = unwrap(result);
|
||||||
finish(r.ok, r.err);
|
finish(r.ok, r.err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a bare or full key to the actual runtime key. */
|
||||||
|
function resolveKey(
|
||||||
|
inputKey: string,
|
||||||
|
statDefs: typeof comp.data.stats,
|
||||||
|
playerName: string,
|
||||||
|
): string {
|
||||||
|
// Exact match
|
||||||
|
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
|
||||||
|
return inputKey;
|
||||||
|
// Bare key → full key
|
||||||
|
const def = statDefs.find((d) => d.key === inputKey);
|
||||||
|
if (def) return fullKey(def, playerName);
|
||||||
|
return inputKey;
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear text + error on success, or set error on failure. */
|
/** Clear text + error on success, or set error on failure. */
|
||||||
function finish(success: boolean, err?: string) {
|
function finish(success: boolean, err?: string) {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* StatsView — table view of all stat key-value pairs
|
* StatsView — table view of all stat key-value pairs
|
||||||
*
|
*
|
||||||
* Groups stats by owner (global, then per-player). Shows computed values
|
* Groups stats by scope (global, then per-player). Shows computed values
|
||||||
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, For, createMemo, Show } from "solid-js";
|
import { Component, For, createMemo, Show } from "solid-js";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
import { useJournalCompletions } from "./completions";
|
import { useJournalCompletions } from "./completions";
|
||||||
|
import { fullKey } from "./stat-helpers";
|
||||||
import type { StatDef } from "./completions";
|
import type { StatDef } from "./completions";
|
||||||
|
|
||||||
export const StatsView: Component = () => {
|
export const StatsView: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const comp = useJournalCompletions();
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
/** All stat definitions from YAML */
|
|
||||||
const statDefs = createMemo(() => comp.data.stats);
|
const statDefs = createMemo(() => comp.data.stats);
|
||||||
|
|
||||||
/** Current runtime values */
|
|
||||||
const values = createMemo(() => stream.stats);
|
const values = createMemo(() => stream.stats);
|
||||||
|
|
||||||
/** Build a lookup: key -> StatDef */
|
/** Build a lookup: fullKey -> StatDef */
|
||||||
const defMap = createMemo(() => {
|
const defMap = createMemo(() => {
|
||||||
const map = new Map<string, StatDef>();
|
const map = new Map<string, StatDef>();
|
||||||
for (const def of statDefs()) {
|
for (const def of statDefs()) {
|
||||||
map.set(def.key, def);
|
const fk = fullKey(def, stream.myName);
|
||||||
|
map.set(fk, def);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
@ -38,10 +37,17 @@ export const StatsView: Component = () => {
|
||||||
const baseNum = parseFloat(base);
|
const baseNum = parseFloat(base);
|
||||||
|
|
||||||
if (def.type === "number" || def.type === "derived") {
|
if (def.type === "number" || def.type === "derived") {
|
||||||
// Sum modifiers that target this key
|
// Sum modifiers that target this key (fullKey matching)
|
||||||
const modKeys = statDefs()
|
const modKeys: string[] = [];
|
||||||
.filter((d) => d.type === "modifier" && d.target === key)
|
for (const [fk, d] of defMap()) {
|
||||||
.map((d) => d.key);
|
if (
|
||||||
|
d.type === "modifier" &&
|
||||||
|
d.target &&
|
||||||
|
fullKeyTarget(d.target, d, def)
|
||||||
|
) {
|
||||||
|
modKeys.push(fk);
|
||||||
|
}
|
||||||
|
}
|
||||||
let total = isNaN(baseNum) ? 0 : baseNum;
|
let total = isNaN(baseNum) ? 0 : baseNum;
|
||||||
for (const mk of modKeys) {
|
for (const mk of modKeys) {
|
||||||
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
|
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
|
||||||
|
|
@ -53,29 +59,50 @@ export const StatsView: Component = () => {
|
||||||
return base || "—";
|
return base || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group stats by owner */
|
/** 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 groups = createMemo(() => {
|
||||||
const result: { owner: string; label: string; keys: string[] }[] = [];
|
const result: {
|
||||||
const globalKeys: string[] = [];
|
scope: string;
|
||||||
const playerMap = new Map<string, 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()) {
|
for (const def of statDefs()) {
|
||||||
const colonIdx = def.key.indexOf(":");
|
const fk = fullKey(def, stream.myName);
|
||||||
if (colonIdx === -1) {
|
if (def.scope === "global") {
|
||||||
globalKeys.push(def.key);
|
globalEntries.push({ fullKey: fk, def });
|
||||||
} else {
|
} else {
|
||||||
const owner = def.key.slice(0, colonIdx);
|
playerEntries.push({ fullKey: fk, def });
|
||||||
if (!playerMap.has(owner)) playerMap.set(owner, []);
|
|
||||||
playerMap.get(owner)!.push(def.key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (globalKeys.length > 0) {
|
if (globalEntries.length > 0) {
|
||||||
result.push({ owner: "", label: "全局", keys: globalKeys });
|
result.push({ scope: "global", label: "全局", entries: globalEntries });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [owner, keys] of playerMap) {
|
if (playerEntries.length > 0) {
|
||||||
result.push({ owner, label: owner, keys });
|
result.push({
|
||||||
|
scope: "player",
|
||||||
|
label: `玩家 (${stream.myName})`,
|
||||||
|
entries: playerEntries,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -100,42 +127,47 @@ export const StatsView: Component = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<For each={group.keys}>
|
<For each={group.entries}>
|
||||||
{(key) => {
|
{({ fullKey: fk, def }) => {
|
||||||
const def = () => defMap().get(key);
|
const val = () => values()[fk];
|
||||||
const val = () => values()[key];
|
const comp = () => computedValue(fk);
|
||||||
const comp = () => computedValue(key);
|
const hasModifiers = () => {
|
||||||
const hasModifiers = () =>
|
for (const [mfk, md] of defMap()) {
|
||||||
statDefs().some(
|
if (
|
||||||
(d) => d.type === "modifier" && d.target === key,
|
md.type === "modifier" &&
|
||||||
);
|
md.target &&
|
||||||
|
fullKeyTarget(md.target, md, def)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
const canRoll = () =>
|
const canRoll = () =>
|
||||||
def()?.roll || def()?.formula || def()?.type === "enum";
|
def.roll || def.formula || def.type === "enum";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
<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">
|
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
||||||
<span class="text-sm text-gray-800 truncate">
|
<span class="text-sm text-gray-800 truncate">
|
||||||
{def()?.label ?? key}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||||
{key}
|
{def.key}
|
||||||
</span>
|
</span>
|
||||||
<Show when={def()?.type === "modifier"}>
|
<Show when={def.type === "modifier"}>
|
||||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
||||||
→{def()?.target}
|
→{def.target}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Value */}
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<Show
|
<Show
|
||||||
when={val() !== undefined}
|
when={val() !== undefined}
|
||||||
fallback={
|
fallback={
|
||||||
<span class="text-sm text-gray-300">
|
<span class="text-sm text-gray-300">
|
||||||
{def()?.default ?? "—"}
|
{def.default ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -144,11 +176,10 @@ export const StatsView: Component = () => {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Computed value (shown when different from base) */}
|
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
hasModifiers() &&
|
hasModifiers() &&
|
||||||
comp() !== (val() ?? def()?.default ?? "")
|
comp() !== (val() ?? def.default ?? "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
|
|
@ -156,13 +187,11 @@ export const StatsView: Component = () => {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Roll button */}
|
|
||||||
<Show when={canRoll()}>
|
<Show when={canRoll()}>
|
||||||
<button
|
<button
|
||||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
||||||
title="掷骰"
|
title="掷骰"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Prefill the input with /stat roll key
|
|
||||||
const textarea =
|
const textarea =
|
||||||
document.querySelector<HTMLTextAreaElement>(
|
document.querySelector<HTMLTextAreaElement>(
|
||||||
"#journal-input-textarea",
|
"#journal-input-textarea",
|
||||||
|
|
@ -175,7 +204,7 @@ export const StatsView: Component = () => {
|
||||||
)?.set;
|
)?.set;
|
||||||
nativeInputValueSetter?.call(
|
nativeInputValueSetter?.call(
|
||||||
textarea,
|
textarea,
|
||||||
`/stat roll ${key}`,
|
`/stat roll ${def.key}`,
|
||||||
);
|
);
|
||||||
textarea.dispatchEvent(
|
textarea.dispatchEvent(
|
||||||
new Event("input", { bubbles: true }),
|
new Event("input", { bubbles: true }),
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface StatDef {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "number" | "string" | "enum" | "modifier" | "derived";
|
type: "number" | "string" | "enum" | "modifier" | "derived";
|
||||||
|
scope: "player" | "global";
|
||||||
default?: string;
|
default?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
|
|
@ -204,10 +205,12 @@ function parseStatYaml(yaml: string, source: string): StatDef[] {
|
||||||
function flushCurrent() {
|
function flushCurrent() {
|
||||||
if (!current || !current.key) return;
|
if (!current || !current.key) return;
|
||||||
const type = (current.type || "number") as StatDef["type"];
|
const type = (current.type || "number") as StatDef["type"];
|
||||||
|
const scope = (current.scope || "global") as StatDef["scope"];
|
||||||
defs.push({
|
defs.push({
|
||||||
key: current.key,
|
key: current.key,
|
||||||
label: current.label || current.key,
|
label: current.label || current.key,
|
||||||
type,
|
type,
|
||||||
|
scope,
|
||||||
default: current.default,
|
default: current.default,
|
||||||
target: current.target,
|
target: current.target,
|
||||||
options:
|
options:
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,39 @@ import { evaluateFormula } from "./stat-formula";
|
||||||
import type { StatDef } from "./completions";
|
import type { StatDef } from "./completions";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Key resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Get the full runtime key for a stat def, given a player name. */
|
||||||
|
export function fullKey(def: StatDef, playerName: string): string {
|
||||||
|
return def.scope === "player" ? `${playerName}:${def.key}` : def.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a stat def by its full runtime key. */
|
||||||
|
export function findStatDef(
|
||||||
|
fk: string,
|
||||||
|
statDefs: StatDef[],
|
||||||
|
playerName: string,
|
||||||
|
): StatDef | undefined {
|
||||||
|
return statDefs.find((d) => fullKey(d, playerName) === fk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formula stat reference resolution
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
|
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
|
||||||
* with their numeric values, so the result can be passed to rollFormula.
|
* with their numeric values, so the result can be passed to rollFormula.
|
||||||
|
*
|
||||||
|
* Bare keys in formulas resolve relative to the caller's scope: if the
|
||||||
|
* caller def is `scope: player`, then `attack` resolves to `alice:attack`.
|
||||||
*/
|
*/
|
||||||
export function resolveStatRefs(
|
export function resolveStatRefs(
|
||||||
formula: string,
|
formula: string,
|
||||||
lookup: (key: string) => number,
|
lookup: (key: string) => number,
|
||||||
): string {
|
): string {
|
||||||
return formula.replace(/[a-zA-Z_]\w*/g, (match) => {
|
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 (/^d\d/i.test(match)) return match;
|
||||||
if (/^[kdh]\d/i.test(match)) return match;
|
if (/^[kdh]\d/i.test(match)) return match;
|
||||||
const val = lookup(match);
|
const val = lookup(match);
|
||||||
|
|
@ -30,21 +50,35 @@ export function resolveStatRefs(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a stat lookup function that resolves keys to numbers. */
|
/**
|
||||||
|
* Build a stat lookup function that resolves bare keys to numbers.
|
||||||
|
* Bare keys are scoped by `playerName` if the originating def is player-scoped.
|
||||||
|
*/
|
||||||
export function makeStatLookup(
|
export function makeStatLookup(
|
||||||
runtimeStats: Record<string, string>,
|
runtimeStats: Record<string, string>,
|
||||||
statDefs: StatDef[],
|
statDefs: StatDef[],
|
||||||
): (key: string) => number {
|
playerName: string,
|
||||||
return (k: string): number => {
|
/** The def that references are being resolved for (for scoping bare keys). */
|
||||||
const val = runtimeStats[k];
|
callerDef?: StatDef,
|
||||||
if (val !== undefined) {
|
): (bareKey: string) => number {
|
||||||
const n = parseFloat(val);
|
return (bareKey: string): number => {
|
||||||
return isNaN(n) ? 0 : n;
|
// Try as a full key first, then try scoped
|
||||||
|
const candidates = [bareKey];
|
||||||
|
if (callerDef && callerDef.scope === "player") {
|
||||||
|
candidates.push(`${playerName}:${bareKey}`);
|
||||||
}
|
}
|
||||||
const sdef = statDefs.find((s) => s.key === k);
|
|
||||||
if (sdef?.default !== undefined) {
|
for (const k of candidates) {
|
||||||
const n = parseFloat(sdef.default);
|
const val = runtimeStats[k];
|
||||||
return isNaN(n) ? 0 : n;
|
if (val !== undefined) {
|
||||||
|
const n = parseFloat(val);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
const sdef = statDefs.find((d) => fullKey(d, playerName) === k);
|
||||||
|
if (sdef?.default !== undefined) {
|
||||||
|
const n = parseFloat(sdef.default);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
@ -58,11 +92,19 @@ export function makeStatLookup(
|
||||||
export function canModifyStat(
|
export function canModifyStat(
|
||||||
role: string,
|
role: string,
|
||||||
myName: string,
|
myName: string,
|
||||||
key: string,
|
fullKey: string,
|
||||||
|
statDefs: StatDef[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (role === "gm") return true;
|
if (role === "gm") return true;
|
||||||
if (role === "observer") return false;
|
if (role === "observer") return false;
|
||||||
return key.startsWith(myName + ":");
|
|
||||||
|
const def = findStatDef(fullKey, statDefs, myName);
|
||||||
|
if (!def) return false;
|
||||||
|
if (def.scope === "player") {
|
||||||
|
return fullKey.startsWith(myName + ":");
|
||||||
|
}
|
||||||
|
// Global stats: player can't modify
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -70,38 +112,54 @@ export function canModifyStat(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface StatRollResult {
|
export interface StatRollResult {
|
||||||
|
fullKey: string;
|
||||||
value: string;
|
value: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a /stat roll command: look up the stat definition, evaluate the
|
* Resolve a /stat roll command: look up the stat definition (by bare or full
|
||||||
* appropriate resolution strategy (enum random, derived formula, dice roll),
|
* key), evaluate the appropriate resolution strategy, and return the string
|
||||||
* and return the string value to publish.
|
* value to publish.
|
||||||
*/
|
*/
|
||||||
export function resolveStatRoll(
|
export function resolveStatRoll(
|
||||||
key: string,
|
inputKey: string,
|
||||||
statDefs: StatDef[],
|
statDefs: StatDef[],
|
||||||
runtimeStats: Record<string, string>,
|
runtimeStats: Record<string, string>,
|
||||||
|
playerName: string,
|
||||||
): StatRollResult {
|
): StatRollResult {
|
||||||
const def = statDefs.find((s) => s.key === key);
|
// Try exact match first, then resolve bare key → full key
|
||||||
|
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
return { value: "", error: `未知属性: ${key}` };
|
// Try bare key: find a player-scoped def whose fullKey would match
|
||||||
|
def = statDefs.find(
|
||||||
|
(d) => d.scope === "player" && fullKey(d, playerName) === inputKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!def) {
|
||||||
|
// Try finding a bare key match (for global or player)
|
||||||
|
def = statDefs.find((d) => d.key === inputKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = makeStatLookup(runtimeStats, statDefs);
|
if (!def) {
|
||||||
|
return { fullKey: inputKey, value: "", error: `未知属性: ${inputKey}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fk = fullKey(def, playerName);
|
||||||
|
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||||
|
|
||||||
if (def.type === "enum" && def.options && def.options.length > 0) {
|
if (def.type === "enum" && def.options && def.options.length > 0) {
|
||||||
const idx = Math.floor(Math.random() * def.options.length);
|
const idx = Math.floor(Math.random() * def.options.length);
|
||||||
return { value: def.options[idx] };
|
return { fullKey: fk, value: def.options[idx] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (def.type === "derived" && def.formula) {
|
if (def.type === "derived" && def.formula) {
|
||||||
try {
|
try {
|
||||||
const result = evaluateFormula(def.formula, lookup);
|
const result = evaluateFormula(def.formula, lookup);
|
||||||
return { value: String(result) };
|
return { fullKey: fk, value: String(result) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
|
fullKey: fk,
|
||||||
value: "",
|
value: "",
|
||||||
error: e instanceof Error ? e.message : "公式计算失败",
|
error: e instanceof Error ? e.message : "公式计算失败",
|
||||||
};
|
};
|
||||||
|
|
@ -111,8 +169,8 @@ export function resolveStatRoll(
|
||||||
if (def.roll) {
|
if (def.roll) {
|
||||||
const resolvedFormula = resolveStatRefs(def.roll, lookup);
|
const resolvedFormula = resolveStatRefs(def.roll, lookup);
|
||||||
const roll = rollFormula(resolvedFormula);
|
const roll = rollFormula(resolvedFormula);
|
||||||
return { value: String(roll.result.total) };
|
return { fullKey: fk, value: String(roll.result.total) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { value: "", error: `属性 "${key}" 不支持掷骰` };
|
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
---
|
||||||
|
tag: journal-stat
|
||||||
|
icon: 📊
|
||||||
|
title: 属性系统
|
||||||
|
description: 在文档中定义属性,通过命令设置/删除/掷骰,在面板中查看属性表。
|
||||||
|
syntax: '```yaml role=stat'
|
||||||
|
props:
|
||||||
|
- name: 定义文件
|
||||||
|
type: —
|
||||||
|
desc: 在任意 .md 文档中使用 yaml role=stat 代码块定义属性
|
||||||
|
- name: 命令
|
||||||
|
type: —
|
||||||
|
desc: /stat set key=value | /stat del key | /stat roll key
|
||||||
|
- name: 属性类型
|
||||||
|
type: —
|
||||||
|
desc: number, string, enum, modifier, derived
|
||||||
|
- name: 权限
|
||||||
|
type: —
|
||||||
|
desc: GM 可修改所有属性,玩家只能修改自己的属性 (玩家名:xxx)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
属性系统允许你在文档中定义角色属性(如力量、生命值、技能等),
|
||||||
|
通过命令设置和掷骰,并在 Journal 面板的属性视图中查看当前值。
|
||||||
|
|
||||||
|
属性分为两层:
|
||||||
|
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 代码块中定义
|
||||||
|
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
||||||
|
|
||||||
|
## 属性类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 支持掷骰 |
|
||||||
|
|---|---|---|
|
||||||
|
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
||||||
|
| `string` | 自由文本 | ❌ |
|
||||||
|
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||||
|
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||||
|
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||||
|
|
||||||
|
## 属性定义语法
|
||||||
|
|
||||||
|
在任意 `.md` 文档中插入 ` ```yaml role=stat ` 代码块:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: strength
|
||||||
|
scope: player
|
||||||
|
label: "力量"
|
||||||
|
type: number
|
||||||
|
default: 10
|
||||||
|
|
||||||
|
- key: str_mod
|
||||||
|
scope: player
|
||||||
|
label: "力量调整"
|
||||||
|
type: modifier
|
||||||
|
target: strength
|
||||||
|
|
||||||
|
- key: attack
|
||||||
|
scope: player
|
||||||
|
label: "近战攻击"
|
||||||
|
type: number
|
||||||
|
default: 0
|
||||||
|
roll: "1d20 + attack"
|
||||||
|
|
||||||
|
- key: loot
|
||||||
|
scope: player
|
||||||
|
label: "战利品"
|
||||||
|
type: enum
|
||||||
|
options:
|
||||||
|
- 金币 x10
|
||||||
|
- 魔法药水
|
||||||
|
- 破旧长剑
|
||||||
|
|
||||||
|
- key: hp_max
|
||||||
|
scope: player
|
||||||
|
label: "最大生命值"
|
||||||
|
type: derived
|
||||||
|
formula: "strength * 2 + 10"
|
||||||
|
|
||||||
|
- key: notes
|
||||||
|
scope: player
|
||||||
|
label: "备注"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
- key: weather
|
||||||
|
scope: global
|
||||||
|
label: "天气"
|
||||||
|
type: enum
|
||||||
|
options:
|
||||||
|
- 晴天
|
||||||
|
- 阴天
|
||||||
|
- 雨天
|
||||||
|
- 暴风雨
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键语法说明
|
||||||
|
|
||||||
|
- **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀
|
||||||
|
- **`scope`**:`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength`;`global` 表示所有玩家共享
|
||||||
|
- **`label`**:在属性视图中显示的名称
|
||||||
|
- **`type`**:属性类型(见上表)
|
||||||
|
- **`default`**:默认值,在未通过命令设置时使用
|
||||||
|
- **`roll`**:掷骰公式(仅 `number` 类型),支持引用其他属性值(使用 bare key,自动同 scope 解析)
|
||||||
|
- **`target`**:`modifier` 类型的目标属性 key(bare key,同 scope 内解析)
|
||||||
|
- **`options`**:`enum` 类型的选项列表,支持多行 `- value` 语法
|
||||||
|
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`,属性引用使用 bare key
|
||||||
|
|
||||||
|
### 作用域说明
|
||||||
|
|
||||||
|
`scope: player` 的属性在运行时会自动加上玩家名前缀。例如 Alice 连接时,`strength` 的实际 key 是 `alice:strength`。
|
||||||
|
|
||||||
|
在公式(`roll`、`formula`)和 `target` 中,使用 bare key 即可,系统会自动在相同 scope 内查找:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: attack
|
||||||
|
scope: player
|
||||||
|
roll: "1d20 + attack" # attack 自动解析为 alice:attack
|
||||||
|
|
||||||
|
- key: str_mod
|
||||||
|
scope: player
|
||||||
|
target: strength # 自动解析为 alice:strength
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
所有命令在 Journal 输入框中输入,前缀为 `/stat`。命令中使用 bare key:
|
||||||
|
|
||||||
|
| 命令 | 示例 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/stat set key=value` | `/stat set strength=16` | 设置属性值 |
|
||||||
|
| `/stat del key` | `/stat del strength` | 删除属性值,恢复默认 |
|
||||||
|
| `/stat roll key` | `/stat roll attack` | 掷骰并发布结果 |
|
||||||
|
|
||||||
|
### 掷骰行为
|
||||||
|
|
||||||
|
- **`number` + `roll`**:解析公式中的属性引用,掷骰,结果写入属性值
|
||||||
|
- **`enum`**:从选项列表中随机选择一项
|
||||||
|
- **`derived` + `formula`**:计算公式,结果写入属性值
|
||||||
|
|
||||||
|
例如 `/stat roll attack` 会:
|
||||||
|
1. 在当前玩家 scope 下查找 `attack` 的定义
|
||||||
|
2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ 如 `1d20 + 3`
|
||||||
|
3. 掷骰 → 如结果 `15`
|
||||||
|
4. 将 `15` 写入 `alice:attack`,同步到所有连接的客户端
|
||||||
|
|
||||||
|
## 属性视图
|
||||||
|
|
||||||
|
在 Journal 面板顶部点击 **属性** 标签切换视图:
|
||||||
|
|
||||||
|
- 属性按 scope 分组(全局属性 + 玩家属性)
|
||||||
|
- 显示属性名、当前值、默认值
|
||||||
|
- 修饰符自动合并显示(`strength = 16` 时,`str_mod` 自动加到 `strength` 上)
|
||||||
|
- 有掷骰属性的行显示 🎲 按钮,点击可快速掷骰
|
||||||
|
|
||||||
|
## 权限
|
||||||
|
|
||||||
|
- **GM**:可以修改所有属性
|
||||||
|
- **玩家**:只能修改自己 scope 下的属性(`scope: player` 的属性)
|
||||||
|
- **观察者**:不能修改任何属性
|
||||||
|
|
||||||
|
## 修饰符(modifier)详解
|
||||||
|
|
||||||
|
修饰符类型的属性会自动加到其 `target` 属性上:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: strength
|
||||||
|
scope: player
|
||||||
|
type: number
|
||||||
|
default: 10
|
||||||
|
|
||||||
|
- key: str_mod
|
||||||
|
scope: player
|
||||||
|
type: modifier
|
||||||
|
target: strength
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `/stat set str_mod=3`,则 `strength` 的**计算值**为 `10 + 3 = 13`。
|
||||||
|
多个修饰符指向同一个目标时会累加。
|
||||||
|
|
||||||
|
## 派生属性(derived)详解
|
||||||
|
|
||||||
|
派生属性通过公式从其他属性计算:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: hp_max
|
||||||
|
scope: player
|
||||||
|
type: derived
|
||||||
|
formula: "strength * 2 + 10"
|
||||||
|
```
|
||||||
|
|
||||||
|
公式中引用其他属性时,会自动使用其计算值(包含修饰符)。
|
||||||
|
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
||||||
|
|
||||||
|
## 掷骰公式中的属性引用
|
||||||
|
|
||||||
|
`roll` 字段中的标识符会自动替换为当前属性值:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: attack
|
||||||
|
scope: player
|
||||||
|
type: number
|
||||||
|
default: 0
|
||||||
|
roll: "1d20 + attack + str_mod"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/stat roll alice:attack` 时,`alice:attack` 和 `alice:str_mod` 会被替换为当前值后再掷骰。
|
||||||
Loading…
Reference in New Issue