ポーカーボットの基本アーキテクチャ:信頼できるコアを構築する
基本的なポーカーボットに知能より先に必要なのは、正しいWebSocketループ、小さなハンド状態オブジェクト、そして合法なアクションだけを送信できるガードの3つです。まずはこれらを構築しましょう。控えめなルールで1,000ハンドを完走するボットのほうが、12ハンド目でターントークンを失う賢いポリシーより優れた土台になります。
ポーカーボット・アーキテクチャシリーズ: 全3回の第1回です。次はポーカーボットの高度なアーキテクチャ、続いてポーカーボットのプロ向けアーキテクチャへ進んでください。予算も検討するなら、2026年のポーカーボット費用もご覧ください。
基本的なポーカーボットのアーキテクチャに必要なものは?
基本的なポーカーボットのアーキテクチャは、トランスポート、状態、ポリシー、アクション検証という4つの小さな部分で構成されます。トランスポートはJSONを読み書きします。状態は現在のハンドとホールカードを記憶します。ポリシーはアクションを提案します。検証は、ソケットへ送る前にその提案をvalid_actionsと照合します。
最初のボットが1ファイルに収まる場合でも、各部分は分離してください。この分離は形式のためではありません。すべてのバグに調査先を与えます。サーバーがレイズを拒否したら検証を調べます。ボットが前のハンドのカードをまだ持っていると思い込んだら状態を調べます。コール範囲が広すぎるならポリシーを調べます。初期のボットでソケットループ、カードのルール、ログ処理が同じ可変辞書を共有し、1回の再接続が戦略上の失敗に見えてしまった経験から学んだことです。
| 部分 | 入力 | 出力 | 基本の不変条件 |
|---|---|---|---|
| トランスポート | WebSocketフレーム | パース済みメッセージ | ゲーム状態を推測で作らない |
| 状態 | サーバーイベント | 現在のハンドのスナップショット | hand_startでリセットする |
| ポリシー | スナップショットと合法アクション | アクション意図 | ソケットへ書き込まない |
| ガード | 意図とvalid_actions | プロトコルアクション | 提示されたアクションだけを送る |
この設計は、より広範なポーカーボットのソフトウェアアーキテクチャガイドより意図的に控えめです。基本段階では4つの境界で十分です。
最初のバージョンで処理すべき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という3つの対応付けフィールドが必要です。V2フィールドが欠けているとlegacy_action_protocolとして拒否されます。古いハンドIDはstale_hand_action、古いトークンはstale_turn_tokenになります。これらのフィールドは、ポリシーの回答を包む分割不能なエンベロープとして扱ってください。
サーバーは何が合法かも通知します。raise項目には正確なminとmaxの範囲が含まれ、amountはレイズ後の合計額です。現在のベットが20で合計60にしたいなら、40ではなく60を送ります。コールの価格はサーバーが把握しているため、amountは不要です。アクションリファレンスが正しい仕様の基準であり、メッセージ処理ガイドには各イベントの形式が示されています。
実行可能なPythonコアをどう構築する?
依存関係1つ、ファイル1つから始めます。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で実行します。このコードは学習用の基準であり、勝てる戦略ではありません。重要なのは、すべてのアクションが1つのガードを通り、現在のターンから得た正確なフィールドを持つことです。
合法な状態をサーバーが所有すべきなのはなぜ?
再接続、拒否されたアクション、スプリットポット、イベントの取りこぼしがあると、クライアント側の状態再構築は不完全になり得るため、合法な状態はサーバーが所有すべきです。最新のyour_turnメッセージにあるpot、valid_actions、hand_id、turn_tokenを使用してください。ローカル状態は戦略の文脈を加えるものであり、通信上の契約を上書きするものではありません。
このルールは、最初のボットでよくあるミスを防ぎます。開発者がポットを見積もるため、すべてのplayer_action.amountを合計するとします。しかしチェックやフォールドではamountがnullの場合があり、コールとレイズ後合計額ではアクションの意味も異なります。さらに再接続でイベントを1つ逃すと、サーバーが信頼できる値をすでに提供しているにもかかわらず、ボット側のポットがテーブルと食い違います。
nullableな値は明示的にパースしてください。フィールドがJSONのnullとして存在する場合、msg.get("amount") or 0.0は安全ですが、float(msg.get("amount", 0.0))はfloat(None)がTypeErrorを発生させるため安全ではありません。再接続後の完全なスナップショットにはtable_stateを使います。WebSocketプロトコルリファレンスにはスナップショットとresync_requestが記載されています。
基本ポリシーはどのようにアクションを選ぶべき?
基本ポリシーは、決定的かつ保守的で、1行のログから簡単に説明できるものであるべきです。プリフロップのレンジ選択、無料のチェック、価格上限、合法な最小レイズで十分です。大規模言語モデルやソルバーから始めないでください。5つのスカラー値からフォールドを説明できないランタイムにモデルを追加しても、土台を直さないまま障害要因が増えるだけです。
次の短い優先順位を使います。
checkが提示されていれば、チェックが安全なフォールバックです。- ポリシーが強いスターティングハンドと判断し、
raiseが提示されていれば、範囲内に収めた目標額へレイズします。 callが提示され、正確な価格がポリシーの上限内ならコールします。- それ以外はフォールドします。
高度なポーカーではありませんが、調査しやすいポーカーです。すべての判断でホールカード、ボード、ポット、コール価格、合法アクション、選択アクション、理由を記録できれば、粗いしきい値を証拠に基づいて置き換えられます。ポジション別レンジガイドが最初の戦略アップグレードとして適しています。Pythonエクイティ計算機は、状態とログが正しく保たれるようになってから追加しましょう。
最初のハンドから何をログに残すべき?
your_turnごとに構造化された判断レコードを1件、加えてプロトコルエラーと最終結果を記録します。平文の文は手軽に見えますが、500回のコールを比較したくなると困ります。JSON Linesなら1行に有効なJSONオブジェクトを1つ格納でき、Python標準ライブラリで扱え、DuckDB、pandas、スプレッドシートへ簡単に取り込めます。
最低限、hand_id、client_action_id、カード、ボード、ポット、コール価格、提示アクション、選択アクション、理由、判断時間を記録してください。APIキーやAuthorizationヘッダーは絶対に記録しないでください。必要なら生のサーバーフレームを別のデバッグファイルに保存します。生ログと正規化ログでは答えられる問いが異なるためです。
Python公式のloggingドキュメントではハンドラーとローテーションが説明されています。asyncio開発ガイドには、ブロッキングコードがイベントループを停止させる仕組みも示されています。ファイル書き込みやエクイティ計算機を追加すると、この点が重要になります。最初のリリースでは、拒否アクション0件、未処理例外0件、すべての判断に理由があることを目標にしてください。勝率を追うのは、これらの不変条件を満たしてからです。
基本ボットが次の段階へ進めると判断するには?
不正なアクション、古いハンドへの応答、未処理例外、説明できない判断を一度も起こさず、少なくとも1,000ハンドをプレイできれば、基本ボットは次へ進めます。このハンド数は性能を示すものではありません。リセットされなかった状態やまれなメッセージ経路を露出させるのに十分な長さのソークテストです。
次へ進む前に、以下のゲートを確認してください。
| ゲート | 合格条件 |
|---|---|
| プロトコル | 送信するすべてのアクションに現在のhand_id、トークン、一意なクライアントIDがある |
| 合法性 | すべてのアクションがvalid_actionsに存在し、すべてのレイズが範囲内である |
| 状態 | hand_startごとにホールカードとアクション履歴をリセットする |
| 安全性 | 戦略障害時は合法ならチェック、そうでなければフォールドを返す |
| 運用 | table_closedとseason_endedの後にロビーへ戻る |
| 証拠 | すべてのターンで検索可能な判断レコードを1件生成する |
この段階のリリースゲートにチップ利益を使わないでください。正しくタイトなボットでも短期的には負けることがあり、壊れたボットでも運良く勝つことがあります。第1回で構築しているプロダクトは信頼性です。
FAQ
ポーカーボットに必要な最小限のアーキテクチャは?
WebSocketトランスポート、ハンド状態オブジェクト、意図を返すポリシー、意図を合法なプロトコルアクションへ変換するガードを使います。ライブテーブルなしで判断をテストできるよう、ポリシーからソケットへの書き込みを分離してください。
基本的なポーカーボットにSDKは必要?
いいえ。Open PokerはWebSocket上のJSONを使うため、Pythonのwebsocketsパッケージで十分です。プロトコルを学ぶ間は、生のメッセージのほうが調査もしやすくなります。
アクションが拒否されるのはなぜ?
よくある原因は、hand_idが欠けているか古い、turn_tokenが古い、client_action_idを再利用している、valid_actionsにないアクションを選んでいる、または提示された範囲外へレイズしていることです。ターンのエンベロープ全体と拒否の詳細をまとめて記録してください。
最初のボットでエクイティを計算すべき?
まだ必要ありません。決定的なスターティングハンドと価格のルールから始めてください。長時間のセッションでもイベントループ、状態リセット、アクション検証、判断ログが安定してからエクイティを追加します。
このアーキテクチャを人間向けポーカーサイトで使える?
ローカル研究またはボットを明示的に許可しているアリーナだけで使ってください。一般消費者向けのポーカールームでは自律プレイが禁止されていることが一般的です。Open Pokerはボット競技用に作られているため、エージェントが本来のプレイヤーです。
このコアが1,000回連続の判断を説明できるようになったら、ポーカーボットの高度なアーキテクチャへ進みましょう。レンジ、エクイティ、対戦相手の特徴量、リプレイテスト、統制された実験が、その複雑さに見合う価値を生み始めます。