diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..41df210d --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# gem "rails" + +gem "rspec", "~> 3.12" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..135b57b2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,26 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.5.0) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.0) + +PLATFORMS + x86_64-darwin-20 + +DEPENDENCIES + rspec (~> 3.12) + +BUNDLED WITH + 2.4.13 diff --git a/README.md b/README.md index 15dc4762..4c2bd6c9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -Bowling Challenge in Ruby -================= +# Bowling Challenge in Ruby -* Feel free to use google, your notes, books, etc. but work on your own -* If you refer to the solution of another coach or student, please put a link to that in your README -* If you have a partial solution, **still check in a partial solution** -* You must submit a pull request to this repo with your code by 9am Monday week +Initially I struggled to understand what was being asked in this task, and what the inputs and outputs should be. The Scorecard class works alone but I have added a Bowling class and Application class as this helped me better understand the functionality of the Scorecard. + +I found this link useful to understand what should happen step by step: [How Is Bowling Scored?](https://bowlingforbeginners.com/how-is-bowling-scored/) + +I wanted my challenge to reflect a real life bowling situation to produce a scorecard that could look something like this: +![bowling scorecard](images/example-scorecard.png) + +This was my initial design of the steps. I have decided to create a running total for each frame, and if there is a bonus updates once the next balls have been played. + +![my design](images/bowling-design.jpg) ## The Task @@ -16,22 +21,24 @@ A bowling game consists of 10 frames in which the player tries to knock down the As usual please start by -* Forking this repo +- Forking this repo -* Finally submit a pull request before Monday week at 9am with your solution or partial solution. However much or little amount of code you wrote please please please submit a pull request before Monday week at 9am. +- Finally submit a pull request before Monday week at 9am with your solution or partial solution. However much or little amount of code you wrote please please please submit a pull request before Monday week at 9am. -___STRONG HINT, IGNORE AT YOUR PERIL:___ Bowling is a deceptively complex game. Careful thought and thorough diagramming — both before and throughout — will save you literal hours of your life. +**STRONG HINT, IGNORE AT YOUR PERIL:** Bowling is a deceptively complex game. Careful thought and thorough diagramming — both before and throughout — will save you literal hours of your life. ## Focus for this challenge + The focus for this challenge is to write high-quality code. In order to do this, you may pay particular attention to the following: -* Using diagramming to plan your approach to the challenge -* TDD your code -* Focus on testing behaviour rather than state -* Commit often, with good commit messages -* Single Responsibility Principle and encapsulation -* Clear and readable code + +- Using diagramming to plan your approach to the challenge +- TDD your code +- Focus on testing behaviour rather than state +- Commit often, with good commit messages +- Single Responsibility Principle and encapsulation +- Clear and readable code ## Bowling — how does it work? @@ -60,6 +67,6 @@ A Perfect Game is when the player rolls 12 strikes (10 regular strikes and 2 str In the image below you can find some score examples. -More about ten pin bowling here: http://en.wikipedia.org/wiki/Ten-pin_bowling +More about ten pin bowling here: [http://en.wikipedia.org/wiki/Ten-pin_bowling](http://en.wikipedia.org/wiki/Ten-pin_bowling) ![Ten Pin Score Example](images/example_ten_pin_scoring.png) diff --git a/app.rb b/app.rb new file mode 100644 index 00000000..ba930c6c --- /dev/null +++ b/app.rb @@ -0,0 +1,26 @@ +require_relative 'lib/bowling' +require_relative 'lib/scorecard' + +class Application + + Frames = 10 + + def play + bowling = Bowling.new(Kernel) + scorecard = Scorecard.new + + 9.times do |i| + frame = bowling.frame + scorecard.add_frame(frame) + end + + # 10th frame? + frame_10 = bowling.frame + scorecard.add_final_frame(frame_10) + + p scorecard.scores + end +end + +app = Application.new +app.play \ No newline at end of file diff --git a/images/bowling-design.jpg b/images/bowling-design.jpg new file mode 100644 index 00000000..f0b436be Binary files /dev/null and b/images/bowling-design.jpg differ diff --git a/images/example-scorecard.png b/images/example-scorecard.png new file mode 100644 index 00000000..65fa3aec Binary files /dev/null and b/images/example-scorecard.png differ diff --git a/lib/bowling.rb b/lib/bowling.rb new file mode 100644 index 00000000..1ff16c64 --- /dev/null +++ b/lib/bowling.rb @@ -0,0 +1,26 @@ +class Bowling + def initialize(io) + @io = io + end + + def frame + roll_1 = roll(1) + return [roll_1] if roll_1 == 10 + roll_2 = roll(2) + return [roll_1, roll_2] + end + + def final_frame + score = frame + + return if score.sum != 10 + score << roll("bonus 1") + score << roll("bonus 2") if score.include?(10) + return score + end + + def roll(number) + @io.puts "Roll #{number} - number of pins down: " + return @io.gets.chomp.to_i + end +end \ No newline at end of file diff --git a/lib/scorecard.rb b/lib/scorecard.rb new file mode 100644 index 00000000..e733951b --- /dev/null +++ b/lib/scorecard.rb @@ -0,0 +1,66 @@ +class Scorecard + def initialize + @scores = [] + end + + def scores + @scores + end + + def add_frame(pins_down) + calculate_bonus(pins_down) unless @scores.empty? || @scores.last[:rolls].sum != 10 + + return @scores << { rolls: pins_down } if pins_down.sum == 10 + + # Not a spare or strike + total = @scores.empty? ? pins_down.sum : @scores.last[:score] + pins_down.sum + @scores << { rolls: pins_down, score: total } + end + + def add_final_frame(pins_down) + + if @scores[-2][:rolls].include?(10) + get_prev = @scores[-3][:score] + @scores[-2][:score] = get_prev + 20 + pins_down[0] + end + + if @scores[-1][:rolls].include?(10) + add_strike_bonus(pins_down.slice(0, 2)) + elsif @scores[-1][:rolls].sum == 10 + add_spare_bonus(pins_down[0]) + end + + total = @scores.last[:score] + pins_down.sum + @scores << { rolls: pins_down, score: total } + end + + private + + def calculate_bonus(pins_down) + # last roll was spare + if !@scores.last[:rolls].include?(10) + add_spare_bonus(pins_down[0]) + else + # last roll was strike + # check if previous was also strike + if @scores[-2] && @scores[-2][:rolls].include?(10) + get_prev = @scores[-3] ? @scores[-3][:score] : 0 + @scores[-2][:score] = get_prev + 20 + pins_down[0] + end + + add_strike_bonus(pins_down) if !pins_down.include?(10) + end + end + + def add_spare_bonus(bonus_1) + @scores.last[:score] = previous_score + 10 + bonus_1 + end + + def add_strike_bonus(bonus_2) + @scores.last[:score] = previous_score + 10 + bonus_2.sum + end + + def previous_score + return @scores[-2] ? @scores[-2][:score] : 0 + end +end \ No newline at end of file diff --git a/spec/bowling_spec.rb b/spec/bowling_spec.rb new file mode 100644 index 00000000..66df1072 --- /dev/null +++ b/spec/bowling_spec.rb @@ -0,0 +1,54 @@ +require 'bowling' + +describe Bowling do + context '#roll' do + it "gets the number of pins down on one roll" do + io = double :io + + expect(io).to receive(:puts).with("Roll 1 - number of pins down: ") + expect(io).to receive(:gets).and_return("4") + + bowling = Bowling.new(io) + bowling.roll(1) + end + end + + context '#frame' do + it 'returns an array containing 10 if roll a strike' do + io = double :io + + expect(io).to receive(:puts).with("Roll 1 - number of pins down: ") + expect(io).to receive(:gets).and_return("10") + + bowling = Bowling.new(io) + result = bowling.frame + expect(result).to eq [10] + end + + it 'returns an array of two rolls' do + io = double :io + + expect(io).to receive(:puts).with("Roll 1 - number of pins down: ") + expect(io).to receive(:gets).and_return("3") + expect(io).to receive(:puts).with("Roll 2 - number of pins down: ") + expect(io).to receive(:gets).and_return("4") + + bowling = Bowling.new(io) + result = bowling.frame + expect(result).to eq [3, 4] + end + + it 'returns an array of 0s if no pins knocked down' do + io = double :io + + expect(io).to receive(:puts).with("Roll 1 - number of pins down: ") + expect(io).to receive(:gets).and_return("0") + expect(io).to receive(:puts).with("Roll 2 - number of pins down: ") + expect(io).to receive(:gets).and_return("0") + + bowling = Bowling.new(io) + result = bowling.frame + expect(result).to eq [0, 0] + end + end +end \ No newline at end of file diff --git a/spec/scorecard_spec.rb b/spec/scorecard_spec.rb new file mode 100644 index 00000000..e441e602 --- /dev/null +++ b/spec/scorecard_spec.rb @@ -0,0 +1,218 @@ +require 'scorecard' + +context Scorecard do + it 'initialises with an empty array of scores' do + scorecard = Scorecard.new + expect(scorecard.scores).to eq [] + end + + it 'adds a single "open" frame' do + scorecard = Scorecard.new + scorecard.add_frame([3, 4]) + expect(scorecard.scores).to eq [{ rolls: [3, 4], score: 7 }] + end + + it 'adds multiple "open" frames' do + scorecard = Scorecard.new + scorecard.add_frame([3, 4]) + scorecard.add_frame([2, 0]) + scorecard.add_frame([6, 3]) + expect(scorecard.scores).to eq [ + { rolls: [3, 4], score: 7 }, + { rolls: [2, 0], score: 9 }, + { rolls: [6, 3], score: 18 } + ] + end + + it 'adds a single spare' do + scorecard = Scorecard.new + scorecard.add_frame([9, 1]) + expect(scorecard.scores).to eq [{ rolls: [9, 1] }] + end + + it 'correctly calculates spare bonus' do + scorecard = Scorecard.new + scorecard.add_frame([9, 1]) + scorecard.add_frame([5, 3]) + expect(scorecard.scores).to eq [ + { rolls: [9, 1], score: 15 }, + { rolls: [5, 3], score: 23 } + ] + end + + it 'adds a single strike' do + scorecard = Scorecard.new + scorecard.add_frame([10]) + expect(scorecard.scores).to eq [{ rolls: [10] }] + end + + it 'correctly adds strike bonus if next frame not a strike' do + scorecard = Scorecard.new + scorecard.add_frame([8, 1]) + scorecard.add_frame([10]) + scorecard.add_frame([3, 4]) + expect(scorecard.scores).to eq [ + { rolls: [8, 1], score: 9 }, + { rolls: [10], score: 26 }, + { rolls: [3, 4], score: 33 }, + ] + end + + it 'correctly adds two strikes in a row' do + scorecard = Scorecard.new + scorecard.add_frame([10]) + scorecard.add_frame([10]) + expect(scorecard.scores).to eq [ + { rolls: [10] }, + { rolls: [10] } + ] + end + + it 'correctly adds strike bonus after two strikes' do + scorecard = Scorecard.new + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([3, 6]) + scorecard.add_frame([5, 1]) + expect(scorecard.scores).to eq [ + { rolls: [10], score: 23 }, + { rolls: [10], score: 42 }, + { rolls: [3, 6], score: 51 }, + { rolls: [5, 1], score: 57 }, + ] + end + + it 'correctly calculates a game not ending on strike or spare' do + scorecard = Scorecard.new + scorecard.add_frame([10]) + scorecard.add_frame([3, 6]) + scorecard.add_frame([3, 7]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([5, 1]) + scorecard.add_frame([8, 2]) + scorecard.add_frame([7, 3]) + scorecard.add_frame([5, 4]) + scorecard.add_frame([2, 1]) + expect(scorecard.scores).to eq [ + { rolls: [10], score: 19 }, + { rolls: [3, 6], score: 28 }, + { rolls: [3, 7], score: 48 }, + { rolls: [10], score: 73 }, + { rolls: [10], score: 89 }, + { rolls: [5, 1], score: 95 }, + { rolls: [8, 2], score: 112 }, + { rolls: [7, 3], score: 127 }, + { rolls: [5, 4], score: 136 }, + { rolls: [2, 1], score: 139 }, + ] + end + + it 'correctly scores a gutter game' do + scorecard = Scorecard.new + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + scorecard.add_frame([0, 0]) + expect(scorecard.scores.length).to eq 10 + expect(scorecard.scores).to eq [ + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 }, + { rolls: [0, 0], score: 0 } + ] + end + + it 'correctly scores a perfect game' do + scorecard = Scorecard.new + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_frame([10]) + scorecard.add_final_frame([10, 10, 10]) + expect(scorecard.scores.length).to eq 10 + expect(scorecard.scores).to eq [ + { rolls: [10], score: 30 }, + { rolls: [10], score: 60 }, + { rolls: [10], score: 90 }, + { rolls: [10], score: 120 }, + { rolls: [10], score: 150 }, + { rolls: [10], score: 180 }, + { rolls: [10], score: 210 }, + { rolls: [10], score: 240 }, + { rolls: [10], score: 270 }, + { rolls: [10, 10, 10], score: 300 } + ] + end + + it 'correctly scores a full game with 1 bonus roll' do + scorecard = Scorecard.new + scorecard.add_frame([3, 5]) + scorecard.add_frame([6, 4]) + scorecard.add_frame([7, 2]) + scorecard.add_frame([10]) + scorecard.add_frame([7, 1]) + scorecard.add_frame([9, 1]) + scorecard.add_frame([10]) + scorecard.add_frame([1, 8]) + scorecard.add_frame([8, 2]) + scorecard.add_final_frame([6, 4, 5]) + expect(scorecard.scores.length).to eq 10 + expect(scorecard.scores).to eq [ + { rolls: [3, 5], score: 8 }, + { rolls: [6, 4], score: 25 }, + { rolls: [7, 2], score: 34 }, + { rolls: [10], score: 52 }, + { rolls: [7, 1], score: 60 }, + { rolls: [9, 1], score: 80 }, + { rolls: [10], score: 99 }, + { rolls: [1, 8], score: 108 }, + { rolls: [8, 2], score: 124 }, + { rolls: [6, 4, 5], score: 139 } + ] + end + + it 'correctly scores a full game with 2 bonus rolls' do + scorecard = Scorecard.new + scorecard.add_frame([3, 5]) + scorecard.add_frame([6, 4]) + scorecard.add_frame([7, 2]) + scorecard.add_frame([10]) + scorecard.add_frame([7, 1]) + scorecard.add_frame([9, 1]) + scorecard.add_frame([10]) + scorecard.add_frame([1, 8]) + scorecard.add_frame([8, 2]) + scorecard.add_final_frame([10, 4, 5]) + expect(scorecard.scores.length).to eq 10 + expect(scorecard.scores).to eq [ + { rolls: [3, 5], score: 8 }, + { rolls: [6, 4], score: 25 }, + { rolls: [7, 2], score: 34 }, + { rolls: [10], score: 52 }, + { rolls: [7, 1], score: 60 }, + { rolls: [9, 1], score: 80 }, + { rolls: [10], score: 99 }, + { rolls: [1, 8], score: 108 }, + { rolls: [8, 2], score: 128 }, + { rolls: [10, 4, 5], score: 147 } + ] + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..c80d44b9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,98 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end