Sunday, April 17, 2011

Greed Kata–Second attempt

When I wrote the post on my first attempt, I mentioned that I thought I was “missing something obvious.” It occurred to me after I put up that post.

A die can only be used in one scoring combination

This is probably obvious to everyone else but I’m only responsible for what goes on in my head.

This realization push me to think that calculating the score using the Pipes and Filters pattern would probably be a good fit. I started with the data that each scorer, the filter, would operate on:

   1: public class GameContext
   2:  {
   3:      public int Score;
   4:      public int[] DiceValues;
   5:  }

Each filter would use the die values stored in the DiceValues field and update the Score field.


For the scorers, I started with the single value rules:



A single one (1) is worth 100 points.
A single five (5) is worth 50 points.


Working through lead to the following:



   1: public class ValueScorer
   2: {
   3:     private readonly int _die;
   4:     private readonly int _value;
   5:  
   6:     public ValueScorer(int die, int value)
   7:     {
   8:         _die = die;
   9:         _value = value;
  10:     }
  11:  
  12:     public GameContext Compute(GameContext context)
  13:     {
  14:         if (0 == context.DiceValues.Length) return context;
  15:         var occurances = Array.FindAll(context.DiceValues, v => v== this._die).Length;
  16:         context.Score += occurances * this._value;
  17:  
  18:         List<int> diceValues = RemoveValuesUsedInAScoringCombination(context.DiceValues, occurances);
  19:         context.DiceValues = diceValues.ToArray();
  20:  
  21:         return context;
  22:     }
  23:  
  24:     private List<int> RemoveValuesUsedInAScoringCombination(int[] currentDiceValues, int occurances)
  25:     {
  26:         var diceValues = new List<int>(currentDiceValues);
  27:         foreach (var ii in Enumerable.Range(1, occurances))
  28:         {
  29:             diceValues.Remove(this._die);
  30:         }
  31:         return diceValues;
  32:     }
  33: }


The next set of rules:


A set of three ones (1) is worth 1000 points
A set of three of any other number is worth 100 time that number (ex. {2,2,2} = 200 points}.

For which I ended up with:

 


   1: public class TripleScorer
   2: {
   3:     public GameContext Compute(GameContext context)
   4:     {
   5:         if (0 == context.DiceValues.Length) return context;
   6:  
   7:         var triples = context.DiceValues.GroupBy(d => d).Where(g => g.Count() >= 3);
   8:  
   9:         context.Score += triples.Sum(g => (g.Count() / 3) * ((1 == g.Key) ? 1000 : g.Key * 100));
  10:  
  11:         List<int> diceValues = RemoveValuesUsedInAScoringCombination(context.DiceValues, triples);
  12:         context.DiceValues = diceValues.ToArray();
  13:  
  14:         return context;
  15:     }
  16:  
  17:     private List<int> RemoveValuesUsedInAScoringCombination(int[] currentDiceValues, IEnumerable<IGrouping<int, int>> triples)
  18:     {
  19:         var diceValues = new List<int>(currentDiceValues);
  20:         foreach (var tripleGroup in triples)
  21:         {
  22:             int digitsToRemove = (tripleGroup.Count()/3)*3;
  23:             foreach (var ii in Enumerable.Range(1,digitsToRemove))
  24:             {
  25:                 diceValues.Remove(tripleGroup.Key);
  26:             }
  27:         }
  28:         return diceValues;
  29:     }
  30: }


The method RemoveValuesUsedInAScoringCombination() is key to making this approach work. This method updates the DiceValues array in the context to remove the values that were used. This piece enforces the constraint that a die is only used once in a scoring combination.


The driver is really straight forward and look like:



   1: public class Scorer
   2: {
   3:     public int Computer(int[] diceValues)
   4:     {
   5:         if (0 == diceValues.Length) return 0;
   6:  
   7:         var tripleScorer = new TripleScorer();
   8:         var oneScorer = new ValueScorer(1,100);
   9:         var fiveScorer = new ValueScorer(5,50);
  10:  
  11:         var context = new GameContext() {DiceValues = diceValues, Score = 0};
  12:  
  13:         context = tripleScorer.Compute(context);
  14:         context = oneScorer.Compute(context);
  15:         context = fiveScorer.Compute(context);
  16:  
  17:         return context.Score;
  18:     }
  19: }

What I find really nice about this approach is that adding additional scoring combinations is just a matter of creating another scorer. For example, say that the ruling body for the game Greed introduces the following rule:



A set of five consecutive numbers (ex. {1,2,3,4,5} or {2,3,4,5,6}) is called a Straight and is worth 2000 points.


This is easily implemented with a Scorer such as:



   1: public class StraightScorer
   2: {
   3:     public GameContext Compute(GameContext context)
   4:     {
   5:         if (0 == context.DiceValues.Length) return context;
   6:  
   7:         foreach (int startingAt in Enumerable.Range(1,2))
   8:         {
   9:             if (this.DiceValuesContainStraight(startingAt, context.DiceValues))
  10:             {
  11:                 context.Score += 2000;
  12:                 context.DiceValues = this.RemoveValuesUsedInAScoringCombination(context.DiceValues, startingAt).ToArray();
  13:             }
  14:         }
  15:  
  16:         return context;
  17:     }
  18:  
  19:     private bool DiceValuesContainStraight(int straightStartsAt, int[] diceValues)
  20:     {
  21:         foreach (var n in Enumerable.Range(straightStartsAt,5))
  22:         {
  23:             if (!diceValues.Contains(n)) return false;
  24:         }
  25:         return true;
  26:     }
  27:  
  28:     private List<int> RemoveValuesUsedInAScoringCombination(int[] currentDiceValues, int startingAt)
  29:     {
  30:         var diceValues = new List<int>(currentDiceValues);
  31:         foreach (var n in Enumerable.Range(startingAt, 5))
  32:         {
  33:             diceValues.Remove(n);
  34:         }
  35:         return diceValues;
  36:     }
  37:  
  38: }

The driver gets modified as:



   1: public class Scorer
   2: {
   3:     public int Computer(int[] diceValues)
   4:     {
   5:         if (0 == diceValues.Length) return 0;
   6:  
   7:         var straightScorer = new StraightScorer();
   8:         var tripleScorer = new TripleScorer();
   9:         var oneScorer = new ValueScorer(1,100);
  10:         var fiveScorer = new ValueScorer(5,50);
  11:  
  12:         var context = new GameContext() {DiceValues = diceValues, Score = 0};
  13:  
  14:         context = straightScorer.Compute(context);
  15:         context = tripleScorer.Compute(context);
  16:         context = oneScorer.Compute(context);
  17:         context = fiveScorer.Compute(context);
  18:  
  19:         return context.Score;
  20:     }
  21: }

Modifying the previous version would not have been nearly as simple.

1 comment:

  1. "Modifying the previous version would not have been nearly as simple." A lesson in itself maybe.

    The refactoring exercise would have been most compelling. We all know a better way after shipping. The challenge lies in working that better way into the product safely.

    ReplyDelete