# pylint: disable=invalid-name,too-many-lines
from __future__ import annotations
import enum
import logging
import math
import platform
import shutil
import re
import sys
from typing import Iterable, Optional, Union, Tuple, Dict, List
import curses
from collections import namedtuple, deque
import signal
from importlib.metadata import version
from deprecated.sphinx import deprecated
from texasholdem.util.errors import Ignore
from texasholdem.util.functions import preflight, handle, raise_if
from texasholdem.card import card
from texasholdem.game.game import TexasHoldEm
from texasholdem.game.action_type import ActionType
from texasholdem.game.hand_phase import HandPhase
from texasholdem.game.player_state import PlayerState
from texasholdem.gui.abstract_gui import AbstractGUI
# Windows Compatibility
_OS = platform.system()
_IS_WINDOWS = _OS == "Windows"
if _IS_WINDOWS:
curses.resizeterm = curses.resize_term
logger = logging.getLogger(__name__)
_BlockDim = namedtuple("_BlockDim", ["rows", "cols"])
class _Ellipse:
"""
Represents an ellipse, contains methods to
- Get a :meth:`point_yx` from radians
- Get the :meth:`derivative` from radians
- Get the :meth:`char_at` radians which describes the derivative
Arguments:
major (float): The semi-major axis length
minor (float): The semi-minor axis length
center (Tuple[float, float]): The center of the ellipse
"""
def __init__(
self,
major: float = None,
minor: float = None,
center: Tuple[float, float] = (0, 0),
):
self.major = major
self.minor = minor
self.center = center
def point_yx(self, rads: float) -> Tuple[float, float]:
"""
Arguments:
rads (float): The radians
Returns:
Tuple[float, float]: The point y, x
"""
return (
self.minor * math.sin(rads) + self.center[1],
self.major * math.cos(rads) + self.center[0],
)
def derivative(self, rads: float) -> float:
"""
Arguments:
rads (float): The radians
Returns:
float: The derivative dy/dx
"""
return (rads * self.minor * math.cos(rads)) / (
rads * self.major * -math.sin(rads)
)
def char_at(self, rads: float) -> str:
"""
Arguments:
rads (float): The radians
Returns:
str: The character that describes the derivative at the point
"""
derivative = self.derivative(rads=rads)
if derivative < -0.5:
return "|"
if derivative < -0.25:
return "/"
if derivative < 0.25:
if math.sin(rads) >= 0:
return "_"
return "‾"
if derivative < 0.5:
return "\\"
return "|"
class _Align(enum.Enum):
"""
Enum that represents the alignment in box top/middle/bottom
"""
TOP = enum.auto()
MIDDLE = enum.auto()
BOTTOM = enum.auto()
class _Justify(enum.Enum):
"""
Enum that represents how a text is justified in a box
left, center, right.
"""
LEFT = enum.auto()
CENTER = enum.auto()
RIGHT = enum.auto()
class _Block:
"""
Core class of the Text GUI system. Wraps around the curses._CursesWindow object
to provide helper functions that makes working with text, centering, resizing,
erasing, and working with nested windows easier.
Arguments:
name (str): The name of the block element.
window (curses._CursesWindow): The window object for this block (usually given
from another :class:`_Block` with the :meth:`new_block` call (which
calls :code:`curses.newwin`).
Attributes:
name (str): The name of the block element.
window (curses._CursesWindow): The window object for this block (usually given
from another :class:`_Block` or :class:`_CursesHelper` with the :meth:`new_block`
call (which calls :code:`curses.newwin`).
blocks (Dict[str, _Block]): a dictionary of child blocks.
content (list, dict): The last *args, and **kwargs passed in to the :meth:`add_content`
method. Used to refresh and save state.
content_stack (deque): A stack of the previous contents saved with :meth:`stash_state`
"""
def __init__(self, name: str, window: curses._CursesWindow = None):
# pylint: disable=no-member
self.name: str = name
self.blocks: Dict[str, _Block] = {}
self.window = window
self.content_stack = deque(maxlen=10)
self.content = None
def _set_content_call(self, *args, **kwargs):
self.content = (args, kwargs)
@staticmethod
def _pad(
obj: Union[List[str], str],
pad_obj: Union[List[str], str],
padding_len: int,
min_padding: int,
align: _Align,
) -> Union[List[str], str]:
# pylint: disable=too-many-arguments
"""
Helper function to pad a list or string
"""
if align == _Align.BOTTOM:
before_padding = pad_obj * max(padding_len, min_padding)
after_padding = pad_obj * min_padding
elif align == _Align.MIDDLE:
before_padding = after_padding = pad_obj * max(
padding_len // 2, min_padding
)
else:
after_padding = pad_obj * max(padding_len, min_padding)
before_padding = pad_obj * min_padding
return before_padding + obj + after_padding
@handle(
handler=lambda exc: logger.debug(str(exc), exc_info=exc), exc_type=curses.error
)
@preflight(
prerun=lambda self, *args, **kwargs: self._set_content_call(*args, **kwargs)
)
def add_content(
self,
content: List[str],
align: _Align = _Align.MIDDLE,
justify: _Justify = _Justify.CENTER,
border: bool = False,
wrap_line: bool = False,
):
# pylint: disable=too-many-arguments
"""
Add the given string list to the block. Each element is placed on a new line.
Pass in parameters to modify the alignment, justification, border, etc.
Arguments:
content (List[str]): The content to add to the block, each element is a
new line.
align (_Align): The alignment (top to bottom) for the content.
justify (_Justify): The justification (left to right) for the content.
wrap_line (bool): Set to True to split lines that would extend past the box
boundary. Set to False to replace any overflow with '...'. Defaults to False
border (bool): Set to True to add a border, default False.
"""
self.window.erase()
rows, cols = self.window.getmaxyx()
border_int = 1 if border else 0
if wrap_line:
for i, text in enumerate(content):
if len(text) >= cols:
end = cols - len(_DOTS) - 1
content[i] = text[:end]
content.insert(i + 1, text[end:])
# align top/middle/bottom
content = self._pad(
obj=content,
pad_obj=[""],
padding_len=rows - len(content) - 1,
min_padding=border_int,
align=align,
)
# align left/center/right
for i, text in enumerate(content):
if len(text) >= cols:
continue
if justify == _Justify.RIGHT:
align = _Align.BOTTOM
elif justify == _Justify.CENTER:
align = _Align.MIDDLE
else:
align = _Align.TOP
content[i] = self._pad(
obj=content[i],
pad_obj=" ",
padding_len=cols - len(text) - 1,
min_padding=border_int,
align=align,
)
# right out, masking with dots if too long
for i, text in enumerate(content):
if len(text) >= cols:
text = text[: (cols - 1 - len(_DOTS))] + _DOTS
text += "\n" if i != rows - 1 else ""
self.window.addstr(text)
# add border
if border:
self.window.border(*_BLOCK_BORDER)
def stash_state(self):
"""
Saves the current content call onto the :attr:`content_stack` and
calls :meth:`stash_state` on any child blocks.
"""
if self.content:
self.content_stack.appendleft(self.content)
for block in self.blocks.values():
block.stash_state()
def pop_state(self):
"""
Pops from the :attr:`content_stack` and restores the content call and calls
:meth:`pop_state` on any child blocks.
.. note::
Will be a noop if :attr:`content_stack` is empty
"""
if self.content_stack:
args, kwargs = self.content_stack.popleft()
self.add_content(*args, **kwargs)
for block in self.blocks.values():
block.pop_state()
@handle(
handler=lambda exc: logger.debug(str(exc), exc_info=exc), exc_type=curses.error
)
def new_block(
self, name: str, nlines: int, ncols: int, begin_y: int = 0, begin_x: int = 0
) -> _Block:
# pylint: disable=too-many-arguments
"""
Creates and returns a new block (that wraps around curses._CursesWindow). Note
that this method is smart and so if the given block already exists, it will
resize and move the block with the given arguments.
Arguments:
name (str): The name to give to the child block
nlines (int): The number of rows to give the new block
ncols (int): The number of columns to give to the new block
begin_y (int): The topleft y coordinate of the block
begin_x (int): The topleft x coordinate of the block
Returns:
_Block: The newly created block (or the existing block
"""
if name in self.blocks:
self.blocks[name].window.resize(nlines, ncols)
self.blocks[name].window.mvwin(*self.bound_coords(begin_y, begin_x))
return self.blocks[name]
block = _Block(
name=name,
window=curses.newwin(nlines, ncols, *self.bound_coords(begin_y, begin_x)),
)
self.blocks[name] = block
return block
def get_block(self, name: str) -> Optional[_Block]:
"""
Get the child block by name (also searches sub-children)
Arguments:
name (str): The block name to get
Returns:
Optional[_Block]: The _Block by name or None
"""
if name in self.blocks:
return self.blocks[name]
for block in self.blocks.values():
child = block.get_block(name)
if child:
return child
return None
def erase(self):
"""
Erases the window and unsets the :attr:`content` attributes.
.. note::
You should call this method :code:`block.erase()` instead of
:code:`block.window.erase()` as it does not erase the content from
the stack.
"""
self.content = None
self.window.erase()
def refresh(self):
"""
Refreshes the block window and any child blocks.
"""
self.window.refresh()
for block in self.blocks.values():
block.refresh()
def bound_coords(self, y: int, x: int) -> Tuple[int, int]:
"""
Ensures the given y, x will lay in the window.
Arguments:
y (int): The y coordinate
x (int): The x coordinate
Returns:
Tuple[int, int]: The safe bounded coordinates
"""
max_y, max_x = self.window.getmaxyx()
y_start, x_start = self.window.getbegyx()
return (
min(max(y_start, y), y_start + max_y),
min(max(x_start, x), x_start + max_x),
)
# STRING CONSTANTS
_PROMPT = "$ "
_BLOCK_BORDER = ("|", "|", "-", "-", "+", "+", "+", "+")
_DOTS = "..."
# BLOCK DIMENSIONS
_PLAYER_BLOCK_SIZE = _BlockDim(rows=7, cols=20)
_PLAYER_BET_BLOCK_SIZE = _BlockDim(rows=1, cols=10)
_BOARD_BLOCK_SIZE = _BlockDim(rows=5, cols=50)
_PROMPT_BLOCK_SIZE = _BlockDim(rows=2, cols=35)
_ERROR_BLOCK_SIZE = _BlockDim(rows=1, cols=80)
_HISTORY_BLOCK_SIZE = _BlockDim(rows=-1, cols=28)
_ACTION_BLOCK_SIZE = _BlockDim(rows=1, cols=2)
_VERSION_BLOCK_SIZE = _BlockDim(rows=1, cols=25)
_AVAILABLE_ACTIONS_BLOCK_SIZE = _BlockDim(rows=1, cols=-1)
# OFFSETS & SIZE MULTIPLIERS
_HISTORY_BLOCK_SIZE_FACTOR = 0.95
_TABLE_ELLIPSE_OFFSET = (-15, -2)
_AVAILABLE_ACTIONS_OFFSET = (
_TABLE_ELLIPSE_OFFSET[0],
_PROMPT_BLOCK_SIZE.rows + _ERROR_BLOCK_SIZE.rows + 1,
)
_PLAYER_ELLIPSE_SIZE_FACTOR = 0.72
_TABLE_ELLIPSE_SIZE_FACTOR = 0.5
_AVAILABLE_ACTIONS_SIZE_FACTOR = _PLAYER_ELLIPSE_SIZE_FACTOR
_AVAILABLE_ACTIONS_FREE_SPACE = 10
_PLAYER_BET_ELLIPSE_SIZE_FACTOR = 0.35
_TABLE_STEPS_RESOLUTION = 400
# KEY STROKES
_BACKSPACE = 127 if not _IS_WINDOWS else 8
_NEWLINE = 10
_RESIZE = -1
_CTRL_C = 3 # Windows Only
# ANIMATION TIMING
_ACTION_STEPS = 10
_ACTION_SLEEP_MS = 20 if not _IS_WINDOWS else 10
[docs]
class TextGUI(AbstractGUI):
"""
Text-based GUI. Play Texas Hold 'Em on the command line.
Arguments:
game (TexasHoldEm, optional): The game object to attach to, all methods will
default to this game. (Not necessary if only showing the history)
visible_players (Iterable[int], optional): The players whose cards should be
displayed whenever the :meth:`display_state` method is called, defaults to every
player.
enable_animation (bool): If set to True, will play animations, default True.
no_wait (bool): If set to True, disables waiting mechanisms and will not block.
Attributes:
game (TexasHoldEm, optional): The game object to attach to, all methods will
default to this game. (Not necessary if only showing the history)
visible_players (Iterable[int], optional): The players whose cards should be
displayed whenever the :meth:`display_state` method is called, defaults to every
player.
enable_animation (bool): If set to True, will play animations, default True.
no_wait (bool): If set to True, disables waiting mechanisms and will not block.
"""
_action_patterns = (
(r"^all(\-|\s|_)?in$", ActionType.ALL_IN),
(r"^call$", ActionType.CALL),
(r"^check$", ActionType.CHECK),
(r"^fold$", ActionType.FOLD),
(r"^raise (to )?([0-9]+)$", ActionType.RAISE),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._command_patterns = ((r"^exit|quit$", self._exit_handler),)
# init curses
self.main_block = _Block(name="Main Window", window=curses.initscr())
# handle resize gracefully
if not _IS_WINDOWS:
signal.signal(
signal.SIGWINCH,
lambda signals, frame: (self.refresh(), self.main_block.window.getch()),
)
# cleanup before exit
signal.signal(signal.SIGINT, lambda signals, frame: self._exit_handler())
# hide screen until called
if self.game:
self.refresh()
self.hide()
def _exit_handler(self):
"""
Exit handler snippet
"""
self.hide()
sys.exit(2)
[docs]
@deprecated(
version="0.7.0",
reason="Use the :meth:`set_visible_players` method instead. "
"This function will be removed in version 1.0.0.",
)
def set_player_ids(self, ids: Iterable[int]):
"""
Make the given players' cards visible.
Arguments:
ids (Iterable[int]): The players whose cards should be visible when the
:meth:`display_state` method is called.
"""
self.visible_players = list(ids)
[docs]
@deprecated(
version="0.7.0",
reason="Use the :meth:`display_action` method instead. "
"This function will be removed in version 1.0.0.",
)
def print_action(self, id: int, action: ActionType, val: Optional[int] = None):
# pylint: disable=redefined-builtin,unused-argument
"""
Display the most recent action
"""
self._display_action(player_id=id, action=action)
[docs]
@deprecated(
version="0.7.0",
reason="Use the :meth:`display_state` method instead. "
"This function will be removed in version 1.0.0.",
)
def print_state(self, poker_game: TexasHoldEm):
"""
Display the state of the game.
"""
self.game = poker_game
self.refresh()
return self.display_state()
def _capture_string(self) -> str:
"""
Helper function for the :meth:`accept_input` method. Captures an inputed
string and handles backspaces, newlines, resize key strokes, etc.
Returns:
str: The captured string ended by a newline
"""
rows, _ = self.main_block.window.getmaxyx()
string = ""
i = len(_PROMPT)
while True:
ord_ = self.main_block.window.getch(rows - 1, i)
if ord_ == _BACKSPACE:
# Delete the backspace char
self.main_block.window.delch(rows - 1, i + 1)
self.main_block.window.delch(rows - 1, i)
# Don't delete the prompt
if i <= len(_PROMPT):
continue
# delete the previous char
self.main_block.window.delch(rows - 1, i - 1)
i -= 1
string = string[:-1]
# stop string collection on newline
elif ord_ == _NEWLINE:
break
# Windows Compatibility, need a workaround for SIGINT
# For now, allow users to ctrl+c in the input phase
elif _IS_WINDOWS and ord_ == _CTRL_C:
self._exit_handler()
# add to the string
else:
i += 1
try:
string += chr(ord_)
except ValueError as err:
# fail silently (don't want to echo) but preserve
# stack trace
raise Ignore() from err
return string.strip()
[docs]
@preflight(prerun=lambda self: self.refresh())
def accept_input(self) -> Tuple[ActionType, Optional[int]]:
curses.echo(True)
curses.curs_set(1)
string = self._capture_string()
curses.echo(False)
curses.curs_set(0)
self.main_block.get_block("INPUT").erase()
self.main_block.refresh()
string = string.lower().strip()
# empty string noop
if not string:
raise Ignore()
# special functions
for pattern, func in self._command_patterns:
if re.match(pattern, string):
func()
raise Ignore()
# actions
for pattern, action_type in self._action_patterns:
match = re.match(pattern, string)
if match:
total = None
if action_type == ActionType.RAISE:
total = int(match.group(2))
# erase any errors
self.main_block.get_block("ERROR").erase()
return action_type, total
raise ValueError(f"Could not parse '{string}'")
def _recalculate_object_blocks(self):
"""
Recalculates every block location and places them there
(does not fill with with content, only places them)
"""
rows, cols = self.main_block.window.getmaxyx()
# Place input box on the bottom left
self.main_block.new_block(
"INPUT", *_PROMPT_BLOCK_SIZE, (rows - _PROMPT_BLOCK_SIZE[0]), 0
)
input_size = self.main_block.get_block("INPUT").window.getmaxyx()
# Place error box on the bottom left right above the input
self.main_block.new_block(
"ERROR",
*_ERROR_BLOCK_SIZE,
(rows - input_size[0] - _ERROR_BLOCK_SIZE[0]),
0,
)
player_ellipse = _Ellipse(
major=(cols / 2) * _PLAYER_ELLIPSE_SIZE_FACTOR,
minor=(rows / 2) * _PLAYER_ELLIPSE_SIZE_FACTOR,
center=(
cols / 2 + _TABLE_ELLIPSE_OFFSET[0],
rows / 2 + _TABLE_ELLIPSE_OFFSET[1],
),
)
player_bet_ellipse = _Ellipse(
major=(cols / 2) * _PLAYER_BET_ELLIPSE_SIZE_FACTOR,
minor=(rows / 2) * _PLAYER_BET_ELLIPSE_SIZE_FACTOR,
center=(
cols / 2 + _TABLE_ELLIPSE_OFFSET[0],
rows / 2 + _TABLE_ELLIPSE_OFFSET[1],
),
)
# Place player windows in an ellipse with player 0 at the bottom of the screen
# and continuing clockwise.
start_rad = math.pi / 2
rad_per_player = (2 * math.pi) / self.game.max_players
for player_id in range(self.game.max_players):
rad = start_rad + rad_per_player * player_id
y, x = player_ellipse.point_yx(rad)
self.main_block.new_block(
f"PLAYER_INFO_{player_id}",
*_PLAYER_BLOCK_SIZE,
round(y) - _PLAYER_BLOCK_SIZE[0] // 2,
round(x) - _PLAYER_BLOCK_SIZE[1] // 2,
)
y, x = player_bet_ellipse.point_yx(rad)
self.main_block.new_block(
f"PLAYER_CHIPS_{player_id}",
*_PLAYER_BET_BLOCK_SIZE,
round(y) - _PLAYER_BET_BLOCK_SIZE[0] // 2,
round(x) - _PLAYER_BET_BLOCK_SIZE[1] // 2,
)
# Place the board and pots in the center
self.main_block.new_block(
"BOARD",
*_BOARD_BLOCK_SIZE,
(rows - _BOARD_BLOCK_SIZE[0]) // 2 + _TABLE_ELLIPSE_OFFSET[1],
(cols - _BOARD_BLOCK_SIZE[1]) // 2 + _TABLE_ELLIPSE_OFFSET[0],
)
# Place the available actions above the prompt
avail_cols = round(cols * _AVAILABLE_ACTIONS_SIZE_FACTOR)
self.main_block.new_block(
"AVAILABLE_ACTIONS",
_AVAILABLE_ACTIONS_BLOCK_SIZE[0],
avail_cols,
rows - _AVAILABLE_ACTIONS_OFFSET[1],
(cols - avail_cols) // 2 + _AVAILABLE_ACTIONS_OFFSET[0],
)
# Place history on the right
self.main_block.new_block(
"HISTORY",
round(rows * _HISTORY_BLOCK_SIZE_FACTOR),
_HISTORY_BLOCK_SIZE[1],
rows - round(rows * _HISTORY_BLOCK_SIZE_FACTOR),
cols - _HISTORY_BLOCK_SIZE[1],
)
# version block above history
self.main_block.new_block(
"VERSION", *_VERSION_BLOCK_SIZE, 0, cols - _VERSION_BLOCK_SIZE[1]
)
def _player_block(self, player_id: int) -> List[str]:
"""
Arguments:
player_id (int): The player id
Returns:
List[str]: The content for the given player
"""
block = []
block.extend(
[
f"Player {player_id}",
f"{self.game.players[player_id].state.name}",
f"Chips: {self.game.players[player_id].chips}",
]
)
for blind, blind_str in (
(self.game.btn_loc, "Button"),
(self.game.sb_loc, "Small Blind"),
(self.game.bb_loc, "Big Blind"),
):
if player_id == blind:
block.append(blind_str)
break
if self.game.players[player_id].state != PlayerState.SKIP:
in_pot = list(self.game.in_pot_iter())
if player_id in self.visible_players or (
self.game.hand_phase == HandPhase.SETTLE
and len(in_pot) > 1
and player_id in in_pot
):
block.append(
card.card_list_to_pretty_str(self.game.get_hand(player_id))
)
else:
block.append("[ * ] [ * ]")
return block
def _player_bet_block(self, player_id: int) -> List[str]:
"""
Arguments:
player_id (int): The player id
Returns:
List[str]: The player bet amount content
"""
return [f"Bet: {self.game.player_bet_amount(player_id)}"]
@staticmethod
def _version_block() -> List[str]:
"""
Returns:
List[str]: The version block content
"""
return [f"texaholdem: v{version('texasholdem')}"]
def _history_block(self) -> List[str]:
"""
The history block includes headers for each hand phase, action callouts
for each player during their turn, and how many chips for winners.
Returns:
List[str]: The content for the history
"""
history_rows, history_cols = self.main_block.get_block(
"HISTORY"
).window.getmaxyx()
history_rows, history_cols = (
history_rows - 2,
history_cols - 3,
) # for the border / newline
history_border = "-" * history_cols
block = deque(maxlen=history_rows)
block.append(f"Hand #{self.game.num_hands}")
for hand_phase in (
HandPhase.PREFLOP,
HandPhase.FLOP,
HandPhase.TURN,
HandPhase.RIVER,
):
if hand_phase in self.game.hand_history:
block.append(history_border)
block.append(hand_phase.name)
block.append(history_border)
for action in self.game.hand_history[hand_phase].actions:
block.append(str(action))
if HandPhase.SETTLE in self.game.hand_history:
block.append(history_border)
block.append(HandPhase.SETTLE.name)
block.append(history_border)
block.extend(str(self.game.hand_history[HandPhase.SETTLE]).split("\n"))
return list(block)
def _board_block(self) -> List[str]:
"""
The board block includes the board cards and the pots.
Returns:
List[str]: The content for the board and for the pots
"""
return [
f"Board: {card.card_list_to_pretty_str(self.game.board)}",
"",
*(
f"Pot {i}: {pot.get_amount()} ({pot.get_total_amount() - pot.get_amount()})"
for i, pot in enumerate(self.game.pots)
),
]
def _paint_table_ring(self):
"""
Paints the table ellipse directly the main window.
"""
# paint table
rows, cols = self.main_block.window.getmaxyx()
table_ellipse = _Ellipse(
major=(cols / 2) * _TABLE_ELLIPSE_SIZE_FACTOR,
minor=(rows / 2) * _TABLE_ELLIPSE_SIZE_FACTOR,
center=(
cols / 2 + _TABLE_ELLIPSE_OFFSET[0],
rows / 2 + _TABLE_ELLIPSE_OFFSET[1],
),
)
for step in range(1, _TABLE_STEPS_RESOLUTION):
rad = ((2 * math.pi) / _TABLE_STEPS_RESOLUTION) * step
y, x = table_ellipse.point_yx(rad)
self.main_block.window.addstr(
*self.main_block.bound_coords(round(y), round(x)),
table_ellipse.char_at(rad),
)
def _available_actions_block(self):
moves = self.game.get_available_moves()
ret = []
for action_type in (*moves.action_types, ActionType.ALL_IN):
if action_type == ActionType.RAISE:
ret.append(
f"{action_type.name} to "
f"{moves.raise_range.start} - {moves.raise_range.stop - 1}"
)
else:
ret.append(action_type.name)
return [(" " * _AVAILABLE_ACTIONS_FREE_SPACE).join(ret)]
[docs]
def refresh(self):
"""
Refreshes the display
"""
self.main_block.stash_state()
self.main_block.window.clear()
x, y = shutil.get_terminal_size()
curses.resizeterm(y, x)
self._paint_table_ring()
self._recalculate_object_blocks()
self.main_block.pop_state()
self.main_block.refresh()
[docs]
def hide(self):
curses.endwin()
[docs]
def display_state(self):
# paint board
self.main_block.blocks["BOARD"].add_content(content=self._board_block())
# paint players
for player_id in range(self.game.max_players):
self.main_block.blocks[f"PLAYER_INFO_{player_id}"].add_content(
content=self._player_block(player_id),
border=player_id == self.game.current_player,
)
self.main_block.blocks[f"PLAYER_CHIPS_{player_id}"].add_content(
content=self._player_bet_block(player_id)
)
# available actions
self.main_block.blocks["AVAILABLE_ACTIONS"].add_content(
content=self._available_actions_block(),
justify=_Justify.CENTER,
)
# history
self.main_block.blocks["HISTORY"].add_content(
content=self._history_block(),
align=_Align.BOTTOM,
border=True,
wrap_line=True,
)
# version
self.main_block.blocks["VERSION"].add_content(content=self._version_block())
self.main_block.refresh()
[docs]
def prompt_input(self, preamble: Optional[List[str]] = None):
if preamble is None:
preamble = [f"Player {self.game.current_player}'s turn"]
self.main_block.get_block("INPUT").erase()
self.main_block.get_block("INPUT").add_content(
[*preamble, _PROMPT], align=_Align.BOTTOM, justify=_Justify.LEFT
)
self.main_block.refresh()
[docs]
def display_error(self, error: str):
self.main_block.get_block("ERROR").erase()
self.main_block.get_block("ERROR").add_content(
[error], align=_Align.BOTTOM, justify=_Justify.LEFT
)
self.main_block.refresh()
@handle(
handler=lambda exc: logger.info("Skipping because animation is disabled"),
exc_type=Ignore,
)
@preflight(
prerun=lambda self, *args, **kwargs: raise_if(
Ignore(), not self.enable_animation
)
)
def _display_action(self, player_id: int, action: ActionType):
"""
Animates the chip movement for raise and call actions.
"""
curses.curs_set(0)
if action in (ActionType.RAISE, ActionType.CALL):
player_y, player_x = self.main_block.get_block(
f"PLAYER_INFO_{player_id}"
).window.getbegyx()
bet_y, bet_x = self.main_block.get_block(
f"PLAYER_CHIPS_{player_id}"
).window.getbegyx()
start_y, start_x = (
player_y + _PLAYER_BLOCK_SIZE[0] // 2,
player_x + _PLAYER_BLOCK_SIZE[1] // 2,
)
end_y, end_x = (
bet_y + _PLAYER_BET_BLOCK_SIZE[0] // 2,
bet_x + _PLAYER_BET_BLOCK_SIZE[1] // 2,
)
y, x = start_y, start_x
tick_y, tick_x = (
(end_y - start_y) / _ACTION_STEPS,
(end_x - start_x) / _ACTION_STEPS,
)
self.main_block.new_block(
"ACTION", *_ACTION_BLOCK_SIZE, round(y), round(x)
).add_content(["*"])
while (round(y), round(x)) != (end_y, end_x):
y += tick_y
x += tick_x
self.main_block.get_block("ACTION").erase()
self.main_block.new_block(
"ACTION", *_ACTION_BLOCK_SIZE, round(y), round(x)
).add_content(["*"])
curses.napms(_ACTION_SLEEP_MS)
self.refresh()
self.main_block.blocks.pop("ACTION").window.erase()
[docs]
def display_action(self):
if not self.game.hand_history.combined_actions():
return
player_action = self.game.hand_history.combined_actions()[-1]
player_id, action = player_action.player_id, player_action.action_type
self._display_action(player_id, action)
[docs]
def display_win(self):
old_visible_players = self.visible_players
# in the settle phase, players going to showdown show cards
extras = set(self.game.in_pot_iter())
extras = (
extras if len(extras) > 1 else []
) # don't out players win without contest
self.set_visible_players(set(self.visible_players).union(extras))
self.display_state()
# clear available actions
self.main_block.blocks["AVAILABLE_ACTIONS"].erase()
self.main_block.refresh()
self.wait_until_prompted()
self.visible_players = old_visible_players
[docs]
@handle(
handler=lambda exc: logger.info("Skipping because no_wait is True"),
exc_type=Ignore,
)
@preflight(prerun=lambda self: raise_if(Ignore(), self.no_wait))
def wait_until_prompted(self):
self.prompt_input(preamble=["Press enter to continue"])
curses.curs_set(0)
self.main_block.refresh()
self.main_block.window.getstr()