xo 1.0.0 → 1.1.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.
@@ -0,0 +1,44 @@
1
+ module XO
2
+
3
+ module AI
4
+
5
+ class Player
6
+
7
+ attr_reader :token
8
+
9
+ def initialize(token)
10
+ @token = token
11
+ end
12
+
13
+ def terminal_score(outcome)
14
+ send("#{outcome}_value")
15
+ end
16
+
17
+ def non_terminal_score(next_grids, scores)
18
+ best_score(compute_next_grids_scores(next_grids, scores))
19
+ end
20
+
21
+ def best_score(next_grids_scores)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def winner_value
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def loser_value
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def squashed_value
34
+ 0
35
+ end
36
+
37
+ private
38
+
39
+ def compute_next_grids_scores(next_grids, scores)
40
+ next_grids.map { |grid| scores[grid] }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,256 +1,54 @@
1
+ require 'state_design_pattern'
2
+
1
3
  require 'xo/grid'
2
4
  require 'xo/evaluator'
5
+ require 'xo/engine/game_context'
6
+ require 'xo/engine/init'
3
7
 
4
8
  module XO
5
9
 
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}.
10
+ # The {Engine} encapsulates the game logic for Tic-tac-toe.
13
11
  #
14
- # The engine can be in one of the 3 following states (represented by a symbol):
12
+ # It is controlled by 4 actions:
15
13
  #
16
- # - :init
17
- # - :playing
18
- # - :game_over
14
+ # - start
15
+ # - stop
16
+ # - play
17
+ # - continue_playing
19
18
  #
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:
19
+ # Each action may or may not change the state of the engine. This is important
20
+ # because it is the state of the engine that determines when the above actions
21
+ # can be called.
23
22
  #
24
- # - {#start}: [:init]
25
- # - {#stop}: [:playing, :game_over]
26
- # - {#play}: [:playing]
27
- # - {#continue_playing}: [:game_over]
23
+ # The engine can be in 1 of 3 states:
28
24
  #
29
- # The array of symbols after each method lists the states in which the method is allowed
30
- # to be called.
25
+ # - {Init}
26
+ # - {Playing}
27
+ # - {GameOver}
31
28
  #
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)
29
+ # The engine begins life in the {Init} state.
35
30
  #
36
- # event = e.last_event
37
- # puts event[:name] # => :game_over
38
- # puts event[:type] # => :winner
39
- # puts event[:last_move][:turn] # => Grid::X
31
+ # Here's a table showing which actions can be called in a given state:
40
32
  #
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)
33
+ # State | Actions
34
+ # ----------------------------------
35
+ # Init | start
36
+ # Playing | play, stop
37
+ # GameOver | continue_playing, stop
42
38
  #
43
- # event = e.last_event
44
- # puts event[:name] # => :game_over
45
- # puts event[:type] # => :squashed
46
- # puts event[:last_move][:turn] # => Grid::O
47
- class Engine
48
-
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
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 }.
60
- def initialize
61
- @grid = Grid.new
62
-
63
- reset
64
-
65
- set_event(:new)
66
- end
39
+ # Each action is defined in their respective state class.
40
+ class Engine < StateDesignPattern::StateMachine
67
41
 
68
- # Get the grid that's managed by the engine.
69
- #
70
- # @return [Grid] a copy of the grid that the engine uses
71
- def grid
72
- @grid.dup
42
+ def start_state
43
+ Init
73
44
  end
74
45
 
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]
79
- def next_turn
80
- Grid.other_token(turn)
46
+ def initial_context
47
+ GameContext.new(:nobody, Grid.new)
81
48
  end
82
49
 
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)
95
-
96
- case state
97
- when :init
98
- handle_start(turn)
99
- else
100
- raise IllegalStateError, "must be in the :init state but state = :#{state}"
101
- end
102
- end
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]
112
- def stop
113
- case state
114
- when :playing, :game_over
115
- handle_stop
116
- else
117
- raise IllegalStateError, "must be in the :playing or :game_over state but state = :#{state}"
118
- end
119
- end
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]
157
- def play(r, c)
158
- case state
159
- when :playing
160
- handle_play(r, c)
161
- else
162
- raise IllegalStateError, "must be in the :playing state but state = :#{state}"
163
- end
164
- end
165
-
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)
179
-
180
- case state
181
- when :game_over
182
- handle_continue_playing(turn)
183
- else
184
- raise IllegalStateError, "must be in the :game_over state but state = :#{state}"
185
- end
50
+ def evaluator
51
+ Evaluator.instance
186
52
  end
187
-
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
191
-
192
- private
193
-
194
- def handle_start(turn)
195
- @state = :playing
196
- @turn = turn
197
- @grid.clear
198
-
199
- set_event(:game_started)
200
- end
201
-
202
- def handle_stop
203
- reset
204
-
205
- set_event(:game_stopped)
206
- end
207
-
208
- def handle_play(r, c)
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)
229
- end
230
- end
231
- end
232
-
233
- def handle_continue_playing(turn)
234
- @turn = turn
235
- @state = :playing
236
- @grid.clear
237
-
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
249
- end
250
-
251
- def set_event(name, message = {})
252
- @last_event = { name: name }.merge(message)
253
- self
254
- end
255
53
  end
256
54
  end
@@ -0,0 +1,28 @@
1
+ require 'xo/grid'
2
+
3
+ module XO
4
+
5
+ class GameContext < Struct.new(:turn, :grid)
6
+
7
+ def reset
8
+ set_turn_and_clear_grid(:nobody)
9
+ end
10
+
11
+ def set_turn_and_clear_grid(turn)
12
+ self.turn = turn
13
+ grid.clear
14
+ end
15
+
16
+ def switch_turns
17
+ self.turn = next_turn
18
+ end
19
+
20
+ def next_turn
21
+ Grid.other_token(turn)
22
+ end
23
+
24
+ def check_turn(turn)
25
+ raise ArgumentError, "invalid turn symbol, #{turn}" unless Grid.is_token?(turn)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ require 'xo/engine/game_state'
2
+
3
+ module XO
4
+
5
+ class GameOver < GameState
6
+
7
+ # Stops and resets a game.
8
+ #
9
+ # The engine is transitioned into the {Init} state and the event
10
+ #
11
+ # { name: :game_stopped }
12
+ #
13
+ # is triggered.
14
+ def stop
15
+ stop_game
16
+ end
17
+
18
+ # Starts another new game.
19
+ #
20
+ # The token that won plays first, otherwise the game was squashed and so
21
+ # the next token plays first.
22
+ #
23
+ # The engine is transitioned into the {Playing} state and the event
24
+ #
25
+ # { name: :game_started, type: :continue_playing }
26
+ #
27
+ # is triggered.
28
+ def continue_playing
29
+ game_context.set_turn_and_clear_grid(game_context.turn)
30
+ engine.transition_to_state_and_send_event(Playing, :game_started, type: :continue_playing)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ require 'state_design_pattern'
2
+
3
+ module XO
4
+
5
+ class GameState < StateDesignPattern::BaseState
6
+ def_actions :start, :stop, :play, :continue_playing
7
+
8
+ private
9
+
10
+ alias_method :engine, :state_machine
11
+
12
+ def game_context
13
+ engine.context
14
+ end
15
+
16
+ def stop_game
17
+ game_context.reset
18
+ engine.transition_to_state_and_send_event(Init, :game_stopped)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ require 'xo/engine/game_state'
2
+ require 'xo/engine/playing'
3
+
4
+ module XO
5
+
6
+ class Init < GameState
7
+
8
+ # Starts a new game.
9
+ #
10
+ # The engine is transitioned into the {Playing} state and the event
11
+ #
12
+ # { name: :game_started }
13
+ #
14
+ # is triggered.
15
+ #
16
+ # @param turn [Grid::X, Grid::O] specifies which token has first play
17
+ # @raise [ArgumentError] unless turn is either {Grid::X} or {Grid::O}
18
+ def start(turn)
19
+ game_context.check_turn(turn)
20
+ game_context.set_turn_and_clear_grid(turn)
21
+ engine.transition_to_state_and_send_event(Playing, :game_started)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ require 'xo/engine/game_state'
2
+ require 'xo/engine/game_over'
3
+
4
+ module XO
5
+
6
+ class Playing < GameState
7
+
8
+ # Stops and resets a game.
9
+ #
10
+ # The engine is transitioned into the {Init} state and the event
11
+ #
12
+ # { name: :game_stopped }
13
+ #
14
+ # is triggered.
15
+ def stop
16
+ stop_game
17
+ end
18
+
19
+ # Attempts to make a move at the given position (r, c).
20
+ #
21
+ # The following outcomes are possible:
22
+ #
23
+ # - If the position is *out* *of* *bounds*, then the event below is
24
+ # triggered and the engine remains in this state.
25
+ #
26
+ # { name: :invalid_move, type: :out_of_bounds }
27
+ #
28
+ # - If the position is *occupied*, then the event below is triggered and the
29
+ # engine remains in this state.
30
+ #
31
+ # { name: :invalid_move, type: :occupied }
32
+ #
33
+ # - If the move results in a *win*, then the event below is triggered and
34
+ # the engine is transitioned into the {GameOver} state.
35
+ #
36
+ # { name: :game_over, type: :winner, last_move: { turn: :token, r: :row, c: :column }, details: :details }
37
+ #
38
+ # - If the move results in a *squashed* game, then the event below is
39
+ # triggered and the engine is transitioned into the {GameOver} state.
40
+ #
41
+ # { name: :game_over, type: :squashed, last_move: { turn: :next_token, r: :row, c: :column } }
42
+ #
43
+ # - Otherwise, the event below is triggered and the engine remains in this
44
+ # state.
45
+ #
46
+ # { name: :next_turn, last_move: { turn: :token, r: :row, c: :column } }
47
+ #
48
+ # *Legend:*
49
+ #
50
+ # - *:token* is one of {Grid::X} or {Grid::O}
51
+ # - *:next_token* is one of {Grid::X} or {Grid::O}
52
+ # - *:row* is one of 1, 2 or 3
53
+ # - *:column* is one of 1, 2 or 3
54
+ # - *:details* is taken verbatim from the :details key of the returned hash of {Evaluator#analyze}
55
+ #
56
+ # @param r [Integer] the row
57
+ # @param c [Integer] the column
58
+ def play(r, c)
59
+ return engine.send_event(:invalid_move, type: :out_of_bounds) unless Grid.contains?(r, c)
60
+ return engine.send_event(:invalid_move, type: :occupied) unless game_context.grid.open?(r, c)
61
+
62
+ game_context.grid[r, c] = game_context.turn
63
+ last_move = { turn: game_context.turn, r: r, c: c }
64
+
65
+ result = engine.evaluator.analyze(game_context.grid, game_context.turn)
66
+
67
+ case result[:status]
68
+ when :ok
69
+ game_context.switch_turns
70
+ engine.send_event(:next_turn, last_move: last_move)
71
+ when :game_over
72
+ case result[:type]
73
+ when :winner
74
+ engine.transition_to_state_and_send_event(
75
+ GameOver,
76
+ :game_over, type: :winner, last_move: last_move, details: result[:details]
77
+ )
78
+ when :squashed
79
+ game_context.switch_turns
80
+ engine.transition_to_state_and_send_event(
81
+ GameOver,
82
+ :game_over, type: :squashed, last_move: last_move
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end