Compare commits
3 Commits
3c027eae0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ee963ce2e | |||
| 351cac7734 | |||
| 79dccde963 |
@@ -32,8 +32,12 @@ htmlcov/
|
|||||||
# IDE and editor files
|
# IDE and editor files
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.codex/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# debug resources
|
# debug resources
|
||||||
debug/
|
debug/
|
||||||
|
|
||||||
|
# Node dependencies for browser automation tooling
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
Core poker service code lives in `texas_holdem/`. Important modules include `engine.py` for Texas Hold'em rules, `service.py` for game management, `server.py` for the HTTP API, `agents.py` for local/HTTP agents, and `ai_client.py` / `human_client.py` for standalone agents. Prompt templates live in `texas_holdem/prompts/`.
|
||||||
|
|
||||||
|
Replay UI code lives in `texas_holdem_replay/`, with static browser assets under `texas_holdem_replay/static/`. Tests are in `tests/`, named by feature area such as `test_engine.py`, `test_service.py`, and `test_replay_server.py`. Design notes belong in `docs/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
- `python -m unittest discover -v` runs the full test suite.
|
||||||
|
- `python -m compileall texas_holdem texas_holdem_replay tests` checks import and syntax validity.
|
||||||
|
- `python -m texas_holdem.server --host 127.0.0.1 --port 8000` starts the game service.
|
||||||
|
- `python -m texas_holdem.human_client --port 9001 --keep-history` starts an interactive human HTTP agent.
|
||||||
|
- `python -m texas_holdem.ai_client --port 9101 --api-key "$OPENAI_API_KEY" --model gpt-4o-mini` starts an OpenAI-compatible AI agent.
|
||||||
|
- `python -m texas_holdem_replay.server --port 8088` starts the replay viewer.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use Python 3.11+ standard-library APIs unless a dependency is intentionally added to `pyproject.toml`. Keep modules focused and prefer explicit dataclasses for wire/state models. Use 4-space indentation, type hints, `snake_case` for functions and variables, `PascalCase` for classes, and concise comments only where logic is non-obvious.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`, and replay UI server helpers in `tests/test_replay_server.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses short Conventional Commit-style subjects, for example `feat: add replay server and web client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for replay UI or visible terminal-output changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit API keys. Pass LLM credentials through `OPENAI_API_KEY` or CLI flags in local shells only. HTTP Agent endpoints are exclusive per active game; preserve this invariant when changing service concurrency.
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
|
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
|
||||||
- 支持本地 Agent 和 HTTP Agent。
|
- 支持本地 Agent 和 HTTP Agent。
|
||||||
- 支持 Human Agent 和 OpenAI-compatible AI Agent 的终端过程输出。
|
- 支持 Human Agent 和 OpenAI-compatible AI Agent 的终端过程输出。
|
||||||
|
- 游戏运行中可以并发查询状态;查询返回上一手完成后的稳定快照。
|
||||||
|
|
||||||
## 运行服务
|
## 运行服务
|
||||||
|
|
||||||
@@ -51,6 +52,12 @@ curl -X POST http://127.0.0.1:8000/games/demo/hands/run \
|
|||||||
curl http://127.0.0.1:8000/games/demo
|
curl http://127.0.0.1:8000/games/demo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以使用单数别名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/game/demo
|
||||||
|
```
|
||||||
|
|
||||||
## HTTP Agent 协议
|
## HTTP Agent 协议
|
||||||
|
|
||||||
玩家配置可以使用远程 HTTP Agent:
|
玩家配置可以使用远程 HTTP Agent:
|
||||||
@@ -62,12 +69,19 @@ curl http://127.0.0.1:8000/games/demo
|
|||||||
"agent": {
|
"agent": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"endpoint": "http://127.0.0.1:9101",
|
"endpoint": "http://127.0.0.1:9101",
|
||||||
"timeout_seconds": 10
|
"timeout_seconds": 10,
|
||||||
|
"game_update_timeout_seconds": 3,
|
||||||
|
"retries": 2,
|
||||||
|
"retry_backoff_seconds": 0.25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
服务会向 `endpoint` 发送当前行动玩家的观察 JSON。Agent 返回:
|
服务会向 `endpoint + /game` 推送每手开始时的游戏快照,向 `endpoint + /act` 发送当前行动玩家的观察 JSON。`endpoint` 也可以传入历史形式的 `/act` 或 `/game` 后缀,服务会归一化为 base URL。
|
||||||
|
|
||||||
|
同一个 HTTP Agent endpoint 不能同时被不同游戏占用;后创建的游戏会返回错误。服务会给 HTTP Agent 请求自动重试,`/act` 重试仍失败时,规则引擎会按 `check > call > fold` 选择默认动作,避免整桌卡死。
|
||||||
|
|
||||||
|
Agent 返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"action": "call"}
|
{"action": "call"}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
from texas_holdem.agents import HttpAgent, normalise_http_agent_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload: dict[str, object]) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return json.dumps(self.payload).encode("utf-8")
|
||||||
|
|
||||||
|
def __enter__(self) -> "FakeResponse":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTests(unittest.TestCase):
|
||||||
|
def test_normalise_http_agent_endpoint_accepts_action_or_game_paths(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
normalise_http_agent_endpoint("http://127.0.0.1:9101/act"),
|
||||||
|
"http://127.0.0.1:9101",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
normalise_http_agent_endpoint("http://127.0.0.1:9101/game/"),
|
||||||
|
"http://127.0.0.1:9101",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_http_agent_post_retries_and_sets_player_header(self) -> None:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout): # type: ignore[no-untyped-def]
|
||||||
|
calls.append((request, timeout))
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise URLError("temporary")
|
||||||
|
return FakeResponse({"ok": True})
|
||||||
|
|
||||||
|
agent = HttpAgent(
|
||||||
|
"http://agent.test/act",
|
||||||
|
player_id="p1",
|
||||||
|
retries=1,
|
||||||
|
retry_backoff_seconds=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("texas_holdem.agents.urlopen", fake_urlopen):
|
||||||
|
payload = agent._post_json("/game", {"game_id": "g1"}, timeout_seconds=2)
|
||||||
|
|
||||||
|
self.assertEqual(payload, {"ok": True})
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
self.assertEqual(calls[1][0].headers["X-player-id"], "p1")
|
||||||
|
self.assertEqual(calls[1][1], 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+74
-1
@@ -1,8 +1,26 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from threading import Event, Thread
|
||||||
|
|
||||||
|
from texas_holdem.agents import PokerAgent
|
||||||
|
from texas_holdem.models import Observation, PlayerAction
|
||||||
from texas_holdem.service import GameManager
|
from texas_holdem.service import GameManager
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingAgent(PokerAgent):
|
||||||
|
def __init__(self, entered: Event, release: Event) -> None:
|
||||||
|
self.entered = entered
|
||||||
|
self.release = release
|
||||||
|
|
||||||
|
def decide(self, observation: Observation) -> PlayerAction:
|
||||||
|
self.entered.set()
|
||||||
|
if not self.release.wait(timeout=5):
|
||||||
|
raise RuntimeError("test timed out waiting to release blocking agent")
|
||||||
|
for action in observation.legal_actions:
|
||||||
|
if action["action"] == "check":
|
||||||
|
return PlayerAction("check")
|
||||||
|
return PlayerAction("call")
|
||||||
|
|
||||||
|
|
||||||
class ServiceTests(unittest.TestCase):
|
class ServiceTests(unittest.TestCase):
|
||||||
def test_create_and_run_game(self) -> None:
|
def test_create_and_run_game(self) -> None:
|
||||||
manager = GameManager()
|
manager = GameManager()
|
||||||
@@ -23,7 +41,62 @@ class ServiceTests(unittest.TestCase):
|
|||||||
hands = manager.run_hands(game.game_id, count=1)
|
hands = manager.run_hands(game.game_id, count=1)
|
||||||
|
|
||||||
self.assertEqual(len(hands), 1)
|
self.assertEqual(len(hands), 1)
|
||||||
self.assertEqual(manager.get_game("demo").to_dict()["hand_number"], 1)
|
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1)
|
||||||
|
|
||||||
|
def test_get_game_state_does_not_block_during_run(self) -> None:
|
||||||
|
manager = GameManager()
|
||||||
|
entered = Event()
|
||||||
|
release = Event()
|
||||||
|
game = manager.create_game(
|
||||||
|
{
|
||||||
|
"game_id": "blocking",
|
||||||
|
"seed": 13,
|
||||||
|
"starting_stack": 200,
|
||||||
|
"small_blind": 5,
|
||||||
|
"big_blind": 10,
|
||||||
|
"players": [
|
||||||
|
{"id": "a", "type": "calling"},
|
||||||
|
{"id": "b", "type": "calling"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manager.run_hands("blocking", count=1)
|
||||||
|
game.agents["a"] = BlockingAgent(entered, release)
|
||||||
|
|
||||||
|
thread = Thread(target=lambda: manager.run_hands("blocking", count=1))
|
||||||
|
thread.start()
|
||||||
|
self.assertTrue(entered.wait(timeout=2))
|
||||||
|
|
||||||
|
state = manager.get_game_state("blocking")
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
self.assertEqual(state["hand_number"], 1)
|
||||||
|
self.assertEqual(len(state["hands"]), 1)
|
||||||
|
|
||||||
|
def test_duplicate_http_agent_endpoint_is_rejected_across_active_games(self) -> None:
|
||||||
|
manager = GameManager()
|
||||||
|
payload = {
|
||||||
|
"starting_stack": 200,
|
||||||
|
"small_blind": 5,
|
||||||
|
"big_blind": 10,
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"id": "ai",
|
||||||
|
"agent": {
|
||||||
|
"type": "http",
|
||||||
|
"endpoint": "http://127.0.0.1:9101/act",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"id": "b", "type": "calling"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.create_game({"game_id": "g1", **payload})
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "already belongs to game g1"):
|
||||||
|
manager.create_game({"game_id": "g2", **payload})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+106
-31
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from random import Random
|
from random import Random
|
||||||
from typing import IO, Any
|
from typing import IO, Any
|
||||||
from urllib.error import URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
|
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
|
||||||
@@ -54,6 +55,27 @@ class CallingStationAgent(PokerAgent):
|
|||||||
return PlayerAction("fold")
|
return PlayerAction("fold")
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_http_agent_endpoint(raw: str) -> str:
|
||||||
|
"""Return the canonical base URL for an HTTP agent endpoint."""
|
||||||
|
url = raw.rstrip("/")
|
||||||
|
if url.endswith("/act"):
|
||||||
|
url = url[: -len("/act")]
|
||||||
|
if url.endswith("/game"):
|
||||||
|
url = url[: -len("/game")]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def http_agent_endpoint_from_spec(spec: dict[str, Any]) -> str | None:
|
||||||
|
"""Extract the canonical HTTP endpoint from an agent spec, if present."""
|
||||||
|
agent_type = str(spec.get("type", "calling")).lower()
|
||||||
|
if agent_type != "http":
|
||||||
|
return None
|
||||||
|
endpoint = spec.get("endpoint")
|
||||||
|
if not endpoint:
|
||||||
|
raise ValueError("http agent requires an endpoint")
|
||||||
|
return normalise_http_agent_endpoint(str(endpoint))
|
||||||
|
|
||||||
|
|
||||||
class HttpAgent(PokerAgent):
|
class HttpAgent(PokerAgent):
|
||||||
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
|
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
|
||||||
|
|
||||||
@@ -66,28 +88,36 @@ class HttpAgent(PokerAgent):
|
|||||||
ACT_PATH = "/act"
|
ACT_PATH = "/act"
|
||||||
GAME_PATH = "/game"
|
GAME_PATH = "/game"
|
||||||
|
|
||||||
def __init__(self, endpoint: str, timeout_seconds: float = 10.0) -> None:
|
def __init__(
|
||||||
self.base_url = self._normalise_base_url(endpoint)
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
timeout_seconds: float = 10.0,
|
||||||
|
player_id: str | None = None,
|
||||||
|
game_update_timeout_seconds: float | None = None,
|
||||||
|
retries: int = 2,
|
||||||
|
retry_backoff_seconds: float = 0.25,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = normalise_http_agent_endpoint(endpoint)
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.player_id = player_id
|
||||||
@staticmethod
|
self.game_update_timeout_seconds = (
|
||||||
def _normalise_base_url(raw: str) -> str:
|
float(game_update_timeout_seconds)
|
||||||
"""Strip a trailing slash so URL joins do not produce double slashes.
|
if game_update_timeout_seconds is not None
|
||||||
|
else min(timeout_seconds, 3.0)
|
||||||
Centralising this also tolerates the legacy "endpoint already points
|
)
|
||||||
at /act" mistake by chopping off a redundant ``/act`` suffix.
|
self.retries = max(0, retries)
|
||||||
"""
|
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
|
||||||
url = raw.rstrip("/")
|
|
||||||
if url.endswith("/act"):
|
|
||||||
url = url[: -len("/act")]
|
|
||||||
return url
|
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
def _url(self, path: str) -> str:
|
||||||
"""Compose a full URL by joining the base with a path component."""
|
"""Compose a full URL by joining the base with a path component."""
|
||||||
return f"{self.base_url}{path}"
|
return f"{self.base_url}{path}"
|
||||||
|
|
||||||
def decide(self, observation: Observation) -> PlayerAction:
|
def decide(self, observation: Observation) -> PlayerAction:
|
||||||
payload = self._post_json(self.ACT_PATH, observation.to_dict())
|
payload = self._post_json(
|
||||||
|
self.ACT_PATH,
|
||||||
|
observation.to_dict(),
|
||||||
|
timeout_seconds=self.timeout_seconds,
|
||||||
|
)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise RuntimeError("agent endpoint must return a JSON object")
|
raise RuntimeError("agent endpoint must return a JSON object")
|
||||||
return PlayerAction.from_dict(payload)
|
return PlayerAction.from_dict(payload)
|
||||||
@@ -100,30 +130,54 @@ class HttpAgent(PokerAgent):
|
|||||||
only by way of the raised exception bubbling to the engine guard.
|
only by way of the raised exception bubbling to the engine guard.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self._post_json(self.GAME_PATH, game_state)
|
self._post_json(
|
||||||
|
self.GAME_PATH,
|
||||||
|
game_state,
|
||||||
|
timeout_seconds=self.game_update_timeout_seconds,
|
||||||
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# ``/game`` is informational; treat any HTTP error as a benign
|
# ``/game`` is informational; treat any HTTP error as a benign
|
||||||
# drop rather than reraising and aborting the hand loop.
|
# drop rather than reraising and aborting the hand loop.
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
|
def _post_json(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> Any:
|
||||||
"""POST ``payload`` as JSON to ``base_url + path`` and return parsed body.
|
"""POST ``payload`` as JSON to ``base_url + path`` and return parsed body.
|
||||||
|
|
||||||
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
|
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
|
||||||
identical transport semantics (timeout, error wrapping, content-type).
|
identical transport semantics (timeout, error wrapping, content-type).
|
||||||
"""
|
"""
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
request = Request(
|
last_error: BaseException | None = None
|
||||||
self._url(path),
|
raw = ""
|
||||||
data=body,
|
for attempt in range(self.retries + 1):
|
||||||
headers={"Content-Type": "application/json"},
|
request = Request(
|
||||||
method="POST",
|
self._url(path),
|
||||||
)
|
data=body,
|
||||||
try:
|
headers=self._headers(),
|
||||||
with urlopen(request, timeout=self.timeout_seconds) as response:
|
method="POST",
|
||||||
raw = response.read().decode("utf-8")
|
)
|
||||||
except (OSError, URLError) as exc:
|
try:
|
||||||
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from exc
|
with urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
break
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
last_error = RuntimeError(
|
||||||
|
f"agent endpoint failed with HTTP {exc.code}: "
|
||||||
|
f"{self._url(path)} {detail}"
|
||||||
|
)
|
||||||
|
except (OSError, URLError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt < self.retries and self.retry_backoff_seconds > 0:
|
||||||
|
time.sleep(self.retry_backoff_seconds * (2**attempt))
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from last_error
|
||||||
|
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -133,6 +187,12 @@ class HttpAgent(PokerAgent):
|
|||||||
f"agent endpoint returned invalid JSON: {self._url(path)}"
|
f"agent endpoint returned invalid JSON: {self._url(path)}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json", "Connection": "close"}
|
||||||
|
if self.player_id:
|
||||||
|
headers["X-Player-Id"] = self.player_id
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
class HumanAgent(PokerAgent):
|
class HumanAgent(PokerAgent):
|
||||||
"""Interactive CLI agent for debugging and manual play.
|
"""Interactive CLI agent for debugging and manual play.
|
||||||
@@ -189,7 +249,11 @@ class HumanAgent(PokerAgent):
|
|||||||
return line.rstrip("\n")
|
return line.rstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
|
def build_agent(
|
||||||
|
spec: dict[str, Any],
|
||||||
|
rng: Random | None = None,
|
||||||
|
player_id: str | None = None,
|
||||||
|
) -> PokerAgent:
|
||||||
agent_type = str(spec.get("type", "calling")).lower()
|
agent_type = str(spec.get("type", "calling")).lower()
|
||||||
if agent_type == "random":
|
if agent_type == "random":
|
||||||
return RandomAgent(rng)
|
return RandomAgent(rng)
|
||||||
@@ -199,7 +263,18 @@ def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
|
|||||||
endpoint = spec.get("endpoint")
|
endpoint = spec.get("endpoint")
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise ValueError("http agent requires an endpoint")
|
raise ValueError("http agent requires an endpoint")
|
||||||
return HttpAgent(str(endpoint), float(spec.get("timeout_seconds", 10.0)))
|
return HttpAgent(
|
||||||
|
str(endpoint),
|
||||||
|
timeout_seconds=float(spec.get("timeout_seconds", 10.0)),
|
||||||
|
player_id=player_id,
|
||||||
|
game_update_timeout_seconds=(
|
||||||
|
float(spec["game_update_timeout_seconds"])
|
||||||
|
if "game_update_timeout_seconds" in spec
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retries=int(spec.get("retries", 2)),
|
||||||
|
retry_backoff_seconds=float(spec.get("retry_backoff_seconds", 0.25)),
|
||||||
|
)
|
||||||
if agent_type in {"human", "cli", "interactive"}:
|
if agent_type in {"human", "cli", "interactive"}:
|
||||||
return HumanAgent()
|
return HumanAgent()
|
||||||
raise ValueError(f"unknown agent type: {agent_type}")
|
raise ValueError(f"unknown agent type: {agent_type}")
|
||||||
|
|||||||
+63
-18
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from random import Random
|
from random import Random
|
||||||
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from texas_holdem.agents import PokerAgent
|
from texas_holdem.agents import PokerAgent
|
||||||
@@ -54,6 +56,7 @@ class TableGame:
|
|||||||
self.small_blind = small_blind
|
self.small_blind = small_blind
|
||||||
self.big_blind = big_blind
|
self.big_blind = big_blind
|
||||||
self.rng = rng or Random()
|
self.rng = rng or Random()
|
||||||
|
self.lock = RLock()
|
||||||
self.hand_number = 0
|
self.hand_number = 0
|
||||||
self.button_index: int | None = None
|
self.button_index: int | None = None
|
||||||
self.board = []
|
self.board = []
|
||||||
@@ -64,6 +67,7 @@ class TableGame:
|
|||||||
# first hand that played under those stakes, which makes it trivial
|
# first hand that played under those stakes, which makes it trivial
|
||||||
# to reconstruct the schedule from the outside.
|
# to reconstruct the schedule from the outside.
|
||||||
self.blind_history: list[BlindLevel] = []
|
self.blind_history: list[BlindLevel] = []
|
||||||
|
self._completed_snapshot: dict[str, object] = self._to_dict_unlocked()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
@@ -83,6 +87,14 @@ class TableGame:
|
|||||||
:attr:`blind_history` whenever it changes (including the very first
|
:attr:`blind_history` whenever it changes (including the very first
|
||||||
hand) so external observers can replay the schedule.
|
hand) so external observers can replay the schedule.
|
||||||
"""
|
"""
|
||||||
|
with self.lock:
|
||||||
|
return self._run_hand_locked(small_blind=small_blind, big_blind=big_blind)
|
||||||
|
|
||||||
|
def _run_hand_locked(
|
||||||
|
self,
|
||||||
|
small_blind: int | None = None,
|
||||||
|
big_blind: int | None = None,
|
||||||
|
) -> HandSummary:
|
||||||
if self.is_complete:
|
if self.is_complete:
|
||||||
raise GameComplete("game is complete")
|
raise GameComplete("game is complete")
|
||||||
|
|
||||||
@@ -151,6 +163,7 @@ class TableGame:
|
|||||||
finished_at=time(),
|
finished_at=time(),
|
||||||
)
|
)
|
||||||
self.hand_summaries.append(summary)
|
self.hand_summaries.append(summary)
|
||||||
|
self._completed_snapshot = deepcopy(self._to_dict_unlocked())
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def run_hands(
|
def run_hands(
|
||||||
@@ -166,24 +179,47 @@ class TableGame:
|
|||||||
with the first hand of this call; subsequent calls can raise them
|
with the first hand of this call; subsequent calls can raise them
|
||||||
again. Leaving them ``None`` keeps the current level unchanged.
|
again. Leaving them ``None`` keeps the current level unchanged.
|
||||||
"""
|
"""
|
||||||
if max_hands <= 0:
|
with self.lock:
|
||||||
raise ValueError("max_hands must be positive")
|
if max_hands <= 0:
|
||||||
summaries = []
|
raise ValueError("max_hands must be positive")
|
||||||
for _ in range(max_hands):
|
summaries = []
|
||||||
if self.is_complete:
|
for _ in range(max_hands):
|
||||||
break
|
if self.is_complete:
|
||||||
# Only the first hand of the batch needs to apply the blind
|
break
|
||||||
# override; after that the engine reuses the stored values.
|
# Only the first hand of the batch needs to apply the blind
|
||||||
summaries.append(
|
# override; after that the engine reuses the stored values.
|
||||||
self.run_hand(small_blind=small_blind, big_blind=big_blind)
|
summaries.append(
|
||||||
)
|
self._run_hand_locked(
|
||||||
small_blind = None
|
small_blind=small_blind,
|
||||||
big_blind = None
|
big_blind=big_blind,
|
||||||
if until_one_left and self.is_complete:
|
)
|
||||||
break
|
)
|
||||||
return summaries
|
small_blind = None
|
||||||
|
big_blind = None
|
||||||
|
if until_one_left and self.is_complete:
|
||||||
|
break
|
||||||
|
return summaries
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
with self.lock:
|
||||||
|
return self._to_dict_unlocked()
|
||||||
|
|
||||||
|
def snapshot_completed(self) -> dict[str, object]:
|
||||||
|
"""Return a stable snapshot from the latest completed hand boundary.
|
||||||
|
|
||||||
|
If a hand is currently running under ``self.lock``, this method does
|
||||||
|
not block. It returns the most recent completed hand summary and
|
||||||
|
stacks captured in memory, which is exactly what status endpoints
|
||||||
|
need while a long-running HTTP-agent decision is in progress.
|
||||||
|
"""
|
||||||
|
if self.lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
return deepcopy(self._to_dict_unlocked())
|
||||||
|
finally:
|
||||||
|
self.lock.release()
|
||||||
|
return deepcopy(self._completed_snapshot)
|
||||||
|
|
||||||
|
def _to_dict_unlocked(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"game_id": self.game_id,
|
"game_id": self.game_id,
|
||||||
"status": "complete" if self.is_complete else "running",
|
"status": "complete" if self.is_complete else "running",
|
||||||
@@ -449,9 +485,18 @@ class TableGame:
|
|||||||
try:
|
try:
|
||||||
requested = agent.decide(observation)
|
requested = agent.decide(observation)
|
||||||
except Exception:
|
except Exception:
|
||||||
requested = PlayerAction("fold")
|
requested = self._default_action(observation.legal_actions)
|
||||||
return self._coerce_action(requested, observation.legal_actions)
|
return self._coerce_action(requested, observation.legal_actions)
|
||||||
|
|
||||||
|
def _default_action(self, legal_actions: list[dict[str, object]]) -> PlayerAction:
|
||||||
|
by_action = {str(action["action"]): action for action in legal_actions}
|
||||||
|
for action_type in ("check", "call", "fold"):
|
||||||
|
if action_type in by_action:
|
||||||
|
legal = by_action[action_type]
|
||||||
|
return PlayerAction(action_type, int(legal.get("amount") or 0))
|
||||||
|
legal = legal_actions[0]
|
||||||
|
return PlayerAction(str(legal["action"]), int(legal.get("amount") or 0))
|
||||||
|
|
||||||
def _coerce_action(
|
def _coerce_action(
|
||||||
self,
|
self,
|
||||||
requested: PlayerAction,
|
requested: PlayerAction,
|
||||||
@@ -581,7 +626,7 @@ class TableGame:
|
|||||||
swallow individual exceptions so a flaky remote endpoint cannot
|
swallow individual exceptions so a flaky remote endpoint cannot
|
||||||
break the table flow.
|
break the table flow.
|
||||||
"""
|
"""
|
||||||
snapshot = self.to_dict()
|
snapshot = self._to_dict_unlocked()
|
||||||
for agent in self.agents.values():
|
for agent in self.agents.values():
|
||||||
try:
|
try:
|
||||||
agent.on_game_update(snapshot)
|
agent.on_game_update(snapshot)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if path == ["games"]:
|
if path == ["games"]:
|
||||||
self._json({"games": MANAGER.list_games()})
|
self._json({"games": MANAGER.list_games()})
|
||||||
return
|
return
|
||||||
if len(path) == 2 and path[0] == "games":
|
if len(path) == 2 and path[0] in {"game", "games"}:
|
||||||
self._json(MANAGER.get_game(path[1]).to_dict())
|
self._json(MANAGER.get_game_state(path[1]))
|
||||||
return
|
return
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
@@ -35,11 +35,11 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
path = self._path_parts()
|
path = self._path_parts()
|
||||||
try:
|
try:
|
||||||
if path == ["games"]:
|
if path in (["game"], ["games"]):
|
||||||
game = MANAGER.create_game(self._read_json())
|
game = MANAGER.create_game(self._read_json())
|
||||||
self._json(game.to_dict(), HTTPStatus.CREATED)
|
self._json(game.snapshot_completed(), HTTPStatus.CREATED)
|
||||||
return
|
return
|
||||||
if len(path) == 3 and path[0] == "games" and path[2] == "hands":
|
if len(path) == 3 and path[0] in {"game", "games"} and path[2] == "hands":
|
||||||
body = self._read_json()
|
body = self._read_json()
|
||||||
count = int(body.get("count", 1))
|
count = int(body.get("count", 1))
|
||||||
until_one_left = bool(body.get("until_one_left", False))
|
until_one_left = bool(body.get("until_one_left", False))
|
||||||
@@ -51,9 +51,9 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
small_blind=small_blind,
|
small_blind=small_blind,
|
||||||
big_blind=big_blind,
|
big_blind=big_blind,
|
||||||
)
|
)
|
||||||
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
|
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
|
||||||
return
|
return
|
||||||
if len(path) == 4 and path[0] == "games" and path[2] == "hands" and path[3] == "run":
|
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands" and path[3] == "run":
|
||||||
body = self._read_json()
|
body = self._read_json()
|
||||||
count = int(body.get("count", 1))
|
count = int(body.get("count", 1))
|
||||||
until_one_left = bool(body.get("until_one_left", False))
|
until_one_left = bool(body.get("until_one_left", False))
|
||||||
@@ -65,7 +65,7 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
small_blind=small_blind,
|
small_blind=small_blind,
|
||||||
big_blind=big_blind,
|
big_blind=big_blind,
|
||||||
)
|
)
|
||||||
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
|
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
|
||||||
return
|
return
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
|||||||
+51
-13
@@ -5,13 +5,14 @@ from threading import RLock
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from texas_holdem.agents import build_agent
|
from texas_holdem.agents import build_agent, http_agent_endpoint_from_spec
|
||||||
from texas_holdem.engine import TableGame
|
from texas_holdem.engine import TableGame
|
||||||
|
|
||||||
|
|
||||||
class GameManager:
|
class GameManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._games: dict[str, TableGame] = {}
|
self._games: dict[str, TableGame] = {}
|
||||||
|
self._http_endpoint_owners: dict[str, str] = {}
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
|
|
||||||
def create_game(self, payload: dict[str, Any]) -> TableGame:
|
def create_game(self, payload: dict[str, Any]) -> TableGame:
|
||||||
@@ -29,12 +30,19 @@ class GameManager:
|
|||||||
big_blind = int(payload.get("big_blind", 10))
|
big_blind = int(payload.get("big_blind", 10))
|
||||||
|
|
||||||
specs = []
|
specs = []
|
||||||
|
http_endpoints: set[str] = set()
|
||||||
for seat, raw_spec in enumerate(players):
|
for seat, raw_spec in enumerate(players):
|
||||||
if not isinstance(raw_spec, dict):
|
if not isinstance(raw_spec, dict):
|
||||||
raise ValueError("each player must be an object")
|
raise ValueError("each player must be an object")
|
||||||
player_id = str(raw_spec.get("id") or raw_spec.get("player_id") or f"p{seat + 1}")
|
player_id = str(raw_spec.get("id") or raw_spec.get("player_id") or f"p{seat + 1}")
|
||||||
name = str(raw_spec.get("name") or player_id)
|
name = str(raw_spec.get("name") or player_id)
|
||||||
agent = build_agent(raw_spec.get("agent", raw_spec), rng)
|
agent_spec = raw_spec.get("agent", raw_spec)
|
||||||
|
if not isinstance(agent_spec, dict):
|
||||||
|
raise ValueError("agent spec must be an object")
|
||||||
|
endpoint = http_agent_endpoint_from_spec(agent_spec)
|
||||||
|
if endpoint is not None:
|
||||||
|
http_endpoints.add(endpoint)
|
||||||
|
agent = build_agent(agent_spec, rng, player_id=player_id)
|
||||||
specs.append((player_id, name, agent))
|
specs.append((player_id, name, agent))
|
||||||
|
|
||||||
game = TableGame(
|
game = TableGame(
|
||||||
@@ -46,9 +54,18 @@ class GameManager:
|
|||||||
rng=rng,
|
rng=rng,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._release_completed_http_endpoints_locked()
|
||||||
if game_id in self._games:
|
if game_id in self._games:
|
||||||
raise ValueError(f"game already exists: {game_id}")
|
raise ValueError(f"game already exists: {game_id}")
|
||||||
|
for endpoint in http_endpoints:
|
||||||
|
owner = self._http_endpoint_owners.get(endpoint)
|
||||||
|
if owner is not None and owner != game_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"http agent endpoint already belongs to game {owner}: {endpoint}"
|
||||||
|
)
|
||||||
self._games[game_id] = game
|
self._games[game_id] = game
|
||||||
|
for endpoint in http_endpoints:
|
||||||
|
self._http_endpoint_owners[endpoint] = game_id
|
||||||
return game
|
return game
|
||||||
|
|
||||||
def get_game(self, game_id: str) -> TableGame:
|
def get_game(self, game_id: str) -> TableGame:
|
||||||
@@ -58,9 +75,13 @@ class GameManager:
|
|||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise KeyError(f"game not found: {game_id}") from exc
|
raise KeyError(f"game not found: {game_id}") from exc
|
||||||
|
|
||||||
|
def get_game_state(self, game_id: str) -> dict[str, object]:
|
||||||
|
return self.get_game(game_id).snapshot_completed()
|
||||||
|
|
||||||
def list_games(self) -> list[dict[str, object]]:
|
def list_games(self) -> list[dict[str, object]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return [game.to_dict() for game in self._games.values()]
|
games = list(self._games.values())
|
||||||
|
return [game.snapshot_completed() for game in games]
|
||||||
|
|
||||||
def run_hands(
|
def run_hands(
|
||||||
self,
|
self,
|
||||||
@@ -78,13 +99,30 @@ class GameManager:
|
|||||||
no-argument behaviour.
|
no-argument behaviour.
|
||||||
"""
|
"""
|
||||||
game = self.get_game(game_id)
|
game = self.get_game(game_id)
|
||||||
with self._lock:
|
summaries = [
|
||||||
return [
|
summary.to_dict()
|
||||||
summary.to_dict()
|
for summary in game.run_hands(
|
||||||
for summary in game.run_hands(
|
count,
|
||||||
count,
|
until_one_left=until_one_left,
|
||||||
until_one_left=until_one_left,
|
small_blind=small_blind,
|
||||||
small_blind=small_blind,
|
big_blind=big_blind,
|
||||||
big_blind=big_blind,
|
)
|
||||||
)
|
]
|
||||||
]
|
if game.is_complete:
|
||||||
|
with self._lock:
|
||||||
|
self._release_http_endpoints_for_game_locked(game_id)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
def _release_completed_http_endpoints_locked(self) -> None:
|
||||||
|
for game_id, game in list(self._games.items()):
|
||||||
|
if game.lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
if game.is_complete:
|
||||||
|
self._release_http_endpoints_for_game_locked(game_id)
|
||||||
|
finally:
|
||||||
|
game.lock.release()
|
||||||
|
|
||||||
|
def _release_http_endpoints_for_game_locked(self, game_id: str) -> None:
|
||||||
|
for endpoint, owner in list(self._http_endpoint_owners.items()):
|
||||||
|
if owner == game_id:
|
||||||
|
del self._http_endpoint_owners[endpoint]
|
||||||
|
|||||||
Reference in New Issue
Block a user