
COC跑团和雷兄弟这种角色桌放在一起真正让 GM 头疼的往往不是模组地图和调查报告而是角色之间的互动缺少可被触发的戏剧节点。两三个 PC 都围绕同一段师门关系展开时玩家一旦不知道“接下来这个角色会做什么”整个跑团节奏就会明显慢下来。为了解决这个问题我搭建了一个以“自动售货机”为外形的角色扮演提示生成器取名“稻玉狯岳自动售货机”。它的用途很简单GM 或玩家投一枚硬币机器返回一条和当前情绪、当前冲突节点、当前场景气质匹配的角色反应提示。这里不讨论复杂的 AI只讲如何用 Python、JSON 和 FastAPI 做一个可复现的随机提示服务并把它扩展到实际的跑团桌中。1. 先拆解“稻玉狯岳自动售货机”到底要解决什么问题1.1 它不是抽卡系统而是跑团的节奏工具如果只是做一个随机抽取系统用 Excel 和RANDBETWEEN就够用了。真正值得做成“自动售货机”的原因是跑团过程中需要一种低摩擦的随机内容获取方式投币出货然后马上把结果使用到当轮扮演里。“稻玉狯岳自动售货机”本质上是一个面向特定角色桌的随机内容售货机。它输出的不是游戏数值而是角色扮演提示、场景碎片、关系冲突点、NPC 反应和可以插入当前叙事的微型事件。在雷兄弟角色桌里PC 往往围绕雷之呼吸的师承、同门竞争、背叛与和解展开剧情。这类剧情天然适合“应激式”扮演。比如某个 NPC 提到“师兄”两个字场上瞬间安静又比如某位角色嘴上说着无所谓却在下一轮主动选择断后。这些细节如果全部靠 GM 临场想很容易卡住如果让售货机在需要时抛出一条GM 就能把注意力集中在剧情推进和判罚上。1.2 售货机和手工写剧本的边界在哪里这里要明确一个边界自动售货机是辅助工具不是剧本生成器。GM 可以在跑团前把关键剧情节点设计好把“角色会经历哪些冲突”写清楚然后把售货机当作补充细节的工具。比如当前场景需要缓和气氛投币一次拿到一条轻量 RP 提示。玩家在关键对白中犹豫投币一次拿到该角色可能的应激反应。PC 之间即将发生冲突投币一次拿到一个可以加速冲突的线索。某段调查陷入停滞投币一次拿到一个和角色性格相关的旁支事件。换句话说售货机负责“在正确的时间给出正确的钩子”剧本主线、数值平衡和剧情因果仍然由 GM 控制。不要把随机结果当成规则权威它只是抛到桌面上的一个球接不接由玩家决定。1.3 最小需求清单要做一个真正能投入跑团使用的售货机至少要满足五个条件内容库与代码分离GM 可以随时修改提示条目。支持按情绪、场景、角色池过滤。随机结果可重复调用且权重可控。API 形式便于接入网页、聊天机器人或本地命令行。返回结果足够干净包含提示文本、标签和出货来源。下面就从数据结构开始一步步把这个最小闭环搭出来。2. 数据结构设计把角色桌内容改造成可被机器消费的 JSON2.1 为什么要用 JSON 而不是直接写在 Python 里最容易踩的坑是把角色提示直接以数组形式写死在 Python 文件里。这样做不是不能跑而是后续改内容非常痛苦。跑团桌的内容迭代很快今天玩家觉得某个提示很好用明天 GM 想删掉另一条容易破坏气氛的提示。如果内容埋在代码里每次修改都要重新发版还要小心改错缩进导致整个脚本挂掉。更好的做法是把内容抽到一个独立的 JSON 文件中。Python 代码只负责读取、过滤和随机抽取JSON 文件负责表达“这台售货机里到底装了哪些商品”。GM 不需要理解 Python只需要打开 JSON 文件添加一条带text的 JSON 对象保存后重启服务即可。JSON 的结构天然适合表达这种内容库。每条提示可以有唯一编号、标签、适用情绪、权重和正文后续要扩展也只需要增加字段不需要改动接口。2.2 条目字段和权重设计在设计第一个版本时我建议每个售货机条目包含以下字段字段类型是否必填说明idstring是条目标识用于日志和去重tagstring否标签比如“雷兄弟”“任务”“日常”moodarray否适用情绪比如 tense、calm、sadweightnumber否随机权重默认 1textstring是实际返回到桌面的提示文本这里最需要解释的是weight。它不是概率百分比而是相对权重。比如 A 条 weight 为 10B 条 weight 为 2那么随机抽取时 A 被选中的概率是 B 的 5 倍。这样 GM 可以把更常用、更安全的提示权重调高把容易破坏剧情的极端提示权重调低而不用精确计算概率总和。mood字段用于过滤。跑团中的场景情绪有时很明确一场争执处于tense状态一次休整处于calm状态。售货机可以在投币时传入moodtense只从符合该情绪的提示里抽取让结果更贴合当前场景。2.3 示例内容库雷兄弟角色桌的 8 条 RP 种子下面是一份最小可用的 JSON 内容库。它不是一个完整跑团模组而是一组原创的角色扮演提示用于演示自动售货机如何组织内容。{ machine_name: 稻玉狯岳自动售货机, version: 1.0, currency: 气势硬币, pools: [ { name: rp_seed, label: 角色扮演提示, entries: [ { id: k-001, tag: 雷兄弟, mood: [tense], weight: 10, text: 他听到“师兄”两个字时停顿了一拍随后用更硬的语气把话题拨回眼前的任务。 }, { id: k-002, tag: 雷兄弟, mood: [calm, sad], weight: 6, text: 他看着面前的旧伤疤只说了一句有些招式你练得越久越不知道是为了谁。 }, { id: k-003, tag: 日常, mood: [calm], weight: 8, text: 他偶尔会检查同伴的佩刀嘴上说是防止对方拖后腿动作却比平时轻很多。 }, { id: k-004, tag: 冲突, mood: [tense, conflict], weight: 7, text: 当队友建议示弱诱敌时他把拒绝的话咽了回去因为师父曾经说过同样的话。 }, { id: k-005, tag: 行动, mood: [danger], weight: 5, text: 他主动提出去断后理由是不能让 NPC 再死一次。 }, { id: k-006, tag: 关系, mood: [calm, sad], weight: 4, text: 谈起过去时他会用一句“记不清了”遮住所有细节然后沉默很久。 }, { id: k-007, tag: 雷兄弟, mood: [tense, conflict], weight: 9, text: 他对同门使用严厉措辞但治疗时动作很轻仿佛担心握刀的手会弄疼对方。 }, { id: k-008, tag: 意外, mood: [calm, tense], weight: 3, text: 收到意外的善意后他的第一反应是检查自己有没有被下咒。 } ] } ] }这份内容库在实际使用中不需要拘泥于上面的文字。GM 可以按自己角色桌设定替换把“师兄”“师父”等关键词调整成与具体模组一致的内容。这里的重点是每条提示都在描述一个“可供玩家接住的反应”而不是直接规定剧情结果。3. Python 随机引擎先让售货机能吐出结果3.1 核心类 VendingMachine内容库准备好之后开始写核心逻辑。这个阶段的代码不依赖 FastAPI先写一个纯 Python 类负责读取 JSON、过滤条目、按权重抽取。import json import random from pathlib import Path class VendingMachine: def __init__(self, config_path: Path): self.config json.loads(config_path.read_text(encodingutf-8)) self.machine_name self.config.get(machine_name, 自动售货机) self.pools self.config[pools] def _find_pool(self, pool_name: str): for pool in self.pools: if pool[name] pool_name: return pool raise KeyError(f未知内容池: {pool_name}) def sell(self, pool_name: str rp_seed, mood: str | None None): pool self._find_pool(pool_name) entries pool[entries] if mood: entries [ entry for entry in entries if mood not in entry or mood in entry[mood] ] if not entries: return None weights [entry.get(weight, 1) for entry in entries] picked random.choices(entries, weightsweights, k1)[0] return { machine: self.machine_name, pool: pool[label], id: picked[id], tag: picked.get(tag), text: picked[text], }这个类只有两个核心方法_find_pool按名字找到对应内容池。内容池可以理解为售货机里的一排货架目前只有rp_seed后续可以增加“道具池”“事件池”“线索池”。sell执行投币出货的核心动作。sell方法先用mood过滤出候选条目。如果传入moodtense只会保留mood数组包含tense的条目或者没有声明mood字段的条目。然后通过random.choices按权重随机选中一条最终返回一个包含机器名、内容池名、条目 ID、标签和提示文本的结果。这种设计的最大好处是什么它把“随机”看作一个纯粹的服务动作而不是把随机结果直接写入主线。调用方拿到返回值后可以自由决定是把这条提示直接抛出还是把它作为隐藏信息交给某个玩家。3.2 权重抽取原理与 random.choices 的坑Python 标准库里的random.choices是抽取带权随机对象最直接的办法。它接受三个参数候选序列、权重序列和抽取次数。k1表示只抽取一条。这里有一个常见误区random.choices的weights不需要归一化程序内部会自动处理相对权重。只要权重都是非负数传入[10, 6, 8]和传入[5, 3, 4]的抽取概率比例完全一样。但并不是所有情况都适合用random.choices。如果某条内容的weight为 0它依然可能被抽中吗在random.choices中权重为 0 的条目不会被选中。这里要注意如果你把一条内容weight设为 0却在调试时发现它偶尔出现通常是因为列表里存在另一个相同的条目或者数据文件没有生效。实际排错时先打印过滤后的候选列表再检查权重。另一个更隐蔽的坑是random.choices每次调用都会消耗一次系统随机数。如果售货机服务在极端高频下被调用比如被自动化脚本每秒调用上百次建议将该方法放入服务层统一管理而不是让每个请求都直接构造新的随机源。3.3 输入过滤与空结果处理在跑团场景中GM 不一定记得所有情绪标签。比如某一次投币传入了moodhappy但内容库中没有任何happy条目此时entries会变成空列表。如果不加处理random.choices会直接抛IndexError: Cannot choose from an empty sequence。在sell方法中已经提前判断了if not entries: return None。这种处理比抛异常更友好调用方收到None后可以提示“当前条件未匹配到内容请换个情绪标签”而不是看到一整个堆栈报错。不过“返回 None”可以但最好在日志里保留一条信息方便后续调整内容库。比如某个情绪标签被反复使用却没有结果说明内容库缺少该情绪下的条目GM 应该补充内容而不是每次投币后都无货可出。4. 用 FastAPI 把它变成可以投币的 HTTP 接口4.1 为什么选 FastAPIFastAPI 在接口快速原型阶段非常合适。它自带参数校验、请求文档和异步支持。对于一个跑团辅助工具这些能力已经足够而且依赖很少。FastAPI 的交互式文档可以直接当作“售货机操作面板”使用。浏览器打开/docs后GM 可以看到/v1/sell接口填入参数点击执行就能模拟一次投币不需要再打开命令行。这个阶段不需要引入数据库。内容库是 JSON 文件接口是无状态服务随机抽取结果不落库所以服务启动和部署都很轻。如果后续要接入群聊机器人或者网页前端FastAPI 也能直接提供 REST 接口。4.2 项目文件结构建议按下面的结构组织项目避免代码和内容混在一起。kaigaku-vending-machine/ ├── app.py ├── vending.py ├── requirements.txt ├── data/ │ └── kaigaku.json └── README.mdvending.py存放VendingMachine核心类。app.pyFastAPI 应用负责 HTTP 接口。data/kaigaku.json内容库。requirements.txt依赖清单。这样一个最小项目无论放在本地还是部署到云服务器都可以快速启动。4.3 接口设计与参数接口GET /v1/sell接收两个查询参数参数类型必填默认值说明poolstring否rp_seed内容池名称moodstring否无按情绪过滤比如 tense、calm为什么不使用 POST因为sell是一个幂等性比较弱但有明确参数的读取操作。它不做数据写库也不会产生副作用用 GET 可以让 GM 直接在浏览器地址栏里测试。当然如果后续要加入“投币扣费记录”或“抽取历史”则应改成 POST避免请求被浏览器缓存。4.4 核心 API 代码在app.py中写入以下内容from pathlib import Path from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel from vending import VendingMachine app FastAPI(title稻玉狯岳自动售货机) machine VendingMachine(Path(data/kaigaku.json)) class SellResult(BaseModel): machine: str pool: str id: str tag: str | None None text: str app.get(/v1/sell, response_modelSellResult) def sell( pool: str Query(rp_seed, description内容池名称), mood: str | None Query(None, description情绪过滤标签), ): result machine.sell(pool_namepool, moodmood) if result is None: raise HTTPException(status_code404, detail没有匹配到内容请检查 pool 或 mood 参数) return result这里有几个值得注意的设计。machine在模块加载时实例化服务启动后只需要读取一次 JSON 文件。后续每次请求都复用同一个VendingMachine对象不会因为并发请求导致重复读取文件。前提是内容库在运行期间不会被频繁修改。如果 GM 希望修改 JSON 后不用重启进程就需要加一个文件监听或定时重载机制这部分会在生产化改造里说明。response_modelSellResult会让 FastAPI 对返回值做一次校验并生成对应的接口文档。即使内部返回了多余字段响应中只会出现模型里定义的字段这样接口输出更干净。HTTPException(status_code404)是空结果的处理方式。这个选择不是唯一的也可以在接口层接受None返回 200 和空数据。我这里选择 404是希望调用方明确知道“没有匹配内容”是一种异常状态而不是正常的售货结果。5. 本地验证命令行、浏览器和 Postman 三种方式5.1 安装依赖推荐使用虚拟环境隔离依赖避免污染系统 Python。mkdir kaigaku-vending-machine cd kaigaku-vending-machine python -m venv .venv source .venv/bin/activateWindows 环境下激活命令是.venv\Scripts\activate然后准备requirements.txtfastapi0.110.0 uvicorn0.29.0执行安装pip install -r requirements.txt如果后续 Python 版本有差异可以在安装后执行pip freeze核对实际版本。不同电脑上 FastAPI 和 Uvicorn 的小版本差异通常不会影响这个示例但部署到正式环境前最好固定版本。5.2 启动服务确保当前目录下存在vending.py、app.py和data/kaigaku.json然后运行uvicorn app:app --reload --host 0.0.0.0 --port 8000--reload是开发模式用的热重载选项。修改app.py或vending.py后服务会自动重启。但注意--reload不会监听data/kaigaku.json的变化VendingMachine在启动时已经加载了文件内容。修改 JSON 后需要手动重启服务或者参考后续第 7 节实现热加载。启动后如果看到类似下面的输出说明服务正常INFO: Uvicorn running on http://127.0.0.1:8000 INFO: Application startup complete.5.3 curl 验证打开一个新终端执行curl http://127.0.0.1:8000/v1/sell?moodtense这会向售货机投币一次并指定moodtense。返回结果类似{ machine: 稻玉狯岳自动售货机, pool: 角色扮演提示, id: k-001, tag: 雷兄弟, text: 他听到“师兄”两个字时停顿了一拍随后用更硬的语气把话题拨回眼前的任务。 }如果不传mood则会从全部条目中按权重抽取curl http://127.0.0.1:8000/v1/sell如果传入一个不存在的内容池会得到 500 错误。原因是在machine.sell()内部对唯一存在的rp_seed池进行了硬编码查找。实际使用中建议把pool参数先做校验或者让_find_pool方法抛出更友好的异常。5.4 预期返回结果一个健康的售货机服务应该满足以下行为正常投币返回 200响应体包含machine、pool、id、tag、text。加权抽取高频标记的内容出现概率高但低频标记偶尔也会出现。情绪过滤传入不存在的情绪标签时返回 404。未知内容池返回 500但日志中应该有未知内容池的信息。如果返回结果始终是某一条固定文本说明随机种子服务有问题或者weights配置得极端不平衡。5.5 在浏览器中按参数刷新打开浏览器访问http://127.0.0.1:8000/docs可以看到 FastAPI 自动生成的接口文档。在/v1/sell的 Try it out 按钮中填入moodcalm点击 Execute即可完成一次投币。浏览器方式适合跑团现场快速测试。GM 可以把这个服务部署在局域网内让玩家用自己的手机浏览器访问同一个地址随时投币。由于host0.0.0.0服务会监听所有网卡地址但这里要注意这只能用于可信局域网环境。如果部署到公网必须增加访问控制否则任何人都能调用接口也可能造成内容库泄露或资源被刷。6. 常见问题排查从“抽不到理想内容”到“API 报 500”6.1 问题排查表问题现象常见原因检查方式处理建议修改 JSON 后内容未更新服务启动时就加载了文件没有热加载查看启动日志检查重启时间修改后手动重启服务或实现文件监听传入 mood 后经常返回 404内容库中没有该情绪标签打开 JSON 文件搜索该 mood 值为对应情绪补充条目或减少该情绪的使用某条提示一直不被抽中weight 设置过低或该条被 mood 过滤打印过滤后的候选列表调高 weight或移除 mood 限制接口返回 500内容池名称错误或 JSON 格式损坏查看终端堆栈执行 python 读取 JSON确认池名称使用 JSON 校验工具中文返回乱码终端编码或 HTTP 响应编码不一致检查read_text(encodingutf-8)是否保留统一使用 UTF-8 保存文件接口可以被任意外部设备访问绑定了 0.0.0.0 并且没有鉴权使用curl从其他设备访问部署到公网的场景增加 Token 或反代鉴权6.2 中文乱码问题中文乱码最常见的原因是 JSON 文件没有以 UTF-8 保存。Windows 系统下用记事本保存 JSON 时默认编码可能是 UTF-8 with BOM 或 GBKPython 的read_text(encodingutf-8)读到 BOM 后会报错或者读取到乱码。推荐用 VSCode 等现代编辑器保存 JSON统一设置为 UTF-8。在 Python 代码中读取 JSON 时显式传入encodingutf-8而不是省略参数依赖系统默认编码。接口返回中文乱码更多是终端问题。Windows 命令行执行curl时如果终端代码页不是 UTF-8显示就会乱。可以先输出到文件再查看curl -s http://127.0.0.1:8000/v1/sell out.json然后打开out.json确认内容是否正常。6.3 权重不生效如果发现某些条目的 weight 明明很低却每次都出现先检查是不是过滤后候选列表里只剩这一条。例如内容库中只有一条moodconflict的条目那么即使它的 weight 设为 1也会 100% 被抽中。另一个容易忽略的问题是random.choices的weights参数与候选列表长度不一致。如果手写weights列表长度必须等于候选列表长度否则会抛异常。上面的代码使用列表推导式动态生成weights已经规避了这个长度不一致的坑。想验证权重是否生效可以写一个循环脚本连续调用 1000 次统计各条目出现次数。这样能直观看到权重比例是否符合预期。6.4 返回内容与角色桌设定不匹配如果售货机返回的内容听起来完全不像“稻玉狯岳”或雷兄弟角色桌问题往往不在代码而在内容库设计。角色桌的每个 PC 都有独特的行为逻辑。提示文本如果只描述“他做了一件事”却没有体现角色特有的性格张力玩家就很难接住。建议在写内容库时每条text都遵守“行为 动机 矛盾”的结构。比如不是单纯写“他沉默”而是写“他用更硬的语气把话题拨回任务因为他不想暴露自己在意师兄这件事”。6.5 并发投币是否安全多个玩家同时投币时VendingMachine类并没有共享可变状态每次sell都是从头过滤再抽取因此不会出现数据竞争。FastAPI 对于只读请求可以安全并发处理。但如果后续加入“投币记录”或“库存计数”就要考虑锁和数据库事务。这个阶段不需要过早设计先把无状态的随机接口跑通再根据实际使用量决定是否加状态。7. 从个人桌到群机器人生产化改造怎么落地7.1 学习环境、开发环境、生产环境怎么区分个人跑团桌和正式部署到群聊机器人的场景要求完全不同。学习环境只需要本地跑通使用uvicorn --reload内容库直接放在data/目录即可。开发环境可以加入简单的日志输出打印每次投币的参数和返回结果。生产环境则要额外考虑内容库热更新、访问控制、日志持久化和异常恢复。维度学习环境生产环境启动方式uvicorn --reload使用 systemd 或容器内容库本地 JSON 文件直接读取远程配置仓库或数据库鉴权无Token 或反向代理日志终端输出文件日志 结构化日志监控不要求请求数、错误率、抽取计数7.2 内容库外置与热更新当前代码只在启动时读取一次 JSON。生产环境中GM 可能希望随时调整内容库而不重启服务。可以在VendingMachine中增加一个reload方法并在每次请求时记录文件修改时间。from filecmp import cmp from pathlib import Path import time class HotReloadVendingMachine(VendingMachine): def __init__(self, config_path: Path): super().__init__(config_path) self.config_path config_path self._mtime config_path.stat().st_mtime def _maybe_reload(self): mtime self.config_path.stat().st_mtime if mtime ! self._mtime: self.config json.loads(self.config_path.read_text(encodingutf-8)) self._mtime mtime在sell方法开头调用_maybe_reload()即可在每次投币时检查文件是否变化。这样 GM 改完 JSON 保存下一次请求就会自动使用新内容。这个方案有性能开销但跑团服务调用频率很低完全够用。如果部署到公网建议改成监听消息队列或配置中心而不是每次请求都 stat 文件。7.3 日志、配额与冷却时间生产环境一定要记录投币日志。至少记录以下字段请求时间调用方 IP 或用户标识传入的 pool 和 mood返回的条目 id耗时如果售货机接入群聊机器人还需要考虑冷却时间。否则玩家会在短时间内反复刷取把随机提示当成高频抽卡反而破坏跑团节奏。可以在接口层加一个简单的冷却判断比如相同用户两次投币必须间隔 10 秒以上。7.4 扩展方向接入聊天工具、Notion、Markov 生成售货机目前返回的是固定文本。后续可以扩展为接入聊天工具把/v1/sell封装成交互命令让玩家在群里直接触发。使用 Notion 管理内容库团队协作时用 Notion 数据库维护讲稿然后导出 JSON 给售货机。加入 Markov 链生成从大量角色对白中学习句式生成新的提示。但这需要更严格的文本预处理且生成质量不稳定建议作为进阶玩法不要依赖它保证跑团质量。8. 给 GM 的实战建议随机结果如何变成故事8.1 先定规则再投币在跑团开始前GM 要告诉玩家这台售货机的使用规则。比如投币后拿到的提示是“角色可能的反应”不是强制行为。如果当前提示与玩家已经表达的行为冲突可以重新投币。同一场景最多投币两次避免随机结果冲淡剧情。投币结果由 GM 决定是公开给所有人还是私下发给某位玩家。规则越清晰随机提示越不会变成干扰。玩家拿到一条提示后可以选择采纳、修改或拒绝但至少要说明拒绝的理由。拒绝本身也可以成为角色扮演内容。8.2 随机结果不是剧情决定而是“抛出的钩子”售货机返回的内容不应该直接写成“他离开了队伍”。这种结果剥夺了玩家的判断权会让人感觉被系统逼着走。更好的写法是给出“一个动作 一个矛盾”。比如“他主动提出去断后理由是不能让 NPC 再死一次”是一句话钩子。玩家可以追问他是真的想保护 NPC还是在逃避和同门继续聊下去一追问剧情自然展开。所以写内容库时不要写封闭结局尽量写开放动作。让玩家看到提示后产生“这背后有什么”的疑问才算是合格的角色扮演提示。8.3 售货机内容库维护清单内容库需要持续维护而不是一次性写完。每次跑团结束后GM 可以按下面的清单检查哪些提示被频繁抽中它们是否已经变得太套路化哪些提示从未被抽中是权重过低还是与当前角色桌风格不符哪些情绪标签下没有足够条目预计下个场景会用到什么情绪是否有提示文本过于具体导致换一个故事背景后就无法使用是否有提示直接决定了角色行为而没有留给玩家选择空间维护清单的目的是让售货机的内容和角色桌一起成长。第一次跑团时可能只需要 8 条提示跑过几次后玩家会形成固定角色关系对细节的要求也会变高这时再逐步扩充条目。以上就是一个完整的“稻玉狯岳自动售货机”最小实现。核心流程并不复杂用 JSON 描述内容库用 Python 实现加权随机抽取用 FastAPI 暴露投币接口。真正决定这台售货机有没有价值的是内容库是否贴合角色桌、GM 是否合理使用随机结果。建议先用这个小项目跑一次本地试运行再根据实际桌面反应决定要不要接聊天机器人、加内容热更新或者引入更复杂的生成逻辑。