Last active
October 14, 2015 17:25
-
-
Save bitwalker/ab540d82090810041155 to your computer and use it in GitHub Desktop.
Bowling scorer in Elixir
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Bowling do | |
@moduledoc """ | |
Takes a bowling game score, with 10 frames seperated by spaces: | |
`X 53 18 5/ 5/ 44 36 35 9/ 9/4` | |
An ‘X’ marks a strike, a ‘/’ marks a spare. All frames can have 2 bowls, | |
except for the last which has up to 3. Your challenge is to come up with a | |
program that takes the above string as an input and outputs the integral score. | |
""" | |
@doc """ | |
Scores a given bowling game given an input string which represents the scoring of each frame. | |
## Examples | |
iex> Bowling.score "X 53 18 5/ 5/ 44 36 35 9/ 9/4" | |
{:ok, 122} | |
iex> Bowling.score "X X X X X X X X X XXX" | |
{:ok, 300} | |
""" | |
def score(str) when is_binary(str) do | |
str | |
|> String.split(" ", trim: true) | |
|> calculate(0) | |
end | |
def score(_), do: {:error, "Scoring requires a string representing the score board."} | |
############ | |
# Explanation of how `calculate` works: | |
# | |
# First, `score` takes the input string, and splits it into a list of substrings, | |
# So when `calculate` is called, it's first argument is that list, and it's second | |
# argument is the accumulator for the score, initialized to zero. | |
# | |
# `calculate` is recursive, and the recursion is controlled by pattern matching against | |
# the list of strings to be parsed. When we get an empty list, or pattern match against | |
# the last element of the list (as we do with the last frame section), we know we can stop | |
# recursing and return the score. | |
# | |
# Pattern matching in Elixir is quite rich, and allows you to nest matches, for instance, | |
# the version of `calculate` which matches a triple with a strike in the last frame. We | |
# are matching on three list elements, one of which is a string which we expect to start | |
# with a strike symbol, but we don't care what comes after. | |
# | |
# One of the advantages of this style of programming is that the rules for scoring | |
# are declaritive. Given a specific pattern for a given frame or set of frames, we | |
# can see precisely how that will be scored. It also allows us to avoid nested conditionals, | |
# error handling, and logic for different situations being mixed together. | |
# | |
# Another benefit of this approach, at least as it applies to Elixir/Erlang, is that the input | |
# is only ever iterated over twice (once to split the string, once to score each frame). We could | |
# easily modify this to read the string a single time, but for this exercise I went for the slightly | |
# clearer solution of splitting the string first. | |
# We've reached the end of the calculation, return the score | |
defp calculate([], score), do: {:ok, score} | |
# Triple | |
defp calculate(["X", "X", "X" | rest], score) do | |
calculate(["X", "X" | rest], score + 30) | |
end | |
# Special case: Triple, including strike in last frame | |
defp calculate(["X", "X", <<?X, _::binary>> = frame], score) do | |
calculate(["X", frame], score + 30) | |
end | |
# Special case: Triple, including strikes from last frame | |
defp calculate(["X", frame | rest], score) when frame in ["XX", "XXX"] do | |
calculate([frame | rest], score + 30) | |
end | |
# Double | |
defp calculate(["X", "X", <<roll::utf8, _::utf8>> = frame | rest], score) when roll in ?0..?9 do | |
calculate(["X", frame | rest], score + 20 + List.to_integer([roll])) | |
end | |
# Special case, double with strike from last frame | |
defp calculate(["X", <<?X, roll::utf8>> = frame | rest], score) when roll in ?0..?9 do | |
calculate([frame | rest], score + 20 + List.to_integer([roll])) | |
end | |
# Strike, 10 + value of next two rolls | |
defp calculate(["X", <<roll1::utf8, roll2::utf8>> = frame | rest], score) when roll1 in ?0..?9 and roll2 in ?0..?9 do | |
calculate([frame | rest], score + 10 + List.to_integer([roll1]) + List.to_integer([roll2])) | |
end | |
# Special case, spare followed by strike. | |
defp calculate([<<_::utf8, ?/>>, "X" | rest], score) do | |
calculate(["X" | rest], score + 20) | |
end | |
# Spare, 10 + value of next roll | |
defp calculate([<<_::utf8, ?/>>, <<roll2::utf8, _::binary>> = frame | rest], score) do | |
case roll2 do | |
?X -> calculate([frame | rest], score + 20) | |
n when n in ?0..?9 -> calculate([frame | rest], score + 10 + List.to_integer([roll2])) | |
end | |
end | |
# Special case, last frame | |
defp calculate([<<?X, ?X, ?X>>], score), do: {:ok, score + 30} | |
defp calculate([<<?X, ?X, roll3::utf8>>], score), do: {:ok, score + 20 + List.to_integer([roll3])} | |
defp calculate([<<?X, ?X>>], _), do: {:error, "Incomplete game, last frame has two strikes, one more roll left."} | |
defp calculate([<<?X, roll2::utf8>>], score), do: {:ok, score + 10 + List.to_integer([roll2])} | |
defp calculate([<<_::utf8, ?/, roll2::utf8>>], score) when roll2 in ?0..?9 do | |
{:ok, score + 10 + List.to_integer([roll2])} | |
end | |
# Normal frame, two rolls | |
defp calculate([<<roll1::utf8, roll2::utf8>> | rest], score) when roll1 in ?0..?9 and roll2 in ?0..?9 do | |
calculate(rest, score + List.to_integer([roll1]) + List.to_integer([roll2])) | |
end | |
defp calculate([frame | _], _) do | |
{:error, "Invalid frame: `#{frame}`"} | |
end | |
end | |
ExUnit.start autorun: true | |
defmodule BowlingTests do | |
use ExUnit.Case, async: true | |
# doctests don't work in Elixir Playground apparently :/ | |
# doctest Bowling | |
test "can score a game" do | |
assert {:ok, 122} = Bowling.score "X 53 18 5/ 5/ 44 36 35 9/ 9/4" | |
end | |
test "can score a perfect game" do | |
assert {:ok, 300} = Bowling.score "X X X X X X X X X XXX" | |
end | |
test "input strings are validated" do | |
{:error, err} = Bowling.score "X 53 18 5/ 5/ 44 36 35 9/ 9/F" | |
assert ^err = "Invalid frame: `9/F`" | |
end | |
test "only complete games are scored" do | |
{:error, err} = Bowling.score "X 53 18 5/ 5/ 44 36 35 9/ XX" | |
assert ^err = "Incomplete game, last frame has two strikes, one more roll left." | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Run it here