248 lines
8.6 KiB
Python
248 lines
8.6 KiB
Python
"""Pure helpers for rendering observations and prompting human actions.
|
|
|
|
This module is intentionally I/O-injected and dict-based so that both the
|
|
in-process :class:`HumanAgent` and the standalone HTTP human client can share
|
|
the same presentation and validation logic.
|
|
|
|
Design rationale:
|
|
- Functions accept the *dict* form of an observation (the same payload that
|
|
:meth:`Observation.to_dict` produces and that flows over HTTP). That keeps
|
|
the helpers agnostic to whether the caller has a real ``Observation``
|
|
object or a freshly parsed JSON document.
|
|
- Reader/Writer callables are passed in (rather than reading ``stdin`` /
|
|
writing ``stdout`` directly) so the helpers stay testable and reusable in
|
|
any context (CLI, sockets, GUI, etc.).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable
|
|
|
|
# Type aliases keep the public function signatures self-documenting and make
|
|
# it trivial to swap in alternative IO backends (e.g. async streams).
|
|
Reader = Callable[[str], str]
|
|
Writer = Callable[[str], None]
|
|
|
|
# Mapping from internal one-letter suit codes to Unicode pip glyphs. Defined
|
|
# at module scope so it is cheap to look up and easy to override in tests.
|
|
SUIT_GLYPHS: dict[str, str] = {
|
|
"s": "\u2660", # ♠ spades
|
|
"h": "\u2665", # ♥ hearts
|
|
"c": "\u2663", # ♣ clubs
|
|
"d": "\u2666", # ♦ diamonds
|
|
}
|
|
|
|
|
|
def pretty_card(label: str) -> str:
|
|
"""Render a two-character card label like ``"8h"`` as ``"♥8"``.
|
|
|
|
Designed as a small, total function so it can be reused anywhere card
|
|
strings need to be displayed (terminal, future GUI, logs). Unknown
|
|
suits fall through unchanged so we never crash on malformed data.
|
|
"""
|
|
if not isinstance(label, str) or len(label) < 2:
|
|
return str(label)
|
|
rank, suit = label[0], label[1].lower()
|
|
glyph = SUIT_GLYPHS.get(suit)
|
|
if glyph is None:
|
|
return label
|
|
return f"{glyph}{rank}"
|
|
|
|
|
|
def _format_cards(cards: list[Any], empty_text: str) -> str:
|
|
"""Render a list of card labels using :func:`pretty_card` with separators.
|
|
|
|
Extracted as a tiny helper so both the board and hole-cards lines share
|
|
one definition of "what an empty hand looks like".
|
|
"""
|
|
if not cards:
|
|
return empty_text
|
|
return " ".join(pretty_card(str(card)) for card in cards)
|
|
|
|
|
|
def render_observation(observation: dict[str, Any]) -> str:
|
|
"""Render an observation dict as a multi-line, human-readable block.
|
|
|
|
Returning a single string (instead of writing directly) lets the caller
|
|
decide where the output should go, and keeps the function pure and easy
|
|
to unit-test.
|
|
"""
|
|
lines: list[str] = []
|
|
lines.append("=" * 60)
|
|
lines.append(
|
|
f"Game {observation.get('game_id')} | Hand #{observation.get('hand_number')} "
|
|
f"| Street: {observation.get('street')}"
|
|
)
|
|
lines.append(
|
|
f"Blinds {observation.get('small_blind')}/{observation.get('big_blind')} "
|
|
f"| Button seat: {observation.get('button_seat')} "
|
|
f"| Pot: {observation.get('pot')}"
|
|
)
|
|
|
|
board = observation.get("board") or []
|
|
hole_cards = observation.get("hole_cards") or []
|
|
lines.append(f"Board : {_format_cards(board, '(empty)')}")
|
|
lines.append(
|
|
f"Your hand : {_format_cards(hole_cards, '(none)')}"
|
|
f" (seat {observation.get('seat')}, id={observation.get('player_id')})"
|
|
)
|
|
|
|
lines.append("-" * 60)
|
|
lines.append("Players:")
|
|
current_id = observation.get("player_id")
|
|
for player in observation.get("players", []):
|
|
marker = _player_marker(player, current_id)
|
|
lines.append(
|
|
f" {marker} seat {int(player.get('seat', 0)):>2} "
|
|
f"| {str(player.get('name', '')):<16} "
|
|
f"| stack {int(player.get('stack', 0)):>6} "
|
|
f"| street_bet {int(player.get('street_bet', 0)):>6} "
|
|
f"| total_bet {int(player.get('total_bet', 0)):>6}"
|
|
)
|
|
|
|
lines.append("-" * 60)
|
|
min_raise_to = observation.get("min_raise_to")
|
|
lines.append(
|
|
f"To call: {observation.get('to_call')} "
|
|
f"| Min raise to: {min_raise_to if min_raise_to is not None else '-'}"
|
|
)
|
|
|
|
lines.append("Recent actions:")
|
|
history = observation.get("action_history") or []
|
|
if not history:
|
|
lines.append(" (no actions yet)")
|
|
else:
|
|
for record in history[-8:]:
|
|
lines.append(
|
|
f" [{str(record.get('street', '')):<7}] "
|
|
f"{str(record.get('player_id', '')):<12} "
|
|
f"-> {str(record.get('action', '')):<6} "
|
|
f"amount={record.get('amount', 0)}"
|
|
)
|
|
|
|
lines.append("=" * 60)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _player_marker(player: dict[str, Any], current_player_id: Any) -> str:
|
|
"""Produce a single-character marker describing a player's status.
|
|
|
|
``*`` highlights the player who is currently to act, ``F`` flags a folded
|
|
seat, ``A`` an all-in seat. Isolated as a helper to keep the rendering
|
|
loop free of cosmetic branching.
|
|
"""
|
|
if player.get("player_id") == current_player_id:
|
|
return "*"
|
|
if player.get("folded"):
|
|
return "F"
|
|
if player.get("all_in"):
|
|
return "A"
|
|
return " "
|
|
|
|
|
|
def prompt_action(
|
|
legal_actions: list[dict[str, Any]],
|
|
reader: Reader,
|
|
writer: Writer,
|
|
) -> dict[str, Any]:
|
|
"""Drive an interactive menu and return a chosen ``{action, amount}`` dict.
|
|
|
|
The function loops until a valid choice is made because, for an
|
|
interactive debugger, treating typos as fatal would be hostile. The
|
|
returned dict matches the JSON schema accepted by ``PlayerAction.from_dict``.
|
|
A trailing separator + blank line is emitted right before returning so
|
|
consecutive turns are visually separated in the terminal log.
|
|
"""
|
|
if not legal_actions:
|
|
raise RuntimeError("no legal actions available")
|
|
|
|
while True:
|
|
writer("Choose an action:\n")
|
|
for index, action in enumerate(legal_actions, start=1):
|
|
writer(f" [{index}] {format_legal_action(action)}\n")
|
|
|
|
raw = reader("Enter choice number: ").strip()
|
|
choice = _parse_choice(raw, len(legal_actions))
|
|
if choice is None:
|
|
writer("Invalid choice, please try again.\n")
|
|
continue
|
|
|
|
selected = legal_actions[choice - 1]
|
|
action_type = str(selected["action"])
|
|
|
|
if action_type in {"bet", "raise"}:
|
|
amount = _prompt_amount(
|
|
int(selected["min_amount"]),
|
|
int(selected["max_amount"]),
|
|
reader,
|
|
writer,
|
|
)
|
|
if amount is None:
|
|
# Operator cancelled the amount entry; redisplay the menu.
|
|
continue
|
|
_emit_turn_separator(writer)
|
|
return {"action": action_type, "amount": amount}
|
|
|
|
_emit_turn_separator(writer)
|
|
return {"action": action_type, "amount": int(selected.get("amount") or 0)}
|
|
|
|
|
|
def _emit_turn_separator(writer: Writer) -> None:
|
|
"""Print a divider plus a blank line to delimit consecutive turns.
|
|
|
|
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")
|
|
|
|
|
|
def format_legal_action(action: dict[str, Any]) -> str:
|
|
"""Render one legal-action dict as a one-line description for the menu."""
|
|
action_type = str(action["action"])
|
|
if action_type in {"bet", "raise"}:
|
|
return (
|
|
f"{action_type} (street_total in "
|
|
f"[{action['min_amount']}, {action['max_amount']}])"
|
|
)
|
|
if action_type in {"call", "all_in"}:
|
|
return f"{action_type} {action.get('amount', 0)}"
|
|
return action_type
|
|
|
|
|
|
def _parse_choice(raw: str, upper: int) -> int | None:
|
|
"""Parse a 1-based menu index, returning ``None`` on any out-of-range input."""
|
|
if not raw.isdigit():
|
|
return None
|
|
value = int(raw)
|
|
if not 1 <= value <= upper:
|
|
return None
|
|
return value
|
|
|
|
|
|
def _prompt_amount(
|
|
min_amount: int,
|
|
max_amount: int,
|
|
reader: Reader,
|
|
writer: Writer,
|
|
) -> int | None:
|
|
"""Prompt for a bet/raise street-total in ``[min_amount, max_amount]``.
|
|
|
|
Returning ``None`` lets the caller back out of an accidental selection
|
|
(operator just presses Enter on an empty line).
|
|
"""
|
|
while True:
|
|
raw = reader(
|
|
f"Enter target street total in [{min_amount}, {max_amount}] "
|
|
f"(blank to cancel): "
|
|
).strip()
|
|
if raw == "":
|
|
return None
|
|
if not raw.lstrip("-").isdigit():
|
|
writer("Amount must be an integer.\n")
|
|
continue
|
|
value = int(raw)
|
|
if not min_amount <= value <= max_amount:
|
|
writer(f"Amount {value} out of range [{min_amount}, {max_amount}].\n")
|
|
continue
|
|
return value
|