feat: add human http agent

This commit is contained in:
qianrui.mmmy
2026-05-11 19:53:40 +08:00
parent e46b2b84c5
commit 6014ec0707
5 changed files with 335 additions and 41 deletions
+106 -1
View File
@@ -193,7 +193,112 @@ def _emit_turn_separator(writer: Writer) -> None:
Centralised so the exact glyph/length of the separator can be changed
in one place if the visual style ever needs tweaking.
"""
writer("=====\n\n")
line = "~" * 60
writer(line + "\n\n")
# ANSI control sequence: ``ESC[2J`` clears the entire screen and ``ESC[H``
# moves the cursor back to the top-left. Kept as a module constant so any
# caller can reuse the exact same sequence and tests can monkey-patch it.
CLEAR_SCREEN_SEQUENCE = "\x1b[2J\x1b[H"
def clear_screen(writer: Writer) -> None:
"""Wipe the terminal via ANSI control sequences.
Implemented as a tiny helper rather than each caller inlining the escape
code so we have a single location to swap in alternative strategies
(e.g. printing many newlines on terminals that ignore ANSI).
"""
writer(CLEAR_SCREEN_SEQUENCE)
def render_game_state(game_state: dict[str, Any]) -> str:
"""Render a full ``GameManager.to_dict()`` snapshot for terminal display.
The resulting block is intended for the standalone HTTP human client's
``POST /game`` callback so the operator sees the up-to-date table state
plus a per-hand digest (winners, awards, showdown hole cards).
"""
lines: list[str] = []
lines.append("#" * 60)
lines.append(
f"GAME UPDATE game_id={game_state.get('game_id')} "
f"status={game_state.get('status')} hand={game_state.get('hand_number')}"
)
lines.append(
f"Blinds {game_state.get('small_blind')}/{game_state.get('big_blind')} "
f"| Button seat: {game_state.get('button_seat')} "
f"| Starting stack: {game_state.get('starting_stack')}"
)
lines.append("-" * 60)
lines.append("Stacks:")
for player in game_state.get("players", []):
flags = _player_flags(player)
lines.append(
f" seat {int(player.get('seat', 0)):>2} "
f"| {str(player.get('name', '')):<16} "
f"| stack {int(player.get('stack', 0)):>6} "
f"| {flags}"
)
hands = game_state.get("hands") or []
lines.append("-" * 60)
lines.append(f"Hands played: {len(hands)}")
for hand in hands:
lines.extend(_render_hand_digest(hand))
lines.append("#" * 60)
return "\n".join(lines) + "\n"
def _player_flags(player: dict[str, Any]) -> str:
"""Render the boolean state of a player as a compact tag list."""
tags: list[str] = []
if player.get("folded"):
tags.append("folded")
if player.get("all_in"):
tags.append("all_in")
if not player.get("in_hand"):
tags.append("out")
return ",".join(tags) if tags else "active"
def _render_hand_digest(hand: dict[str, Any]) -> list[str]:
"""Render a single hand summary as a compact, multi-line digest.
Kept separate from :func:`render_game_state` so the per-hand format can
be reused or extended (e.g. detailed action log) without entangling
with the table-level header layout.
"""
lines: list[str] = []
lines.append(
f" Hand #{hand.get('hand_number')} "
f"| button_seat={hand.get('button_seat')} "
f"| board: {_format_cards(hand.get('board') or [], '(folded out)')}"
)
awards = hand.get("awards") or []
if not awards:
lines.append(" (no awards recorded)")
for award in awards:
winners = ", ".join(str(w) for w in award.get("winners") or [])
hand_value = award.get("hand_value") or {}
value_label = hand_value.get("name") or "-"
lines.append(
f" pot {int(award.get('amount', 0)):>6} -> "
f"{winners or '(no winner)'} ({value_label})"
)
showdown = hand.get("showdown_hands") or {}
if showdown:
lines.append(" showdown:")
for player_id, cards in showdown.items():
lines.append(
f" {player_id}: {_format_cards(cards, '(empty)')}"
)
return lines
def format_legal_action(action: dict[str, Any]) -> str: