
我先说说这次为什么想写这个工具。最近在整理“三角机构TRPG”的跑团记录时我们重新回顾了“老友记#02震撼美味”这一期。活动过程非常欢乐但整理时遇到一个很现实的痛点角色卡散落在不同人的文档里掷骰结果靠手打或者聊天记录往上翻日志更是五花八门。想把一场 TRPG 的“现场感”完整留下来比跑团本身还累。于是我把这套流程做成了一个小工具用 Flask SQLite 原生 JavaScript 搭建一个轻量的 TRPG 跑团辅助系统把掷骰判定、角色属性记录、跑团日志和临时事件比如“震撼美味”这个突发事件统一管理起来。这篇文章会完整展示从需求拆解、数据表设计、骰子引擎实现到前端交互的全过程并附上可复现代码和常见报错排查。无论你是跑团主持想要一套记录工具还是想练习 Python Web 开发都可以跟着本文落地一套属于自己的版本。1. 背景与核心概念1.1 TRPG 跑团记录为什么需要工具支撑TRPGTabletop Role-Playing Game桌上角色扮演游戏的核心是玩家和主持人围绕规则书进行角色扮演与判定。跑团过程中主持人会随机抛出事件玩家需要根据角色属性进行检定而检定结果通常由“骰子点数 属性修正值”决定。像“老友记#02震撼美味”这种偏轻松和突发状况的剧情会出现大量临时事件和随机判定。如果只靠纸质记录或聊天记录会面临三个问题第一角色属性分散。每个玩家的力量、敏捷、智力、幸运值等属性如果存在不同文件主持人在判定时很难快速比较。第二掷骰结果不可复现。聊天记录里的骰子结果虽然能看到但无法和当时的“属性加成”“技能加成”对应起来。第三剧情时间线混乱。跑团记录本质上是一条带有分支的事件流没有结构化的记录方式后续复盘时很难还原“是什么原因导致了哪个结果”。所以一套能统一管理角色、骰子、事件和日志的小系统对独立跑团团体来说非常实用。1.2 本文工具的核心能力下面这套系统包含四个模块角色卡管理维护角色名称、职业、属性数值。掷骰判定支持d20、2d6、3d102这类常见的 TRPG 掷骰表达式自动附带加成修正。事件记录把主持人抛出的突发事件作为一条记录保存下来例如“餐桌上的神秘食物震撼美味”。日志展示按时间倒序展示跑团过程回看时一眼看到“谁、做了什么判定、掷出了多少点、最终结果如何”。从技术角度看这个项目麻雀虽小五脏俱全。它涉及 Web 后端接口设计、表达式解析、数据库增删改查、前端异步交互非常适合作为 Python Web 入门或 Flask 练手的实战项目。1.3 为什么选择 Flask SQLite选型上我优先考虑了部署成本和代码可读性Flask 轻量单文件可以启动适合做工具型应用不需要引入重型框架。SQLite 是 Python 内置支持的数据库无需单独安装数据库服务数据存储在单个文件里备份和迁移都很方便。原生 JavaScript 做前端交互不需要构建工具打开浏览器就能用。这套组合特别适合“小团队内部工具”或“个人学习项目”。如果把场景换成大型线上跑团平台可能需要换成 PostgreSQL 和 WebSocket 实现实时同步但那是后话。2. 环境准备与项目结构2.1 运行环境说明本文示例以常见开发环境为准如下操作系统Windows 10/11、macOS 或 Linux 均可Python 版本3.8 及以上Flask 版本2.x本文示例使用 Flask 2.2兼容 3.x数据库SQLitePython 自带的 sqlite3 模块浏览器Chrome、Edge、Firefox 等现代浏览器如果你的 Python 版本较新只要 Flask 能正常安装即可。版本需要根据你的项目实际情况调整本文重点演示实现思路。2.2 安装 Flask建议先创建虚拟环境避免依赖混乱。打开命令行执行mkdir trpg-assistant cd trpg-assistant python -m venv venvWindows 下激活虚拟环境venv\Scripts\activatemacOS / Linux 下激活虚拟环境source venv/bin/activate然后安装 Flaskpip install Flask安装完成后可以用下面命令确认版本pip show Flask2.3 项目目录结构整个项目保持简洁目录结构如下trpg-assistant/ ├── app.py # Flask 应用入口 ├── dice_engine.py # 掷骰表达式解析与实现 ├── schema.sql # 数据库建表语句 ├── static/ │ └── style.css # 前端样式可选 ├── templates/ │ └── index.html # 主页面 └── trpg.db # 运行时自动生成的 SQLite 数据库其中app.py负责 Web 路由和数据库操作dice_engine.py单独拆出骰子逻辑这样便于测试和复用。2.4 初始化数据库在项目根目录下创建一个 Python 脚本init_db.py来初始化表结构或者直接在应用启动时自动建表。为了保证首次运行不报错我在app.py里加了自动初始化逻辑后面会展示。3. 核心功能设计3.1 掷骰表达式设计TRPG 中最常见的掷骰格式是“xDym”表示投掷 x 个 y 面的骰子然后加上修正值 m。常见表达式的含义如下表达式含义d20投掷 1 个 20 面骰子2d6投掷 2 个 6 面骰子合计点数3d102投掷 3 个 10 面骰子合计后加 21d8-1投掷 1 个 8 面骰子合计后减 1设计时需要注意表达式解析不能使用eval()因为eval()会执行任意 Python 代码存在严重安全风险。正确的做法是用正则表达式拆解骰子数量、骰子面数和修正值然后逐个骰子随机生成结果。3.2 数据表设计系统需要三张表角色表、事件表、掷骰记录表。角色表characters字段类型说明idINTEGER 主键角色 IDnameTEXT角色名称player_nameTEXT玩家名称strengthINTEGER力量agilityINTEGER敏捷intelligenceINTEGER智力luckINTEGER幸运值事件表events字段类型说明idINTEGER 主键事件 IDtitleTEXT事件标题descriptionTEXT事件描述created_atTEXT创建时间掷骰记录表roll_logs字段类型说明idINTEGER 主键记录 IDcharacter_idINTEGER角色 ID关联角色表expressionTEXT掷骰表达式resultINTEGER最终结果detailTEXT每个骰子的明细created_atTEXT创建时间三张表之间通过角色 ID 关联。掷骰日志和事件记录组合起来就是一份结构化的跑团时间线。3.3 API 接口规划前后端交互接口如下方法路径功能GET/返回主页面GET/api/characters获取角色列表POST/api/characters新增角色POST/api/roll执行掷骰并保存记录GET/api/logs获取掷骰日志POST/api/events新增事件记录GET/api/events获取事件列表接口全部返回 JSON方便前端通过fetch调用。4. 完整实战搭建跑团辅助系统4.1 创建 Flask 应用入口在项目根目录下创建app.py代码如下# app.py import sqlite3 from datetime import datetime from flask import Flask, jsonify, render_template, request, g from dice_engine import roll_expression DATABASE trpg.db app Flask(__name__) def get_db(): db getattr(g, _database, None) if db is None: db g._database sqlite3.connect(DATABASE) db.row_factory sqlite3.Row return db app.teardown_appcontext def close_connection(exception): db getattr(g, _database, None) if db is not None: db.close() def init_db(): with app.app_context(): db get_db() with app.open_resource(schema.sql, moder) as f: db.executescript(f.read()) db.commit() app.route(/) def index(): return render_template(index.html) app.route(/api/characters, methods[GET]) def list_characters(): db get_db() rows db.execute(SELECT * FROM characters ORDER BY id).fetchall() return jsonify([dict(row) for row in rows]) app.route(/api/characters, methods[POST]) def create_character(): data request.get_json(forceTrue) name data.get(name, ).strip() player_name data.get(player_name, ).strip() strength int(data.get(strength, 10)) agility int(data.get(agility, 10)) intelligence int(data.get(intelligence, 10)) luck int(data.get(luck, 10)) if not name: return jsonify({error: 角色名不能为空}), 400 db get_db() cur db.execute( INSERT INTO characters (name, player_name, strength, agility, intelligence, luck) VALUES (?, ?, ?, ?, ?, ?), (name, player_name, strength, agility, intelligence, luck) ) db.commit() return jsonify({id: cur.lastrowid, name: name}), 201 app.route(/api/roll, methods[POST]) def roll(): data request.get_json(forceTrue) character_id data.get(character_id) expression data.get(expression, ) if character_id is None: return jsonify({error: 缺少角色 ID}), 400 if not expression: return jsonify({error: 缺少掷骰表达式}), 400 db get_db() char db.execute( SELECT * FROM characters WHERE id ?, (character_id,) ).fetchone() if char is None: return jsonify({error: 角色不存在}), 404 try: result_info roll_expression(expression) except ValueError as e: return jsonify({error: str(e)}), 400 detail .join(map(str, result_info[rolls])) \ (f {result_info[modifier]:d} if result_info[modifier] else ) created_at datetime.now().strftime(%Y-%m-%d %H:%M:%S) db.execute( INSERT INTO roll_logs (character_id, expression, result, detail, created_at) VALUES (?, ?, ?, ?, ?), (character_id, expression, result_info[result], detail, created_at) ) db.commit() return jsonify({ character_id: character_id, character_name: char[name], expression: expression, result: result_info[result], detail: detail, created_at: created_at }), 201 app.route(/api/logs, methods[GET]) def list_logs(): db get_db() rows db.execute( SELECT r.id, r.expression, r.result, r.detail, r.created_at, c.name AS character_name FROM roll_logs r JOIN characters c ON r.character_id c.id ORDER BY r.id DESC LIMIT 50 ).fetchall() return jsonify([dict(row) for row in rows]) app.route(/api/events, methods[GET]) def list_events(): db get_db() rows db.execute(SELECT * FROM events ORDER BY id DESC).fetchall() return jsonify([dict(row) for row in rows]) app.route(/api/events, methods[POST]) def create_event(): data request.get_json(forceTrue) title data.get(title, ).strip() description data.get(description, ).strip() if not title: return jsonify({error: 事件标题不能为空}), 400 db get_db() created_at datetime.now().strftime(%Y-%m-%d %H:%M:%S) cur db.execute( INSERT INTO events (title, description, created_at) VALUES (?, ?, ?), (title, description, created_at) ) db.commit() return jsonify({id: cur.lastrowid, title: title, created_at: created_at}), 201 if __name__ __main__: init_db() app.run(debugTrue)这段代码里有一个值得注意的地方init_db()在启动时调用schema.sql建表。如果trpg.db已经存在且表结构未变重复执行CREATE TABLE IF NOT EXISTS不会报错。4.2 编写数据库建表语句在项目根目录下创建schema.sql-- schema.sql CREATE TABLE IF NOT EXISTS characters ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, player_name TEXT, strength INTEGER DEFAULT 10, agility INTEGER DEFAULT 10, intelligence INTEGER DEFAULT 10, luck INTEGER DEFAULT 10 ); CREATE TABLE IF NOT EXISTS roll_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, character_id INTEGER NOT NULL, expression TEXT NOT NULL, result INTEGER NOT NULL, detail TEXT, created_at TEXT, FOREIGN KEY (character_id) REFERENCES characters(id) ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, created_at TEXT );这里使用FOREIGN KEY约束保证掷骰记录引用的角色必须存在。SQLite 默认没有开启外键级联删除后续如果要删角色需要先清理关联的日志。4.3 实现掷骰引擎在项目根目录下创建dice_engine.py# dice_engine.py import random import re # 支持的骰子面数可以按需扩展 DICE_MAP { d4: 4, d6: 6, d8: 8, d10: 10, d12: 12, d20: 20, d100: 100, } # 匹配格式数字d骰子面数 或 数字d骰子面数/-修正 _PATTERN re.compile(r^(\d*)d(\d)([-]\d)?$) def roll_expression(expression: str) - dict: 解析并执行 TRPG 掷骰表达式。 支持的格式 d20 2d6 3d102 1d8-1 返回 { rolls: [每个骰子的点数], modifier: 修正值, result: 最终结果 } expression expression.strip().lower().replace( , ) match _PATTERN.match(expression) if not match: raise ValueError(f不支持的掷骰表达式: {expression}) dice_count_str, dice_faces_str, modifier_str match.groups() dice_count int(dice_count_str) if dice_count_str else 1 dice_faces int(dice_faces_str) if dice_count 0: raise ValueError(骰子数量必须大于 0) if dice_faces not in DICE_MAP.values() and dice_faces not in DICE_MAP.values(): # 这里允许任意面数比如 d7、d13部分房规会用到 if dice_faces 1: raise ValueError(骰子面数必须大于 1) rolls [random.randint(1, dice_faces) for _ in range(dice_count)] modifier int(modifier_str) if modifier_str else 0 return { rolls: rolls, modifier: modifier, result: sum(rolls) modifier }这里有一个细节表达式里d20没有写数量时默认按 1 个骰子处理。正则表达式(\d*)匹配空字符串时会进入dice_count_str为空的情况。这个默认行为符合 TRPG 的习惯。同样dice_faces not in DICE_MAP.values()的判断看起来有些冗余实际上 DICE_MAP 里的值只是为了方便查看支持的常规骰子。代码里允许自定义面数只是要求面数大于 1这样更灵活。4.4 编写前端页面在templates目录下创建index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title三角机构 TRPG 跑团辅助/title style body { font-family: Microsoft YaHei, PingFang SC, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f7f9fc; } .card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } input, textarea, select, button { font-size: 14px; padding: 8px; margin: 4px 0; border: 1px solid #d8dde3; border-radius: 4px; box-sizing: border-box; } button { background: #4a6cf7; color: #fff; border: none; cursor: pointer; } button:hover { background: #3a5cd9; } .log-item { border-left: 4px solid #4a6cf7; padding: 8px 12px; margin: 8px 0; background: #f0f4ff; } .event-item { border-left: 4px solid #e6a23c; padding: 8px 12px; margin: 8px 0; background: #fdf6ec; } label { display: inline-block; min-width: 60px; } /style /head body h1三角机构 TRPG 跑团辅助/h1 div classcard h2新增角色/h2 div label角色名/labelinput idcharName placeholder举例厨神老王 label玩家名/labelinput idplayerName placeholder举例阿明 /div div label力量/labelinput idstrength typenumber value10 label敏捷/labelinput idagility typenumber value10 /div div label智力/labelinput idintelligence typenumber value10 label幸运/labelinput idluck typenumber value10 /div button onclickcreateCharacter()添加角色/button /div div classgrid div classcard h2掷骰判定/h2 div label角色/label select idcharacterSelect/select /div div label表达式/label input idrollExpression placeholder例如1d202 /div button onclickrollDice()掷骰/button div idrollResult stylemargin-top: 12px;/div /div div classcard h2记录事件/h2 div label事件标题/label input ideventTitle placeholder震撼美味 /div div label描述/label textarea ideventDesc rows3 placeholder主持人抛出的突发情况/textarea /div button onclickcreateEvent()保存事件/button /div /div div classcard h2跑团时间线/h2 div idtimeline/div /div script function api(path, options) { return fetch(path, options).then(res res.json()); } async function loadCharacters() { const list await api(/api/characters); const select document.getElementById(characterSelect); select.innerHTML ; list.forEach(c { const opt document.createElement(option); opt.value c.id; opt.textContent c.name c.player_name ; select.appendChild(opt); }); } async function loadTimeline() { const logs await api(/api/logs); const events await api(/api/events); const container document.getElementById(timeline); container.innerHTML ; const all [ ...logs.map(r ({ type: log, data: r })), ...events.map(e ({ type: event, data: e })) ]; all.sort((a, b) (b.data.created_at || ).localeCompare(a.data.created_at || )); all.forEach(item { const div document.createElement(div); if (item.type log) { div.className log-item; div.innerHTML strong${item.data.character_name}/strong 掷 ${item.data.expression}结果 ${item.data.result}${item.data.detail}; } else { div.className event-item; div.innerHTML strong${item.data.title}/strong${item.data.description || }; } container.appendChild(div); }); } async function createCharacter() { const payload { name: document.getElementById(charName).value.trim(), player_name: document.getElementById(playerName).value.trim(), strength: Number(document.getElementById(strength).value), agility: Number(document.getElementById(agility).value), intelligence: Number(document.getElementById(intelligence).value), luck: Number(document.getElementById(luck).value) }; if (!payload.name) { alert(请填写角色名); return; } await api(/api/characters, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); loadCharacters(); loadTimeline(); } async function rollDice() { const character_id Number(document.getElementById(characterSelect).value); const expression document.getElementById(rollExpression).value.trim(); if (!expression) { alert(请填写掷骰表达式); return; } const result await api(/api/roll, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ character_id, expression }) }); if (result.error) { document.getElementById(rollResult).innerHTML span stylecolor:red${result.error}/span; } else { document.getElementById(rollResult).innerHTML strong${result.character_name}/strong 掷 ${result.expression}结果${result.result}; } loadTimeline(); } async function createEvent() { const payload { title: document.getElementById(eventTitle).value.trim(), description: document.getElementById(eventDesc).value.trim() }; if (!payload.title) { alert(请填写事件标题); return; } await api(/api/events, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); document.getElementById(eventTitle).value ; document.getElementById(eventDesc).value ; loadTimeline(); } loadCharacters(); loadTimeline(); /script /body /html这个页面没有使用前端框架逻辑清晰loadCharacters()在页面加载时拉取角色列表填充下拉框。loadTimeline()同时拉取日志和事件合并后按时间倒序渲染。createCharacter()、rollDice()、createEvent()分别调用对应接口。需要注意的是前端代码里没有做复杂的错误处理真实项目中建议加上try-catch和用户提示。4.5 运行与验证在项目根目录下执行python app.py如果看到类似下面的输出说明服务启动成功* Running on http://127.0.0.1:5000 (Press CTRLC to quit) * Restarting with stat * Debugger is active!用浏览器打开http://127.0.0.1:5000操作流程如下添加一个角色比如“厨神老王”玩家名“阿明”。在掷骰面板选择角色输入1d202点击掷骰。添加一条事件标题写“震撼美味”描述写“主持人抛出一道神秘料理全员进行幸运判定”。观察跑团时间线可以看到事件和掷骰记录按时间倒序排列。预期输出类似[日志] 厨神老王 掷 1d202结果 17[15] 2 [事件] 震撼美味主持人抛出一道神秘料理全员进行幸运判定4.6 演示数据的场景结合为了让“老友记#02”这个场景能直接复盘我建议在空库中先加入三名示例角色角色名玩家名力量敏捷智力幸运厨神老王阿明1410128神秘店员小雅8151016吐槽学者大飞1091711这三个角色分别代表力量型、敏捷型和智力型能够在演示掷骰时展示不同属性风格的差异。例如“神秘店员”幸运较高在处理“震撼美味”这种突发状况时更容易通过幸运判定。5. 常见问题与排查思路5.1 启动时报错找不到 Flask这个问题最常见的原因是虚拟环境没有激活或者当前 Python 环境里没有安装 Flask。排查步骤pip show Flask如果提示未安装重新执行pip install Flask如果你同时安装了 Python 2 和 Python 3注意使用python3和pip3。5.2 掷骰表达式返回 400当输入3d62x或abc这类非法表达式时dice_engine.py会抛出ValueError接口捕获后返回 400 和错误消息。如果你希望前端更友好可以先在rollDice()里用正则做一次简单校验或者在后端增加更详细的错误分类。5.3 前端页面能打开但接口报错接口报错一般可以从浏览器开发者工具里看到。按 F12 打开控制台切换到 Network 面板重新点击按钮查看对应请求的状态码和响应内容。常见问题请求路径写错例如少了/api前缀。请求方法不是 POST服务端返回 405。请求体没有设置Content-Type: application/json。5.4 SQLite 文件被占用Windows 下有时会出现database is locked错误特别是调试模式重启时。解决方法是关闭正在运行的 Python 进程或者避免在多个终端同时启动python app.py。5.5 时间线显示不全list_logs()接口限制了最近 50 条记录。如果跑团记录特别多建议增加分页参数。可以在请求时传入limit和offsetlimit min(int(request.args.get(limit, 50)), 200) offset int(request.args.get(offset, 0))然后把 SQL 改成SELECT ... ORDER BY r.id DESC LIMIT ? OFFSET ?这样日志再多也不会一次性拖垮页面。6. 最佳实践与工程改进6.1 掷骰引擎的边界情况dice_engine.py目前支持常见的xDym表达式但 TRPG 房规中可能有更复杂的公式例如2d61d43。如果要支持多重骰子组合可以扩展解析逻辑# 伪代码示例仅供扩展参考 def roll_complex_expression(expression: str) - int: parts re.findall(r\d*d\d|[-]\d, expression) total 0 for part in parts: if part.startswith((, -)): total int(part) else: total roll_expression(part)[result] return total在实现时一定要注意不要在解析过程中使用eval()或exec()否则用户输入可能变成任意代码执行入口。6.2 数据安全与权限边界这个工具虽然定位是内部系统但如果部署到服务器上需要注意几点不要把debugTrue用于生产环境调试模式会暴露交互式调试器存在代码执行风险。接口没有做身份认证任何人知道地址都能增删数据。如果要多人使用建议加一层简单的 Token 校验或使用 Flask-Login。发布前备份trpg.db。SQLite 是文件型数据库直接复制文件即可完成备份但要在应用停止写入时进行。删除角色的接口目前没有提供。如果后续增加必须同时处理关联的roll_logs否则会留下无主记录。6.3 前端交互优化方向目前页面刷新后角色下拉框会自动加载但掷骰结果没有持久化到前端状态。如果想提升体验可以做以下改进掷骰时显示动画等动画结束后再显示结果营造桌面跑团感。增加“主持人模式”只显示事件详情隐藏掷骰明细保留悬念。将时间线按章节分组方便快速定位到“老友记#02”特定阶段。6.4 从单机到多人协作的演进思路如果未来希望多个玩家同时操作可以考虑数据库从 SQLite 换成 PostgreSQL支持并发写入。使用 Flask-SocketIO 实现实时同步当有人掷骰时其他玩家页面自动更新。将角色卡与用户账号绑定不同玩家只能编辑自己的角色。不过这些都是后话。对当前阶段来说文件型 SQLite 加简单 Web 接口已经足够支撑一个跑团团体的日常记录需求。7. 总结与下一步本文围绕“三角机构TRPG 老友记#02”的跑团记录场景从实际痛点出发实现了一个轻量跑团辅助系统。核心内容包括用 Flask 搭建 Web 服务提供角色、掷骰、事件三类接口。用正则实现安全的 TRPG 骰子表达式解析避免eval()风险。用 SQLite 存储角色、事件和日志并通过外键保证数据关联。前端通过fetch调用接口在时间线中统一展示事件和掷骰记录。你可以继续在dice_engine.py中加入更多房规表达式也可以把前端升级成 Vue 或 React 版本。如果想把历史跑团记录导入可以在事件接口中增加created_at字段的自定义传入这样“老友记#01”到“老友记#03”都能按真实时间顺序归档。下次跑团前建议先往系统里录入角色卡和事件模板跑团过程中只需要在浏览器里点几下就能完整留下一份可检索、可复盘、可分享的跑团记录。