← 返回博客2026-07-05

我们会故意发冤家牌吗?从架构到发牌讲清楚

一句“不可能”没有说服力。真正能回答这个问题的,是一手牌从浏览器到后端 session task、从 seed commit 到 flop / turn / river、再到断线与发布重启恢复的完整链路。

先把问题说准

「你们系统会不会故意发冤家牌?」这个问题,最短的回答是:不会。

但在扑克产品里,光说「不会」没有说服力。用户真正想知道的是:系统有没有一条路径,可以在知道玩家是谁、谁输谁赢、谁更容易上头之后,临时挑一张更刺激的牌发出去?如果牌局状态都在内存里,服务发布重启后,会不会又靠某个黑盒逻辑「补」回一手牌?

我做 BluffKing 的一个很朴素的出发点是:我一直认为德州扑克不是单纯比运气,而是一场智力、纪律和勇气的对决。真正有意思的牌局,应该让玩家输赢于范围判断、下注尺度、心理压力和长期决策,而不是输给一个看不见、说不清的发牌系统。所以我们做线上牌局时,目标不是把牌面做得更刺激,而是把系统边界做清楚:客户端不能决定牌,服务端不能在一手牌中途临时换牌,发牌流程要能被代码、日志和复核链路解释。

整篇文章的主线只有一个:代码路径里到底有没有「造冤家牌」需要的能力。我会沿着后端工程师关心的边界来查——哪条请求创建持久化状态,哪条连接推进实时状态,谁有资格修改 GameHand——并在每个边界上问:能不能先看玩家是谁再挑牌堆?能不能发牌后换 seed 或换 deck?能不能根据 flop 之后的下注走势临时决定 turn / river?下面每一节都围绕这些能力是否存在来展开。

源码入口:BluffKing 的开源核心在 github.com/CisaSettle/bluffking,AGPL-3.0。后端发牌核心,也就是纯 poker engine 和 Mental Poker 可验证发牌这层,是开源可核对的;WASM 绑定和 solver wrapper 也在同一个入口。下面讲生产服务端时,我会把「开源核心负责规则与发牌」和「线上房间、WebSocket、session task 如何把这些核心能力接入实时牌桌」分开说明:前者是统一规则与发牌实现,后者负责连接管理、鉴权、房间状态和事件广播。

先把 HTTP 和 WebSocket 分开

从后端架构看,BluffKing 的牌桌不是「浏览器自己算一局牌」,也不是「每个 HTTP 请求都直接改牌局」。它分成两种交互模式:HTTP 负责建房、入座、查询这类请求响应;WebSocket 负责实时命令和事件流。真正能推进手牌状态的是后端按 session_id 绑定的 session task。

这个拆分很关键:HTTP 返回的是「你进入了哪个房间、坐在哪个座位、后续应该连哪个 session_id」;WebSocket 承载的是「开始本手、下注、弃牌、发事件、推快照」。前者写持久化关系,后者连接到内存里的房间 actor。

HTTP:创建 / 加入房间,只返回可恢复的会话指针 Setup / Join UI 选择配置 / 输入房号 lobbyApi POST create / join quick-join Axum handler 鉴权 / 校验 / 分座 Postgres room / user config HTTP response { session_id, code, seat } 浏览器本地保存 tc.session_id -> /table 这里不发牌,也不创建 GameHand

HTTP 这层创建的是可持久化的房间关系:game_sessionssession_users,房间配置存在 game_sessions.session_config(一个 JSONB 列)里。它返回 session_id,但不会发牌,也不会推进一手牌。

具体到实现,前端的 SetupView / JoinViewlobbyApi.createRoomjoinByCodequickJoin。后端 handler 做鉴权、房号校验、座位分配,然后写数据库。返回成功后,前端把 tc.session_id、座位和房号写入本地,再跳到牌桌页。到这里为止,后端只建立了「这个用户属于这个房间」的持久化事实。

WebSocket:实时命令进入 session task,后端广播权威结果 TableView 读 tc.session_id WsTransport /ws?session_id=... ws.rs cookie + membership session task 唯一状态写入者 Postgres 读房间 / 成员 必要时懒启动 task 房主点击开局 startHand(true) ClientCmd kind=start_hand StartHand gate 房主 / 人数 / 座位 GameHand seed / deck / start RoomState / Snapshot / HandStarted / HoleCardsDealt / StreetRevealed

WS 这层才进入实时牌局。ws.rs 做连接鉴权和路由,session task 接收命令并广播结果;数据库只提供房间恢复和完成手牌落库,不保存当前半手牌的完整内存态。

开局时的前后端分工可以按代码路径理解:前端 TableView 读取 tc.session_idWsTransport 打开 /ws?session_id=...。后端 ws.rs 校验 cookie、session_idgame_sessions 是否存在且未结束、以及 session_users 里这个用户是不是成员。如果内存里还没有对应 task,就从数据库里的房间配置和成员关系懒启动一个新的 session task;然后通过 JoinHuman 把这条连接挂进去,并推 RoomState / Snapshot

房主点「开始本手」时,前端发的是 {"kind":"start_hand","force_start":true} 这种命令,不是「给我发这两张牌」。后端的 session.rs 会先做 StartHand gate:是不是多人 session、是不是房主、在线人数够不够、是否允许空位补 bot、座位和筹码状态能不能组成至少两个玩家。通过以后才构建 players、选择发牌 provider、准备 seed / deck,然后调用 GameHand::new_with_rng(...)GameHand::start()

这里最重要的后端不变量是:客户端只能提交意图,不能提交事实。它可以说「我想 fold / call / raise / start hand」,但下注是否合法、轮到谁行动、底池怎么变、下一条街能不能开、下一张公共牌是什么,全部由 session task 持有的 engine 状态决定。浏览器收到的 action_appliedstreet_revealedhand_finishedsnapshot 都是后端广播的结果。

私牌也不是一个全量状态丢给所有人。后端生成快照时会先确定观看者坐在哪个座位,只给那个座位填自己的 hole cards——快照从不携带其他座位的私牌,摊牌后也一样。摊牌亮牌走的是另一条事件:hand_finishedrevealed_hole_cards 只包含真正摊牌获胜的座位;其他人默认埋牌,也可以主动亮牌,由后续的 cards_shown 送达。hole_cards_dealt 事件同样按接收者翻译:自己的消息里有两张牌,别人的消息里没有。

这里还有一个容易误解的点:session_id 不是房间号,也不是客户端根据房间号、用户 ID 或时间戳算出来的值。它是 game_sessions 的主键,由数据库端 gen_random_uuid() 生成——是把持久化房间行绑定到实时 session task 的路由指针,而 6 位 room_code 才是给人输入和分享的找房入口。它也不是「拿到就能旁观所有私牌」的 bearer token:WS upgrade 会先用 HttpOnly 的 session cookie 解析出当前 user_id,再查 session_users(session_id, user_id) 确认成员资格并从这条记录拿座位,查不到就拒绝连接——所以单独知道一个 session_id 什么也看不到,改本地 tc.session_id 也不会让后端把对手私牌发给你。真正危险的是另一类问题:登录 cookie 被盗,或者浏览器上下文里被执行同源脚本,那是账号接管,看到的也只是你的身份本来就会收到的牌。

发牌前,先固定随机源

默认发牌路径是 server-authoritative:服务端持有牌堆并推进游戏。但「服务端可见牌」不等于「服务端按人挑牌」。这一步发生在 start_hand 命令进入 session task 并通过 gate 之后;一手牌真正开始前,系统会先生成这一手的随机材料,并把承诺发给客户端。

  1. 先生成 hand_id这一手牌的身份在洗牌前就确定,后面的 seed 派生会绑定它。
  2. 服务端生成 32 字节 server_seed随机源来自操作系统安全随机数,而不是时间戳或简单伪随机。
  3. 先广播 seed_commit客户端拿到的是 server_seed 的 hash,不是 seed 本身。也就是说,服务端先把「我这手牌准备用的秘密」锁住,但还不能泄露它。
  4. 客户端可提交 per-hand client_seed支持可验证发牌的浏览器在收到 commit 之后,才生成这一手新的 32 字节随机数并发回后端。它不是连接时缓存的旧 seed,而是每手新生成的 seed,目的是避免服务端在知道客户端熵的前提下提前挑 deck。
  5. 派生 deck_seed后端按固定顺序把 server_seed、本手实际收到的 client_seedhand_id 混合成 deck_seed

这里的 hash 不是让用户「反解」的短密码。server_seed 是操作系统安全随机数生成的 32 字节随机值,搜索空间是 2^256;在正常密码学假设下,拿到 seed_commit 不能反推出 server_seed。如果有人真的能在手牌结束前拿到明文 server_seed,那就不是「反 hash 成功」,而是 seed 提前泄露;这会变成严重漏洞,因为攻击者可以在知道 hand_id 和实际混入的 client_seeds 后重算 deck_seed,从而提前推导牌堆和公共牌。也正因为如此,协议要求手牌开始前只广播 commit,明文 server_seed 只在 seed_reveal 阶段、手牌结束后公开,用来复核而不是用来提前预测。

客户端 seed 有一个 1.5 秒的硬时间窗。如果旧客户端、移动端、bot 或网络抖动没有提交,牌局不会卡死,而是退化成 server-commit-only,牌桌不会因为某个客户端掉线而无法开牌。这个退化的边界要说准:只有混入至少一个 commit 之后新生成的 client_seed,才能封死服务端在 commit 前反复试 seed、挑一副有利牌堆的空间。server-commit-only 的手牌——移动端 / 旧客户端从不参与、时间窗超时、或没有参与的真人在线——仍能证明「commit 之后牌堆没被换过」,但不能证明「commit 之前服务端没有挑过一副对某方有利的牌」。这是 ADR-064 明确接受的 T3 残余风险:在 play-money 层,磨 seed 没有筹码收益。

逐手可验证公平发牌:牌堆何时锁定? 1 生成 hand_id UUID 早于任何洗牌 2 server_seed 32 字节,来自 操作系统随机源 3 seed_commit 只广播哈希 server_seed 本身保密 4 client_seed 窗口 硬截止 1.5 秒 可选——超时退化为 server-commit-only 牌堆已锁定——此线之后 没有任何东西能改变一张牌 承诺阶段 只按序取牌 5 deck_seed = hash(server_seed, client_seeds 按座位顺序, hand_id) 6 Fisher-Yates 洗牌 → 固定 52 张牌堆 7 按序取牌 只取固定牌堆 底牌 → 翻牌 → 转牌 → 河牌 8 本手结束 再 seed_reveal 客户端重算 seed_commit + deck_seed 玩家画像 / 历史输赢 / 付费状态 / 下注行为——不是输入

到橙色分界线为止,牌堆已完全确定;之后只有按序取牌和赛后复核——默认路径下服务端仍然看得到所有牌(复核发生在一手牌结束之后),而玩家数据从不进入洗牌输入。

两张手牌、flop、turn、river 怎么发

deck_seed 出来以后,后端用它初始化 poker RNG,再洗一副标准 52 张牌。牌堆是一个顺序结构:洗好以后,每次发牌都只从顶部取下一张。

deck_seed
  -> PokerRng::from_seed_bytes(...)
  -> Deck::new(...)        // Fisher-Yates 洗 52 张牌
  -> GameHand::new_with_rng(...)
  -> GameHand::start()

PreflopGameHand::start() 会先记录盲注,然后按座位顺序发两轮牌。第一轮每个仍在本手的座位拿一张,第二轮再每个座位拿一张。这样得到每个玩家两张 hole cards。engine 内部会产生 HoleCardsDealt 事件,但发到前端前会按 viewer 做脱敏。

Flop:当翻前下注轮结束,engine 进入下一条街,从同一副牌堆顶部连续取三张,写入 board 的 flop,并广播 street_revealed,其中 new_cards 是这三张公共牌。

Turn:flop 下注轮结束后,再从同一副牌堆顶部取一张,写入 turn,广播 street_revealed

River:turn 下注轮结束后,再取一张,写入 river,广播 street_revealed。如果之后还有两个或更多未弃牌玩家,就进入摊牌;如果只剩一个人,直接结算。

注意这里没有「看完玩家反应再重洗」的入口。牌堆在一手牌开始前由 deck_seed 固定,street 只是按游戏规则消耗同一副牌堆的下一张或下三张。下注会影响谁赢、谁弃牌、谁能看到摊牌,但不会重新挑下一张牌。

为什么它能被复核

一手牌结束后,后端会广播 seed_reveal:这时才公开 server_seed、本手实际混入的 client_seedsdeck_seed。浏览器会本地重算两件事:

  1. server_seed 重新 hash 后,是否等于开牌前收到的 seed_commit
  2. server_seed + client_seeds + hand_id 重新派生出的 deck_seed,是否等于后端 reveal 的 deck_seed

如果这两步对不上,说明这手牌的 seed 链路被篡改或前后不一致,客户端会把它标成 mismatch。注意每一步各管什么:承诺校验抓的是被换掉的 server_seed,派生校验抓的是前后不一致的 deck_seed;要抓「换牌堆 / 换实际发出的牌」,靠的是第三步离线复核——用同一个 deck_seed 重建牌堆,确认落库的手牌和公共牌确实按顺序来自那副牌。

deck_seed 的输入同样具体:server_seed、本手实际收集到的 client_seedshand_id。不是玩家画像、历史输赢、付费状态、当前情绪、行动压力,也没有「给这两个人制造碰撞牌」的参数。

所以默认模式证明的是两件很具体的事:第一,系统没有在 turn / river 临时根据玩家反应挑牌的接口;第二,commit 之后换 seed 会在赛后校验里留下 mismatch,换牌堆会在牌面级重建时对不上。上一节说的边界在这里同样成立:server-commit-only 的手牌里,这些校验证明的是「commit 之后牌堆没被换过」,而不是「服务端 commit 之前没挑过牌」(ADR-064 T3)。它也不是在承诺「服务端在牌局进行中完全看不到牌」:默认路径里服务端仍然是权威发牌方,会持有明文 deck、board 和完成手牌数据,用于实时推进、落库、复盘和教练。

代码库还保留了一套想把信任边界继续前推的高级加密发牌实现(engine-blind / Mental Poker),让服务端在这一手不持有你未公开的底牌明文。在 ADR-101(可信单一运营者范围)下,该模式现已开放:房主可在全真人、无 AI 的桌上开启。必备披露:这份保密只在开启该模式的这一手成立;任一玩家掉线,整桌会公开降级为普通(服务器可见)发牌;仅练习币。默认牌桌仍是服务器可见的 commit-reveal 发牌,不受影响。

这套实现使用加密发牌和客户端本地解密,只适合全真人桌,并关闭 AI / bot、普通 Coach 复盘和完整明文历史,同时要求每位玩家在线参与。更强的保密设计也带来 terminal action 或 reveal share 不可用时的 liveness 问题——由上面那套可见降级处理,绝不静默。它不是对抗运营者的无信任:在单协调方信道上,玩家已被接受的最终动作和 reveal 消息是否送达无法被独立证明,所以结算走作废退还筹码,而不是没收诚实玩家——这是一处公开披露的残余(ADR-101 §8)。彻底消除该歧义(独立可认证送达,或结果产生前托管 reveal 材料)是 ADR-090 另开的 v2 出口。

发布重启后恢复什么

这个问题必须拆成三种恢复情形,外加一条关于内存态和数据库为什么不强同步的架构说明——不然很容易把「断线重连」和「服务进程重启」混在一起。

直接结论:服务重启时,不是整个房间都丢;丢的是当前正在进行那一手的内存态。如果房间没有过期、没有被标记结束,玩家重连后仍回到同一个房间,但那一手不会继续到刚才那一秒,后续从等待 / 下一手流程重新开始。

1. WebSocket 断线,session task 还活着

这是最常见的恢复。手机切后台、网络闪断、浏览器重新打开,WebSocket 连接会断,但后端的 session task 还在内存里持有 GameHand。客户端重新连上同一个 session_id 后,ws 层把它重新绑定到原来的 task,后端发一份新的 per-user snapshot。前端不靠本地 replay 猜状态,而是用这份快照重画牌桌。

2. 服务发布 / 进程重启,房间还在数据库里

房间本身不是只在内存里。game_sessions 记录房间,session_users 记录成员,已完成的手牌会写入 hands,包括 action log、hole cards、community cards、结算结果、deck_seedserver_seedclient_seeds 和本手发到哪些座位。发布重启以后,新的进程不会把未过期的多人房间全关掉;当玩家下一次用同一个 session_id 连接时,后端可以从数据库房间与成员关系懒启动一个新的 session task。

3. 正在进行中的那一手,会丢失而不是继续

最关键也最容易被误解的是这一点:当前实现没有把 mid-hand 的完整 engine 状态做事件溯源持久化。GameHand、当前街、当前行动者、内存里的 seat stacks / buy-in totals、还没完成结算的中途状态,都属于活着的 session task。进程如果在一手牌中间被杀掉,丢的是这一手的运行态:当前 board、还没结算的下注轮、行动计时器、未持久化的本手结果,都不会被恢复到刚才那一秒。

系统保留的是房间、成员、已成功落库的完成手牌及其可复核种子——如果崩溃恰好落在「手牌完成」到「异步落库」之间的短窗口里,这一手也会像其他中途状态一样丢失。中途手牌如果没有完成并持久化结果,就不会被系统编造一个 board 或赢家补进去。玩家重连后回到仍然存在的房间,重新进入等待 / 下一手流程。换句话说:当前手牌丢失,下一手可以正常开始;不是整个房间记录都丢失。

对称的诚实边界也要写在这里:没有落库的手牌,对历史和统计来说就是没发生过。所以理论上,恶意服务端可以假装崩溃,把一手它不喜欢的牌「作废」——不 reveal、不落库——而这不会留下任何 mismatch。它有日志,但没有被密码学阻止:ADR-064 把它记为 T10,在 play-money 层接受的残余风险。受监管的层级需要把 reveal 和结算做成原子绑定,那是 ADR-064 刻意放弃的更重设计。

如果未来要做真正的 mid-hand 无损恢复,需要的是另一套设计:每个客户端命令、engine 事件、行动序号、计时器边界都要事件溯源写入数据库,并且重启后能幂等 replay 到同一个 GameHand。这不是现在默认牌桌偷偷在做的事。

4. 内存态和数据库不是每一步强同步

实时牌局的热路径更接近 actor 模型:WS 任务只负责收帧、解析 ClientCmd,再通过 Rust 的 mpsc channel 发给 session task;真正校验动作、推进 GameHand、广播 action_applied / street_revealed / snapshot 的,是这个 session task。也就是说,fold / call / raise / 发下一条街这类实时操作,不是每一步先写数据库再继续。

数据库主要卡在生命周期边界:HTTP 建房、入座、成员关系和房间配置会写 game_sessions / session_users;WS upgrade 会用 cookie 和 session_id 查成员资格;内存里没有 live task 时,会从这些持久化关系懒启动新的 session task;一手牌完成后,hands / hand_actions 再异步持久化。多人手牌的落库单元是一个逻辑事务,成功后通过 SessionCtrl::HandPersisted 回通知 session task,失败则用 PersistFailed 把错误反馈出来。

有人会追问:既然热路径不等落库,为什么不把所有写库都塞进内存队列、再快一点?因为那要补上带幂等、顺序保证、背压和崩溃恢复的 durable outbox——而单纯的内存队列在进程死掉时,会把还没落库的事实一起丢掉。当前选择是:实时手牌由 session task 内存推进,房间 / 成员 / 已完成手牌落库,mid-hand 不假装无损恢复。

这回答了「冤家牌」吗

从工程上看,要「故意发冤家牌」,系统至少要有其中一种能力:把玩家画像、输赢历史、付费状态或当前下注压力作为洗牌输入;在看完底牌 / flop / 行动后重新派生牌堆;让 engine 在发下一张牌时调用某个「策略服务」而不是固定 deck;或者在服务重启 / 恢复时补写一手没有真实发生过的牌面和结果。

对已完成、已 reveal、已落库的手牌,当前默认链路把这些能力切掉了:deck_seed 在发第一张底牌前由随机材料和 hand_id 派生,输入里没有玩家业务字段;GameHand 只从同一副已洗好的牌堆按顺序取牌,下注事件只改变底池、行动权和结算,不改变下一张牌;客户端只能提交动作意图,不能提交牌面事实;完成后公开 seed_reveal,让客户端和离线工具复核 seed、deck 和实际牌面是否一致。

所以,更专业的回答不是「相信我们不会」,而是:当前产品里,后端是权威且可见牌面的发牌方,但它没有一条按玩家或局势动态选牌的执行路径;对任何完成、reveal 并落库的手牌,commit 之后改 seed、换 deck 或补造结果,都会在可复核链路、持久化记录或事件序列里留下不一致。诚实的残余就是前面点名的两个:零客户端熵手牌的 commit 前挑牌(T3),和用假崩溃在落库前作废一手牌(T10)。engine-blind 实现瞄准更强的边界,现已在 ADR-101 的可信运营者范围内开放(这一手底牌保密,但非无信任结算)。