Thibaud’s blog

Notes, thoughts, and open-source software

Python Koans Completed

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 - ImplementationSource on GitHub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#!/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 casesSource on GitHub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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!