
你很可能刷到过标题里带“熟肉”“馒馒来”“克苏鲁神话”的跑团视频。主持人把故事讲得跌宕起伏玩家在关键剧情里掷出大失败弹幕一边刷“鬼畜”一边争论这次判定到底合不合理。第一次接触这类内容的人会意识到跑团里一个行动能否成功并不是主持人拍脑袋说了算而是一套可以被精确描述和计算的判定规则。这套规则一旦写成程序就会出现程序员熟悉的另一种东西Bug。技能值、骰子面数、成功等级、大成功和大失败的位置任何边界写错或随机数用法不对都会让“神话与科学”变成“Bug与玄学”。本文用一个最小 Python 项目把一套简化的 TRPG 检定流程完整实现出来再沿着项目里最容易出错的 6 个 Bug拆解从现象、原因、检查到解决的排查链路。学完你会发现这类小工具特别适合练手输入是骰子表达式处理是随机投掷和分级判定输出是一个可复现的结果。把这条链路做扎实随后无论是做跑团骰子机器人、自动化团务还是把日志、测试、故障分类等工程方法补上去都有清晰的扩展方向。1. 先理解检定规则里的成功等级再决定怎么写分支跑团视频里最紧张的时刻常常是一个百分比骰被掷出的瞬间。观众看到的是戏剧张力程序里看到的是一个随机事件映射到预定义结果区间的过程。把这个过程写清楚比急着敲代码重要得多。1.1 什么是检定为什么适合做成程序在 TRPG 跑团中“检定”是解决不确定行动结果的标准方式。主持人给出行动描述和难度玩家掷骰获得一个随机结果再与角色属性或技能值比较得到成功或失败。克苏鲁主题跑团里最常见的是百分比检定掷 1d100把结果与技能值比较。最小示例是这样的角色技能值80玩家掷出4242 小于等于 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。玩家可能输入2d63、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、2d63 ├── 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(frozenTrue) 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(countcount, sidessides, modifiermodifier)这段代码解决了几个问题正则^(\d*)d(\d)([-]\d)?$兼容1d100、d20、2d63、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(argvNone) - int: parser argparse.ArgumentParser( descriptionTRPG 骰子检定小工具 ) parser.add_argument(dice, help骰子表达式例如 1d100、2d63) parser.add_argument( skill, typeint, nargs?, defaultNone, help技能值1-100不传则只掷骰 ) parser.add_argument( --seed, typeint, defaultNone, 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(2d63), 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(3d61) 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 2d63输出示例骰面 [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 小于等于 40judge_percent(16, 80)极难成功16 小于等于 16judge_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 次左右偏差可能在几十次范围内。如果某个面偏差上千或者最大值和最小值相差悬殊就要怀疑随机数用法或系统随机源配置。注意随机性验证适合发现极端异常不适合用来证明“绝对均匀”。样本量、种子、系统熵源都会影响统计结果。它应该作为项目里的一项常规检查脚本