Forum: Ruby Bowling Scores (#181)

Announcement (2017-05-07): www.ruby-forum.com is now read-only since I unfortunately do not have the time to support and maintain the forum any more. Please see rubyonrails.org/community and ruby-lang.org/en/community for other Rails- und Ruby-related community platforms.
Matthew M. (Guest)
on 2008-10-31 19:02
(Received via mailing list)
Calculating bowling scores seems to be trivial, at least until you
start dealing with the exceptions. Strikes and spares can't be scored
until at least one ball from the next frame is thrown, and of course
the next frame's score must include the current frame's score.
Similarly, strikes and spares in the tenth frame require additional
balls be thrown, but not counted as an eleventh frame. There are
simple exceptions, but it's perhaps because it seems too simple a
problem that some of these exceptions and edge cases are forgotten or
handled improperly.

I'm going to look at the solution from _Douglas S._; it was well
documented, easy to read, and passed most of the edge cases I tested
(i.e. dealing primarily with strikes, spares and the tenth frame).
Let's look first at the main code and work backwards:

     if __FILE__ == $0
       name, *pins = *ARGV
       game = BowlingGame.new(name)
       pins.inject(game) {|game, p| game.score_roll(p.to_i); game}
       game.print_score_sheet
     end

Inside of the standard "Am I running from command-line?" test, Douglas
first separates the name argument from all the others, then constructs
a new `BowlingGame` object for the player. The next line scores each
roll, though I don't understand the reason for using `inject` over the
simpler and more typical code which accomplishes the same task:

       pins.each { |p| game.score_roll(p.to_i) }

In any case, once all pins have been scored, the game score sheet is
displayed.

Now let's look at the `BowlingGame` class, starting with initialization:

     # Create a bowling game for the given named player
     def initialize(name)
       @name = name
       @frames = Array.new(10) { |i| Frame.new(i+1) }
       @working = Array.new
     end

The player's name is remembered, and ten `Frame` objects are
constructed with appropriate frame numbers. A "working" frame array is
created, initially empty. The working array will keep references to
frames when a strike or spare was rolled, until all the bonus pins
have been counted.

The method `score_roll` is where the bulk of the work is accomplished.
Each roll's number of pins felled is the parameter.

     # Score a roll of the given number of pins
     def score_roll(pins)

Finding the current frame is simply looking for the first frame that
isn't finished. Finished frames are those containing strikes, spares,
or open (i.e. neither a strike or spare, but two balls have been
scored for the frame).

       # Find the current frame
       frame = @frames.find {|f| !f.finished? }

Next, a quick sanity check against too many reported scores. If all
frames are finished (i.e. `frame` is nil) and there are no working
frames (i.e. not waiting on bonus points for a spare or strike), but
there is still input, then there was too many input values provided.

       # If we have no current frame and nothing is working, we are
       # scoring too many rolls
       if frame.nil? && @working.empty?
         raise "Too many rolls are being scored in this game."
       end

The next part was a little tricky to follow at first, but does make
sense. We delete any working frames if they're not working... but note
that the "not working" condition is checked _after_ a call to
`f.bonus`, which will store the bonus points for working frames (i.e.
strikes and spares). The call to `bonus` can change the frame's
working status (which it should do after one bonus roll for spares,
and two bonus rolls for strikes).

       # Score bonus pins for strikes and spares that are working
       @working.delete_if {|f| f.bonus(pins); !f.working? }

Keep in mind that `@working` needs to be an array, rather than a
single frame. Two sequential strikes, or a strike followed by a spare,
leaves two frames waiting for bonus points, so we need the array.

Finally, we score the current round. We skip this part if there is no
frame (which implies the roll is just for bonus points, as the comment
suggest). If there is a frame, we call `score_roll` on it, then append
it to `@working` if it was a strike or a spare and needs bonus points.

       # If we found no current frame, we are in bonus rolls of
       # the tenth frame
       return if !frame

       # Score this ball on the current frame and move it to
       # working if we rolled a spare or strike
       frame.score_roll(pins)
       if frame.spare? || frame.strike?
         @working << frame
       end
     end

That's it for the main game. The rest of `BowlingGame` is quite simple
and needs little explanation, so I'll pass describing it here, except
to say that I was pleased to see output more like a typical bowling
game scoring table:

     John+---+---+---+---+---+---+---+---+---+---+
     | 62| 71|  X| 9-| 8/|  X|  X| 35| 72|5/8|140|
     |  8| 16| 35| 44| 64| 87|105|113|122|140|   |
     +---+---+---+---+---+---+---+---+---+---+---+

In each square, the characters in the top row represent the individual
rolls (e.g. "62" means two rolls: 6 pins followed by 2 pins). The
bottom row is the accumulating score. The sizes work out just
perfectly, since there can never be more than three rolls used per
frame, and the score is capped at three digits. Highly compact and
complete.

I don't want to skip out completely on the `Frame` object. Most of it
is concerned with status information (e.g. methods like `strike?`) and
display, but let's take a look at the `score_roll` method, since this
goes hand-in-hand with `BowlingGame#score_roll`.

To start, we keep track of the first roll for a frame in `@first_pin`.

     def score_roll(pins)
       @first_pin ||= pins

For anyone unfamiliar with this little technique, realize that this
line is the same as:

       @first_pin = @first_pin || pins

When you see that `@first_pin` is initialized with nil (in the
initializer for `Frame`), you should realize this technique allows us
to assign a value to `@first_pin` once. After the first assignment, it
won't change again.

Back to the bowling, note that `@first_pin` is used only to help with
proper display; it has no direct effect on the scoring process. Let's
now move onto the rest of `score_roll`, which is a simple state machine.

       if @state.nil?
         @score += pins
         if @score == 10
           @state = :strike
         else
           @state = :incomplete
         end

Our first section of this state machine is when `@state` is nil, which
is only the case when the frame is first created, before any rolls
have been scored. In this case, we update the score and change the
state, either to a strike (when all ten pins have been knocked down)
or incomplete. Now let's see how to handle the incomplete state.

       elsif @state == :incomplete
         @score += pins
         if @score > 10
           raise "Illegal roll in incomplete frame with score #{@score
- pins}: #{pins}"
         end
         if @score == 10
           @state = :spare
         else
           @state = :open
         end

Again, we update the score, but also check that the score looks
reasonable, and throw an exception if not. If the score is now ten,
it's a spare; it can't be a strike, since to be in the incomplete
state, it must have score at least one ball prior. If the score is
other than ten, it's called an open frame (i.e. the frame is finished,
with a simple score, needing no bonus points).

       end
     end

There are no other states to handle; any other state is ignored.
Actually, `score_roll` should never be called on frames in any other
state. An exception here could be used to check that claim, or at
least sufficient unit tests and/or code coverage tools.

Another error check might be worthwhile, that every pin count passed
into `score_roll` (originating from the command-line) is strictly
within the range zero to ten, inclusive. Right now, I can call the
script like so:

     ruby score.rb -16 6 -16 6

No complaints will be generated; the error check in `score_roll` is
good, but not sufficient for all cases. Still, this is Ruby Q., and
we're not gonna get too picky about error checking. But it is
something to keep in mind for the next time you're programming a
bowling scorekeeper.

An error check I would _not_ include is for incomplete games. I
thought about such a thing initially, but it's nice to be able to see
the scorecare for a game in progress.



Great solutions, everyone!  No quiz this week due to work load, but
will be back next week.
This topic is locked and can not be replied to.