xo 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +8 -8
- data/Gemfile.lock +3 -1
- data/README.md +59 -23
- data/bin/xo +22 -27
- data/lib/xo.rb +1 -1
- data/lib/xo/ai/max_player.rb +22 -0
- data/lib/xo/ai/min_player.rb +22 -0
- data/lib/xo/ai/minimax.rb +47 -108
- data/lib/xo/ai/player.rb +44 -0
- data/lib/xo/engine.rb +32 -234
- data/lib/xo/engine/game_context.rb +28 -0
- data/lib/xo/engine/game_over.rb +33 -0
- data/lib/xo/engine/game_state.rb +21 -0
- data/lib/xo/engine/init.rb +24 -0
- data/lib/xo/engine/playing.rb +88 -0
- data/lib/xo/evaluator.rb +50 -66
- data/lib/xo/grid.rb +41 -93
- data/lib/xo/version.rb +1 -1
- data/spec/engine_spec.rb +323 -0
- data/spec/{xo/evaluator_spec.rb → evaluator_spec.rb} +25 -10
- data/spec/{xo/ai/geometric_grid_spec.rb → geometric_grid_spec.rb} +1 -3
- data/spec/{xo/grid_spec.rb → grid_spec.rb} +13 -16
- data/spec/{xo/ai/minimax_spec.rb → minimax_spec.rb} +0 -0
- data/xo.gemspec +3 -1
- metadata +36 -14
- data/lib/xo/ai.rb +0 -2
- data/spec/xo/engine_spec.rb +0 -331
data/lib/xo/ai/player.rb
ADDED
@@ -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
|
data/lib/xo/engine.rb
CHANGED
@@ -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
|
-
#
|
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
|
-
#
|
12
|
+
# It is controlled by 4 actions:
|
15
13
|
#
|
16
|
-
# -
|
17
|
-
# -
|
18
|
-
# -
|
14
|
+
# - start
|
15
|
+
# - stop
|
16
|
+
# - play
|
17
|
+
# - continue_playing
|
19
18
|
#
|
20
|
-
#
|
21
|
-
#
|
22
|
-
#
|
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
|
-
#
|
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
|
-
#
|
30
|
-
#
|
25
|
+
# - {Init}
|
26
|
+
# - {Playing}
|
27
|
+
# - {GameOver}
|
31
28
|
#
|
32
|
-
#
|
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
|
-
#
|
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
|
-
#
|
33
|
+
# State | Actions
|
34
|
+
# ----------------------------------
|
35
|
+
# Init | start
|
36
|
+
# Playing | play, stop
|
37
|
+
# GameOver | continue_playing, stop
|
42
38
|
#
|
43
|
-
#
|
44
|
-
|
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
|
-
|
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
|
-
|
76
|
-
|
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
|
-
|
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
|