ボウリングスコア計算問題を解いてみた

# はじめに

今回は、ボウリングのスコアを算出するプログラムの作成を行いました。
自分が書いたソースコードと、先輩にいただいた回答例を紹介していきます。

## ボウリングスコア計算問題

- 投球を完了した状態で最終的なスコアを算出する。
- 10フレーム分の投球を配列とする。
- 各フレームも配列とする。
- ストライクがある場合、フレームの配列は 1つの要素とする。
- 10フレーム目は、スペアまたはストライクの場合、3つの要素とする。
- ガーターの場合、0 とする。

### 入力例

```ruby
[
  [1, 1],   # 1フレーム目
  [0, 2],   # 2フレーム目
  [3, 0],   # 3フレーム目
  [4, 4],   # 4フレーム目
  [5, 5],   # 5フレーム目
  [1, 1],   # 6フレーム目
  [10],     # 7フレーム目
  [4, 4],   # 8フレーム目
  [1, 1],   # 9フレーム目
  [5, 5, 5] # 10フレーム目
]
```

### 要求仕様

- 最終スコアを数値（整数）で返す。
- 入力値が整数ではない、ボウリングのルール上計算出来ない値の場合、例外を返す（例外はカスタム例外を作成する）。
- カレントフレームスコアシステムは適用しない。（これまで通りの計算方法）
- ファールの投球はないものとする。

# 自分が書いたソースコード

```ruby
class InvalidError < StandardError
end

class Bowling
  def initialize(score)
    @score = score
  end

  def result
    total_score = 0
    @score.each.with_index(1) do |points, frame|
      raise InvalidError unless valid?(points)

      total_score += calculate_score(points, frame)
    end
    total_score
  end

  private

  def calculate_score(points, frame)
    total = points.sum
    total += strike_bonus(frame) if strike?(points)
    total += spare_bonus(frame) if spare?(points)
    total
  end

  def spare_bonus(frame)
    @score[frame].first
  end

  def strike_bonus(frame)
    if double_or_turkey?(frame)
      @score[frame].first + @score[frame + 1].first
    else
      @score[frame].first + @score[frame].last
    end
  end

  def valid?(points)
    return false if points.empty?
    return false if points.count > 3
    return false unless @score.count == 10
    return false unless points.all? { |point| (0..10).include?(point) && point.integer? }

    true
  end

  def spare?(points)
    points.count == 2 && points.sum == 10
  end

  def strike?(points)
    points.count == 1 && points.first == 10
  end

  def double_or_turkey?(frame)
    @score[frame].count == 1
  end
end
```

## 処理について

```ruby
  def result
    total_score = 0
    @score.each.with_index(1) do |points, frame|
      raise InvalidError unless valid?(points)

      total_score += calculate_score(points, frame)
    end
    total_score
  end
```

`result`メソッドの中で、計算結果を入れるための`total_score`を定義しています。
`each.with_index()`を使用して、各フレーム数と点数を元に`calculate_score`で計算した値を足し込む処理を作成しました。

フレーム数を`1`から始めると、実際のボウリングのスコアと同じ感覚で進められそうと思ったので、`each.with_index(1)`を使用しています。

また、計算を行う前に、フレーム毎の点数を`valid?`メソッドで有効な値かどうか判定しており、不正な値の場合は例外を発生させています。

```ruby
class InvalidError < StandardError
end
```
カスタム例外は、`InvalidError`を作成しています。


```ruby
  def valid?(points)
    return false if points.empty?
    return false if points.count > 3
    return false unless @score.count == 10
    return false unless points.all? { |point| (0..10).include?(point) && point.integer? }

    true
  end
```

不正な値の判定基準は以下としていました。

- フレームに対する点数が空の場合
- フレームに対する点数が3つより多い場合
- フレーム数が10ではない場合
- フレームに対する点数の全てが0~10の整数でない場合

```ruby
  def calculate_score(points, frame)
    total = points.sum
    total += strike_bonus(frame) if strike?(points)
    total += spare_bonus(frame) if spare?(points)
    total
  end
```

`calculate_score`では、フレーム毎の点数を合計し、ストライク(ダブルとターキーも含む)やスペアの場合は、それぞれボウリングの計算方法に則って加点をする処理を行っています。


## 課題

- 想定される値に考慮漏れがあり、不正な値が弾けていない。
- 10フレーム目の条件が全く考慮できていない。
- `result`メソッドで、`total_score`を無駄に宣言している。

# 回答例

```ruby
class InvalidScoreError < StandardError; end

class Bowling
  attr_reader :score

  def initialize(score)
    @score = score
  end

  def result
    raise InvalidScoreError unless score_valid?

    calculate_total_score
  end

  private

  def score_valid?
    return false unless valid_game?
    return false unless valid_pitching?

    true
  end

  def valid_game?
    score.is_a?(Array) && score.size == 10
  end

  def valid_pitching?
    return false unless score.all? { |flame| flame.is_a?(Array) }
    return false unless score[0..8].all?(&method(:valid_flame_score?))
    return false unless valid_final_flame?

    true
  end

  def valid_flame_score?(flame)
    return false unless flame.all? { |pitching| pitching.is_a?(Integer) && (0..10).cover?(pitching) }

    flame.size == 1 && flame[0] == 10 ||
      flame.size == 2 && (0..10).cover?(flame.sum)
  end

  def valid_final_flame?
    return true if final_flame_strike_or_spare?
    return true if valid_final_flame_score?

    false
  end

  def final_flame_strike_or_spare?
    score[9].size == 3 &&
      (score[9][0] == 10 || score[9][0..1].sum == 10)
  end

  def valid_final_flame_score?
    score[9].size == 2 && score[9][0..1].sum < 10
  end

  def calculate_total_score
    score.each.with_index(1).inject(0) do |total_score, (flame, index)|
      total_score + calculate_flame_score(index, flame)
    end
  end

  def calculate_flame_score(index, flame)
    return flame.sum if index == 10

    calculate_score(index, flame)
  end

  def calculate_score(index, flame)
    calculated_score = flame.sum
    calculated_score += strike_score(index) if strike?(flame)
    calculated_score += spare_score(index) if spare?(flame)

    calculated_score
  end

  def strike?(flame)
    flame.size == 1 && flame[0] == 10
  end

  def strike_score(index)
    return score[index][0] + score[index + 1][0] if score[index].size == 1

    score[index][0..1].sum
  end

  def spare?(flame)
    flame.size == 2 && flame.sum == 10
  end

  def spare_score(index)
    score[index][0]
  end
end
```

## 処理について

```ruby
attr_reader :score
```

まず、`attr_reader`で`score`が定義されています。
(以下と同じ意味)

```ruby
def score
  @score
end
```

(`attr_reader`をよく知らなかったので、覚えておこうと思いました。)

```ruby
  def result
    raise InvalidScoreError unless score_valid?

    calculate_total_score
  end
```


`result`メソッドでは、計算処理を行う前に、`score_valid?`で不正なスコアが弾かれています。

```ruby
  def score_valid?
    return false unless valid_game?
    return false unless valid_pitching?

    true
  end

  def valid_game?
    score.is_a?(Array) && score.size == 10
  end

  def valid_pitching?
    return false unless score.all? { |flame| flame.is_a?(Array) }
    return false unless score[0..8].all?(&method(:valid_flame_score?))
    return false unless valid_final_flame?

    true
  end

  def valid_flame_score?(flame)
    return false unless flame.all? { |pitching| pitching.is_a?(Integer) && (0..10).cover?(pitching) }

    flame.size == 1 && flame[0] == 10 ||
      flame.size == 2 && (0..10).cover?(flame.sum)
  end

  def valid_final_flame?
    return true if final_flame_strike_or_spare?
    return true if valid_final_flame_score?

    false
  end

  def final_flame_strike_or_spare?
    score[9].size == 3 &&
      (score[9][0] == 10 || score[9][0..1].sum == 10)
  end

  def valid_final_flame_score?
    score[9].size == 2 && score[9][0..1].sum < 10
  end
```

`score_valid?`では、ボウリングゲーム全体に関する判定(`valid_game?`)と、投球に関する判定(`valid_pitching?`)に分かれており、有効な値のみ`true`が返されています。

`valid_pitching?`内では、各フレーム毎のスコアが有効かどうか(`valid_flame_score?`)、10フレーム目として有効かどうか(`valid_final_flame?`)を判定しています。

さらに、`valid_final_flame?`内では、10フレーム目の有効なスコアかどうか(`valid_final_flame_score`)、10フレーム目のスペアかストライクか(`final_flame_strike_or_spare`)を判定しています。

これで、ボウリングのスコアとして有効な値のみが抽出されたことになります。

```ruby
  def calculate_total_score
    score.each.with_index(1).inject(0) do |total_score, (flame, index)|
      total_score + calculate_flame_score(index, flame)
    end
  end
```

計算処理は、`each.with_index(1)`に`inject(0)`がチェインされているので、
事前に`total_score`を定義しなくても良くなっています。


# 学んだこと

- パターンの洗い出しに漏れがないようにする。
  - 起こり得る全ての場合を想像する。
  - テストファーストで実装すると、contextの洗い出しのタイミンングで漏れに気付きやすい。
- コードの統一感を意識する。
  - 配列の要素の取得方法が混在していた。(`@score[frame].first` → `@score[frame][0]`)
- `Enamerable`のはメソッドはいくらでもチェインして使える。
  - `with_index` + `inject`等
- レシーバが配列であれば、`count`ではなく`size`を使う方が良い。
  - `count`は、ActiveRecordの件数を取得する際に使うため、配列の要素数を取得する際は、`size`を使用する方が良い。
  - `size`の方が処理速度が早い。
- クラスやメソッドの中身を書かない場合は、`end`だけで改行せずに1行で書ける。
  - `class InvalidScoreError < StandardError; end`


# まとめ

今回は、ボウリングのスコア計算問題ということで、想定されるパターンが多く、パターンの洗い出しが不足していたことを感じました。
実際に、どのような値が入るかというイメージを実装前に固めておくことが大切だと実感したので、今後意識して取り組みたいと思います。