xo 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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