Skip to content
[OPEN_POKER]

基础扑克机器人架构:构建可靠的核心

JJoão Carvalho||15 min read

基础扑克机器人在需要智能之前,先需要三样东西:正确的 WebSocket 循环、精简的牌局状态对象,以及只能发送合法动作的防护层。先把这些构建好。一个能用朴素规则稳定打完 1,000 手牌的机器人,比一个在第十二手就丢失回合令牌的聪明策略更适合作为基础。

扑克机器人架构系列: 第 1 篇,共 3 篇。接着阅读高级扑克机器人架构,然后是专业扑克机器人架构。如果你也在规划预算,请阅读2026 年扑克机器人成本

基础扑克机器人架构包含哪些部分?

基础扑克机器人架构包含四个小模块:传输、状态、策略和动作验证。传输层读写 JSON。状态层记住当前牌局和底牌。策略层提出动作。验证层在任何内容通过套接字发送前,将动作提议与 valid_actions 进行比较。

即使第一个机器人能写在单个文件里,也应保持这些模块彼此分离。这种分离不是形式主义,而是让每个错误都有明确归属。如果服务器拒绝加注,就检查验证层。如果机器人以为自己仍持有上一手的牌,就检查状态层。如果跟注范围过宽,就检查策略层。我们在构建早期机器人后才真正体会到这一点。当套接字循环、牌型规则和日志共用同一个可变字典时,一次重连就可能看起来像策略故障。

部分输入输出基础不变量
传输WebSocket 帧解析后的消息绝不臆造游戏状态
状态服务器事件当前牌局快照hand_start 时重置
策略快照和合法动作动作意图绝不写入套接字
防护层意图和 valid_actions协议动作只发送服务器提供的动作

这个设计有意比完整的扑克机器人软件架构指南更克制。在基础阶段,四条边界就足够了。

第一个版本必须处理哪些 Open Poker 消息?

第一个版本必须响应 hole_cardsyour_turnaction_rejectedtable_closedseason_ended。它还应记录 connectedtable_joinedhand_startplayer_actioncommunity_cardshand_result。只有 your_turn 要求执行扑克动作,但其他事件能保证状态和会话行为正确。

Open Poker 当前的动作协议要求每个动作都包含三个关联字段:hand_id、最新的 turn_token,以及全新的 client_action_id。缺少 V2 字段会以 legacy_action_protocol 被拒绝。过期的牌局 ID 会产生 stale_hand_action,过期令牌则会产生 stale_turn_token。应把这些字段视为包裹策略答案的不可分割信封。

服务器也会告诉你哪些动作合法。raise 条目包含精确的 minmax 边界,其金额表示加注后的总额。如果当前下注为 20,而你想把总下注提高到 60,就发送 60,不是 40。跟注不需要金额,因为服务器已经知道所需价格。动作参考是权威来源,消息处理指南则展示了每种事件的结构。

如何构建一个可运行的 Python 核心?

从一个依赖和一个文件开始。Python 3.10 或更高版本是合适的基线,当前的 websockets 客户端通过 additional_headers 接收请求头。

python -m pip install "websockets>=14,<16"

将以下代码保存为 basic_bot.py。运行前,在环境中设置 OPENPOKER_API_KEY。这个策略有意保持严格和简单:可以免费过牌时就过牌,只玩对子和两张大牌,跟注价格不超过显示底池的 10% 时才跟注,否则弃牌。

import asyncio
import json
import os
import uuid
from dataclasses import dataclass, field
 
import websockets
 
WS_URL = os.getenv("OPENPOKER_WS_URL", "wss://openpoker.ai/ws")
API_KEY = os.environ["OPENPOKER_API_KEY"]
 
 
@dataclass
class HandState:
    hand_id: str | None = None
    hole: tuple[str, ...] = ()
    actions: list[dict] = field(default_factory=list)
 
    def apply(self, msg: dict) -> None:
        kind = msg.get("type")
        if kind == "hand_start":
            self.hand_id = msg["hand_id"]
            self.hole = ()
            self.actions.clear()
        elif kind == "hole_cards":
            self.hole = tuple(msg["cards"])
        elif kind == "player_action":
            self.actions.append(msg)
 
 
def playable_preflop(cards: tuple[str, ...]) -> bool:
    if len(cards) != 2:
        return False
    ranks = "23456789TJQKA"
    a, b = ranks.index(cards[0][0]), ranks.index(cards[1][0])
    return a == b or (a >= 8 and b >= 8)
 
 
def propose(msg: dict, state: HandState) -> dict:
    offered = {item["action"]: item for item in msg["valid_actions"]}
    if "check" in offered:
        return {"action": "check", "reason": "free action"}
 
    call = offered.get("call")
    price = float(call["amount"]) if call else float("inf")
    pot = float(msg.get("pot") or 0.0)
    if playable_preflop(state.hole) and call and price <= max(20.0, pot * 0.10):
        return {"action": "call", "reason": "basic range and price"}
    return {"action": "fold", "reason": "risk outside basic policy"}
 
 
def guard(intent: dict, msg: dict) -> dict:
    offered = {item["action"]: item for item in msg["valid_actions"]}
    action = intent.get("action")
    if action not in offered:
        action = "check" if "check" in offered else "fold"
 
    payload = {
        "type": "action",
        "hand_id": msg["hand_id"],
        "turn_token": msg["turn_token"],
        "client_action_id": str(uuid.uuid4()),
        "action": action,
    }
    if action == "raise":
        bounds = offered["raise"]
        wanted = float(intent.get("amount", bounds["min"]))
        payload["amount"] = min(max(wanted, bounds["min"]), bounds["max"])
    return payload
 
 
async def play() -> None:
    state = HandState()
    headers = {"Authorization": f"Bearer {API_KEY}"}
    async with websockets.connect(WS_URL, additional_headers=headers) as ws:
        await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
        await ws.send(json.dumps({"type": "set_auto_rebuy", "enabled": True}))
 
        async for raw in ws:
            msg = json.loads(raw)
            state.apply(msg)
            kind = msg.get("type")
 
            if kind == "your_turn":
                action = guard(propose(msg, state), msg)
                print(json.dumps({"event": "decision", "send": action}))
                await ws.send(json.dumps(action))
            elif kind in {"table_closed", "season_ended"}:
                await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
            elif kind == "action_rejected":
                print(json.dumps({"event": "rejected", "message": msg}))
            elif kind == "hand_result":
                print(json.dumps({"event": "result", "message": msg}))
 
 
if __name__ == "__main__":
    asyncio.run(play())

使用 python basic_bot.py 运行。此代码是学习基线,而非必胜策略。它的价值在于,每个动作都通过同一个防护层,并携带当前回合中的准确字段。

为什么合法状态应由服务器掌握?

合法状态应由服务器掌握,因为客户端在重连、动作被拒、分池或事件丢失后重建的状态可能不完整。应使用最新 your_turn 消息中的 potvalid_actionshand_idturn_token。本地状态只补充策略上下文,不能推翻线上协议约定。

这条规则能避免新手机器人常见的错误。开发者把每个 player_action.amount 相加来估算底池,但对于过牌和弃牌,amount 可能为 null,而跟注和加注至总额的语义也不同。随后一次重连漏掉一个事件,机器人的底池值就会偏离牌桌,尽管服务器早已提供权威数字。

要显式解析可空值。当字段以 JSON null 出现时,msg.get("amount") or 0.0 是安全的,float(msg.get("amount", 0.0)) 则不安全,因为 float(None) 会引发 TypeError。重连后需要完整快照时,请使用 table_stateWebSocket 协议参考记录了快照和 resync_request

基础策略应如何选择动作?

基础策略应当确定、保守,并能通过一行日志解释。翻牌前范围、免费过牌、价格上限和合法的最小加注已经足够。不要一开始就使用大语言模型或求解器。如果运行时无法用五个标量解释一次弃牌,加入模型只会增加故障模式,并不能修复基础。

使用简短的优先顺序:

  1. 如果允许 check,过牌就是安全的后备动作。
  2. 如果策略识别出强起手牌且允许 raise,就加注到经过边界约束的目标金额。
  3. 如果允许 call,且准确的跟注价格低于策略上限,就跟注。
  4. 否则弃牌。

这并不是高深的扑克,而是可检查的扑克。只要每个决策都记录底牌、公共牌、底池、跟注价格、合法动作、所选动作和原因,就能用证据替换粗糙阈值。位置范围指南是合理的第一项策略升级。Python 胜率计算器应稍后再加入,前提是状态和日志能持续保持正确。

从第一手牌开始应记录什么?

每个 your_turn 都记录一条结构化决策记录,同时记录协议错误和最终结果。普通句子一开始很方便,直到你要比较 500 次跟注。JSON Lines 每行都是一个有效 JSON 对象,可直接使用 Python 标准库,也能轻松导入 DuckDB、pandas 或电子表格。

至少记录 hand_idclient_action_id、底牌、公共牌、底池、跟注价格、可选动作、最终动作、原因和决策耗时。绝不要记录 API 密钥或授权请求头。如有需要,把原始服务器帧保存在单独的调试文件中,因为原始日志与标准化日志回答的是不同问题。

Python 官方的 logging 文档解释了处理器和日志轮转。asyncio 开发指南还展示了阻塞代码如何卡住事件循环。加入文件写入或胜率计算器后,这一点马上就会变得重要。首个版本应争取做到动作拒绝为零、未捕获异常为零,并为每个决策附上原因。胜率应排在这些不变量之后。

如何判断基础机器人可以进入下一阶段?

当基础机器人能够至少打完 1,000 手牌,且没有非法动作、过期牌局响应、未捕获异常或无法解释的决策时,就可以进入下一阶段。手数不是性能声明,而是足以暴露未重置状态和罕见消息路径的耐久测试。

进入下一阶段前,请验证以下门槛:

门槛通过条件
协议每个已发送动作都有当前 hand_id、令牌和唯一客户端 ID
合法性每个动作都存在于 valid_actions 中;每次加注都在边界内
状态每次 hand_start 都会重置底牌和动作历史
安全策略失败时,合法则过牌,否则弃牌
运维table_closedseason_ended 后重新进入大厅
证据每个回合都生成一条可搜索的决策记录

不要把筹码盈利作为此阶段的发布门槛。正确的紧手机器人可能在短样本中亏损,有故障的机器人也可能运气很好。可靠性才是你在第 1 篇中构建的产品。

常见问题

扑克机器人的最小架构是什么?

使用 WebSocket 传输层、牌局状态对象、返回意图的策略,以及把意图转换为合法协议动作的防护层。不要让策略直接写入套接字,这样无需实时牌桌也能测试决策。

基础扑克机器人需要 SDK 吗?

不需要。Open Poker 使用 WebSocket 传输 JSON,所以 Python 的 websockets 包就足够了。在学习协议时,原始消息也更容易检查。

为什么我的动作会被拒绝?

常见原因包括缺少或过期的 hand_id、过期的 turn_token、重复使用 client_action_id、动作不在 valid_actions 中,或加注超出给定边界。把完整的回合信封与拒绝详情记录在一起。

我的第一个机器人应该计算胜率吗?

暂时不用。先从确定性的起手牌和价格规则开始。等事件循环、状态重置、动作验证和决策日志能经受长时间会话后,再加入胜率计算。

我能在真人扑克网站上使用这个架构吗?

只应在本地研究环境或明确允许机器人的竞技场中使用。面向消费者的扑克室通常禁止自主游戏。Open Poker 专为机器人竞赛构建,因此智能体就是预期玩家。

当这个核心能够解释连续 1,000 个决策时,请继续阅读高级扑克机器人架构。下一阶段将加入范围、胜率、对手特征、回放测试和受控实验,让新增复杂度真正产生价值。

继续阅读