In this article we’re going to tackle the extra credit portion of the kata we worked on in Part 1 (feel free to take a step back to that article if you haven’t yet read it). We’re going to continue where Part 1 left off, so make sure your code is in a state that resembles that given at the end of Part 1, or download it here.
In Part 1 we covered everything up to the extra credit portion (we ended with verifying the last example roll of [3,4,5,3,3] = 350 points). In this part, we are going to work on the extra credit section of the kata. I’ve posted the entire kata below for reference:
Greed is a press-your-luck dice rolling game. In the game, the player rolls the dice and tries to earn as many points as possible from the result. For the purposes of this kata, we will just be scoring a single roll of five dice (but see Extra Credit below).
Write a scoring method that calculates the best score based on a given roll using the following set of scoring rules. Each die can only be scored once (so single die scores cannot be combined with triple die scores for the same individual die, but for instance four 5s could count as 1 Triple (500) and 1 Single (50) for a total of 550.
Modify your scoring method to support the following rules:
Let’s jump right into things. The first thing we want to test is that a player can throw anywhere from 1 to 6 dice at a time. To test that, I’m going to write a test that checks for an exception when the number of dice thrown is less than 1, or more than 6:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[Theory] [InlineData(true)] [InlineData(false,1)] [InlineData(false, 1,2)] [InlineData(false, 1,2,3)] [InlineData(false, 1,2,3,4)] [InlineData(false, 1,2,3,4,5)] [InlineData(false, 1,2,3,4,5,6)] [InlineData(true, 1,2,3,4,5,6,6)] public void ThrowsExceptionIfLessThanOneOrMoreThanSixDiceRolled(bool exceptionThrown, params int[] diceRolled) { var exception = Record.Exception(() => _game.CalculateScore(diceRolled)); Assert.Equal(exceptionThrown, exception != null); } |
I decided to use a Theory for this test so that I can test multiple cases. First, I test the result when no dice are thrown, then 1 – 6 dice, and finally a roll with 7 dice thrown. I then use xUnit’s Record.Exception method to check if an exception was thrown, and compare it to my expected outcome.
If you run this test now, you should have 2 failing tests:
The two tests that fail are the ones that check for more than 6 dice thrown, and no dice thrown. Let’s update our CalculateScore method to make it pass. I added 2 lines of code at the start of the method to accomplish this:
1 2 3 4 5 6 7 8 9 10 11 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
Run your tests again – they should all pass.
Next, let’s write a test that checks the scoring of a 4-of-a-kind roll. Rolling a four-of-a-kind should return the score of rolling three-of-a-kind times 2 (e.g. 4 ones = 1000, 4 twos = 400, 4 threes = 600, etc…). Again, I will use a Theory/InlineData test for this
1 2 3 4 5 6 7 8 9 10 11 |
[Theory] [InlineData(1, 2000)] [InlineData(2, 400)] [InlineData(3, 600)] [InlineData(4, 800)] [InlineData(5, 1000)] [InlineData(6, 1200)] public void ReturnsExpectedValueWhenFourOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue)); } |
Run the test, and you should see a fail result similar to this:
Let’s think about what we need to do to create a passing test. We’ll need a new method to calculate 4-of-a-kind rolls, which should return the score of rolling a triple multiplied by two. Since we’re also rolling one more die than the triple score, we’ll also need to reduce the die count by one more. Try and come up with something on your own first before you look ahead. I came up with something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public int ScoreFourOfAKind() { int score = 0; if (_dieCounts[1] >= 4) { score = ScoreTripleOnes(); _dieCounts[1] -= 1; return score * 2; } for (int i = 2; i <= 6; i++) { if (_dieCounts[i] >= 4) { score = ScoreTripleOthers(); _dieCounts[i] -= 1; return score * 2; } } return 0; } |
You may come up with something different/better, but here I am first checking the case of rolling 4 ones, since those are scored differently. I then store the resulting score from the ScoreTripleOnes() method into a variable, and then return that result times 2. I then do the same for rolling 2 through 6. Since we’re only rolling a max of 6 dice, 4-of-a-kind can only happen up to 1 time, so I immediately return the score once a match is found.
In order to use this method, we’ll have to update our CalculateScore() method to include scoring 4-of-a-kind rolls. Let’s update CalculateScore() like so:
1 2 3 4 5 6 7 8 9 10 11 12 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreFourOfAKind(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
Now let’s run our tests:
It looks like all of our new tests passed, but three old tests have now failed! If you look closer at the tests that failed, you should notice that those tests have 4-of-a-kind rolls in them, therefore the scoring and test names need to be updated. These tests were great before the new rules came into play, but we’ll have to fix them.
The first test that failed is Returns1100WhenOneOneOneOneRolled. On the old rules, before 4-of-a-kind was a thing, the score should have been 1100. Now, it should be 2000 (remember, 4-of-a-kind is the triple score times 2, so triple ones = 1000, times 2 = 2000). Let’s update it:
1 2 3 4 5 6 |
[Fact] public void Returns2000WhenOneOneOneOneRolled() { int[] dieValues = { 1, 1, 1, 1 }; Assert.Equal(2000, _game.CalculateScore(dieValues)); } |
The second test that failed was Returns1150GivenOneOneOneFiveOne. Again, we have a 4-of-a-kind in here, plus an extra 5, so the new score should actually be 2050 (2000 for the 4-of-a-kind, plus 50 for rolling a 5). Let’s update that test as well:
1 2 3 4 5 6 |
[Fact] public void Returns2050GivenOneOneOneFiveOne() { int[] dieValues = { 1, 1, 1, 5, 1 }; Assert.Equal(2050, _game.CalculateScore(dieValues)); } |
The last test that failed was Returns600GivenAllFives. That score should now return 1050 (quad 5s = 1000, plus 50 for rolling a 5):
1 2 3 4 5 6 |
[Fact] public void Returns1050GivenAllFives() { int[] dieValues = { 5, 5, 5, 5, 5 }; Assert.Equal(1050, _game.CalculateScore(dieValues)); } |
Run your tests again, and they should all now pass!
When you take a TDD approach to development, you deal with the rules as they come. Sometimes a side effect of this is having to re-do some of the tests, as you’ve just seen.
Let’s move on to scoring 5-of-a-kind. The test will essentially be the same as scoring 4-of-a-kind, with the exception of the test name, the expectedScore values, and adding an additional dieValue to the CalculateScore call:
1 2 3 4 5 6 7 8 9 10 11 |
[Theory] [InlineData(1, 4000)] [InlineData(2, 800)] [InlineData(3, 1200)] [InlineData(4, 1600)] [InlineData(5, 2000)] [InlineData(6, 2400)] public void ReturnsExpectedValueWhenFiveOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue, dieValue)); } |
If we run the tests, they should all fail (and they do – I’ll spare you the screenshot). To make them pass we can essentially do the same thing we did for scoring 4-of-a-kind. We’ll have to tweak it a little bit though, instead of deducting 1 additional dieCount, we’ll deduct 2, and instead of returning the score times 2, we’ll return the score times 4:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public int ScoreFiveOfAKind() { int score = 0; if (_dieCounts[1] >= 5) { score = ScoreTripleOnes(); _dieCounts[1] -= 2; return score * 4; } for (int i = 2; i <= 6; i++) { if (_dieCounts[i] >= 5) { score = ScoreTripleOthers(); _dieCounts[i] -= 2; return score * 4; } } return 0; } |
We’ll also update CalculateScore to call the new method:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreFiveOfAKind(); score += ScoreFourOfAKind(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
Run the tests now, and we’ll see something familiar. All the new tests pass, but an old one fails:
Returns1050GivenAllFives (the one we just fixed in Step 3) fails. Again, this is because the scoring rules have now changed. Since this test is actually covered in the one we just wrote (it’s scoring 5 fives), we can just delete it.
Let’s look at the next feature: scoring 6-of-a-kind. By now, we should know what the test will look like, so let’s write that:
1 2 3 4 5 6 7 8 9 10 11 |
[Theory] [InlineData(1, 8000)] [InlineData(2, 1600)] [InlineData(3, 2400)] [InlineData(4, 3200)] [InlineData(5, 4000)] [InlineData(6, 4800)] public void ReturnsExpectedValueWhenSixOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue, dieValue, dieValue)); } |
We can do a few things to make this test pass. The easiest thing to do is essentially copy the same method we used for scoring 5-of-a-kind, and updating the dieCount reduction, and the score multiplier:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public int ScoreSixOfAKind() { int score = 0; if (_dieCounts[1] >= 6) { score = ScoreTripleOnes(); _dieCounts[1] -= 3; return score * 8; } for (int i = 2; i <= 6; i++) { if (_dieCounts[i] >= 6) { score = ScoreTripleOthers(); _dieCounts[i] -= 3; return score * 8; } } return 0; } |
And update the CalculateScore() method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreSixOfAKind(); score += ScoreFiveOfAKind(); score += ScoreFourOfAKind(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
Run your tests. Spoiler: they all pass! At this point, you should start to recognize that your code is getting pretty repetitive. The scoring model for 4, 5, and 6-of-a-kind is repetitive and based on the score of 3-of-a-kind. Try and refactor it a little bit if you want. There may be many ways to do this, but here is what I chose to do:
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 |
public int ScoreMoreThanThreeOfAKind(int multiplier = 2, int reductionCounter = 1, int dieCountValue = 4) { int score = 0; if (_dieCounts[1] == dieCountValue) { score += ScoreTripleOnes(); _dieCounts[1] -= reductionCounter; return score * multiplier; } for (int i = 2; i <= 6; i++) { if (_dieCounts[i] == dieCountValue) { score = ScoreTripleOthers(); _dieCounts[i] -= reductionCounter; return score * multiplier; } } if (dieCountValue < 6) { score = ScoreMoreThanThreeOfAKind(multiplier * 2, ++reductionCounter, ++dieCountValue); } return score; } |
Then update the CalculateScore() method (my new method ScoreMoreThanThreeOfAKind() replaced ScoreFourOfAKind(), ScoreFiveOfAKind() and ScoreSixOfAKind(), so you can just delete them:
1 2 3 4 5 6 7 8 9 10 11 12 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreMoreThanThreeOfAKind(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
After refactoring, all of my tests still pass. I’ll consider that successful, and move on to the next test!
Next up on the list is calculating triple pairs ([1, 1, 2, 2, 3, 3], [2, 2, 3, 3, 4, 4], etc…). The score for any triple pair is 1800 points. Here’s my test, again using a theory:
1 2 3 4 5 6 7 8 9 10 |
[Theory] [InlineData(1800, 1, 1, 2, 2, 3, 3)] [InlineData(1800, 2, 2, 3, 3, 4, 4)] [InlineData(1800, 1, 1, 3, 3, 5, 5)] [InlineData(1800, 2, 2, 4, 4, 6, 6)] [InlineData(1800, 2, 2, 5, 5, 6, 6)] public void Returns1800WhenTriplePairsRolled(int expectedValue, params int[] dieValues) { Assert.Equal(expectedValue, _game.CalculateScore(dieValues)); } |
Run your tests now, and all the new ones fail. Here’s how I made it pass:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public int ScoreTriplePairs() { int numberOfPairs = 0; for (int i = 1; i <= 6; i++) { if (_dieCounts[i] == 2) { numberOfPairs++; _dieCounts[i] -= 2; } } if (numberOfPairs == 3) { return 1800; } return 0; } |
And again, update CalculateScore():
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreMoreThanThreeOfAKind(); score += ScoreTriplePairs(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } |
Give it a run, they should all pass! Let’s get on to our final extra credit piece!
The last piece is checking for a straight and returning a score of 1200 points. Let’s start with the test. There’s only one condition for this, so we can switch back to using a Fact:
1 2 3 4 5 6 |
[Fact] public void Returns1200WhenStraightIsRolled() { int[] dieValues = { 1, 2, 3, 4, 5, 6 }; Assert.Equal(1200, _game.CalculateScore(dieValues)); } |
To make this work, I think I’ll use a method similar to step 6. I’ll just check that the count for each die rolled is equal to 1, and then I’ll set the count to 0 so that the dice aren’t counted in any other scoring methods. I thought I would get a little creative and use LINQ (Enumerable.All) for this:
1 2 3 4 5 6 7 8 9 10 11 12 |
public int ScoreStraight() { if (_dieCounts.All(x => x.Value == 1)) { foreach(var dieValue in _dieCounts.Keys.ToList()) { _dieCounts[dieValue] = 0; } return 1200; } return 0; } |
To my excitement, all tests passed!
The last thing I’ll do is change the access modifiers on all of the methods except CalculateScore() to private…mainly because I didn’t catch it until now. Since all of those methods are internal to the Game class, we should design it so that only the Game class can use them.
This kata is now complete (or is it??)! In Part 3 of this tutorial I will discuss refactoring even further using something called a Rules Engine / Rules Pattern! Stay tuned!
The final result of our code is below (download it here):
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 |
public class CalculateScore { Game _game = new Game(); [Fact] public void Returns100WhenSingleOneIsRolled() { int[] dieValues = { 1 }; Assert.Equal(100, _game.CalculateScore(dieValues)); } [Fact] public void Returns50WhenSingleFiveIsRolled() { int[] dieValues = { 5 }; Assert.Equal(50, _game.CalculateScore(dieValues)); } [Fact] public void Returns1000WhenTripleOnesRolled() { int[] dieValues = { 1, 1, 1 }; Assert.Equal(1000, _game.CalculateScore(dieValues)); } [Fact] public void Returns2000WhenOneOneOneOneRolled() { int[] dieValues = { 1, 1, 1, 1 }; Assert.Equal(2000, _game.CalculateScore(dieValues)); } [Theory] [InlineData(2, 200)] [InlineData(3, 300)] [InlineData(4, 400)] [InlineData(5, 500)] [InlineData(6, 600)] public void ReturnsExpectedValueWhenTripleIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue)); } [Fact] public void Returns2050GivenOneOneOneFiveOne() { int[] dieValues = { 1, 1, 1, 5, 1 }; Assert.Equal(2050, _game.CalculateScore(dieValues)); } [Fact] public void Returns0GivenGarbage() { int[] dieValues = { 2, 3, 4, 6, 2 }; Assert.Equal(0, _game.CalculateScore(dieValues)); } [Fact] public void Returns350GivenThreeFourFiveThreeThree() { int[] dieValues = { 3, 4, 5, 3, 3 }; Assert.Equal(350, _game.CalculateScore(dieValues)); } [Theory] [InlineData(true)] [InlineData(false, 1)] [InlineData(false, 1, 2)] [InlineData(false, 1, 2, 3)] [InlineData(false, 1, 2, 3, 4)] [InlineData(false, 1, 2, 3, 4, 5)] [InlineData(false, 1, 2, 3, 4, 5, 6)] [InlineData(true, 1, 2, 3, 4, 5, 6, 6)] public void ThrowsExceptionIfLessThanOneOrMoreThanSixDiceRolled(bool exceptionThrown, params int[] diceRolled) { var exception = Record.Exception(() => _game.CalculateScore(diceRolled)); Assert.Equal(exceptionThrown, exception != null); } [Theory] [InlineData(1, 2000)] [InlineData(2, 400)] [InlineData(3, 600)] [InlineData(4, 800)] [InlineData(5, 1000)] [InlineData(6, 1200)] public void ReturnsExpectedValueWhenFourOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue)); } [Theory] [InlineData(1, 4000)] [InlineData(2, 800)] [InlineData(3, 1200)] [InlineData(4, 1600)] [InlineData(5, 2000)] [InlineData(6, 2400)] public void ReturnsExpectedValueWhenFiveOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue, dieValue)); } [Theory] [InlineData(1, 8000)] [InlineData(2, 1600)] [InlineData(3, 2400)] [InlineData(4, 3200)] [InlineData(5, 4000)] [InlineData(6, 4800)] public void ReturnsExpectedValueWhenSixOfAKindIsRolled(int dieValue, int expectedScore) { Assert.Equal(expectedScore, _game.CalculateScore(dieValue, dieValue, dieValue, dieValue, dieValue, dieValue)); } [Theory] [InlineData(1800, 1, 1, 2, 2, 3, 3)] [InlineData(1800, 2, 2, 3, 3, 4, 4)] [InlineData(1800, 1, 1, 3, 3, 5, 5)] [InlineData(1800, 2, 2, 4, 4, 6, 6)] [InlineData(1800, 2, 2, 5, 5, 6, 6)] public void Returns1800WhenTriplePairsRolled(int expectedValue, params int[] dieValues) { Assert.Equal(expectedValue, _game.CalculateScore(dieValues)); } [Fact] public void Returns1200WhenStraightIsRolled() { int[] dieValues = { 1, 2, 3, 4, 5, 6 }; Assert.Equal(1200, _game.CalculateScore(dieValues)); } } |
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 |
public class Game { private Dictionary<int, int> _dieCounts = new Dictionary<int, int>(); private int ScoreSingleDie() { var dieValue = 0; dieValue += _dieCounts[1] * 100; dieValue += _dieCounts[5] * 50; return dieValue; } private int ScoreTripleOnes() { if (_dieCounts[1] >= 3) { _dieCounts[1] -= 3; return 1000; } return 0; } private int ScoreTripleOthers() { for (int i = 2; i <= 6; i++) { if (_dieCounts[i] >= 3) { _dieCounts[i] -= 3; return i * 100; } } return 0; } private int ScoreMoreThanThreeOfAKind(int multiplier = 2, int reductionCounter = 1, int dieCountValue = 4) { int score = 0; if (_dieCounts[1] == dieCountValue) { score += ScoreTripleOnes(); _dieCounts[1] -= reductionCounter; return score * multiplier; } for (int i = 2; i <= 6; i++) { if (_dieCounts[i] == dieCountValue) { score = ScoreTripleOthers(); _dieCounts[i] -= reductionCounter; return score * multiplier; } } if (dieCountValue < 6) { score = ScoreMoreThanThreeOfAKind(multiplier * 2, ++reductionCounter, ++dieCountValue); } return score; } private int ScoreTriplePairs() { int numberOfPairs = 0; for (int i = 1; i <= 6; i++) { if (_dieCounts[i] == 2) { numberOfPairs++; _dieCounts[i] -= 2; } } if (numberOfPairs == 3) { return 1800; } return 0; } private int ScoreStraight() { if (_dieCounts.All(x => x.Value == 1)) { foreach(var dieValue in _dieCounts.Keys.ToList()) { _dieCounts[dieValue] = 0; } return 1200; } return 0; } private void PopulateDieCounts(int[] dieValues) { for (int i = 1; i <= 6; i++) { _dieCounts.Add(i, dieValues.Count(d => d == i)); } } public int CalculateScore(params int[] dieValues) { if (dieValues.Length > 6 || dieValues.Length < 1) throw new System.Exception("You may only throw between 1 and 6 dice."); var score = 0; PopulateDieCounts(dieValues); score += ScoreMoreThanThreeOfAKind(); score += ScoreTriplePairs(); score += ScoreStraight(); score += ScoreTripleOnes(); score += ScoreTripleOthers(); score += ScoreSingleDie(); return score; } } |