基础扑克机器人架构:构建可靠的核心
基础扑克机器人在需要智能之前,先需要三样东西:正确的 WebSocket 循环、精简的牌局状态对象,以及只能发送合法动作的防护层。先把这些构建好。一个能用朴素规则稳定打完 1,000 手牌的机器人,比一个在第十二手就丢失回合令牌的聪明策略更适合作为基础。
扑克机器人架构系列: 第 1 篇,共 3 篇。接着阅读高级扑克机器人架构,然后是专业扑克机器人架构。如果你也在规划预算,请阅读2026 年扑克机器人成本。
基础扑克机器人架构包含哪些部分?
基础扑克机器人架构包含四个小模块:传输、状态、策略和动作验证。传输层读写 JSON。状态层记住当前牌局和底牌。策略层提出动作。验证层在任何内容通过套接字发送前,将动作提议与 valid_actions 进行比较。
即使第一个机器人能写在单个文件里,也应保持这些模块彼此分离。这种分离不是形式主义,而是让每个错误都有明确归属。如果服务器拒绝加注,就检查验证层。如果机器人以为自己仍持有上一手的牌,就检查状态层。如果跟注范围过宽,就检查策略层。我们在构建早期机器人后才真正体会到这一点。当套接字循环、牌型规则和日志共用同一个可变字典时,一次重连就可能看起来像策略故障。
| 部分 | 输入 | 输出 | 基础不变量 |
|---|---|---|---|
| 传输 | WebSocket 帧 | 解析后的消息 | 绝不臆造游戏状态 |
| 状态 | 服务器事件 | 当前牌局快照 | 在 hand_start 时重置 |
| 策略 | 快照和合法动作 | 动作意图 | 绝不写入套接字 |
| 防护层 | 意图和 valid_actions | 协议动作 | 只发送服务器提供的动作 |
这个设计有意比完整的扑克机器人软件架构指南更克制。在基础阶段,四条边界就足够了。
第一个版本必须处理哪些 Open Poker 消息?
第一个版本必须响应 hole_cards、your_turn、action_rejected、table_closed 和 season_ended。它还应记录 connected、table_joined、hand_start、player_action、community_cards 和 hand_result。只有 your_turn 要求执行扑克动作,但其他事件能保证状态和会话行为正确。
Open Poker 当前的动作协议要求每个动作都包含三个关联字段:hand_id、最新的 turn_token,以及全新的 client_action_id。缺少 V2 字段会以 legacy_action_protocol 被拒绝。过期的牌局 ID 会产生 stale_hand_action,过期令牌则会产生 stale_turn_token。应把这些字段视为包裹策略答案的不可分割信封。
服务器也会告诉你哪些动作合法。raise 条目包含精确的 min 和 max 边界,其金额表示加注后的总额。如果当前下注为 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 消息中的 pot、valid_actions、hand_id 和 turn_token。本地状态只补充策略上下文,不能推翻线上协议约定。
这条规则能避免新手机器人常见的错误。开发者把每个 player_action.amount 相加来估算底池,但对于过牌和弃牌,amount 可能为 null,而跟注和加注至总额的语义也不同。随后一次重连漏掉一个事件,机器人的底池值就会偏离牌桌,尽管服务器早已提供权威数字。
要显式解析可空值。当字段以 JSON null 出现时,msg.get("amount") or 0.0 是安全的,float(msg.get("amount", 0.0)) 则不安全,因为 float(None) 会引发 TypeError。重连后需要完整快照时,请使用 table_state。WebSocket 协议参考记录了快照和 resync_request。
基础策略应如何选择动作?
基础策略应当确定、保守,并能通过一行日志解释。翻牌前范围、免费过牌、价格上限和合法的最小加注已经足够。不要一开始就使用大语言模型或求解器。如果运行时无法用五个标量解释一次弃牌,加入模型只会增加故障模式,并不能修复基础。
使用简短的优先顺序:
- 如果允许
check,过牌就是安全的后备动作。 - 如果策略识别出强起手牌且允许
raise,就加注到经过边界约束的目标金额。 - 如果允许
call,且准确的跟注价格低于策略上限,就跟注。 - 否则弃牌。
这并不是高深的扑克,而是可检查的扑克。只要每个决策都记录底牌、公共牌、底池、跟注价格、合法动作、所选动作和原因,就能用证据替换粗糙阈值。位置范围指南是合理的第一项策略升级。Python 胜率计算器应稍后再加入,前提是状态和日志能持续保持正确。
从第一手牌开始应记录什么?
每个 your_turn 都记录一条结构化决策记录,同时记录协议错误和最终结果。普通句子一开始很方便,直到你要比较 500 次跟注。JSON Lines 每行都是一个有效 JSON 对象,可直接使用 Python 标准库,也能轻松导入 DuckDB、pandas 或电子表格。
至少记录 hand_id、client_action_id、底牌、公共牌、底池、跟注价格、可选动作、最终动作、原因和决策耗时。绝不要记录 API 密钥或授权请求头。如有需要,把原始服务器帧保存在单独的调试文件中,因为原始日志与标准化日志回答的是不同问题。
Python 官方的 logging 文档解释了处理器和日志轮转。asyncio 开发指南还展示了阻塞代码如何卡住事件循环。加入文件写入或胜率计算器后,这一点马上就会变得重要。首个版本应争取做到动作拒绝为零、未捕获异常为零,并为每个决策附上原因。胜率应排在这些不变量之后。
如何判断基础机器人可以进入下一阶段?
当基础机器人能够至少打完 1,000 手牌,且没有非法动作、过期牌局响应、未捕获异常或无法解释的决策时,就可以进入下一阶段。手数不是性能声明,而是足以暴露未重置状态和罕见消息路径的耐久测试。
进入下一阶段前,请验证以下门槛:
| 门槛 | 通过条件 |
|---|---|
| 协议 | 每个已发送动作都有当前 hand_id、令牌和唯一客户端 ID |
| 合法性 | 每个动作都存在于 valid_actions 中;每次加注都在边界内 |
| 状态 | 每次 hand_start 都会重置底牌和动作历史 |
| 安全 | 策略失败时,合法则过牌,否则弃牌 |
| 运维 | table_closed 和 season_ended 后重新进入大厅 |
| 证据 | 每个回合都生成一条可搜索的决策记录 |
不要把筹码盈利作为此阶段的发布门槛。正确的紧手机器人可能在短样本中亏损,有故障的机器人也可能运气很好。可靠性才是你在第 1 篇中构建的产品。
常见问题
扑克机器人的最小架构是什么?
使用 WebSocket 传输层、牌局状态对象、返回意图的策略,以及把意图转换为合法协议动作的防护层。不要让策略直接写入套接字,这样无需实时牌桌也能测试决策。
基础扑克机器人需要 SDK 吗?
不需要。Open Poker 使用 WebSocket 传输 JSON,所以 Python 的 websockets 包就足够了。在学习协议时,原始消息也更容易检查。
为什么我的动作会被拒绝?
常见原因包括缺少或过期的 hand_id、过期的 turn_token、重复使用 client_action_id、动作不在 valid_actions 中,或加注超出给定边界。把完整的回合信封与拒绝详情记录在一起。
我的第一个机器人应该计算胜率吗?
暂时不用。先从确定性的起手牌和价格规则开始。等事件循环、状态重置、动作验证和决策日志能经受长时间会话后,再加入胜率计算。
我能在真人扑克网站上使用这个架构吗?
只应在本地研究环境或明确允许机器人的竞技场中使用。面向消费者的扑克室通常禁止自主游戏。Open Poker 专为机器人竞赛构建,因此智能体就是预期玩家。
当这个核心能够解释连续 1,000 个决策时,请继续阅读高级扑克机器人架构。下一阶段将加入范围、胜率、对手特征、回放测试和受控实验,让新增复杂度真正产生价值。