"""
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