| name | simple-trpg-chat-rules |
| description | Complete reference for the pluggable rule-template module system in simple-trpg-chat (RuleModule interface + capability-driven UI + registry). USE THIS SKILL whenever the user asks to add a new TRPG ruleset (DnD 5e / d20 / PbtA / 骰池 / WoD / FATE …), modify rule-template behavior, debug rule-gated commands (.rc / .sc / .st), reason about why a check resolved a certain way, change how rule capabilities drive UI (TopBar check menu / character-sheet bars / attribute grid / SAN-check gating), or extend AI-agent rule integration. Also trigger when the user says "规则模板" / "规则模块" / "rule template" / "RuleModule" / "capabilities" / "resolveCheck" / "getRuleForRoom", or names a rule id like coc7th / basic / dnd5e in an implementation context. Even if the user just says "加一套规则" without specifying which system, consult this skill — the registry/capability pattern is the same for every ruleset.
|
Simple TRPG Chat — 规则模板模块系统
本文件是修改/扩展规则系统的唯一权威参考。设计文档在 docs/arch/rule-template-system.md(现状分析)和 docs/arch/rule-template-refactor.md(重构方案、5e drop-in 配方)。
0. 设计意图
规则系统是 插件化 的:每套规则(basic d100、COC 7th、未来的 DnD 5e 等)是一个独立模块,自注册到全局表。引擎/UI/AI 代码 永远不应该 出现 if (ruleId === "xxx") 这种分支——所有规则相关的行为差异都通过 RuleModule 接口或 RuleCapabilities 数据驱动。
验收标准:新增一套规则 = 新增一个 rules/<id>/ 模块 + 注册一行 + i18n + schema 常量数组追加一项。命令引擎、角色系统、宿主动作、UI 组件均不需要改动。
1. 文件地图
| 路径 | 作用 |
|---|
src/lib/rules/types.ts | RuleModule / RuleCapabilities / CheckRequest / CheckResult / VisualGrade / StatRoute / AiRuleHints 接口契约 |
src/lib/rules/registry.ts | getRule(id) / getRuleForRoom(room) / listRules() / listRuleIds() / DEFAULT_RULE_ID;模块注册位置 |
src/lib/rules/index.ts | barrel — 所有外部代码从 @/lib/rules 导入 |
src/lib/rules/basic/index.ts | basic d100 模块(do-nothing baseline) |
src/lib/rules/coc7th/index.ts | COC 7th 模块(包装 coc-stats.ts + computeCocDerived) |
src/lib/__tests__/rules.test.ts | 38 个边界测试,新规则上线必须补类似覆盖 |
src/db/schema.ts | RULE_TEMPLATES 常量数组(必须与注册表同步)+ rooms.rule_template 列 |
src/db/scripts/backfill-rule-template.ts | 历史回填脚本(legacy dice_rules 列已删,留作模板参考) |
diceRules 列已在 PR #125 一并清除——不要重新引入双字段;规则配置只有 rule_template 一个数据源。
2. RuleModule 接口字段
每个规则模块必须实现这 10 个字段。COC 模块在 coc7th/index.ts 是最完整的范例。
| 字段 | 类型 | 作用 |
|---|
id | string | 持久化到 rooms.rule_template,如 "coc7th" / "basic" / "dnd5e"。稳定,不可改 |
labelKey | string | i18n key,UI 下拉框 / 标签从 messages/*.json 查 |
hintKey? | string | 下拉框的提示文案 i18n key |
capabilities | RuleCapabilities | 见 §3。驱动所有 UI/host-action/AI 门控的唯一来源 |
initCharacter() | → CharacterData | 新成员加入时的初始角色卡(COC=9 属性+衍生;basic={ruleTemplate:'basic'}) |
computeDerived(sheet) | → CharacterData | 属性改动后重算衍生 + 保留 player-set 当前值(HP/SAN/MP 钳到新上限)。basic 实现为 identity |
routeStat(name) | → StatRoute | .st <name> <val> 的路由决策:{kind:"skill"|"attribute"|"resource", canonical, key?}。COC 走 resolveCocStat;basic 一律返 {kind:"skill"} |
canonicalStatName(name) | → string | 显示名归一(san → 理智值)。basic 是 identity |
lookupFallback(name, sheet) | → {name, value} | null | .rc <name> 在 room_skills 查不到时的回退(COC 查角色卡属性/资源 current;basic 返 null) |
resolveCheck(req) | → CheckResult | 核心:规则全权拥有"掷什么骰 + 加什么调整值 + 比较方向 + 大成功/失败判定"。rollDie() 必须移进模块,不能由引擎预掷 |
exportSnapshot(sheet) | → Record<string, unknown> | 导出快照里规则相关的字段(COC=hp/hpMax/san/mp/attributes;basic={}) |
describeForAI() | → AiRuleHints | AI bot 的系统提示片段 + 工具 schema 片段 |
CheckResult 关键约束
{
skillName: string;
notation: string;
rolls: number[];
total: number;
target: number;
passed: boolean;
grade: VisualGrade;
detail: Record<string, unknown>;
}
关键 quirk:COC success 布尔严格等于 roll <= target,不因 nat crit 改变。grade 可能升到 critical 而 passed 仍为 false(target<5 的边角)。原代码就是这样,rules.test.ts 锁定了此行为,迁移时不要"修正"。
3. RuleCapabilities 字段速查
interface RuleCapabilities {
hasSanity: boolean;
hasPsychologyRoll: boolean;
hasManaPoints: boolean;
checkMenuModes: ("check"|"psychology"|"sancheck")[];
supportedCommands: string[];
resourceBars: { key, labelKey }[];
attributeKeys: { key, labelKey }[];
}
Capabilities 是纯数据,禁止放 React 类型/组件引用——规则模块要在 server 端也可加载。UI 端的图标/颜色映射(如 RESOURCE_ICON 在 AttributesTab.tsx、CHECK_MODE_UI 在 RoomTopBar.tsx)是 client-only 的静态 map,key 与 capability 的 key 字段对齐。
4. 添加新规则的完整清单(以 DnD 5e 为例)
Step 1 — 新建模块文件
src/lib/rules/dnd5e/index.ts(参考 coc7th/index.ts):
export const dnd5eRule: RuleModule = {
id: "dnd5e",
labelKey: "ruleTemplateDnd5e",
hintKey: "ruleTemplateDnd5eHint",
capabilities: {
hasSanity: false,
hasPsychologyRoll: false,
hasManaPoints: false,
checkMenuModes: ["check"],
supportedCommands: ["help","st","rc","ra","rh","rd","r"],
resourceBars: [{ key: "hp", labelKey: "hp" }],
attributeKeys: [
{ key: "str", labelKey: "str" }, { key: "dex", labelKey: "dex" },
{ key: "con", labelKey: "con" }, { key: "int", labelKey: "int" },
{ key: "wis", labelKey: "wis" }, { key: "cha", labelKey: "cha" },
],
},
initCharacter: () => ({ ruleTemplate: "dnd5e", abilities: {}, level: 1, proficientSkills: [], derived: {} }),
computeDerived: (sheet) => {},
routeStat: (name) => {},
canonicalStatName: (n) => n,
lookupFallback: (name, sheet) => {},
resolveCheck: (req) => {
const roll = rollDie(20);
const mod = ;
const total = roll + mod;
const passed = total >= req.target;
const grade = roll === 20 ? "critical" : roll === 1 ? "fumble" : passed ? "success" : "failure";
return { skillName: req.skillName, notation: `1d20+${mod}`, rolls: [roll], total, target: req.target, passed, grade, detail: {} };
},
exportSnapshot: (sheet) => ({ hp: sheet.hp, abilities: sheet.abilities, level: sheet.level }),
describeForAI: () => ({ rulesPrompt: "Room runs DnD 5e: d20+modifier vs DC, nat20/nat1 are crit/fumble.", sheetToolSchemaFields: {} }),
};
Step 2 — 注册
src/lib/rules/registry.ts:
register(basicRule);
register(coc7thRule);
register(dnd5eRule);
Step 3 — schema 常量同步
src/db/schema.ts 的 RULE_TEMPLATES 数组追加:
export const RULE_TEMPLATES = ['basic', 'coc7th', 'dnd5e'] as const;
server action 的 createRoomAction/updateRoomSettingsAction 用这个常量做 whitelist 校验,不同步会被打回 "Invalid ruleTemplate"。
Step 4 — i18n
messages/zh.json 和 messages/en.json 加 ruleTemplateDnd5e / ruleTemplateDnd5eHint。若 5e 属性键不复用 COC 现有的(str/dex/con),还要加新 labelKey(wis/cha)。
Step 5 — 单元测试
参考 src/lib/__tests__/rules.test.ts 的 COC 边界测试,补:
resolveCheck 在 nat1 / nat20 / 边界 DC 的 grade
routeStat 对 6 项属性别名 + HP 资源的路由
lookupFallback 命中和未命中场景
initCharacter 的字段结构
- 注册表能查到
dnd5e,且 listRuleIds() 包含它
Step 6 — 数据库迁移
无!rooms.rule_template 是 text,接受任何字符串。pnpm db:push 不需要为新规则跑(只在改 schema 结构时跑)。
不需要改的地方(验证抽象成立)
src/lib/commands.ts —— 命令引擎已 100% 走 getRule()
src/app/actions/room.ts —— psychologyHiddenRollAction / requestSanCheckAction / getProxyCheckTargetsAction 走 capabilities
src/app/actions/export.ts —— 走 rule.exportSnapshot()
src/components/room/RoomTopBar.tsx —— 检定下拉读 checkMenuModes
src/components/room/character/AttributesTab.tsx —— 资源条/属性宫格读 capabilities
src/components/room/RoomSettings.tsx / LobbyClient.tsx —— 下拉项来自 listRules()
src/lib/ai_agent.ts —— 系统提示走 rule.describeForAI(),enum 走 listRuleIds()
如果你发现需要改上面任何一个文件来让 5e 工作,说明 5e 模块的某个字段没填对,回头检查模块定义而非改这些文件。
5. 刻意保留的两处例外(不算缺陷,别"顺手清掉")
| 位置 | 现状 | 为何保留 |
|---|
src/app/actions/character.ts — saveCharacterDataAction / updateCocAttributesAction | 仍用 inline computeCocDerived 而非 rule.computeDerived | 两者的 san_current 保留时机不同(rule.computeDerived 同步 .san 和 .san_current;legacy 只保 .san)。差异在罕见边界会改 export 快照中 .san 的值。等 5e 落地时和它的对应 action 一起重写 |
src/components/lobby/LobbyClient.tsx:310 附近的 COC 徽章 | 仍 hardcode ruleTemplate === "coc7th" + Skull 图标 | 规则专属装饰图标,不同规则可能要不同图标(5e=骰子、PbtA=面具…)。capabilities 不适合放 UI 装饰元数据;5e 时按需加 else if 分支 |
剩余几个 <select> 也是"刻意不迁"——参考 src/components/shared/ThemedSelect.tsx 文档注释的 carve-out 说明。
6. 常见错误
- 在引擎里写
if (rule.id === "coc7th") {…} —— 这是 1.0 之前的反模式,新代码不允许。如果某个行为不能用 capabilities 表达,先扩 RuleCapabilities 接口(纯数据),再用 capability 驱动。
RuleCapabilities 里塞 React 组件 / 图标 —— 规则模块要在 server 端可用,不能引 React。客户端的 RESOURCE_ICON / CHECK_MODE_UI 映射用 capability 的 string key 查。
rollDie 留在 commands.ts —— 错。预掷骰使方向无法被规则改写,d20 直接挂掉。rollDie 必须在 resolveCheck 内部调用。
- 新规则加了但 select 里看不到 —— 检查
schema.ts 的 RULE_TEMPLATES 数组(server action 的 whitelist 会丢弃未列出的 id)。
- 写
getRuleForRoom(room) 时假设 room.diceRules —— 该列已删,函数现签名为 { ruleTemplate?: string | null }。
- 修改
coc7th.resolveCheck 的 grade 判定逻辑(以为是 bug 修复) —— rules.test.ts 锁定了 grade=critical / passed=false 的边角行为,1:1 保留是设计意图。
7. 进一步阅读
docs/arch/rule-template-system.md — 重构前的现状分析(为什么要做这次抽象)
docs/arch/rule-template-refactor.md — 设计方案 + 5 phase 计划 + 风险表
src/lib/rules/coc7th/index.ts — 最完整的模块实现范例,所有字段都填了
src/lib/__tests__/rules.test.ts — 38 个测试,新规则上线前请覆盖等量的边界