#! /usr/bin/env python
from __future__ import annotations
import copy
from abc import ABC, abstractmethod
from collections import Counter
from collections.abc import Callable, Generator
from typing import (
Any,
Protocol,
)
import attrs
from pooltool.ai.action import Action
from pooltool.system.datatypes import System
from pooltool.utils import Timer
from pooltool.utils.strenum import StrEnum, auto
[docs]
class AIPlayer(Protocol):
self,
system: System,
game: Ruleset,
callback: Callable[[Action], None] | None = None,
) -> Action: ...
[docs]
def apply(self, system: System, action: Action) -> None: ...
[docs]
@attrs.define
class Player:
"""A player
Attributes:
name:
Player's name.
ai:
Not implemented yet...
"""
name: str
ai: AIPlayer | None = None
@property
def is_ai(self) -> bool:
return self.ai is not None
@attrs.define
class Log:
msgs: list[dict[str, Any]] = attrs.field(factory=list)
timer: Timer = attrs.field(factory=Timer.factory)
update: bool = attrs.field(default=False)
def add_msg(self, msg, sentiment="neutral", quiet=False) -> None:
self.msgs.append(
{
"time": self.timer.timestamp(),
"elapsed": self.timer.time_elapsed(),
"msg": msg,
"quiet": quiet,
"sentiment": sentiment,
"broadcast": False,
}
)
if not quiet:
self.update = True
def copy(self) -> Log:
return attrs.evolve(
self,
timer=copy.deepcopy(self.timer),
msgs=copy.deepcopy(self.msgs),
)
[docs]
class BallInHandOptions(StrEnum):
NONE = auto()
ANYWHERE = auto()
BEHIND_LINE = auto()
SEMICIRCLE = auto()
[docs]
@attrs.define
class ShotConstraints:
"""Constraints for a player's upcoming shot
Attributes:
ball_in_hand:
Enum specifying if and how the player can place the cue ball by hand.
movable:
A list of identifiers for balls that the player is allowed to move
before the shot. If None, all balls are considered movable.
cueable:
A list of identifiers for balls that can be struck by the cue ball
during the shot. If None, all balls are considered cueable.
hittable:
A tuple of identifiers for balls that must be hit for the shot to be
considered legal.
call_shot:
A boolean indicating whether the shot must be called (i.e., the player must
declare which ball they intend to pocket and in which pocket). If False,
ball_call and pocket_call need not be defined.
ball_call:
The identifier of the ball the player has called to be pocketed.
If None, no specific ball has been called.
pocket_call:
The identifier of the pocket the player has called for the ball to
be pocketed in. If None, no specific pocket has been called.
"""
ball_in_hand: BallInHandOptions
movable: list[str] | None
cueable: list[str] | None
hittable: tuple[str, ...]
call_shot: bool
ball_call: str | None = attrs.field(default=None)
pocket_call: str | None = attrs.field(default=None)
[docs]
def cueball(self, balls: dict[str, Any]) -> str:
if self.cueable is None:
assert len(balls)
for cue in ("cue", "white", "yellow"):
if cue in balls:
return cue
return list(balls.keys())[0]
return self.cueable[0]
[docs]
def can_shoot(self) -> bool:
if (
self.call_shot
and self.ball_call is not None
and self.pocket_call is not None
):
return True
elif not self.call_shot:
return True
else:
return False
[docs]
@attrs.define(frozen=True)
class ShotInfo:
"""Info about a played shot
Attributes:
player:
The player who played the shot.
legal:
Whether or not the shot was legal.
reason:
A textual description providing the rationale for whether the shot was
legal.
turn_over:
Whether the player's turn is over.
game_over:
Whether or not the game is over as a result of the shot.
winner:
Who the winner is. None if :attr:`game_over` is False.
score:
The total game score (tallied after the shot). Keys are player names and
values are points.
"""
player: Player
legal: bool
reason: str
turn_over: bool
game_over: bool
winner: Player | None
score: Counter[str]
[docs]
class Ruleset(ABC):
"""Abstract base class for a pool game ruleset.
This class defines the skeleton of a pool game ruleset, including player management,
score tracking, and shot handling. Subclasses must implement the abstract methods to
specify the behavior for specific games. For examples, see currently implemented
games.
"""
def __init__(self, players: list[Player] | None = None) -> None:
# Player info
players = [] if players is None else players
self.players: list[Player] = players
self.active_idx: int = 0
# Game progress tracking
self.score: Counter[str] = Counter()
self.shot_number: int = 0
self.turn_number: int = 0
# Game states
self.shot_constraints: ShotConstraints = self.initial_shot_constraints()
self.shot_info: ShotInfo
self.log: Log = Log()
@property
def active_player(self) -> Player:
return self.players[self.active_idx]
@property
def last_player(self) -> Player:
"""Returns the last player who played before the active player
If no turns have occurred, meaning the active player is the first player to
shoot in the game, this erroneously returns the last player in the player order.
"""
last_idx = (self.active_idx - 1) % len(self.players)
return self.players[last_idx]
[docs]
def set_next_player(self) -> None:
"""Sets the index for the next player
It is through this index that self.last_player and self.active_player are
defined from.
"""
self.active_idx = self.turn_number % len(self.players)
[docs]
def player_order(self) -> Generator[Player, None, None]:
"""Generates player order from current player until last-to-play"""
for i in range(len(self.players)):
yield self.players[(self.turn_number + i) % len(self.players)]
[docs]
def process_shot(self, shot: System) -> None:
"""Processes the information of the shot just played
Args:
shot: The shot data from the system.
"""
self.shot_info = self.build_shot_info(shot)
self.score = self.shot_info.score
self.respot_balls(shot)
[docs]
def advance(self, shot: System) -> None:
"""Advances the game state after a shot has been made and processed
Args:
shot: The shot data from the system.
"""
if self.shot_info.game_over:
if (winner := self.shot_info.winner) is not None:
self.log.add_msg(f"Game over! {winner.name} wins!", sentiment="good")
else:
self.log.add_msg("Game over! Tie game!", sentiment="good")
return
if self.shot_info.turn_over:
self.turn_number += 1
self.shot_number += 1
self.shot_constraints = self.next_shot_constraints(shot)
shot.cue.cue_ball_id = self.shot_constraints.cueball(shot.balls)
self.set_next_player()
[docs]
def process_and_advance(self, shot: System) -> None:
self.process_shot(shot)
self.advance(shot)
[docs]
@abstractmethod
def build_shot_info(self, shot: System) -> ShotInfo:
"""Construct the ShotInfo object for the current shot
This method evaluates the legality of a shot, determines if the turn or game is
over, and if applicable, decides the winner.
Args:
shot: The current shot being played.
Returns:
ShotInfo:
Contains details about the legality of the shot, whether the turn and
game are over, and who the winner is, if there is one.
"""
[docs]
@abstractmethod
def next_shot_constraints(self, shot: System) -> ShotConstraints:
"""Determine the constraints for the next shot based on the current game state.
The method sets the conditions under which the next shot will be played, such as
whether ball-in-hand rules apply and which balls are legally hittable.
Args:
shot: The current shot being played.
Returns:
ShotConstraints:
Shot constraints for the next shot.
"""
[docs]
@abstractmethod
def initial_shot_constraints(self) -> ShotConstraints:
"""Define the initial constraints for the first shot of the game.
Returns:
ShotConstraints:
Predefined shot constraints for the initial shot.
"""
[docs]
@abstractmethod
def respot_balls(self, shot: System) -> None:
"""Respot balls
This method should decide which balls should be respotted, and respot them. This
method should probably make use of ``pooltool.ruleset.utils.respot``
"""
[docs]
@abstractmethod
def copy(self) -> Ruleset:
"""Copy the game state
If you don't know how to implement this method, you can create a placeholder
function:
::
def copy(self):
return self
"""
pass