feat: add hand detail API and enrich hand summary fields

- HandSummary: add hole_cards, starting_stacks, ending_stacks, pot_contributions
- Engine: capture all players' hole cards (not just showdown), pre/post hand stacks, per-level pot contributions
- Server: new GET /game/<game_id>/hands/<hand_number> route
- Service: add get_hand_state() method
- Tests: add ServerTests for new endpoint, update existing tests
- Existing GET /game/<game_id> auto-inherits new fields via shared to_dict()
This commit is contained in:
2026-05-23 22:11:45 +08:00
parent 5899ea0b89
commit c0bc5384f4
7 changed files with 233 additions and 6 deletions
+40
View File
@@ -100,10 +100,50 @@ class EngineTests(unittest.TestCase):
awards = game._award_pots()
self.assertEqual([award.amount for award in awards], [150, 100])
self.assertEqual(
[contribution["amount"] for contribution in game._last_pot_contributions],
[150, 100],
)
self.assertEqual(
game._last_pot_contributions[0]["contributors"],
{"p1": 50, "p2": 50, "p3": 50},
)
self.assertEqual(
game._last_pot_contributions[1]["contributors"],
{"p2": 50, "p3": 50},
)
self.assertEqual(game.players[0].stack, 150)
self.assertEqual(game.players[1].stack, 100)
self.assertEqual(game.players[2].stack, 0)
def test_hand_summary_includes_full_hand_snapshots(self) -> None:
players = [
("p1", "Player 1", CallingStationAgent()),
("p2", "Player 2", CallingStationAgent()),
("p3", "Player 3", CallingStationAgent()),
]
game = TableGame("g5", players, starting_stack=100, small_blind=5, big_blind=10, rng=Random(23))
summary = game.run_hand()
payload = summary.to_dict()
self.assertEqual(set(summary.hole_cards), {"p1", "p2", "p3"})
self.assertTrue(all(len(cards) == 2 for cards in summary.hole_cards.values()))
self.assertEqual(summary.starting_stacks, {"p1": 100, "p2": 100, "p3": 100})
self.assertEqual(set(summary.ending_stacks), {"p1", "p2", "p3"})
self.assertEqual(sum(summary.starting_stacks.values()), sum(summary.ending_stacks.values()))
self.assertGreaterEqual(len(summary.pot_contributions), 1)
self.assertTrue(
all(
contribution["amount"] == sum(contribution["contributors"].values())
for contribution in summary.pot_contributions
)
)
self.assertEqual(set(payload["hole_cards"]), {"p1", "p2", "p3"})
self.assertEqual(payload["starting_stacks"], {"p1": 100, "p2": 100, "p3": 100})
self.assertIn("ending_stacks", payload)
self.assertIn("pot_contributions", payload)
def test_short_all_in_does_not_reopen_raising_to_prior_actor(self) -> None:
seen: list[tuple[str, str, list[str]]] = []
players = [
+76
View File
@@ -0,0 +1,76 @@
import json
import unittest
from threading import Thread
from urllib.request import Request, urlopen
from texas_holdem import server as poker_server
from texas_holdem.service import GameManager
class ServerTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_manager = poker_server.MANAGER
poker_server.MANAGER = GameManager()
self.server = poker_server.create_server("127.0.0.1", 0)
self.thread = Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
host, port = self.server.server_address
self.base_url = f"http://{host}:{port}"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
poker_server.MANAGER = self.previous_manager
def request_json(
self,
method: str,
path: str,
payload: dict[str, object] | None = None,
) -> dict[str, object]:
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self.base_url}{path}",
data=data,
headers=headers,
method=method,
)
with urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def test_get_hand_route_returns_expanded_hand_summary(self) -> None:
self.request_json(
"POST",
"/game",
{
"game_id": "route-demo",
"seed": 17,
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{"id": "a", "type": "calling"},
{"id": "b", "type": "calling"},
],
},
)
self.request_json("POST", "/game/route-demo/hands", {"count": 1})
hand = self.request_json("GET", "/game/route-demo/hands/1")
game = self.request_json("GET", "/game/route-demo")
self.assertEqual(hand["hand_number"], 1)
self.assertEqual(set(hand["hole_cards"]), {"a", "b"})
self.assertEqual(hand["starting_stacks"], {"a": 200, "b": 200})
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
self.assertEqual(game["hands"][0], hand)
if __name__ == "__main__":
unittest.main()
+9 -1
View File
@@ -40,8 +40,16 @@ class ServiceTests(unittest.TestCase):
hands = manager.run_hands(game.game_id, count=1)
state = manager.get_game_state("demo")
hand = manager.get_hand_state("demo", 1)
self.assertEqual(len(hands), 1)
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1)
self.assertEqual(state["hand_number"], 1)
self.assertEqual(hand, state["hands"][0])
self.assertIn("hole_cards", hand)
self.assertIn("starting_stacks", hand)
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
def test_get_game_state_does_not_block_during_run(self) -> None:
manager = GameManager()