☰
用Python实现TRPG跑团骰子检定:解析、判定与边界Bug排查
2026/10/12 13:30:23 网站建设 项目流程

你很可能刷到过标题里带“熟肉”“馒馒来”“克苏鲁神话”的跑团视频。主持人把故事讲得跌宕起伏,玩家在关键剧情里掷出大失败,弹幕一边刷“鬼畜”一边争论这次判定到底合不合理。第一次接触这类内容的人会意识到:跑团里一个行动能否成功,并不是主持人拍脑袋说了算,而是一套可以被精确描述和计算的判定规则。

这套规则一旦写成程序,就会出现程序员熟悉的另一种东西: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 运行环境与前置要求

项目最低要求说明
Python3.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 2024

4.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 次左右,偏差可能在几十次范围内。如果某个面偏差上千,或者最大值和最小值相差悬殊,就要怀疑随机数用法或系统随机源配置。

注意:随机性验证适合发现极端异常,不适合用来证明“绝对均匀”。样本量、种子、系统熵源都会影响统计结果。它应该作为项目里的一项常规检查脚本,

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询