Source code for thekraf.game

"""
thekraf.game
============

The game controller logic
"""
from thekraf import Multilogr
import thekraf.config as cfg
import thekraf.db.models as mdls
import thekraf.diceutils as diceutils
from thekraf.gamemodels import Cycle, Game as GameModel, Turn
import thekraf.score

logr = Multilogr.get_logr('thk')


[docs]def displayable_info(objs, spec): """Semi-automatic formatting of attrs for display Spec format: Iterable of triplets (name, title, value) where: name Name of attr on one of the objs title Display name for the attr. If `None`, it will become title-case of name. value Attr value. If `None`, `objs` will be searched for attr. If attr is not found, it will remain `None`. Args: objs (collections.Iterable[object]): Objects to search for attrs spec (collections.Iterable[collections.Iterable]): Dpecification as explained above Returns: collections.Generator[tuple[str, object]]: Pairs of title str and value """ for name, title, value in spec: # Create title from name if title is None: title = name.replace('_', ' ').title() # Try to get value from one of objs if value is None: for obj in objs: value = getattr(obj, name, None) if value: break yield title, value
[docs]class GameTurn(GameModel): """Game logic specific to :class:`Turn`\ s""" @diceutils.norm_dice(1) def select_and_score(self, dice): """Select dice from rolled, and score them Args: dice (tuple[int]): Dice from rolled to score """ assert all(die in self.turn.rolled for die in dice) if dice: # If some dice have already been scored this turn, prepend those # to the dice that now being included, before scoring if self.turn.scored_dice: dice = diceutils.DiceUtils.concat(self.turn.scored_dice, dice) # Score the dice dice_score_pair = (dice, thekraf.score.Score.calc_score(self.opts, dice)) # If no dice are passed it, use the best scoring set of dice else: dice_str, score = sorted(self.turn.subscores.items(), key=lambda p: p[1], reverse=True)[0] dice_score_pair = (diceutils.DiceUtils.create_dice(dice_str), score) self.turn.dice_score_pairs[-1] = dice_score_pair
[docs] def select_and_score_min(self): """Select the best scoring min number of scorable dice from rolled Frequently, a player may choose to re-roll scoring dice for the chance at a higher score. However, at least one set of scoring dice must be set aside before rolling what it remaining. This method determines the smallest subcombo of the rolled dice that is scorable. If there are multiple combos of the same size, the one with the *highest* score is returned. """ dice_score_pair = self.turn.min_scorable if dice_score_pair: self.turn.dice_score_pairs[-1] = dice_score_pair
@diceutils.norm_dice(1) def unscore(self, dice): """Return scored dice to unscored dice If length of dice is zero, unscore all dice Args: dice (tuple[int]): Dice to remove from the scored dice """ assert all(die in self.turn.scored_dice for die in dice) # If dice to unscore are specified if dice: dice = diceutils.DiceUtils.diff(self.turn.scored_dice, dice) # Re-score the dice dice_score_pair = (dice, thekraf.score.Score.calc_score(self.opts, dice)) self.turn.dice_score_pairs[-1] = dice_score_pair # Otherwise, unscore all else: self.turn.dice_score_pairs[-1] = ((), 0) @diceutils.norm_dice(1) def bankable(self, dice=()): """True if score of dice and already score dice is enough to bank Args: dice (tuple(int)): Additional dice to include before scoring Returns: bool: `True` if enough to bank """ dice = diceutils.DiceUtils.concat(self.turn.scored_dice, dice) score = sum(( self.turn.total, # Remove what was already scored this roll # since we are recalculating -self.turn.score, # New score for this roll thekraf.score.Score.calc_score(self.opts, dice), )) if score >= self.turn.min_bank: return True else: return False
[docs] def bank(self, dice=()): """Bank scored dice, thereby completing a turn Args: dice (tuple(int)): Additional dice to include before banking """ if dice: self.select_and_score(dice) # Banking is only allowed if the minimum bank score has been met if self.turn.total >= self.turn.min_bank: self.turn.complete = True
[docs] def dice_to_roll_num(self): """Calculate how many dice should be rolled Returns: int: The number to roll """ result = 0 turn = self.turn if turn.rolled: assert len(turn.roll_hist) == len(turn.dice_score_pairs) result = len(turn.rolled) - len(turn.scored_dice) # If never rolled or all rolled were score, roll all if not result: result = cfg.GAME_DICE_MAX_COUNT return result
[docs] def roll_dice(self): """Roll any unscored dice or all of them if all have been scored Don't check for busted immediately after roll. That way, the user can be informed of the bust. Rolling at that point will act as an acknowledgement by the user so the turn can be marked complete. """ if self.turn.busted: self.turn.complete = True else: self.turn.roll_hist.append( diceutils.DiceUtils.roll(self.dice_to_roll_num())) self.turn.dice_score_pairs.append(((), 0))
[docs] def turn_info(self): """Format data about the turn so it can easily be displayed This is mostly for debugging purposes Returns: tuple[tuple[str]]: Formatted data """ info_gen = displayable_info((self.turn,), ( ('player', None, self.player.nickname), ('min_bank', 'Min to Bank', None), )) return tuple(info_gen)
[docs] def turn_table_info(self): """Format data about the turn so it is easily displayable in a table Returns: tuple[tuple[str]]: Formatted data """ # Create headings rows = [('Roll', 'Score', 'Dice')] # Create row to contain total rows.append(('Total', str(self.turn.total), '')) # For each roll, create a row with the dice/score pair for n, pair in enumerate(self.turn.dice_score_pairs, start=1): dice, score = pair row = (str(n), str(score), dice if dice else ()) rows.append(row) # Separate headers headers = rows.pop(0) return headers, rows
[docs]class Game(GameTurn, mdls.BaseModel): """A game of thekraf A game consists of a series of :class:`.Cycle`\ s or rounds in which each :class:`Player` takes a :class:`Turn`. """ def __init__(self, **kwargs): super(Game, self).__init__(**kwargs) if not self.cycle: self.new_cycle() elif not self.turn: self.new_turn()
[docs] def do_action(self, action, params): """Perform a game action Args: action (str): Name of the action to perform params (dict): Keyword args to pass on the the action method """ if action == 'score': self.select_and_score(**params) elif action == 'score_min': self.select_and_score_min() elif action == 'bank': self.bank(**params) elif action == 'roll': self.roll_dice() elif action == 'unscore': self.unscore(**params) else: raise ValueError('Unknown action {}'.format(repr(action))) self.cycles.changed() if self.complete: # Wrap up game pass elif self.cycle.complete: self.new_cycle() elif self.turn.complete: # TODO: What happens if new turn is immediately busted? self.new_turn()
[docs] def new_cycle(self): """Start a new cycle of the game""" d = { 'pids': self.pids, } self.cycles.append(Cycle(**d)) self.new_turn()
[docs] def new_turn(self): """Start a new turn of the game""" if self.cycle.pids: pid = self.cycle.pids[len(self.cycle.turns)] else: pid = None if pid is not None and self.totals[pid] >= self.min_first_bank: min_bank = self.min_bank else: min_bank = self.min_first_bank d = { 'pid': pid, 'min_bank': min_bank, 'opts_name': self.opts.name, } self.cycle.turns.append(Turn(**d)) self.roll_dice()
[docs] def game_info(self): """Format data about the game so it can easily be displayed This is mostly for debugging purposes Returns: tuple[tuple[str]]: Formatted data """ info_gen = displayable_info((self,), ( ('mode', None, None), ('goal', None, None), ('min_first_bank', None, None), ('min_bank', None, None), ('complete', None, None), )) return tuple(info_gen)
[docs] def score_table_info(self): """Format data about the game so it is easily displayable in a table Returns: tuple[tuple[str]]: Formatted data """ totals = self.totals # Include the player ids, names and totals rows = [['pids'] + list(self.pids)] rows.append(['Player'] + list(self.player_names)) cols = ['Total'] for pid in self.pids: total = totals[pid] total_str = str(total) # During the current player's turn, display a tmp score that will # become the score if the player banks if (pid == self.turn.pid and not self.turn.complete and self.turn.total): total_str += '/({})'.format(total + self.turn.total) cols.append(total_str) rows.append(cols) len_cycles = len(self.cycles) for i, cycle in enumerate(self.cycles[::-1]): # Cycle number cols = [str(len_cycles - i)] # Score per round. Put current turn in parentheses to indicate it # may not last cols.extend(('{}' if turn.complete else '({})').format(turn.total) for turn in cycle.turns) # Add placeholder for turns not taken yet cols.extend('-' for _ in range(len(rows[0]) - len(cols))) rows.append(cols) # Transpose the table rows = list(zip(*rows)) # Separate the headers headers = rows.pop(0)[1:] # Separate out player id and player name from rest rows = [(row[0], row[1], row[2:]) for row in rows] return headers, rows