If you were foolish enough to read yesterday’s article, you’ll recall that I put a new HoleFrame object in place, and that it wasn’t working. In particular a detailed test to check the open/closed status of the HoleFrame’s holes was failing. Here it is:

function Tests:test_holeframe_strike()
   local frame = HoleFrame()
   function check_tops(m, t1, t2, t3)
      local sp = " "
      local expected = t1 .. sp .. t2 .. sp .. t3
      local h = frame.holes
      local actual = h[1].top_lid .. sp .. h[2].top_lid .. sp .. h[3].top_lid
      self:assert_equals(actual, expected, m) 
   end
   function check_bots(m, t1, t2, t3)
      local sp = " "
      local expected = t1 .. sp .. t2 .. sp .. t3
      local h = frame.holes
      local actual = h[1].bottom_lid .. sp .. h[2].bottom_lid .. sp .. h[3].bottom_lid
      self:assert_equals(actual, expected, m)
   end

   check_tops("start", "open", "open", "closed")
   check_bots("start", "closed", "closed", "open")
   frame:roll(10)
   check_tops("r1", "closed", "open", "open")
   check_bots("r1", "closed", "open", "open")
   frame:roll(10)
   check_tops("r2", "closed", "closed", "open")
   check_bots("r2", "closed", "open", "open")
   frame:roll(10)
   frame:roll(10)
   frame:roll(10)
   self:assert_equals(frame.frame_score, 30)
end

There’s quite a lot there, but it’s really mostly just about giving me a decent way to write and display the hole status.

I wrote the test because this test also fails, and I really had rather expected it to pass:

function Tests:test_holeframe_perfect()
   frames = {}
   for frame = 1, 10 do
      table.insert(frames, HoleFrame())
   end
   for roll = 1, 12 do
      pins = 10
      for _, frame in ipairs(frames) do
         if pins then
            pins = frame:roll(pins)
         end
      end
   end
   self:assert_equals(frames[1].frame_score, 30)
   self:assert_equals(frames[10].frame_score, 300)
end

Yesterday, at the end of a session, I didn’t know what the issue was. Today, I think I do know, and I think it is this:

function HoleFrame:roll(pins)
   if pins == nil then return nil end
   self.roll_count = self.roll_count + 1
   for _, hole in ipairs(self.holes) do
      if pins then
         pins = hole:roll(self, pins)
      end
   end
end

When the frame receives a roll, that roll should be processed by at most one of the frame’s Holes. As written, it is potentially processed by all of them. That won’t do.

Remember the physical model that I have in mind, a ramp with three holes that may be covered or uncovered, into which a ball can fall. If it falls into a given hole, that hole unconditionally closes, and one way or another that roll is never presented to subsequent holes in that frame.

So one thing that is wrong is that we can send the roll to more than one hole. Let’s fix that.

function HoleFrame:roll(pins)
   if pins == nil then return nil end
   self.roll_count = self.roll_count + 1
   if self:hole_open(1) then
      pins = self:roll_hole(1, pins)
   elseif self:hole_open(2) then
      pins = self:roll_hole(2, pins)
   elseif self:hole_open(3) then
      pins = self:roll_hole(3, pins)
   end
   return pins
end

function HoleFrame:hole_open(index)
   return self.holes[index].top_lid == "open"
end

function HoleFrame:roll_hole(index, pins)
   return self.holes[index]:roll(self, pins)
end

I think that can rather clearly be done in a loop somehow but I wrote it out longhand because I was thinking of it one hole at a time. The complicated open/closed test is passing now, and the test_holeframe_perfect is passing the first of its two asserts:

   self:assert_equals(frames[1].frame_score, 30)
   self:assert_equals(frames[10].frame_score, 300)

The second is failing with frame 10’s frame score being 3, which is actually correct. We have no one summing the frames as yet. So the test is confused, and could be this:

function Tests:test_holeframe_perfect()
   frames = {}
   for frame = 1, 10 do
      table.insert(frames, HoleFrame())
   end
   for roll = 1, 12 do
      pins = 10
      for _, frame in ipairs(frames) do
         if pins then
            pins = frame:roll(pins)
         end
      end
   end
   local game_score = 0
   for _, frame in ipairs(frames) do
      game_score = game_score + frame.frame_score
   end
   self:assert_equals(game_score, 300)
end

And that passes. I think we’re all good. Let me do the alternating test. No, it’s too tricky. The test above is trying to do the job without a game object to provide the rolls and the sequence we need is 5 copies of 6, 4, 10 or similar.

Oh, I know. I can do this with a coroutine. Somewhat deep in the bag of tricks but let’s go for it.

OK, this is fancy but it runs—and fails:

function Tests:test_holeframe_alternating()
   function alternating_rolls()
      for i = 1, 5 do
         coroutine.yield(6)
         coroutine.yield(4)
         coroutine.yield(10)
      end
   end
   local frames = {}
   for frame = 1, 10 do
      table.insert(frames, HoleFrame())
   end
   local rolls = coroutine.create(alternating_rolls)
   local status = true
   while status do
      status, pins = coroutine.resume(rolls)
      -- print("roll", status, pins)
      if status and pins ~= nil then
         for _, frame in ipairs(frames) do
            if pins then
               frame:roll(pins)
            end
         end
      end
   end
   local game_score = 0
   for _, frame in ipairs(frames) do
      print(frame.frame_score)
      game_score = game_score + frame.frame_score
   end
   self:assert_equals(game_score, 200)
end

It’s failing with a score of 260 and every frame with a frame_score of 26. And I think I know just what that is We never fixed up set_remaining_holes_open.

function HoleFrame:set_remaining_holes_open()
   if self.roll_count == 1 then
      self.holes[2].top_lid = "open"
      self.holes[2].bottom_lid = "open"
   end
   self.holes[3].top_lid = "open"
   self.holes[3].bottom_lid = "open"
end

Until just now that if wasn’t in there. If we roll a spare, we only want to open the third hole, not re-open the second.

My alternating test runs. I think we’re solid, although we should do an open frames test just to be sure. I’ll adjust the alternating one to produce an open frame test:

function Tests:test_holeframe_open()
   function alternating_rolls()
      for i = 1, 10 do
         coroutine.yield(6)
         coroutine.yield(3)
      end
   end
   local frames = {}
   for frame = 1, 10 do
      table.insert(frames, HoleFrame())
   end
   local rolls = coroutine.create(alternating_rolls)
   local status = true
   while status do
      status, pins = coroutine.resume(rolls)
      if status and pins ~= nil then
         for _, frame in ipairs(frames) do
            if pins then
               frame:roll(pins)
            end
         end
      end
   end
   local game_score = 0
   for _, frame in ipairs(frames) do
      game_score = game_score + frame.frame_score
   end
   self:assert_equals(game_score, 90)
end

That passes. I failed to notice this failure, because my prints to dump status scrolled it off:

[06:20] Tests: Actual: closed, Expected: open.  [2b]  Tests:test_mark_opens_two_holes

What is that test?

function Tests:test_mark_opens_two_holes()
   local frame = HoleFrame()
   local hole = Hole("open", "closed")
   self:assert_equals(frame.holes[1].top_lid, 'open', "i1t")
   self:assert_equals(frame.holes[2].bottom_lid, 'closed', "i1b")
   self:assert_equals(frame.holes[2].top_lid, 'open', "i2t")
   self:assert_equals(frame.holes[2].bottom_lid, 'closed', "i2b")
   self:assert_equals(frame.holes[3].top_lid, 'closed', "i3t")
   self:assert_equals(frame.holes[3].bottom_lid, 'open', "i3b")
   hole:roll(frame, 10)
   self:assert_equals(frame.holes[2].top_lid, 'open', "2t")
   self:assert_equals(frame.holes[2].bottom_lid, 'open', "2b")
   self:assert_equals(frame.holes[3].top_lid, 'open', "3t")
   self:assert_equals(frame.holes[3].bottom_lid, 'open', "3b")
end

The test says that the second hole bottom is closed after we roll a ten. I don’t see how that could happen and my full game tests tell me that it isn’t happening during actual play.

Oh, the test isn’t rolling into the frame’s hole. Silly wabbit. I think we can just roll into the frame now.

   frame:roll(10)

Green.

Reflection

The Coroutine Trick

That’s somewhat fancy but worked well. Coroutines are a bit deeper in the bag of tricks than I’d usually choose to go, but I suspect I’d do well to become more facile with them. They have some very useful applications in some situations that I’m not writing here about. And the code to use the values was really almost simple:

   local status = true
   while status do
      status, pins = coroutine.resume(rolls)
      if status and pins ~= nil then
         for _, frame in ipairs(frames) do
            if pins then
               frame:roll(pins)
            end
         end
      end
   end

Before I’d go to production with something like this I’d want to make the looping and checking a bit less rife with status and nil checking and all that iffing about, but the essential notion of just reading a value and quitting when there are none is pretty nice. I think we could learn to package that up rather nicely.

Idea good, programmer needs to build skill.

Overall Result

I think my original point is (finally) proven: the whole Hole scheme works. (ha ha) We can put three quite simple identical objects into the frame and their interaction can score the frame.

So what? So nothing, the whole series has just been a spike to see whether my Rube Goldberg vision of a frame with holes in it might score bowling. Answer: Yes.

We can improve the code. I’ll do this much, replacing this:

function HoleFrame:roll(pins)
   if pins == nil then return nil end
   self.roll_count = self.roll_count + 1
   if self:hole_open(1) then
      pins = self:roll_hole(1, pins)
   elseif self:hole_open(2) then
      pins = self:roll_hole(2, pins)
   elseif self:hole_open(3) then
      pins = self:roll_hole(3, pins)
   end
   return pins
end

With oh, this:

function HoleFrame:roll(pins)
   if pins == nil then return nil end
   self.roll_count = self.roll_count + 1
   for _, hole in self.holes do
      if hole.top_lid == "open" then
         return hole:roll(self, pins)
      end
   end
   return pins
end

Summary

There are surely other improvements we could make. No one cares.

The purpose of a spike is to provide information. The information in this case is something like this:

  1. Yes, a simple Hole object and Frame containing three Holes can score a frame of bowling.
  2. Yes, a series of ten of these can score a game. We already knew that a properly coded single Frame type can handle all ten frames. No news here.
  3. The two objects aren’t so strikingly simple as to make us want to use them.
  4. They two objects are surely not so strikingly obvious that we would want to use them for the sake of clarity. In fact they’re rather obscure.

If we were building a frame scoring machine out of relays, or flaps and gears, this might be a pretty decent idea. In a software implementation, no.

Summary Summary

  1. I am vindicated: the idea works as I thought it would.
  2. The exercise was fun. I am here for fun.
  3. I suspect no one in their right mind would actually use this implementation.
  4. I’d do well to practice a bit with coroutines, see if I can pull them a bit further up from the depths of the bag of tricks.

Perfect outcome! See you soon!


The Code, just for your amusement.


-- -----------------
-- Hole

local Hole = class()
function Hole:init(top, bot)
   self.top_lid = top
   self.bottom_lid = bot
end

function Hole:roll(frame, pins)
   if self.top_lid == "closed" then
      return pins
   end
   self.top_lid = "closed"
   local total = frame:add_to_score(pins)
   if total == 10 then
      frame:set_remaining_holes_open()
   end
   if self.bottom_lid == "closed" then
      return nil
   else
      return pins
   end
end

-- ----------------
-- HoleFrame

local HoleFrame = class()
function HoleFrame:init()
   self.roll_count = 0
   self.frame_score = 0
   self.holes = {}
   table.insert(self.holes, Hole("open", "closed"))
   table.insert(self.holes, Hole("open", "closed"))
   table.insert(self.holes, Hole("closed", "open"))
end

function HoleFrame:roll(pins)
   if pins == nil then return nil end
   self.roll_count = self.roll_count + 1
   for _, hole in self.holes do
      if hole.top_lid == "open" then
         return hole:roll(self, pins)
      end
   end
   return pins
end

function HoleFrame:add_to_score(pins)
   self.frame_score = self.frame_score + pins
   return self.frame_score
end

function HoleFrame:set_remaining_holes_open()
   if self.roll_count == 1 then
      self.holes[2].top_lid = "open"
      self.holes[2].bottom_lid = "open"
   end
   self.holes[3].top_lid = "open"
   self.holes[3].bottom_lid = "open"
end