I recently discovered the concept of programming Koans: series of TDD-style tutorials to learn parts of a programming language. I’ve been willing to get better at Python for a while and started doing Greg Malcolm’s Python Koans. After a few months of practice, I completed all of them and thought it’d be cool to share my take on the extra credit task.
This task is more complicated than all of the other koans: it is a free-form programming exercice, where you both write tests and implementation for a given problem: the Greed Game and its Greed Rules. It tests your ability to write proper idiomatic (Pythonic) code, and to be thorough when testing it.
My take on about_extra_credit.py’s Greed Game - Implementation https://github.com/thibaudcolas/koans-playground/blob/master/python/python2/koans/about_extra_credit.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# EXTRA CREDIT:
#
# Create a program that will play the Greed Game.
# Rules for the game are in GREED_RULES.TXT.
#
# You already have a DiceSet class and score function you can use.
# Write a player class and a Game class to complete the project. This
# is a free form assignment, so approach it however you desire.
from runner.koan import *
from about_scoring_project import *
from about_dice_project import *
class Player(object):
"Represents a player of the Greed Game. He has a name, points, and the ability to roll dices."
HIGH_DICE_CAP = 4
LOW_DICE_CAP = 2
HIGH_POINTS_CAP = 800
LOW_POINTS_CAP = 200
def __init__(self, name, points = 0):
self._name = name
self._points = points
self._dice = DiceSet()
def __str__(self):
return "{0}: {1}pts".format(self._name, self._points)
def __repr__(self):
return str(self)
@property
def name(self):
return self._name
@property
def points(self):
return self._points
def accumulate_points(self, points):
self._points += points
def roll(self, n):
self._dice.roll(n)
return self._dice.values
def continue_roll(self, points, dices):
stop = [
points > Player.HIGH_POINTS_CAP and dices < Player.HIGH_DICE_CAP,
points > Player.LOW_POINTS_CAP and dices < Player.LOW_DICE_CAP and self.points != 0,
points > Game.ACCUMULATION_CAP and self.points == 0,
self.points + points > Game.FINAL_CAP,
]
return not (True in stop)
class Game(object):
"Represents a game of Greed."
ACCUMULATION_CAP = 300
FINAL_CAP = 3000
ITER_CAP = 30
DICE_NUMBER = 5
def __init__(self, players):
self._players = players
def __str__(self):
return "players:[{0}]".format(", ".join([str(player) for player in self._players]))
def __repr__(self):
return str(self)
def pick_best_player(self, players):
"Pick the best player: the one with the most points."
best = players[0]
for player in players:
if player.points > best.points:
best = player
return best
def play_turn(self, player):
"One turn: actions of a single player in a round."
points = 0
roll_counter = 0
roll = []
dice_number = Game.DICE_NUMBER
zero_roll = False
continue_roll = True
# The player may continue to roll as long as each roll scores points.
while continue_roll and (not zero_roll) and roll_counter < Game.ITER_CAP:
roll_points = 0
roll_counter += 1
roll = player.roll(dice_number)
score_counter = score_hash(roll)
for num, score in score_counter.iteritems():
if score != 0:
roll_points += score
# After a player rolls and the score is calculated, the scoring dice are
# removed and the player has the option of rolling again using only the
# non-scoring dice.
dice_number -= 1
points += roll_points
zero_roll = roll_points == 0
# If all of the thrown dice are scoring, then the
# player may roll all 5 dice in the next roll.
if dice_number == 0:
dice_number = Game.DICE_NUMBER
# If a roll has zero points, then the player loses not only their turn,
# but also accumulated score for that turn.
if zero_roll:
points = 0
continue_roll = player.continue_roll(points, dice_number)
UI.display_roll(player.name, roll_counter, roll, points, dice_number, continue_roll)
return points
def play_round(self, players):
"One round: a turn for each player."
game_ongoing = True
for player in players:
points = self.play_turn(player)
# Before a player is allowed to accumulate points, they must get at
# least 300 points in a single turn. Once they have achieved 300 points
# in a single turn, the points earned in that turn and each following
# turn will be counted toward their total score.
if (player.points >= Game.ACCUMULATION_CAP) or (points >= Game.ACCUMULATION_CAP):
player.accumulate_points(points)
# Once a player reaches 3000 (or more) points, the game enters the final
# round where each of the other players gets one more turn.
game_ongoing = game_ongoing and (player.points < Game.FINAL_CAP)
return game_ongoing
def play_last_round(self, players):
"End game: last round for all but the best player."
best_player = self.pick_best_player(players)
players.remove(best_player)
self.play_round(players)
players.append(best_player)
return self.pick_best_player(players)
def play(self):
"Plays a game of Greed."
playing = True
round_counter = 0
UI.display('START')
# The count flag prevents infinite loop.
while playing and round_counter < Game.ITER_CAP:
round_counter += 1
playing = self.play_round(self._players)
UI.display_round(round_counter, False, self._players)
# The winner is the player with the highest score after the final round.
winner = self.play_last_round(self._players)
UI.display_round(round_counter + 1, True, self._players)
UI.display_winner(winner)
return winner
class UI(object):
"Represents the user interface for a game of Greed"
ITEMS = {
'EN_SHORT': {
'START': 'Start!',
'END': 'End!',
'LAST': 'Last Round Start!',
'PLAYER': 'Player: {0}',
'PLAYERS': 'Players: {0}',
'WINNER': 'Winner: {0}',
'BEST': 'Best: {0}',
'POINTS': 'Points: {0}',
'SCORE': 'Score: {0}',
'ROUND': 'Round: {0}',
'ROLL': 'Roll: {0}',
'DICE': 'Dice: {0}',
},
'EN_LONG': {
'ROLL': '│ {0} #{1} roll: {2}, {3}pts, {4}dcs, again? {5}',
'ROUND': '├ Round #{0}! Last? {1}, Players: {2}',
'WINNER': '└── And the winner is... {0}! With {1}points.',
},
}
@staticmethod
def output_short(item, val):
return UI.ITEMS['EN_SHORT'][item].format(val)
@staticmethod
def display(item, val = None):
print UI.output_short(item, val)
@staticmethod
def display_roll(name, roll_number, roll, points, dice, choice):
print UI.ITEMS['EN_LONG']['ROLL'].format(name, roll_number, roll, points, dice, choice)
@staticmethod
def display_round(round_number, last_round, players):
print UI.ITEMS['EN_LONG']['ROUND'].format(round_number, last_round, players)
@staticmethod
def display_winner(winner):
print UI.ITEMS['EN_LONG']['WINNER'].format(winner.name, winner.points)
My take on about_extra_credit.py’s Greed Game - Test cases https://github.com/thibaudcolas/koans-playground/blob/master/python/python2/koans/about_extra_credit.py
class AboutExtraCredit(Koan):
def test_extra_credit_task(self):
pass
def test_player_initialization(self):
p1 = Player("p1")
self.assertEqual(p1.name, "p1")
self.assertEqual(p1.points, 0)
p2 = Player("p2", 500)
self.assertEqual(p2.name, "p2")
self.assertEqual(p2.points, 500)
def test_player_points_couting(self):
p = Player("p")
self.assertEqual(p.points, 0)
p.accumulate_points(100)
self.assertEqual(p.points, 100)
p.accumulate_points(100)
self.assertEqual(p.points, 200)
def test_player_dice_rolling(self):
p = Player("p")
roll = p.roll(Game.DICE_NUMBER)
self.assertEqual(type(roll), list)
self.assertEqual(len(roll), 5)
self.assertFalse(roll == p.roll(Game.DICE_NUMBER))
def test_player_continue_roll_choice(self):
p1 = Player("p1")
self.assertEqual(True, p1.continue_roll(250, 1))
self.assertEqual(True, p1.continue_roll(250, 4))
self.assertEqual(False, p1.continue_roll(1000, 5))
self.assertEqual(False, p1.continue_roll(1000, 2))
self.assertEqual(False, p1.continue_roll(350, 5))
self.assertEqual(False, p1.continue_roll(350, 1))
self.assertEqual(False, p1.continue_roll(3100, 1))
self.assertEqual(False, p1.continue_roll(3100, 5))
p2 = Player("p2", 500)
self.assertEqual(False, p2.continue_roll(250, 1))
self.assertEqual(True, p2.continue_roll(250, 4))
self.assertEqual(True, p2.continue_roll(1000, 5))
self.assertEqual(False, p2.continue_roll(1000, 2))
self.assertEqual(True, p2.continue_roll(350, 5))
self.assertEqual(False, p2.continue_roll(350, 1))
self.assertEqual(False, p2.continue_roll(2800, 2))
self.assertEqual(False, p2.continue_roll(2800, 5))
def test_game_initialization(self):
g = Game([Player("p1"), Player("p2"), Player("p3")])
self.assertEqual(type(g), Game)
self.assertEqual(str(g), "players:[p1: 0pts, p2: 0pts, p3: 0pts]")
def test_game_best_player_pick(self):
best = Player("ppp", 300)
players = [Player("p", 100), Player("pp", 200), best]
g = Game(players)
self.assertEqual(g.pick_best_player(players), best)
self.assertEqual(g.pick_best_player(players).points, best.points)
def test_game_play_turn(self):
p1 = Player("p1")
g = Game([p1, Player("p2"), Player("p3")])
self.assertEqual(type(g.play_turn(p1)), int)
def test_game_play_round(self):
p1 = Player("p1")
players = [p1, Player("p2"), Player("p3")]
g = Game(players)
self.assertEqual(type(g.play_round(players)), bool)
def test_game_play_last_round(self):
p1 = Player("p1")
players = [p1, Player("p2"), Player("p3")]
g = Game(players)
self.assertEqual(type(g.play_last_round(players)), Player)
def test_game_play(self):
p1 = Player("p1")
players = [p1, Player("p2"), Player("p3")]
g = Game(players)
self.assertEqual(type(g.play()), Player)
def test_score_of_an_empty_list_is_zero(self):
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 0, '4': 0, '6': 0}, score_hash([]))
def test_score_of_a_single_roll_of_5_is_50(self):
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 50, '4': 0, '6': 0}, score_hash([5]))
def test_score_of_a_single_roll_of_1_is_100(self):
self.assertEqual({'1': 100, '3': 0, '2': 0, '5': 0, '4': 0, '6': 0}, score_hash([1]))
def test_score_of_multiple_1s_and_5s_is_the_sum_of_individual_scores(self):
self.assertEqual({'1': 200, '3': 0, '2': 0, '5': 100, '4': 0, '6': 0}, score_hash([1, 5, 5, 1]))
def test_score_of_single_2s_3s_4s_and_6s_are_zero(self):
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 0, '4': 0, '6': 0}, score_hash([2, 3, 4, 6]))
def test_score_of_a_triple_1_is_1000(self):
self.assertEqual({'1': 1000, '3': 0, '2': 0, '5': 0, '4': 0, '6': 0}, score_hash([1, 1, 1]))
def test_score_of_other_triples_is_100x(self):
self.assertEqual({'1': 0, '3': 0, '2': 200, '5': 0, '4': 0, '6': 0}, score_hash([2, 2, 2]))
self.assertEqual({'1': 0, '3': 300, '2': 0, '5': 0, '4': 0, '6': 0}, score_hash([3, 3, 3]))
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 0, '4': 400, '6': 0}, score_hash([4, 4, 4]))
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 500, '4': 0, '6': 0}, score_hash([5, 5, 5]))
self.assertEqual({'1': 0, '3': 0, '2': 0, '5': 0, '4': 0, '6': 600}, score_hash([6, 6, 6]))
def test_score_of_mixed_is_sum(self):
self.assertEqual({'1': 0, '2': 200, '3': 0, '4': 0, '5': 50, '6': 0}, score_hash([2, 5, 2, 2, 3]))
self.assertEqual({'1': 0, '2': 0, '3': 0, '4': 0, '5': 550, '6': 0}, score_hash([5, 5, 5, 5]))
self.assertEqual({'1': 1100, '3': 0, '2': 0, '5': 50, '4': 0, '6': 0}, score_hash([1, 1, 1, 5, 1]))
def test_ones_not_left_out(self):
self.assertEqual({'1': 100, '3': 0, '2': 200, '5': 0, '4': 0, '6': 0}, score_hash([1, 2, 2, 2]))
self.assertEqual({'1': 100, '3': 0, '2': 200, '5': 50, '4': 0, '6': 0}, score_hash([1, 5, 2, 2, 2]))
A few takeaways:
- I decided to code the placer choice (keep rolling dices for a higher score or stop and keep a lower one) as a very simple rules-based AI. As the point is to use TDD as much as possible, it makes no sense to create a manual process / UI that’ll need to receive input on each test case. Automating input on such an interface would be the same as implementing those rules.
- When doing TDD, it is important to remind ourselves of the red-green-refactor cycle. Refactoring should be frequent to keep the routines manageable as more and more rules are added.
- Adding lots of comments for each rule being implemented is a good way to keep track of what should be done: comments need to express the intent, not the implementation.
Now on to a more Web-related Python project!