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.
- 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
|