在 Cloudflare Workers 上实现联机德州扑克:架构与踩坑记录
在 Cloudflare Workers 上实现联机德州扑克:架构与踩坑记录
上一篇写了「在 Cloudflare Workers 上部署 PeerJS 信令服务器」,解决了 WebRTC 建立连接的难题。但真正把联机德州扑克跑起来,比想象中曲折得多——尤其是 Cloudflare 免费计划下 Durable Objects 的休眠机制,差点让我放弃。
这篇文章记录联机版的整体架构,以及我实际踩过的几个坑和最终解决方案。
需求:博客里能双人联机打德州扑克
目标很简单:两个朋友打开同一个网页,输入同一个房间号,就能面对面打德州扑克。
- 发牌、下注、翻牌、结算都在服务端完成(服务端权威,防止作弊,也保证双方状态一致)
- 前端只负责渲染:玩家看到的牌局快照(
snap)由服务端推送 - 座位 0 / 座位 1,两人对战
整体架构
1 | |
- PokerRoom(Durable Object):整个游戏房间,持有两个玩家的 WebSocket 连接 + 全部游戏状态
- PeerServer(Durable Object):保留的 PeerJS 信令逻辑(本文不展开,见上一篇)
- 前端通过
wss://<worker>/poker?room=<房间号>连接,房间号相同就进同一房间
为什么要拆成两个 DO——不是图省事,而是 Durable Object 的两个特性决定的:
| PokerRoom | PeerServer | |
|---|---|---|
| 职责 | 游戏状态 + 玩家 WebSocket | WebRTC 信令转发(SDP/ICE) |
| 实例数量 | 每房间一个(idFromName('poker:'+room)) |
全局一个(idFromName('global')) |
| 状态 | game 整局状态 |
peers Map(peerId → ws) |
- DO 实例是串行单点的:一个实例同时只能处理一件事。游戏逻辑(计算、广播)和信令转发(高频小消息)混在一个 DO 里会互相排队阻塞——某人在摊牌计算时,另一个人的 ICE 转发要干等。
- 扩展维度不同:房间天然平行——100 个房间 = 100 个实例互不干扰;信令全局单点就够。
- 复用既有资产:PeerServer 是上一篇已部署跑通的,直接引用旧命名空间保留,不动它的迁移记录。
核心代码结构(worker.js):
1 | |
关键设计点:
- 服务端权威:所有游戏逻辑(洗牌、发牌、规则判定、边池结算)都放在 Worker 里,前端只发指令、收快照。
- 快照隔离:
buildSnap(seat)给每个座位单独构建快照——只有自己能看到自己的手牌,对手的手牌是隐藏的,直到摊牌才 reveal。 - 心跳保活:客户端定时发
{ t: 'ping' },服务端回{ t: 'pong' },防止空闲超时断连。 - 断线兜底:玩家断线后,如果轮到该玩家行动,60 秒内不重连就自动弃牌(
scheduleTimeout),对局不会卡死。
部署前必配:DO 绑定与 migrations
代码写对了,但如果不在部署配置里声明 Durable Object,env.POKER_ROOM 会是 undefined,idFromName() 直接抛错——页面连房间都建不了。这一步是硬前提,配置全貌如下:
1 | |
三个关键点:
- bindings 声明类名:
class_name必须与worker.js里export class PokerRoom完全一致,否则部署报错。 - 新类必须配 migrations:Cloudflare 靠
[[migrations]]才知道要新建一个 DO 类(SQLite 存储的 DO 用new_sqlite_classes)。已经跑过的类(PeerServer)则不能出现在 migrations 里,否则会尝试重建。 - API 上传时 migrations 是对象不是数组(容易踩):用 Dashboard API 直接上传时,
metadata里 migrations 要写成{ tag, new_sqlite_classes }单对象,而不是[{ ... }]数组——旧文档的数组写法在新 API 下会校验失败。这也是我在踩坑三里遇到编码问题之外,另一个和”上传”相关的坑。
房间路由(入口处):房间号通过 idFromName 哈希到固定的 DO 实例,相同房间号永远命中同一个 PokerRoom:
1 | |
完整部署链路:本地构建 → API 上传
文章开头的 worker.js 里其实有两个 DO 类(PokerRoom + PeerServer),它们分别来自两个独立开发过的源码。为避免手工复制粘贴出错,部署前用一个脚本把两者合并并生成上传物:
1 | |
关键步骤的代码级含义:
① 合并:rebuild-merged.cjs 在 poker-worker.js 的”入口”标记处插入 PeerServer 类,拼出最终单文件——保证 export default 入口只有一个,且两个类都在:
1 | |
② 转 base64:合并后的源码转成 base64 字符串,因为等会儿要把它嵌进一个”上传函数”的字符串里,再贴到浏览器控制台执行——base64 可以安全地内嵌(无引号冲突、无转义问题)。
③ 上传(关键的 form-data 结构):通过 Cloudflare API 上传时,PUT 请求的 FormData 必须同时包含两个字段,缺一个都部署失败:
1 | |
为什么用浏览器控制台而非 wrangler CLI:本地没有配置 wrangler 的 API token,而 Cloudflare 的 API 上传端点可以在浏览器里直接调(配合账号的 API 读取权限),二选一即可,流程等价。
踩坑一:免费计划 DO 休眠,WebSocket 被 1012 强制断开
现象:A 创建房间后等待对手加入,如果超过约 30 秒没人操作,A 的页面就掉线了;重新进房间也连不上。
根因:Cloudflare 免费计划下,Durable Object 空闲约 30 秒后会休眠。休眠时,如果代码用的是普通 addEventListener 模式(而非官方推荐的 WebSocket Hibernation API),已建立的 WebSocket 会被以 1012 (Service Restart) 强制关闭。
1 | |
验证方式:本地起服务分步复现(每步间隔 10~30 秒),发现两边 socket 都是被休眠干掉的——不是代码逻辑问题,是平台行为。
踩坑二:休眠导致”座位死引用”,B 永远等不到 A
现象:A 在线 3.5 秒后连接被休眠断开。B 加入同一房间时,显示”等待对方加入”……永远等不到。
根因:这是最阴险的坑——DO 休眠时,内存里的 conns[0] 还残留着 A 的 WebSocket 对象引用(一个死引用)。而 close 事件在休眠期间根本不会触发,所以:
- A 的连接实际已经断了,但 DO 不知道
- B 加入 → DO 检查座位 → 发现
conns[0]还”占着” → 把 B 塞到座位 1 → 广播”等待对方加入” - 但 A 已经消失了,永远不会有第二个人来 → 死锁
关键代码(有缺陷的版本逻辑上是这样):
1 | |
教训:在 DO 里,”连接关闭”这个事件在休眠期间是不可靠的,不能依赖 close 事件来释放座位。
踩坑三:部署时 atob 的编码陷阱
现象:把 Worker 代码通过 Dashboard API 上传后,中文字符全部乱码。
根因:构建脚本把源码转 base64,再用 atob() 解码——但 atob() 返回的是 Latin-1 字符串,直接塞进 Blob 会被浏览器按 UTF-8 双重编码,中文就废了。
修复:解码后转成 Uint8Array 原始字节再交给 Blob:
1 | |
牌型比较算法:从 7 张牌里选出最好的 5 张
摊牌时要判定谁赢,核心问题是:玩家 2 张底牌 + 5 张公共牌 = 7 张牌,选出其中最强的 5 张组合,再按德州扑克牌型大小排序。
5 张牌评估 eval5
先把 5 张牌按点数、花色分类,映射到 9 档牌型(数值越大越强):
1 | |
两个容易被忽视的细节:
① 轮子顺(A-2-3-4-5):A 默认是 14,但 A 2 3 4 5 是最小的顺子。straightHigh 先查普通连续(u[j]-u[j+4]===4),再单独兜底 A-5 特例,返回 5:
1 | |
② groupsOf 的排序决定了踢脚比较:按「张数降序、点数降序」排,这样平局时逐位比较 tb 数组就是先比主要牌、再比踢脚。例如一对:[对子点数, 最高踢脚, 次高踢脚, 最低踢脚],两对:[大对, 小对, 踢脚],葫芦:[三条点数, 对子点数]。
两手牌比较 compareHands
先比牌型类别,类别相同逐位比 tb(缺位按 0),天然支持”平局即分池”:
1 | |
7 选 5 bestOf7:暴力枚举
德州扑克的经典技巧是——不需要聪明算法,直接枚举。7 张牌删掉任意 2 张 = C(7,2) = 21 种 5 张组合,逐个 eval5 取最大:
1 | |
每次摊牌最多 21 次 eval5,每次都是常数级操作——DO 单线程下摊牌耗时远小于 1ms,完全不需要引入 2+2 / 7-Card Hand Evaluator 这类查表算法。对 2 人小房间来说,简单暴力就是最优解。
下注轮状态机:preflop → flop → turn → river
牌局不是一串 if-else,而是一个状态机。每手牌经历 4 个街道,每个街道内玩家轮流行动,行动完一轮就推进到下一街道。
行动处理 processAction
玩家四种行动(fold / check / call / raise / allin),统一改玩家的 chips / bet / committed / folded / allIn / needsAction 六个字段:
1 | |
关键点 reopenFor:有人加注,其他已”跟平”的玩家必须重新获得行动权(否则可以免费等看牌),这就是”重新开放行动”:
1 | |
街道推进 advance
每处理完一个行动就调用 advance,它是个 while(true) 循环,一口气把牌局推进到”需要玩家行动”或”结算”为止:
1 | |
advance 的巧妙之处:它把「弃牌获胜」「全下提前发牌」「街道推进」「轮转到下一个行动者」全部收敛到一个循环里,任何行动处理完调一次 advance,状态就绝对正确——这也是引擎能被 100 手随机对局测试验证的关键。
边池结算:全下时怎么分钱
2 人局也有全下(all-in)后筹码不对等的情况,底池必须按投入分层(main pot + side pot)。splitPots 从最低投入层开始逐层切分:
1 | |
结算时逐池比较 eligible 里的手牌,赢家平分该池;分不平的余数按座位号从小到大的顺序每人多拿 1(share + (x < rest ? 1 : 0)),保证筹码总量严格守恒——测试里随机打 100 手,前后筹码总和必须完全一致。
1 | |
安全性:为什么这套方案作弊成本极高
联机扑克最大的风险是玩家改客户端看对手手牌。这套架构从三个层面堵死:
快照隔离(最关键):
buildSnap(forSeat)按座位分别构建视图——对手的手牌永远只给hasCards: true,点数花色一个都不下发;只有摊牌结束(!handInProgress)时才同时 reveal 双方底牌:1
2// 座位 0 收到的快照:myHole 只有自己的,对手只有 hasCards
{ myHole: [自己两张牌], seats: [{ seat:1, hasCards: true, ... }] }就算玩家改浏览器拿到
snap,里面根本没有对手的牌——信息压根不出服务器。服务端权威 + 行动校验:客户端只能发
{ t: 'act', act, to }指令,服务端先校验:座位号是否合法(seatOf按 WebSocket 对象引用反查座位)、是否轮到自己(actorIndex === mySeat)、加注额是否达标(低于currentBet + minRaise自动降级为跟注/过牌)。客户端发任何越权指令都会被忽略。房间边界 + 座位上限:
idFromName('poker:' + room)让房间互相隔离;每个 PokerRoom 只有 2 个座位,第三人 join 直接回房间已满。房间号相同的人才能同局——没有全局匹配,也就不存在”陌生人乱入”。断线兜底:玩家断线后若轮到其行动,
scheduleTimeout在 60 秒后自动弃牌,对局不会因一人消失而永久卡死。
测试脚本(test-poker-engine.cjs)专门验证了安全相关断言:200 手随机对局中,座位 0 收到的快照里从未出现座位 1 的手牌字符串(泄漏计数 = 0)。
解决方案:传统 WebSocket 模式 + 心跳保活
面对休眠问题,有两个方向:
| 方案 | 思路 | 代价 |
|---|---|---|
| ① WebSocket Hibernation API | 官方推荐,DO 休眠时连接不断,消息到达自动唤醒 | 需要 ctx.acceptWebSocket() + webSocketMessage/webSocketClose 回调,代码结构调整大 |
| ② 传统模式 + 心跳 | 普通 addEventListener,用 ping/pong 让连接保持活跃 |
简单,但对空闲连接仍需处理休眠 |
我最终选了折中方案:保留传统 server.accept() + addEventListener 模式,但利用 DO 的一个特性——只要有连接打开,DO 就保持活跃不休眠,状态常驻内存,无需 storage 恢复。
配合客户端心跳(每 15~20 秒 ping 一次),实现了:
- 对局中双方随时在线 → DO 活跃,不会休眠
- 等待对手期间 → 心跳维持连接,A 不掉线
- B 加入 → 消息到达,DO 正常处理,无需唤醒恢复
实测:A 在线等待、B 加入、双方正常开局、发牌/下注/翻牌交互全部正常。
最终方案的核心代码
1 | |
前端心跳(示意):
1 | |
经验总结
- 先搞清楚平台行为再写代码。如果一开始就查清楚”免费计划 DO 30 秒休眠 + 非 Hibernation 模式会断连接”,能省下大量排查时间。
- 不要依赖休眠期间的事件。
close事件在 DO 休眠时可能不触发,释放资源(座位/连接引用)不能只靠它。 - 服务端权威是联机游戏的正解。所有逻辑放服务端,前端只渲染快照,既防作弊又保证同步,调试时问题也更好定位。
- 编码问题永远值得警惕。base64 → atob → Blob 这条链路上的字节级错误,排查起来非常隐蔽。
- DO 类不是写了就生效,还要声明。
bindings(绑定类名)+migrations(创建新类)缺一不可,API 上传时 migrations 是对象而非数组——部署配置和代码同样重要。 - 暴力枚举有时就是最优解。7 选 5 只需要
C(7,2)=21次eval5,DO 单线程下远小于 1ms,不值得引入复杂的查表求值器。 - 状态机收敛比散落的 if-else 可靠。所有行动都走
processAction再统一advance推进,配合随机对局测试(筹码守恒、无卡死、无泄漏)能有效兜住边界情况。