COC跑团辅助工具Python实现:骰子解析与技能检定
刚给跑团群写了一个《克苏鲁的呼唤》跑团辅助脚本反复在骰子表达式解析和技能检定边界上踩坑网上的资料要么只讲规则要么只丢一个骰子函数很难直接拼成能用的小工具。这篇文章围绕 COC 跑团的几个高频操作——投骰、技能检定、角色卡管理完整拆解一套 Python 实现方案包含可复现代码和常见 Bug 排查思路。无论你是想给线上团做个辅助机器人还是单纯想把骰子判定逻辑和自己写的规则物品结合这篇文章都适用。需要先说明一点本文不会深入所有 COC 规则细节而是从工程角度把跑团辅助工具的最小闭环做出来。规则部分以 COC 7E 的常用判定思路为参考同时保留足够的扩展口子方便你自己调整成家团房规。1. 背景与核心概念1.1 什么是 COC 跑团COC 是《克苏鲁的呼唤》的英文缩写英文全称 Call of Cthulhu。它是一套以克苏鲁神话为背景的桌面角色扮演游戏规则玩家扮演调查员在守秘人的引导下探索诡异事件。和电子游戏不同COC 跑团的过程依赖玩家描述行动、掷骰判定结果再由守秘人描述剧情走向。跑团过程中的“掷骰”不是简单的随机数而是带有规则意义的概率判定。比如调查员要侦查房间里的线索守秘人会要求玩家进行一次“侦查”技能检定玩家掷一个百面骰 D100结果小于等于技能值时判定成功否则失败。从技术角度看这其实是一个非常典型的业务规则系统输入是角色卡数据。操作是骰子表达式。输出是成功/失败/大成功/大失败等判定结果。规则还会因为房规不同而变化。所以写一个跑团辅助工具本质上是在写一个带规则的随机判定引擎。1.2 为什么需要程序化辅助有人会说跑团本来就是桌游手动掷骰不就行了吗但在实际群团和网团中程序化辅助有几个很现实的价值第一速度。一场团下来骰子动作可能有几十次尤其战斗轮人手计算大成功、大失败、奖励骰、惩罚骰很容易出错。脚本一次就能给出结果。第二一致性。不同玩家对规则细节的理解可能不同比如“96 到底算不算大失败”。用程序统一判定可以把房规沉淀成配置减少争议。第三扩展性。规则写好后可以继续接入角色卡生成器、技能成长记录、伤害投掷、San 值变化追踪等功能。这也是为什么很多跑团群会使用骰子机器人。它们的核心算法并不复杂难点在于边界条件处理和规则版本兼容这和普通业务系统里的 Bug 排查思路完全一样。1.3 本文要实现的功能范围我们不会做一整个跑团平台只做一个足够日常使用的命令行工具包含以下功能支持 NdM、/- 修正值的形式解析骰子表达式。支持 D100 技能检定并给出大成功、大失败、普通成功、失败几种结果。支持从 JSON 文件加载角色卡。支持查看技能值和属性值。留出扩展点方便后面加奖励骰、惩罚骰、战斗轮等。文章会按“概念 → 设计 → 实现 → 排错 → 最佳实践”的顺序展开。代码完整可复制建议你打开编辑器跟着敲一遍。2. 环境准备与版本说明2.1 运行环境本文示例代码使用 Python 3 编写建议使用 Python 3.9 及以上版本。原因很简单代码里用到了list[int]这种内置泛型写法虽然 Python 3.8 也可以跑但更老的版本需要额外处理。为了减少环境差异我们统一按 3.9 来写。依赖方面本项目不需要任何第三方库。骰子随机性使用标准库random角色卡读写使用标准库json数据类使用标准库dataclasses。如果你使用的是 Windows建议在 PowerShell 或 CMD 中运行如果你使用的是 macOS 或 Linux直接在终端运行即可。如果你想把工具扩展到 QQ 群、Telegram Bot 或者 Web 服务再引入对应的 SDK本文不涉及这部分。2.2 项目结构我们采用一个非常清晰的小项目结构方便后续扩展coc_helper/ ├── main.py # 命令行交互入口 ├── dice.py # 骰子表达式解析与投骰 ├── skill_check.py # 技能检定判定逻辑 ├── character.py # 角色卡模型与读写 └── data/ └── sample.json # 示例角色卡没有把代码全部塞进一个文件是为了让每个模块的职责尽量简单也方便你单独写单元测试。后面排查问题的时候这种结构能明显减少定位 Bug 的时间。2.3 关于版本差异的说明不同 COC 规则版本对判定细节的定义略有不同。比如 7E 中D100 掷出 1 通常视为大成功96 到 100 通常视为大失败但某些房规会在技能值很低时使用 95 到 100。本文代码采用一个简化版本骰出 1大成功。骰出 96 到 100大失败。骰点小于等于技能值成功。其他失败。这不是完整的官方规则还原而是为了讲清楚判定逻辑的骨架。真正做工具时你应该把规则参数化比如大失败阈值可以配置这样不同团的房规都能适配。3. 核心模块设计拆解3.1 骰子表达式解析模块骰子表达式的核心需求是把类似于2d63、1d100-5、3d62d41的字符串变成真实的投骰动作。最简单粗暴的方式是用eval直接求值但这非常危险。因为表达式中一旦混入了表达式注入代码就可能执行任意 Python 指令。在跑团工具里虽然威胁不大但一旦工具被做成 Web 服务或者接入聊天机器人eval就是一枚定时炸弹。所以我们必须自己解析表达式。这里的思路是去掉空格统一转小写。用正则把表达式拆成“符号 数值”的片段。判断每个片段是骰子表达式含d还是普通修正值。骰子表达式拆成“数量”和“面数”。逐项投骰并累加结果。比如2d63会被拆成[2d6, 3]其中2d6表示投两个六面骰3表示在结果上加 3。边界情况是如果表达式里有4d6d这类无法解析的内容必须报错而不是静默忽略。否则玩家输错表达式时工具会给出一个莫名其妙的结果让整个判定失效。3.2 技能检定模块技能检定的核心逻辑是“结果判断”输入是技能值输出是结果等级。这里需要注意一个关键点成功与否不是只看随机数而是看随机数落在哪个区间。所以判定逻辑必须用区间方式写避免出现“大于等于”“小于等于”搞反的问题。本文实现中优先级顺序是先判断大成功。再判断大失败。最后判断普通成功和失败。这个顺序很重要因为从集合角度看1 和 96 到 100 是特殊区间不能被普通成功/失败判断吞掉。3.3 角色卡模块角色卡本质是一份结构化数据。最合适的格式是 JSON因为可读性好也方便用 Git 管理变更。角色卡包含两类内容属性Attributes力量、体质、敏捷、智力等。技能Skills侦查、聆听、图书馆使用、潜行等。在 Python 中可以用dataclass定义一个角色类再提供from_json和save方法。这样后续做成长整型角色卡页面时数据模型可以复用。4. 完整实战实现命令行版 COC 跑团辅助工具4.1 创建项目结构在你熟悉的目录下创建文件夹mkdir coc_helper cd coc_helper然后在里面创建子目录mkdir data后续所有代码都放在coc_helper目录下。4.2 添加示例角色卡在data/sample.json中写入如下内容{ name: 示例调查员, attributes: { 力量: 40, 敏捷: 60, 智力: 75, 理智: 55 }, skills: { 侦查: 60, 聆听: 50, 图书馆使用: 70, 潜行: 45, 话术: 55 } }这里使用中文作为 key 是为了贴近实际跑团场景。实际开发中如果你要接入其他系统建议改用英文字段名比如str、dex、spot_hidden避免编码和兼容问题。本文为了演示可读性使用中文 key同时在代码中不依赖关键字参数名称所以后续改动成本也不大。4.3 编写骰子模块创建dice.py# dice.py 骰子表达式解析模块。 支持表达式数量d面数修正值 示例 roll(2d6) - 投两个六面骰 roll(1d1005) - 投一个百面骰结果加 5 roll(3d6-2) - 投三个六面骰结果减 2 import random import re # 匹配类似 3d6 的骰子表达式 DICE_ITEM_RE re.compile(r[-]?\d*d\d|[-]?\d) def roll_dice(count: int, sides: int) - list[int]: 投掷 count 个 sides 面的骰子返回每次点数的列表。 if sides 0: raise ValueError(f骰子面数必须大于 0当前传入 {sides}) if count 0: return [0] return [random.randint(1, sides) for _ in range(count)] def roll(expression: str) - tuple[list[list[int]], int]: 解析骰子表达式。 返回值是一个二元组 - 每组骰子的点数明细 - 最终总和包含纯数字修正值 例如 results, total roll(2d63) results - [[3, 5]] total - 11 if not expression: raise ValueError(表达式不能为空) expression expression.replace( , ).lower() # 按 和 - 拆分出各个组成部分 parts DICE_ITEM_RE.findall(expression) # 检查是否还有解析不了的残留字符 remaining expression for part in parts: remaining remaining.replace(part, , 1) if remaining.strip(): raise ValueError(f无法解析的表达式片段: {remaining.strip()}) total 0 all_results: list[list[int]] [] for part in parts: sign 1 raw part if raw.startswith(-): sign -1 raw raw[1:] elif raw.startswith(): raw raw[1:] if d in raw: count_str, sides_str raw.split(d, 1) count int(count_str) if count_str else 1 sides int(sides_str) results roll_dice(count, sides) all_results.append(results) total sign * sum(results) else: total sign * int(raw) return all_results, total重点解释几个地方。DICE_ITEM_RE这个正则把表达式拆成带符号的片段例如1d100-5会被拆成[1d100, -5]。其中[-]?表示可选正负号\d*d\d匹配类似3d6、d6的骰子表达式[-]?\d匹配普通数字修正值。remaining.replace(part, , 1)这段的作用是检查解析器是否遗漏了表达式中的某些内容。如果玩家输入了2d6abcDICE_ITEM_RE只能匹配到2d6remaining会剩下abc这时就会抛出异常。为什么要这样做因为解析类代码最常见的 Bug 就是“部分吞掉非法输入”。你以为你解析了完整字符串实际上正则只匹配了其中一部分剩余内容被静默忽略。这在跑团场景中可能导致投骰结果完全错误而且极难发现。为了让测试结果稳定你可以临时在roll_dice中设置随机种子def roll_dice(count: int, sides: int) - list[int]: random.seed(42) # 仅测试用 ...但注意正式代码里不要写这行否则每次投骰结果都一样。这个“固定种子导致结果不变”的问题也是后面常见 Bug 之一。4.4 编写角色卡模块创建character.py# character.py 角色卡数据模型支持 JSON 读写。 from dataclasses import dataclass, field import json dataclass class Character: name: str 未命名调查员 attributes: dict[str, int] field(default_factorydict) skills: dict[str, int] field(default_factorydict) staticmethod def from_json(path: str) - Character: 从 JSON 文件加载角色卡。 with open(path, r, encodingutf-8) as f: data json.load(f) return Character( namedata.get(name, 未命名调查员), attributesdata.get(attributes, {}), skillsdata.get(skills, {}), ) def save(self, path: str) - None: 将角色卡保存到 JSON 文件。 data { name: self.name, attributes: self.attributes, skills: self.skills, } with open(path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) def get_attribute(self, name: str) - int | None: return self.attributes.get(name) def get_skill(self, name: str) - int | None: return self.skills.get(name)使用dataclass的好处是代码简洁字段一目了然。默认值default_factorydict是必须的否则多个实例会共享同一个可变字典对象这属于 Python 中非常经典的坑。from_json中使用encodingutf-8非常重要。在 Windows 环境下如果角色卡文件里包含中文不显式指定 UTF-8可能会因为系统默认编码不同而抛出UnicodeDecodeError。4.5 编写技能检定模块创建skill_check.py# skill_check.py 技能检定逻辑。 from dataclasses import dataclass from dice import roll dataclass class CheckResult: d100: int target: int success: bool level: str def skill_check(skill_value: int) - CheckResult: 对技能值进行 D100 检定。 简化规则 - 1大成功 - 96-100大失败 - d100 技能值普通成功 - 其他失败 if skill_value 0 or skill_value 100: raise ValueError(f技能值应在 0~100 之间当前传入 {skill_value}) d100 roll(1d100)[1] if d100 1: return CheckResult(d100, skill_value, True, 大成功) if d100 96: return CheckResult(d100, skill_value, False, 大失败) if d100 skill_value: return CheckResult(d100, skill_value, True, 普通成功) return CheckResult(d100, skill_value, False, 失败)这个模块本质上是把规则中“区间判定”的部分独立出来。如果不同房间采用不同房规比如把大失败改成 95 到 100你只需要调整这里的常量或者把阈值作为参数传入不会影响骰子模块和角色卡模块。要注意的边界是如果d100 1即使技能值只有 5也判定为大成功。这是一种常见房规但并不是所有规则组都这样要求。如果你的团有明确规定按团规调整即可。4.6 编写命令行交互入口创建main.py# main.py COC 跑团辅助工具命令行入口。 from character import Character from dice import roll from skill_check import skill_check def show_help() - None: print(可用指令) print( roll 表达式 投骰例如 roll 2d63) print( check 技能名 进行技能检定例如 check 侦查) print( attr 属性名 查看属性值例如 attr 力量) print( load 角色卡路径 加载 JSON 角色卡) print( help 显示帮助) print( exit / quit 退出) def main() - None: print(COC 跑团辅助工具) print(输入 help 查看帮助) character: Character | None None while True: try: line input( ).strip() if not line: continue parts line.split(maxsplit1) cmd parts[0].lower() arg parts[1] if len(parts) 1 else if cmd in (exit, quit): print(再见) break elif cmd help: show_help() elif cmd roll: if not arg: print(用法roll 表达式例如 roll 1d10010) continue _, total roll(arg) print(f骰子表达式: {arg}) print(f结果: {total}) elif cmd check: if not arg: print(用法check 技能名例如 check 侦查) continue if character is None: print(请先使用 load 加载角色卡) continue value character.get_skill(arg) if value is None: print(f角色卡中没有技能: {arg}) continue result skill_check(value) print(f技能 [{arg}] 目标值 {value}骰出 {result.d100}) print(f判定结果{result.level}) elif cmd attr: if not arg: print(用法attr 属性名例如 attr 敏捷) continue if character is None: print(请先使用 load 加载角色卡) continue value character.get_attribute(arg) if value is None: print(f角色卡中没有属性: {arg}) continue print(f属性 [{arg}]: {value}) elif cmd load: if not arg: print(用法load 角色卡路径例如 load data/sample.json) continue try: character Character.from_json(arg) print(f成功加载角色: {character.name}) except FileNotFoundError: print(f文件不存在: {arg}) except Exception as e: print(f加载失败: {e}) else: print(f未知指令: {cmd}输入 help 查看帮助) except KeyboardInterrupt: print(\n再见) break except EOFError: print(\n再见) break except Exception as e: print(f出错: {e}) if __name__ __main__: main()这个入口把所有交互逻辑都放在一个循环里。每次等待用户输入然后根据关键词分发到不同功能。这里有一个设计细节整个循环外层用了try...except Exception防止某个指令抛异常导致程序直接退出。但这种兜底不能滥用。开发阶段你更希望看到完整堆栈来定位问题所以建议正式开发时再加这个兜底或者在兜底中打印repr(e)和堆栈信息。5. 运行与验证5.1 启动工具在coc_helper目录下执行python main.py看到以下输出即可COC 跑团辅助工具 输入 help 查看帮助 5.2 测试投骰指令输入 roll 2d63输出示例骰子表达式: 2d63 结果: 12你可以多执行几次观察结果在 5 到 15 之间浮动。这里加 3 是固定修正值两个六面骰的原始范围是 2 到 12。再测试一个复杂表达式 roll 3d62d41输出示例骰子表达式: 3d62d41 结果: 20这个结果展示了解析器对多个骰组和修正值混合处理的能力。5.3 测试角色卡加载输入 load data/sample.json输出成功加载角色: 示例调查员然后查看技能 check 侦查输出示例技能 [侦查] 目标值 60骰出 47 判定结果普通成功再查看一个不存在的技能 check 神秘学输出角色卡中没有技能: 神秘学5.4 验证失败和异常场景输入非法表达式 roll 1d100abc输出出错: 无法解析的表达式片段: abc这说明解析器没有静默吞掉非法内容而是给出了明确提示。输入非法技能值边界虽然当前交互入口不会直接传入非法技能值但如果你用 Python 直接调用skill_check(150)会抛出ValueError: 技能值应在 0~100 之间当前传入 150参数校验是判断系统是否健壮的重要标准。尤其是跑团工具如果角色卡 JSON 中写入了 999 这种异常值判定结果就会变得不可信。6. 常见 Bug 与排查思路6.1 投骰结果每次都一样现象连续执行roll多次结果完全相同或序列固定。原因最常见的是在roll_dice里手动调用了random.seed(42)。random.seed是给伪随机算法设置初始状态一旦固定后续所有随机序列都是可预测的。写测试时这个特性很有用但在正式工具中不能出现。排查搜索项目中是否出现random.seed。如果是在测试里设置的确认测试结束后是否恢复了随机状态。解决删除正式代码里的固定种子。要让每次运行结果不同只需要让 Python 自动使用系统熵初始化随机状态即可。6.2 表达式带空格导致解析失败现象roll 2d6 3报错而roll 2d63正常。原因早期版本没有做去空格处理正则匹配时把3前面的空格也留在了表达式里残留内容无法解析。排查在roll()开头的expression.replace( , )是否执行了。如果增加了其他空白字符比如中文全角空格需要一并处理。解决除了替换普通空格还可以用expression.strip()去掉首尾空白。更严谨的做法是用re.sub(r\s, , expression)把所有空白都去掉。6.3 JSON 角色卡读取报错现象load角色卡时抛出UnicodeDecodeError或输出中文乱码。原因Windows 下默认编码可能是gbk而 JSON 文件是按 UTF-8 保存的。排查先确认文件编码。用文本编辑器打开文件查看右下角编码信息。解决代码中已经强制使用encodingutf-8。如果文件本身不是 UTF-8需要把文件另存为 UTF-8 格式。6.4 提示 cannot import dice现象执行python main.py时提示ModuleNotFoundError: No module named dice。原因可能是当前运行目录不对或者文件没有放在同一目录下。Python 导入模块时会按照运行目录和sys.path寻找模块文件。排查检查是否在coc_helper目录下运行。如果目录正确检查dice.py是否存在文件名是否拼错。解决在项目根目录执行python main.py。如果要在其他目录运行需要使用PYTHONPATH指定模块路径。6.5 技能检定结果与预期不符现象玩家觉得应该成功程序判定失败或者出现了玩家认为不该出现的大失败。原因最可能的原因是规则版本或房规差异。比如有的规则把 95 到 100 都判定为大失败有的只有 100 才算。本文代码使用 96 到 100但你的团可能不同。排查先确认判定逻辑里区间边界是否正确再确认技能值是否被正确加载。有时候角色卡技能值写成了字符串60在比较时会导致类型错误或不可预期的问题。解决把规则阈值提取为常量或配置项。角色卡加载时做好类型转换和校验比如把字符串数字强制转为int。问题现象常见原因解决思路结果序列每次一样固定了随机种子删除random.seed恢复默认随机源空格导致解析失败没有对表达式做去空格处理用re.sub(r\s, , expression)统一清理角色卡中文乱码编码不一致读写都指定encodingutf-8模块导入失败运行目录不对在项目根目录运行或通过PYTHONPATH暴露模块判定结果与房规不一致规则阈值是硬编码把阈值做成可配置项6.6 关于“Bug”的一点感悟从某种程度上说跑团判定里的大失败和程序里的 Bug 有相似之处它们都发生在边界条件上。1 是大成功还是失败96 算不算大失败技能值 100 的时候怎么处理这些都是“边界值测试”的标准问题。如果你将来写更复杂的跑团系统建议把边界测试当成第一优先级。每次修改规则都要重新跑一遍边界用例而不是只测正常情况。7. 最佳实践与工程建议7.1 不要用 eval 解析骰子表达式这是最重要的一条。eval(1d100)虽然看起来简单但如果未来表达式来源改成网络输入或聊天内容就存在代码注入风险。即使是在本地跑eval也会让调试变得更困难。正确的做法是像本文一样用正则和解析器把表达式拆开逐项计算。这样每个部分都清晰可控出错时也能准确定位到是哪一步的问题。7.2 把规则参数化不要在判定逻辑里硬编码魔法数字。例如大失败阈值 96应该抽出来作为常量或配置项# skill_check.py BIG_FAIL_THRESHOLD 96如果你对接多个团每个团的房规不同可以把这些规则放在 JSON 配置文件中加载后传给判定函数。这样代码不用改只需要改配置。7.3 输入校验要前置无论是角色卡数据还是用户输入的指令都要在入口处做校验。比如技能值必须是非负整数不能超过 100。如果角色卡 JSON 中被误写为负数立刻报错比后面用错误数据产生奇怪结果要容易排查得多。7.4 善用 dataclass 和类型注解本文使用dataclass定义角色卡使用类型注解标注函数签名。这些习惯在生产项目里很有用因为类型信息可以帮助 IDE 做补全和检查也能减少一部分低级错误。Python 的int | None这种写法需要 Python 3.10 以上。如果你还在用 Python 3.9可以改成Optional[int]。文章示例默认使用较新的写法实际项目请根据环境调整。7.5 日志代替 print命令行工具用print没问题但如果你想要把这个工具扩展成长期运行的机器人建议引入logging。日志可以将不同级别的信息分开输出比如调试信息、正常操作、异常告警。排查线上问题时没有日志几乎等于瞎猜。7.6 数据结构要面向扩展设计现在角色卡只有属性和技能但真实跑团中角色卡还包括装备、法术、背景故事、伤害表等。建议把Character设计得足够灵活比如允许额外字段而不是把所有内容都硬编码成固定属性。一种做法是在 JSON 中增加extra字段运行时放入一个字典。这样即使未来加新字段也不会破坏旧角色卡。7.7 单元测试跑团系统是规则密集型的业务逻辑非常适合单元测试。你可以在不启动交互界面时直接测试roll()和skill_check()函数。这里给一个简单的测试思路# test_dice.py import unittest from dice import roll class TestRoll(unittest.TestCase): def test_constant_expression(self): _, total roll(3) self.assertEqual(total, 3) def test_add_bonus(self): _, total roll(1d15) self.assertEqual(total, 6) def test_invalid_expression(self): with self.assertRaises(ValueError): roll(1d100abc) if __name__ __main__: unittest.main()这里的1d15是一个很有用的边界测试因为面数为 1 的骰子结果永远是 1所以总结果可预测。8. 总结与后续扩展这篇文章从一个非常具体的需求出发给 COC 跑团写一个掷骰辅助工具。我们实现了骰子表达式解析、角色卡 JSON 读写、D100 技能检定以及命令行交互入口。核心代码没有依赖第三方库结构清晰可以直接运行。如果你是自己玩团把main.py稍微改一改加入奖励骰、惩罚骰和伤害投掷就已经是一个能用的日常工具。如果你想把它做成分发版本还可以继续考虑奖励骰和惩罚骰的完整规则。San 值检定与临时疯狂表。多人跑团的记录面板。通过 WebSocket 或机器人接口接入跑团群。角色卡创建向导自动计算衍生属性。写跑团辅助工具最大的乐趣在于规则和代码的相互映射。每一条规则数字背后都有概率曲线和边界条件这让它天然适合用程序实现也天然适合用来练习系统性排查 Bug。如果你在实现过程中遇到了别的怪现象欢迎在评论区贴出报错信息和你的代码片段一起看看问题出在哪一环。