xo 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/xo/engine.rb CHANGED
@@ -1,137 +1,256 @@
1
- require 'observer'
2
- require 'ostruct'
3
-
4
1
  require 'xo/grid'
5
2
  require 'xo/evaluator'
6
3
 
7
4
  module XO
8
5
 
6
+ # A state machine that encapsulates the game logic for Tic-tac-toe. The operation
7
+ # of the engine is completely determined by the properties:
8
+ #
9
+ # - {#state},
10
+ # - {#turn},
11
+ # - {#grid}, and
12
+ # - {#last_event}.
13
+ #
14
+ # The engine can be in one of the 3 following states (represented by a symbol):
15
+ #
16
+ # - :init
17
+ # - :playing
18
+ # - :game_over
19
+ #
20
+ # The engine begins in the :init state. And, the following methods are used to advance
21
+ # a single game of Tic-tac-toe (and transition the engine between its states) by obeying
22
+ # the standard rules of Tic-tac-toe:
23
+ #
24
+ # - {#start}: [:init]
25
+ # - {#stop}: [:playing, :game_over]
26
+ # - {#play}: [:playing]
27
+ # - {#continue_playing}: [:game_over]
28
+ #
29
+ # The array of symbols after each method lists the states in which the method is allowed
30
+ # to be called.
31
+ #
32
+ # @example
33
+ # e = Engine.new
34
+ # e.start(Grid::X).play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
35
+ #
36
+ # event = e.last_event
37
+ # puts event[:name] # => :game_over
38
+ # puts event[:type] # => :winner
39
+ # puts event[:last_move][:turn] # => Grid::X
40
+ #
41
+ # e.continue_playing(Grid::O).play(2, 2).play(1, 1).play(3, 3).play(1, 3).play(1, 2).play(3, 2).play(2, 1).play(2, 3).play(3, 1)
42
+ #
43
+ # event = e.last_event
44
+ # puts event[:name] # => :game_over
45
+ # puts event[:type] # => :squashed
46
+ # puts event[:last_move][:turn] # => Grid::O
9
47
  class Engine
10
- include Observable
11
48
 
12
- attr_reader :turn, :state
49
+ # @return [:init, :playing, :game_over]
50
+ attr_reader :state
51
+
52
+ # @return [Grid::X, Grid::O, :nobody]
53
+ attr_reader :turn
54
+
55
+ # @return [Hash]
56
+ attr_reader :last_event
13
57
 
58
+ # Creates a new {Engine} with its state set to :init, turn set to :nobody, an empty grid and
59
+ # last_event set to { name: :new }.
14
60
  def initialize
15
61
  @grid = Grid.new
16
- @turn = :nobody
17
- @state = :idle
62
+
63
+ reset
64
+
65
+ set_event(:new)
18
66
  end
19
67
 
68
+ # Get the grid that's managed by the engine.
69
+ #
70
+ # @return [Grid] a copy of the grid that the engine uses
20
71
  def grid
21
72
  @grid.dup
22
73
  end
23
74
 
75
+ # If the current turn is either {Grid::X}, {Grid::O} or :nobody then
76
+ # it returns {Grid::O}, {Grid::X}, :nobody respectively.
77
+ #
78
+ # @return [Grid::X, Grid::O, :nobody]
24
79
  def next_turn
25
- XO.other_player(turn)
80
+ Grid.other_token(turn)
26
81
  end
27
82
 
28
- def start(player)
29
- raise ArgumentError, "unknown player #{player}" unless XO.is_player?(player)
83
+ # Transitions the engine from the :init state into the :playing state.
84
+ #
85
+ # Sets the last event to be:
86
+ #
87
+ # { name: :game_started }
88
+ #
89
+ # @param turn [Grid::X, Grid::O] the token to have first play
90
+ # @raise [ArgumentError] unless turn is either {Grid::X} or {Grid::O}
91
+ # @raise [IllegalStateError] unless it's called in the :init state
92
+ # @return [self]
93
+ def start(turn)
94
+ check_turn(turn)
30
95
 
31
96
  case state
32
- when :idle
33
- handle_start(player)
97
+ when :init
98
+ handle_start(turn)
34
99
  else
35
- raise NotImplementedError
100
+ raise IllegalStateError, "must be in the :init state but state = :#{state}"
36
101
  end
37
-
38
- self
39
102
  end
40
103
 
104
+ # Transitions the engine from the :playing or :game_over state into the :game_over state.
105
+ #
106
+ # Sets the last event to be:
107
+ #
108
+ # { name: :game_over }
109
+ #
110
+ # @raise [IllegalStateError] unless it's called in the :playing or :game_over state
111
+ # @return [self]
41
112
  def stop
42
113
  case state
43
114
  when :playing, :game_over
44
115
  handle_stop
45
116
  else
46
- raise NotImplementedError
117
+ raise IllegalStateError, "must be in the :playing or :game_over state but state = :#{state}"
47
118
  end
48
-
49
- self
50
119
  end
51
120
 
121
+ # Makes a move at the given position (r, c) which may transition the engine into the :game_over state
122
+ # or leave it in the :playing state.
123
+ #
124
+ # Sets the last event as follows:
125
+ #
126
+ # - If the position is out of bounds, then
127
+ #
128
+ # { name: :invalid_move, type: :out_of_bounds }
129
+ #
130
+ # - If the position is occupied, then
131
+ #
132
+ # { name: :invalid_move, type: :occupied }
133
+ #
134
+ # - If the move was allowed and didn't result in ending the game, then
135
+ #
136
+ # { name: :next_turn, last_move: { turn: :a_token, r: :a_row, c: :a_column } }
137
+ #
138
+ # - If the move was allowed and resulted in a win, then
139
+ #
140
+ # { name: :game_over, type: :winner, last_move: { turn: :a_token, r: :a_row, c: :a_column }, details: :the_details }
141
+ #
142
+ # - If the move was allowed and resulted in a squashed game, then
143
+ #
144
+ # { name: :game_over, type: :squashed, last_move: { turn: :a_token, r: :a_row, c: :a_column } }
145
+ #
146
+ # Legend:
147
+ #
148
+ # - :a_token is one of {Grid::X} or {Grid::O}
149
+ # - :a_row is one of 1, 2 or 3
150
+ # - :a_column is one of 1, 2 or 3
151
+ # - :the_details is taken verbatim from the :details key of the returned hash of {Evaluator.analyze}
152
+ #
153
+ # @param r [Integer] the row
154
+ # @param c [Integer] the column
155
+ # @raise [IllegalStateError] unless it's called in the :playing state
156
+ # @return [self]
52
157
  def play(r, c)
53
158
  case state
54
159
  when :playing
55
- handle_play(r.to_i, c.to_i)
160
+ handle_play(r, c)
56
161
  else
57
- raise NotImplementedError
162
+ raise IllegalStateError, "must be in the :playing state but state = :#{state}"
58
163
  end
59
-
60
- self
61
164
  end
62
165
 
63
- def continue_playing(player)
64
- raise ArgumentError, "unknown player #{player}" unless XO.is_player?(player)
166
+ # Similar to start but should only be used to play another round when a game has ended. It transitions
167
+ # the engine from the :game_over state into the :playing state.
168
+ #
169
+ # Sets the last event to be:
170
+ #
171
+ # { name: :game_started, type: :continue_playing }
172
+ #
173
+ # @param turn [Grid::X, Grid::O] the token to have first play
174
+ # @raise [ArgumentError] unless turn is either {Grid::X} or {Grid::O}
175
+ # @raise [IllegalStateError] unless it's called in the :game_over state
176
+ # @return [self]
177
+ def continue_playing(turn)
178
+ check_turn(turn)
65
179
 
66
180
  case state
67
181
  when :game_over
68
- handle_continue_playing(player)
182
+ handle_continue_playing(turn)
69
183
  else
70
- raise NotImplementedError
184
+ raise IllegalStateError, "must be in the :game_over state but state = :#{state}"
71
185
  end
72
-
73
- self
74
186
  end
75
187
 
76
- private
188
+ # The exception raised by {#start}, {#stop}, {#play} and {#continue_playing} whenever
189
+ # these methods are called and the engine is in the wrong state.
190
+ class IllegalStateError < StandardError; end
77
191
 
78
- attr_writer :turn, :state
192
+ private
79
193
 
80
- def handle_start(player)
81
- self.turn = player
82
- self.state = :playing
194
+ def handle_start(turn)
195
+ @state = :playing
196
+ @turn = turn
83
197
  @grid.clear
84
198
 
85
- send_event(:game_started, who: player)
199
+ set_event(:game_started)
86
200
  end
87
201
 
88
202
  def handle_stop
89
- self.state = :idle
203
+ reset
90
204
 
91
- send_event(:game_stopped)
205
+ set_event(:game_stopped)
92
206
  end
93
207
 
94
208
  def handle_play(r, c)
95
- if Grid.contains?(r, c)
96
- if @grid.free?(r, c)
97
- @grid[r, c] = turn
98
- last_played_at = OpenStruct.new(row: r, col: c)
99
-
100
- result = Evaluator.analyze(@grid, turn)
101
-
102
- case result[:status]
103
- when :ok
104
- self.turn = next_turn
105
- send_event(:next_turn, who: turn, last_played_at: last_played_at)
106
- when :game_over
107
- self.state = :game_over
108
-
109
- case result[:type]
110
- when :winner
111
- send_event(:game_over, type: :winner, who: turn, last_played_at: last_played_at, details: result[:details])
112
- when :squashed
113
- send_event(:game_over, type: :squashed, who: turn, last_played_at: last_played_at)
114
- end
115
- end
116
- else
117
- send_event(:invalid_move, type: :occupied)
209
+ return set_event(:invalid_move, type: :out_of_bounds) unless Grid.contains?(r, c)
210
+ return set_event(:invalid_move, type: :occupied) unless @grid.open?(r, c)
211
+
212
+ @grid[r, c] = turn
213
+ last_move = { turn: turn, r: r, c: c }
214
+
215
+ result = Evaluator.instance.analyze(@grid, turn)
216
+
217
+ case result[:status]
218
+ when :ok
219
+ @turn = next_turn
220
+ set_event(:next_turn, last_move: last_move)
221
+ when :game_over
222
+ @state = :game_over
223
+
224
+ case result[:type]
225
+ when :winner
226
+ set_event(:game_over, type: :winner, last_move: last_move, details: result[:details])
227
+ when :squashed
228
+ set_event(:game_over, type: :squashed, last_move: last_move)
118
229
  end
119
- else
120
- send_event(:invalid_move, type: :out_of_bounds)
121
230
  end
122
231
  end
123
232
 
124
- def handle_continue_playing(player)
125
- self.turn = player
126
- self.state = :playing
233
+ def handle_continue_playing(turn)
234
+ @turn = turn
235
+ @state = :playing
127
236
  @grid.clear
128
237
 
129
- send_event(:continue_playing, who: player)
238
+ set_event(:game_started, type: :continue_playing)
239
+ end
240
+
241
+ def check_turn(turn)
242
+ raise ArgumentError, "illegal token #{turn}" unless Grid.is_token?(turn)
243
+ end
244
+
245
+ def reset
246
+ @state = :init
247
+ @turn = :nobody
248
+ @grid.clear
130
249
  end
131
250
 
132
- def send_event(name, message = {})
133
- changed
134
- notify_observers({ event: name }.merge(message))
251
+ def set_event(name, message = {})
252
+ @last_event = { name: name }.merge(message)
253
+ self
135
254
  end
136
255
  end
137
256
  end
data/lib/xo/evaluator.rb CHANGED
@@ -1,31 +1,125 @@
1
- module XO
1
+ require 'singleton'
2
2
 
3
- module Evaluator
3
+ require 'xo/grid'
4
4
 
5
- def self.analyze(grid, player)
6
- @grid = grid
7
- @player = player
5
+ module XO
8
6
 
7
+ # This class defines an {Evaluator#analyze} method than can be used to look at a grid and
8
+ # answer the following questions:
9
+ #
10
+ # 1. Is it a valid grid? A grid is considered valid if it possible for
11
+ # two players, taking turns, to reach the given grid configuration.
12
+ # 2. Is there a winner/loser or is the grid squashed?
13
+ # 3. Who is the winner/loser?
14
+ # 4. Which positions make up the winning row, column and/or diagonal?
15
+ #
16
+ # The Evaluator class is a Singleton class and can be used as follows:
17
+ #
18
+ # @example
19
+ # Evaluator.instance.analyze(Grid.new('xo'), Grid::X)
20
+ class Evaluator
21
+ include Singleton
22
+
23
+ # Analyze a given grid assuming that the given token is the one that was last placed on it.
24
+ #
25
+ # It can return a hash in following formats:
26
+ #
27
+ # - If everything is fine, then
28
+ #
29
+ # { status: :ok }
30
+ #
31
+ # - If the game is over and the given token is in a winning position, then
32
+ #
33
+ # { status: :game_over, type: :winner, details: [{ where: :a_where, index: :an_index, positions: :the_positions }] }
34
+ #
35
+ # - If the game is over and the other token is in a winning position, then
36
+ #
37
+ # { status: :game_over, type: :loser, details: [{ where: :a_where, index: :an_index, positions: :the_positions }] }
38
+ #
39
+ # - If the game is over due to a squashed grid, then
40
+ #
41
+ # { status: :game_over, type: :squashed }
42
+ #
43
+ # - If there is too much of one token, then
44
+ #
45
+ # { status: :invalid_grid, type: :too_many_moves_ahead }
46
+ #
47
+ # - If both tokens are arranged in winning positions, then
48
+ #
49
+ # { status: :invalid_grid, type: :two_winners }
50
+ #
51
+ # Legend:
52
+ #
53
+ # - :a_where is one of :row, :column, :diagonal
54
+ # - :an_index is one of 1, 2, 3 if :a_where is :row or :column and one of 1, 2 if :a_where is :diagonal
55
+ # - :the_positions is a 3 element array having the row, column values of the winning position
56
+ #
57
+ # Notice that the :details key is an array since it is possible to win a game in two different ways. For
58
+ # example:
59
+ #
60
+ # x | o | x
61
+ # ---+---+---
62
+ # o | x | o
63
+ # ---+---+---
64
+ # x | o | x
65
+ #
66
+ # # Position (2, 2) would have to be the last position played for this to happen.
67
+ #
68
+ # @param grid [Grid] the grid to be analyzed
69
+ # @param token [Grid::X, Grid::O] the token that was last placed on the grid
70
+ # @raise [ArgumentError] unless token is either {Grid::X} or {Grid::O}
71
+ # @return [Hash]
72
+ def analyze(grid, token)
73
+ check_token(token)
74
+ init_analyzer(grid, token)
9
75
  perform_analysis
10
76
  end
11
77
 
78
+ # Returns the number of {Grid::X}'s and {Grid::O}'s in the given grid.
79
+ #
80
+ # @example
81
+ # g = Grid.new('xoxxo')
82
+ # xs, os = Evaluator.instance.xos(g)
83
+ # puts xs # => 3
84
+ # puts os # => 2
85
+ #
86
+ # @return [Array(Integer, Integer)]
87
+ def xos(grid)
88
+ xs = os = 0
89
+
90
+ grid.each do |_, _, val|
91
+ xs += 1 if val == Grid::X
92
+ os += 1 if val == Grid::O
93
+ end
94
+
95
+ [xs, os]
96
+ end
97
+
12
98
  private
13
99
 
14
- class << self
15
- attr_reader :grid, :player, :winners
100
+ attr_reader :grid, :token, :winners
101
+
102
+ def check_token(token)
103
+ raise ArgumentError, "illegal token #{token}" unless Grid.is_token?(token)
16
104
  end
17
105
 
18
- def self.perform_analysis
19
- return { status: :error, type: :too_many_moves_ahead } if two_or_more_moves_ahead?
106
+ def init_analyzer(grid, token)
107
+ @grid = grid
108
+ @token = token
109
+ @winners = {}
110
+ end
111
+
112
+ def perform_analysis
113
+ return { status: :invalid_grid, type: :too_many_moves_ahead } if two_or_more_moves_ahead?
20
114
 
21
115
  find_winners
22
116
 
23
117
  if two_winners?
24
- { status: :error, type: :two_winners }
25
- elsif winners[player]
26
- { status: :game_over, type: :winner, details: winners[player] }
27
- elsif winners[other_player]
28
- { status: :game_over, type: :loser, details: winners[other_player] }
118
+ { status: :invalid_grid, type: :two_winners }
119
+ elsif winners[token]
120
+ { status: :game_over, type: :winner, details: winners[token] }
121
+ elsif winners[other_token]
122
+ { status: :game_over, type: :loser, details: winners[other_token] }
29
123
  else
30
124
  if grid.full?
31
125
  { status: :game_over, type: :squashed }
@@ -35,74 +129,55 @@ module XO
35
129
  end
36
130
  end
37
131
 
38
- def self.two_or_more_moves_ahead?
132
+ def two_or_more_moves_ahead?
39
133
  moves_ahead >= 2
40
134
  end
41
135
 
42
- def self.moves_ahead
43
- xs = os = 0
44
-
45
- grid.each do |_, _, val|
46
- xs += 1 if val == XO::X
47
- os += 1 if val == XO::O
48
- end
136
+ def moves_ahead
137
+ xs, os = xos(grid)
49
138
 
50
139
  (xs - os).abs
51
140
  end
52
141
 
53
- def self.find_winners
54
- @winners = {}
142
+ def find_winners
143
+ winning_positions.each do |w|
144
+ a = grid[*w[:positions][0]]
145
+ b = grid[*w[:positions][1]]
146
+ c = grid[*w[:positions][2]]
55
147
 
56
- # check rows
57
- if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[1, 2] && grid[1, 2] == grid[1, 3]
58
- add_winner(grid[1, 1], { where: :row, index: 1, positions: [[1, 1], [1, 2], [1, 3]] })
59
- end
60
-
61
- if XO.is_token?(grid[2, 1]) && grid[2, 1] == grid[2, 2] && grid[2, 2] == grid[2, 3]
62
- add_winner(grid[2, 1], { where: :row, index: 2, positions: [[2, 1], [2, 2], [2, 3]] })
63
- end
64
-
65
- if XO.is_token?(grid[3, 1]) && grid[3, 1] == grid[3, 2] && grid[3, 2] == grid[3, 3]
66
- add_winner(grid[3, 1], { where: :row, index: 3, positions: [[3, 1], [3, 2], [3, 3]] })
67
- end
68
-
69
- # check columns
70
- if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[2, 1] && grid[2, 1] == grid[3, 1]
71
- add_winner(grid[1, 1], { where: :column, index: 1, positions: [[1, 1], [2, 1], [3, 1]] })
72
- end
73
-
74
- if XO.is_token?(grid[1, 2]) && grid[1, 2] == grid[2, 2] && grid[2, 2] == grid[3, 2]
75
- add_winner(grid[1, 2], { where: :column, index: 2, positions: [[1, 2], [2, 2], [3, 2]] })
148
+ add_winner(a, w) if Grid.is_token?(a) && a == b && b == c
76
149
  end
150
+ end
77
151
 
78
- if XO.is_token?(grid[1, 3]) && grid[1, 3] == grid[2, 3] && grid[2, 3] == grid[3, 3]
79
- add_winner(grid[1, 3], { where: :column, index: 3, positions: [[1, 3], [2, 3], [3, 3]] })
80
- end
152
+ def winning_positions
153
+ @winning_positions ||= [
154
+ { where: :row, index: 1, positions: [[1, 1], [1, 2], [1, 3]] },
155
+ { where: :row, index: 2, positions: [[2, 1], [2, 2], [2, 3]] },
156
+ { where: :row, index: 3, positions: [[3, 1], [3, 2], [3, 3]] },
81
157
 
82
- # check diagonals
83
- if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[2, 2] && grid[2, 2] == grid[3, 3]
84
- add_winner(grid[1, 1], { where: :diagonal, index: 1, positions: [[1, 1], [2, 2], [3, 3]] })
85
- end
158
+ { where: :column, index: 1, positions: [[1, 1], [2, 1], [3, 1]] },
159
+ { where: :column, index: 2, positions: [[1, 2], [2, 2], [3, 2]] },
160
+ { where: :column, index: 3, positions: [[1, 3], [2, 3], [3, 3]] },
86
161
 
87
- if XO.is_token?(grid[1, 3]) && grid[1, 3] == grid[2, 2] && grid[2, 2] == grid[3, 1]
88
- add_winner(grid[1, 3], { where: :diagonal, index: 2, positions: [[1, 3], [2, 2], [3, 1]] })
89
- end
162
+ { where: :diagonal, index: 1, positions: [[1, 1], [2, 2], [3, 3]] },
163
+ { where: :diagonal, index: 2, positions: [[1, 3], [2, 2], [3, 1]] }
164
+ ]
90
165
  end
91
166
 
92
- def self.add_winner(player, details)
93
- if winners.key?(player)
94
- winners[player] << details
167
+ def add_winner(token, details)
168
+ if winners.key?(token)
169
+ winners[token] << details
95
170
  else
96
- winners[player] = [details]
171
+ winners[token] = [details]
97
172
  end
98
173
  end
99
174
 
100
- def self.two_winners?
101
- winners[XO::X] && winners[XO::O]
175
+ def two_winners?
176
+ winners[Grid::X] && winners[Grid::O]
102
177
  end
103
178
 
104
- def self.other_player
105
- XO.other_player(player)
179
+ def other_token
180
+ Grid.other_token(token)
106
181
  end
107
182
  end
108
183
  end