Programming Fundamentals - Lab 7 - Solution

The problem

From labs/7-replay.rb in your copy of ruby-intro:

The game in 6-craps.rb is random, so running it a few times proves very little. Here is the same game with one change: instead of rolling dice, it reads each total from a list of rolls written down ahead of time. Same rolls in, same result out, every time. That can be checked.

Run it from the top folder of the repo:

bin/rails runner labs/7-replay.rb

The lab comes in four steps, and the order matters: the check is written, and seen to fail, before the game goes in.

1. Decide what right looks like

The file starts with one game: a list of rolls, and the result you expect.

games = [
  { "rolls" => [7], "expected" => "YOU WIN!" }
]

It's an array of hashes, like the teams table: one hash per game. Your job is to add games, and to decide each expected result from the rules, before you run anything. Which lists of rolls would test every rule?

games = [
  { "rolls" => [7], "expected" => "YOU WIN!" },
  { "rolls" => [12], "expected" => "YOU LOSE!" },
  { "rolls" => [8, 4, 8], "expected" => "YOU WIN!" },
  { "rolls" => [8, 7], "expected" => "YOU LOSE!" },
  { "rolls" => [8, 11, 8], "expected" => "YOU WIN!" },
  { "rolls" => [8, 2, 8], "expected" => "YOU WIN!" }
]

Each game is there for a rule:

Rolls Tests
[7] 7 on the first roll wins
[12] 12 on the first roll loses
[8, 4, 8] the point comes up again: win
[8, 7] a 7 after the point: lose
[8, 11, 8] an 11 after the point does nothing
[8, 2, 8] a 2 after the point does nothing

The last two are the ones random play almost never shows you, and the ones a chat most often gets wrong. You could add [11], [2] and [3] too; each is a rule of its own.

2 and 3. Write the check, and watch it fail

The file loops through the games with for game in games, and starts each one with result = "not played yet". Before the game goes in, write the check: the result we got against the result we expected.

  if result == game["expected"]
    puts "PASS: #{rolls} #{result}"
  else
    raise "FAIL: #{rolls} expected #{game["expected"]}, got #{result}"
  end

raise stops the program with an error, and the error says what you told it to say. Run the file now, with no game in it:

FAIL: [7] expected YOU WIN!, got not played yet (RuntimeError)

That's the point of this step. Nothing has played, so the check should fail, and it does. A check you have never seen fail might not be checking anything.

When you see an error, read its first line: that's the message. The lines under it, starting with from, show where the program was when it stopped. Most of them are Rails, not you.

4. Put the game in

Paste the game from 6-craps.rb above the check and change one thing: where each total comes from. Instead of rand, read the next number from rolls. An index that starts at 0 keeps track of which roll is next:

  total = rolls[index]
  index = index + 1

And instead of puts "YOU WIN!", save it: result = "YOU WIN!", so the check can compare it. A chat can make this change for you:

Here is my craps game in plain Ruby:

[paste your game from labs/6-craps.rb here]

Change one thing: instead of rolling dice, each total should come from a list called rolls, read in order with an index that starts at 0. Don't print anything. Save "YOU WIN!" or "YOU LOSE!" in a variable called result. If the list runs out before the game ends, raise an error that says so, so it can't loop forever.

Keep everything else the same: the same rules, plain Ruby, no methods, no classes. Give me just the code, to paste inside a loop that already sets rolls.

That "runs out" line is worth having. [8, 4] sets a point and never ends the game. With dice, you'd just roll again. With a list, rolls[2] is nil, which is neither the point nor a 7, so the loop would go around forever. Checking for nil turns that into an error you can read:

FAIL: [8, 4] ran out of rolls before the game ended (RuntimeError)

Run the file again. Six games, six PASS.

Final code

# 1. Games to replay: the rolls, and the result you expect.
games = [
  { "rolls" => [7], "expected" => "YOU WIN!" },
  { "rolls" => [12], "expected" => "YOU LOSE!" },
  { "rolls" => [8, 4, 8], "expected" => "YOU WIN!" },
  { "rolls" => [8, 7], "expected" => "YOU LOSE!" },
  { "rolls" => [8, 11, 8], "expected" => "YOU WIN!" },
  { "rolls" => [8, 2, 8], "expected" => "YOU WIN!" }
]

# 2. Replay each game
for game in games
  rolls = game["rolls"]
  result = "not played yet"
  index = 0

  # 4. The game, as in 6-craps.rb, except where each total comes from
  total = rolls[index]
  index = index + 1

  if total == 7 || total == 11
    result = "YOU WIN!"
  elsif total == 2 || total == 3 || total == 12
    result = "YOU LOSE!"
  else
    point = total

    loop do
      total = rolls[index]
      index = index + 1

      # a game that runs out of rolls would otherwise loop forever
      if total == nil
        raise "FAIL: #{rolls} ran out of rolls before the game ended"
      end

      if total == point
        result = "YOU WIN!"
        break
      elsif total == 7
        result = "YOU LOSE!"
        break
      end
    end
  end

  # 3. Check: the result we got against the result we expected.
  #    Written and run before the game was in, so we saw it fail first.
  if result == game["expected"]
    puts "PASS: #{rolls} #{result}"
  else
    raise "FAIL: #{rolls} expected #{game["expected"]}, got #{result}"
  end
end

Output:

PASS: [7] YOU WIN!
PASS: [12] YOU LOSE!
PASS: [8, 4, 8] YOU WIN!
PASS: [8, 7] YOU LOSE!
PASS: [8, 11, 8] YOU WIN!
PASS: [8, 2, 8] YOU WIN!

Run it again. And again. The same six lines every time. That's what makes it checkable.

Try breaking it

Two experiments, to see why step 3 came first.

Break the game. Where the point comes up, change result = "YOU WIN!" to result = "YOU LOSE!". Run it:

PASS: [7] YOU WIN!
PASS: [12] YOU LOSE!
FAIL: [8, 4, 8] expected YOU WIN!, got YOU LOSE! (RuntimeError)

The check caught it, and told you which game and what it got. Put it back.

Break the check. In the check, change == to =, so the line reads
if result = game["expected"]. Run it: six PASS. Now break the game again the same way. Still six PASS.

One = doesn't compare; it sets result to the expected value, and then the PASS line prints that. The game is wrong and every line says it's fine. If this check had been written after the game, and passed the first time, you'd have no reason to doubt it. Because you watched it fail when nothing had played, you know it can.

If a check fails and you're sure the game is right, look at the expected result too. Deciding what right looks like is part of the job, and it can be wrong.