你很可能刷到过标题里带“熟肉”“馒馒来”“克苏鲁神话”的跑团视频。主持人把故事讲得跌宕起伏,玩家在关键剧情里掷出大失败,弹幕一边刷“鬼畜”一边争论这次判定到底合不合理。第一次接触这类内容的人会意识到:跑团里一个行动能否成功,并不是主持人拍脑袋说了算,而是一套可以被精确描述和计算的判定规则。
这套规则一旦写成程序,就会出现程序员熟悉的另一种东西:Bug。技能值、骰子面数、成功等级、大成功和大失败的位置,任何边界写错或随机数用法不对,都会让“神话与科学”变成“Bug与玄学”。本文用一个最小 Python 项目,把一套简化的 TRPG 检定流程完整实现出来,再沿着项目里最容易出错的 6 个 Bug,拆解从现象、原因、检查到解决的排查链路。
学完你会发现,这类小工具特别适合练手:输入是骰子表达式,处理是随机投掷和分级判定,输出是一个可复现的结果。把这条链路做扎实,随后无论是做跑团骰子机器人、自动化团务,还是把日志、测试、故障分类等工程方法补上去,都有清晰的扩展方向。
1. 先理解检定规则里的成功等级,再决定怎么写分支
跑团视频里最紧张的时刻,常常是一个百分比骰被掷出的瞬间。观众看到的是戏剧张力,程序里看到的是一个随机事件映射到预定义结果区间的过程。把这个过程写清楚,比急着敲代码重要得多。
1.1 什么是检定,为什么适合做成程序
在 TRPG 跑团中,“检定”是解决不确定行动结果的标准方式。主持人给出行动描述和难度,玩家掷骰获得一个随机结果,再与角色属性或技能值比较,得到成功或失败。克苏鲁主题跑团里最常见的是百分比检定:掷 1d100,把结果与技能值比较。
最小示例是这样的:
- 角色技能值:80
- 玩家掷出:42
- 42 小于等于 80,判定为普通成功。
这个逻辑可以精确写入代码。人工判定容易受现场情绪影响,程序化之后速度快、结果一致、还能留下可复盘记录。这正是跑团工具值得做技术拆解的原因。
需要注意一个容易误解的地方:百分比检定不是“数字越大越好”。在大多数房规里,结果是“越小越好”,但又要看是否落在某个成功等级区间内。也就是说,这不只是布尔值,而是多级判定。
1.2 成功等级如何翻译成代码分支
本文采用一套专门为示例简化的成功等级规则。它不是任何规则书的官方原文,只用来演示程序结构;实际项目中,请以主持人采用的规则书和房规为准。
| 等级 | 条件(示例简化) |
|---|---|
| 大成功 | 掷出 1 |
| 极难成功 | 结果小于等于技能值的五分之一 |
| 困难成功 | 结果小于等于技能值的二分之一 |
| 普通成功 | 结果小于等于技能值 |
| 大失败 | 结果大于等于 96 且大于技能值 |
| 失败 | 以上都不满足 |
对应的判定伪代码如下:
if roll == 1: return "大成功" if roll >= 96 and roll > skill: return "大失败" if roll <= skill // 5: return "极难成功" if roll <= skill // 2: return "困难成功" if roll <= skill: return "普通成功" return "失败"分支顺序是这个函数的核心。如果先判断“普通成功”,那么所有“困难成功”也会进入普通成功的分支,后面的判断永远不会执行。推荐的写法是:先处理大成功、大失败这类特殊结果,再按从难到易的等级依次判断。
1.3 为什么这里最容易出 Bug
判定函数看起来只有几行,但 Bug 往往藏在边界和整除上。
第一个边界是 95、96、100。技能值 95 时,掷出 96 应该算大失败还是普通成功?不同房规可以给出不同答案,代码必须明确表达所用房规的约定。第二个边界是技能值小于 5。此时skill // 5的结果是 0,极难成功条件变成“结果小于等于 0”,永远不可能触发。这是整数除法带来的规则空洞,需要在规则设计阶段就决定:技能值过低时极难成功是否允许出现。
还有一个隐藏问题:骰子表达式不一定只有1d100。玩家可能输入2d6+3、1d20甚至100d6。解析逻辑能否支持倍率、加值,是否限制骰子数量,都会直接影响程序行为。所以项目虽然小,也要拆成解析、投掷、判定、入口四个模块。
2. 用最小 Python 工程把规则落成代码,环境只需要标准库
这个项目不需要第三方框架。Python 标准库里的random、re、argparse、unittest足够完成全部功能。这样做的好处是环境简单,任何人克隆代码后都能直接跑。
2.1 运行环境与前置要求
| 项目 | 最低要求 | 说明 |
|---|---|---|
| Python | 3.9 及以上 | 使用了内置泛型写法,低版本会语法报错 |
| 第三方依赖 | 无 | 只使用标准库 |
| 操作系统 | Windows / Linux / macOS | 命令行运行即可 |
| 前置知识 | Python 基础、正则表达式基础 | 不理解正则也能阅读,但调试时需要查一下语法 |
如果原项目准备接入 QQ 机器人、飞书机器人或 Web 服务,再考虑引入asyncio、FastAPI等依赖。当前阶段保持标准库,能让故障面最小。
2.2 项目结构与文件职责
trpg_dice/ ├── parser.py # 解析骰子表达式,例如 1d100、2d6+3 ├── roller.py # 执行投掷并计算总数 ├── judge.py # 百分比检定分级判定 ├── trpg.py # 命令行入口 ├── test_trpg.py # 单元测试 └── verify_random.py # 随机性验证脚本模块拆分遵循单一职责:解析归解析,随机归随机,规则归规则。后续如果要加“困难成功修复”这类扩展,不需要改动解析代码;如果要换成 D20 系统,只需要替换判定模块。
3. 四个模块拆开实现,核心代码逐段解释
写代码时不要想着一步到位。先把每个模块的最小职责写清楚,再通过入口串联起来。
3.1 解析模块:把字符串变成结构化骰子表达式
# parser.py import re from dataclasses import dataclass @dataclass(frozen=True) class DiceExpr: count: int sides: int modifier: int = 0 _DICE_PATTERN = re.compile(r'^(\d*)d(\d+)([+-]\d+)?$', re.IGNORECASE) def parse_dice(expr: str) -> DiceExpr: if not isinstance(expr, str): raise TypeError("骰子表达式必须是字符串") expr = expr.strip() m = _DICE_PATTERN.match(expr) if not m: raise ValueError(f"无法解析骰子表达式: {expr}") count_str, sides_str, mod_str = m.groups() count = int(count_str) if count_str else 1 sides = int(sides_str) modifier = int(mod_str) if mod_str else 0 if count <= 0 or sides <= 0: raise ValueError("骰子数量和面数必须大于 0") if count > 100: raise ValueError("单次投掷骰子数量不能超过 100") if sides > 1_000_000: raise ValueError("骰子面数超出合理范围") return DiceExpr(count=count, sides=sides, modifier=modifier)这段代码解决了几个问题:
- 正则
^(\d*)d(\d+)([+-]\d+)?$兼容1d100、d20、2d6+3、1D100这几种常见写法。 count为空时按 1 处理,所以d20等价于1d20。- 限制了骰子数量和面数,避免有人传入
999999999d999999999造成内存和 CPU 问题。
3.2 投掷模块:随机数边界要选对
# roller.py import random from parser import DiceExpr def roll_one(sides: int) -> int: if sides <= 0: raise ValueError("骰子面数必须大于 0") return random.randint(1, sides) def roll_expression(expr: DiceExpr): if expr.count <= 0 or expr.count > 100: raise ValueError("骰子数量必须在 1 到 100 之间") values = [roll_one(expr.sides) for _ in range(expr.count)] total = sum(values) + expr.modifier return values, total这里的关键是random.randint(1, sides)。它返回闭区间[1, sides]内的整数,两端都包含。不要写成int(random.random() * sides) + 1,虽然大多数情况下也能用,但在边界处理上更容易出错,也违背“程序员只关心规则、不关心随机算法”的模块划分原则。
3.3 判定模块:把成功等级规则集中在一处
# judge.py def judge_percent(roll: int, skill: int) -> str: if not (1 <= roll <= 100): raise ValueError("百分比骰结果必须在 1 到 100 之间") if not (1 <= skill <= 100): raise ValueError("技能值必须在 1 到 100 之间") if roll == 1: return "大成功" if roll >= 96 and roll > skill: return "大失败" if roll <= skill // 5: return "极难成功" if roll <= skill // 2: return "困难成功" if roll <= skill: return "普通成功" return "失败"这个函数最重要的设计是:它不接收骰子总数之外的信息,只接收最终点数roll和技能值skill。这样做方便单测。后续若要引入“奖励骰”“惩罚骰”,也应该在进入判定函数之前完成计算,保持判定函数纯净。
3.4 CLI 入口:通过 argparse 接收参数
# trpg.py import argparse import random from parser import parse_dice from roller import roll_expression from judge import judge_percent def main(argv=None) -> int: parser = argparse.ArgumentParser( description="TRPG 骰子检定小工具" ) parser.add_argument("dice", help="骰子表达式,例如 1d100、2d6+3") parser.add_argument( "skill", type=int, nargs="?", default=None, help="技能值(1-100),不传则只掷骰" ) parser.add_argument( "--seed", type=int, default=None, help="随机种子,用于复现某次结果" ) args = parser.parse_args(argv) if args.seed is not None: random.seed(args.seed) try: expr = parse_dice(args.dice) values, total = roll_expression(expr) except (TypeError, ValueError) as exc: print(f"输入错误: {exc}") return 1 if args.skill is None: print(f"骰面 {values} 总计 {total}") else: if not (1 <= args.skill <= 100): print("技能值必须在 1 到 100 之间") return 1 level = judge_percent(total, args.skill) print(f"骰面 {values} 总计 {total} | 技能 {args.skill} | {level}") return 0 if __name__ == "__main__": raise SystemExit(main())注意两点:
--seed只用于复现和测试。正常跑团时不要固定种子,否则每次 result 都一样,等于把随机事件变成了确定性事件。- 主程序把异常捕获并转换成友好提示。用户输入
abc时,不应该看到 Python 堆栈,而应该看到“无法解析骰子表达式: abc”。
3.5 单元测试:用断言保护边界
# test_trpg.py import unittest from parser import parse_dice, DiceExpr from roller import roll_expression from judge import judge_percent class ParserTest(unittest.TestCase): def test_parse_common_expr(self): self.assertEqual(parse_dice("1d100"), DiceExpr(1, 100, 0)) self.assertEqual(parse_dice("2d6+3"), DiceExpr(2, 6, 3)) self.assertEqual(parse_dice("d20"), DiceExpr(1, 20, 0)) def test_parse_invalid(self): with self.assertRaises(ValueError): parse_dice("abc") with self.assertRaises(ValueError): parse_dice("0d6") with self.assertRaises(ValueError): parse_dice("101d6") class RollerTest(unittest.TestCase): def test_roll_expression(self): expr = parse_dice("3d6+1") values, total = roll_expression(expr) self.assertEqual(len(values), 3) self.assertTrue(all(1 <= v <= 6 for v in values)) self.assertEqual(total, sum(values) + 1) class JudgeTest(unittest.TestCase): def test_big_success(self): self.assertEqual(judge_percent(1, 80), "大成功") def test_critical_failure(self): self.assertEqual(judge_percent(96, 90), "大失败") def test_boundary_95(self): self.assertEqual(judge_percent(95, 95), "普通成功") def test_hard_and_extreme(self): self.assertEqual(judge_percent(40, 80), "困难成功") self.assertEqual(judge_percent(16, 80), "极难成功") def test_invalid_input(self): with self.assertRaises(ValueError): judge_percent(0, 80) with self.assertRaises(ValueError): judge_percent(50, 101) if __name__ == "__main__": unittest.main()这里的边界用例特别重要:96与技能值90构成大失败,95与技能值95则因为不满足“大于技能值”而落到普通成功。这些用例就是防止回归的保险。
4. 运行、边界测试和概率验证要一起做,程序才算真正可用
很多初学者写完入口函数就认为项目结束。实际上,一个随机判定工具至少需要过三关:正常流程、边界规则、随机分布。
4.1 先跑通正常命令
python trpg.py "1d100" 80输出示例:
骰面 [42] 总计 42 | 技能 80 | 普通成功不带技能值时只掷骰:
python trpg.py "2d6+3"输出示例:
骰面 [2, 5] 总计 10使用固定种子复现:
python trpg.py "1d100" 50 --seed 20244.2 运行单元测试
python -m unittest -v test_trpg预期所有用例通过。此时边界规则已经被保护起来,后续任何人修改判定函数,只要跑一遍测试就能发现回归。
4.3 手工验证边界表
| 输入 | 期望结果 | 说明 |
|---|---|---|
judge_percent(1, 80) | 大成功 | 特殊结果优先 |
judge_percent(96, 90) | 大失败 | 96 到 100 且大于技能值 |
judge_percent(95, 95) | 普通成功 | 不满足大于技能值,所以不是大失败 |
judge_percent(40, 80) | 困难成功 | 40 小于等于 40 |
judge_percent(16, 80) | 极难成功 | 16 小于等于 16 |
judge_percent(81, 80) | 失败 | 超过技能值 |
手工验证一个两个用例很容易,但之后增加房规时,最好把这些用例全部写入参数化测试,而不是每次手动敲命令。
4.4 随机性验证:避免“看起来正常,实际上分布不对”
随机数看似简单,但很多 Bug 是统计层面才能发现的。写一个轻量脚本,统计 1d100 各个面出现的次数:
# verify_random.py import random from collections import Counter def verify_1d100(trials: int = 200_000, seed: int | None = None) -> None: if seed is not None: random.seed(seed) counter = Counter(random.randint(1, 100) for _ in range(trials)) expected = trials / 100 for value in (1, 50, 100): actual = counter[value] print( f"{value:>3}: {actual:6d} 期望约 {expected:.0f} " f"偏差 {actual - expected:+.0f}" ) print("每个面出现次数的最小值/最大值:", min(counter.values()), max(counter.values())) if __name__ == "__main__": verify_1d100()运行:
python verify_random.py在 20 万次样本下,每个面出现次数应该在 2000 次左右,偏差可能在几十次范围内。如果某个面偏差上千,或者最大值和最小值相差悬殊,就要怀疑随机数用法或系统随机源配置。
注意:随机性验证适合发现极端异常,不适合用来证明“绝对均匀”。样本量、种子、系统熵源都会影响统计结果。它应该作为项目里的一项常规检查脚本,