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.
- checksums.yaml +13 -5
- data/.gitignore +4 -0
- data/.travis.yml +3 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +43 -0
- data/README.md +92 -27
- data/Rakefile +2 -0
- data/bin/xo +314 -0
- data/lib/xo.rb +0 -21
- data/lib/xo/ai.rb +1 -3
- data/lib/xo/ai/geometric_grid.rb +113 -0
- data/lib/xo/ai/minimax.rb +187 -89
- data/lib/xo/engine.rb +187 -68
- data/lib/xo/evaluator.rb +137 -62
- data/lib/xo/grid.rb +153 -24
- data/lib/xo/version.rb +1 -1
- data/spec/spec_helper.rb +3 -0
- data/spec/xo/ai/geometric_grid_spec.rb +137 -0
- data/spec/xo/ai/minimax_spec.rb +56 -36
- data/spec/xo/engine_spec.rb +296 -20
- data/spec/xo/evaluator_spec.rb +210 -39
- data/spec/xo/grid_spec.rb +198 -55
- data/xo.gemspec +9 -2
- metadata +63 -27
- data/lib/xo/ai/advanced_beginner.rb +0 -17
- data/lib/xo/ai/expert.rb +0 -64
- data/lib/xo/ai/novice.rb +0 -11
data/lib/xo.rb
CHANGED
@@ -1,24 +1,3 @@
|
|
1
|
-
module XO
|
2
|
-
|
3
|
-
X = :x
|
4
|
-
O = :o
|
5
|
-
|
6
|
-
def self.is_token?(val)
|
7
|
-
[X, O].include?(val)
|
8
|
-
end
|
9
|
-
|
10
|
-
def self.other_token(token)
|
11
|
-
token == X ? O : (token == O ? X : token)
|
12
|
-
end
|
13
|
-
|
14
|
-
class << self
|
15
|
-
alias_method :is_player?, :is_token?
|
16
|
-
alias_method :other_player, :other_token
|
17
|
-
end
|
18
|
-
|
19
|
-
class Position < Struct.new(:row, :column); end
|
20
|
-
end
|
21
|
-
|
22
1
|
require 'xo/grid'
|
23
2
|
require 'xo/evaluator'
|
24
3
|
require 'xo/engine'
|
data/lib/xo/ai.rb
CHANGED
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'xo/grid'
|
2
|
+
|
3
|
+
module XO
|
4
|
+
|
5
|
+
module AI
|
6
|
+
|
7
|
+
# A geometric grid is a Tic-tac-toe grid ({XO::Grid}) with the added benefit that
|
8
|
+
# various geometric transformations (rotation and reflection) can be applied. It
|
9
|
+
# defines a concept of equivalence under these transformations. Geometric grids can
|
10
|
+
# be checked for equality and they define a hash function that allows them to be
|
11
|
+
# used as keys within a Hash.
|
12
|
+
class GeometricGrid < XO::Grid
|
13
|
+
|
14
|
+
# Rotate the geometric grid clockwise by 90 degrees.
|
15
|
+
#
|
16
|
+
# 0 | 1 | 2 6 | 3 | 0
|
17
|
+
# ---+---+--- ---+---+---
|
18
|
+
# 3 | 4 | 5 => 7 | 4 | 1
|
19
|
+
# ---+---+--- ---+---+---
|
20
|
+
# 6 | 7 | 8 8 | 5 | 2
|
21
|
+
#
|
22
|
+
# @return [GeometricGrid]
|
23
|
+
def rotate
|
24
|
+
GeometricGrid.new(
|
25
|
+
"#{self[3, 1]}#{self[2, 1]}#{self[1, 1]}" +
|
26
|
+
"#{self[3, 2]}#{self[2, 2]}#{self[1, 2]}" +
|
27
|
+
"#{self[3, 3]}#{self[2, 3]}#{self[1, 3]}"
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Reflect the geometric grid in its vertical axis.
|
32
|
+
#
|
33
|
+
# 0 | 1 | 2 2 | 1 | 0
|
34
|
+
# ---+---+--- ---+---+---
|
35
|
+
# 3 | 4 | 5 => 5 | 4 | 3
|
36
|
+
# ---+---+--- ---+---+---
|
37
|
+
# 6 | 7 | 8 8 | 7 | 6
|
38
|
+
#
|
39
|
+
# @return [GeometricGrid]
|
40
|
+
def reflect
|
41
|
+
GeometricGrid.new(
|
42
|
+
"#{self[1, 3]}#{self[1, 2]}#{self[1, 1]}" +
|
43
|
+
"#{self[2, 3]}#{self[2, 2]}#{self[2, 1]}" +
|
44
|
+
"#{self[3, 3]}#{self[3, 2]}#{self[3, 1]}"
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Determines whether or not this geometric grid has the same
|
49
|
+
# occupied positions as the given geometric grid.
|
50
|
+
#
|
51
|
+
# @param other [GeometricGrid]
|
52
|
+
# @return [Boolean]
|
53
|
+
def same?(other)
|
54
|
+
self.inspect == other.inspect
|
55
|
+
end
|
56
|
+
|
57
|
+
# Determines whether or not this geometric grid is equivalent to
|
58
|
+
# the given geometric grid.
|
59
|
+
#
|
60
|
+
# Two geometric grids are considered equivalent iff one is a
|
61
|
+
# rotation or reflection of the other.
|
62
|
+
#
|
63
|
+
# @param other [GeometricGrid] the other grid
|
64
|
+
# @return [Boolean]
|
65
|
+
def equivalent?(other)
|
66
|
+
return false unless other.instance_of?(self.class)
|
67
|
+
|
68
|
+
transformations.any? { |grid| other.same?(grid) }
|
69
|
+
end
|
70
|
+
|
71
|
+
# Redefines equality for a geometric grid.
|
72
|
+
#
|
73
|
+
# Two geometric grids are equal iff they are equivalent.
|
74
|
+
#
|
75
|
+
# @return [Boolean]
|
76
|
+
def ==(other)
|
77
|
+
equivalent?(other)
|
78
|
+
end
|
79
|
+
alias_method :eql?, :==
|
80
|
+
|
81
|
+
# Required if you want to be able to use a geometric grid as a key in a Hash.
|
82
|
+
#
|
83
|
+
# Equivalent grids must have the same hash.
|
84
|
+
#
|
85
|
+
# @return [Integer]
|
86
|
+
def hash
|
87
|
+
transformations.map(&:inspect).sort.uniq.join.hash
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def transformations
|
93
|
+
rotations + rotations.map(&:reflect)
|
94
|
+
end
|
95
|
+
|
96
|
+
def rotations
|
97
|
+
[self, rot90, rot180, rot270]
|
98
|
+
end
|
99
|
+
|
100
|
+
def rot90
|
101
|
+
rotate
|
102
|
+
end
|
103
|
+
|
104
|
+
def rot180
|
105
|
+
rotate.rotate
|
106
|
+
end
|
107
|
+
|
108
|
+
def rot270
|
109
|
+
rotate.rotate.rotate
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
data/lib/xo/ai/minimax.rb
CHANGED
@@ -1,125 +1,223 @@
|
|
1
|
-
require '
|
1
|
+
require 'singleton'
|
2
|
+
|
2
3
|
require 'xo/evaluator'
|
4
|
+
require 'xo/ai/geometric_grid'
|
5
|
+
|
6
|
+
module XO
|
7
|
+
|
8
|
+
module AI
|
9
|
+
|
10
|
+
# This class provides an implementation of the
|
11
|
+
# {http://en.wikipedia.org/wiki/Minimax#Minimax_algorithm_with_alternate_moves minimax algorithm}. The minimax algorithm
|
12
|
+
# is a recursive search algorithm used to find the next move in a 2-player (or n-player) game.
|
13
|
+
#
|
14
|
+
# The search space forms a tree where the root is the empty grid and every other node is a possible grid configuration that
|
15
|
+
# can be reached by playing through a game of Tic-tac-toe.
|
16
|
+
#
|
17
|
+
# Given any node in the tree and an indication of whose turn it is to play next, all the node's children can be determined by
|
18
|
+
# making one move in each of its open positions. For example, given the node
|
19
|
+
#
|
20
|
+
# x | o | x
|
21
|
+
# ---+---+---
|
22
|
+
# | x |
|
23
|
+
# ---+---+---
|
24
|
+
# o | | o
|
25
|
+
#
|
26
|
+
# and knowing that it's {XO::Grid::O}'s (the min player) turn to play. Then, its children will be the 3 nodes
|
27
|
+
#
|
28
|
+
# A B C
|
29
|
+
#
|
30
|
+
# x | o | x x | o | x x | o | x
|
31
|
+
# ---+---+--- ---+---+--- ---+---+---
|
32
|
+
# o | x | | x | o | x |
|
33
|
+
# ---+---+--- ---+---+--- ---+---+---
|
34
|
+
# o | | o o | | o o | o | o
|
35
|
+
#
|
36
|
+
# since there are 3 open positions in which {XO::Grid::O} can make a move.
|
37
|
+
#
|
38
|
+
# Within the implementation, A and B will be considered intermediate nodes and so the search algorithm will have to continue until
|
39
|
+
# it can make a conclusive determination. That occurs when it reaches a terminal node, like C. In that case, the algorithm assigns
|
40
|
+
# a value to the terminal node from the perspective of the player that has to play next. So in C's case,
|
41
|
+
# {XO::Grid::X} (the max player) has to play next. But {XO::Grid::X} can't play because {XO::Grid::O} won. So {XO::Grid::X} would
|
42
|
+
# value C with a low value, -1 in this case.
|
43
|
+
#
|
44
|
+
# Each intermediate node can now get a value in the following way. Consider node A. It's {XO::Grid::X}'s turn to play and
|
45
|
+
# {XO::Grid::X} is the max player. The max player seeks to maximize their value over all the values of its children (conversely,
|
46
|
+
# the min player seeks to minimize their value over all its children). It has 2 children and they will eventually be determined
|
47
|
+
# to have the values 0 and -1. Since 0 is greater than -1, A will get the value of 0. What this means essentially is that the max
|
48
|
+
# player will play to favor a squashed game rather than a losing game in this particular instance.
|
49
|
+
#
|
50
|
+
# It is interesting to note that B is simply a reflection of A and so will end up having the same value. The algorithm below is
|
51
|
+
# smart enough to recognize that and so it will not have to perform a similar calculation in B's case.
|
52
|
+
#
|
53
|
+
# The Minimax class is a Singleton class. You use it as follows:
|
54
|
+
#
|
55
|
+
# @example
|
56
|
+
# Minimax.instance.moves(XO::Grid.new('xox x o o'), XO::Grid::O) # => [[3, 2]]
|
57
|
+
#
|
58
|
+
# The first time the instance of Minimax is created, it runs the minimax algorithm to compute the value of all the nodes in the
|
59
|
+
# search space. This of course takes a bit of time (~ 4 seconds), but subsequent calls are instantaneous.
|
60
|
+
class Minimax
|
61
|
+
include Singleton
|
62
|
+
|
63
|
+
# Determines the best moves that can be made on the given grid, knowing that it's turn's time to play.
|
64
|
+
#
|
65
|
+
# @param grid [XO::Grid]
|
66
|
+
# @param turn [XO::Grid::X, XO::Grid::O]
|
67
|
+
# @raise [ArgumentError] if turn is not a token or the combination of the values of grid and turn doesn't make sense
|
68
|
+
# @return [Array<Array(Integer, Integer)>]
|
69
|
+
def moves(grid, turn)
|
70
|
+
raise ArgumentError, "illegal token #{turn}" unless GeometricGrid.is_token?(turn)
|
71
|
+
|
72
|
+
best_moves(*lift(grid, turn))
|
73
|
+
end
|
3
74
|
|
4
|
-
|
75
|
+
private
|
5
76
|
|
6
|
-
|
7
|
-
state = MaxGameState.new(grid, player)
|
8
|
-
moves = state.next_states.select { |next_state| state.score == next_state.score }.map(&:move)
|
77
|
+
attr_reader :the_grid, :scores
|
9
78
|
|
10
|
-
|
11
|
-
|
79
|
+
def initialize
|
80
|
+
init_search
|
81
|
+
build_search_tree
|
82
|
+
end
|
12
83
|
|
13
|
-
|
84
|
+
def init_search
|
85
|
+
@the_grid = GeometricGrid.new
|
86
|
+
@scores = {}
|
87
|
+
end
|
14
88
|
|
15
|
-
|
89
|
+
def build_search_tree(player = MaxPlayer)
|
90
|
+
return if has_score?
|
16
91
|
|
17
|
-
|
18
|
-
@grid = grid.dup
|
19
|
-
@player = player
|
20
|
-
@move = move
|
92
|
+
analyze_grid(player)
|
21
93
|
|
22
|
-
|
23
|
-
|
94
|
+
if terminal?
|
95
|
+
set_score(player)
|
96
|
+
else
|
97
|
+
next_grids = []
|
24
98
|
|
25
|
-
|
26
|
-
|
27
|
-
|
99
|
+
the_grid.each_open do |r, c|
|
100
|
+
the_grid[r, c] = player.token
|
101
|
+
next_grids << the_grid.dup
|
28
102
|
|
29
|
-
|
30
|
-
case result[:status]
|
31
|
-
when :ok
|
32
|
-
false
|
33
|
-
when :game_over
|
34
|
-
true
|
35
|
-
else
|
36
|
-
raise IllegalGridStatusError
|
37
|
-
end
|
38
|
-
end
|
103
|
+
build_search_tree(player.other)
|
39
104
|
|
40
|
-
|
41
|
-
|
42
|
-
end
|
105
|
+
the_grid[r, c] = :e
|
106
|
+
end
|
43
107
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
else
|
48
|
-
non_terminal_score
|
49
|
-
end
|
50
|
-
end
|
108
|
+
set_final_score(player, next_grids)
|
109
|
+
end
|
110
|
+
end
|
51
111
|
|
52
|
-
|
53
|
-
|
54
|
-
|
112
|
+
def has_score?
|
113
|
+
scores.key?(the_grid)
|
114
|
+
end
|
55
115
|
|
56
|
-
|
57
|
-
|
58
|
-
|
116
|
+
def analyze_grid(player)
|
117
|
+
@results = Evaluator.instance.analyze(the_grid, player.token)
|
118
|
+
end
|
59
119
|
|
60
|
-
|
61
|
-
|
62
|
-
|
120
|
+
def terminal?
|
121
|
+
@results[:status] == :game_over
|
122
|
+
end
|
123
|
+
|
124
|
+
def set_score(player)
|
125
|
+
scores[the_grid.dup] = player.score(@results[:type])
|
126
|
+
end
|
127
|
+
|
128
|
+
def set_final_score(player, next_grids)
|
129
|
+
scores[the_grid.dup] = player.final_score(next_grids, scores)
|
130
|
+
end
|
63
131
|
|
64
|
-
|
132
|
+
# The search tree that gets built is for the situation when {XO::Grid::X} is assumed to
|
133
|
+
# have played first. However, if we are given a grid to evaluate such that
|
134
|
+
# it can only be reached by assuming that {XO::Grid::O} played first then we need to
|
135
|
+
# patch things up so that we can find a representative in our search space
|
136
|
+
# for the given configuration.
|
137
|
+
def lift(grid, turn)
|
138
|
+
xs, os = Evaluator.instance.xos(grid)
|
139
|
+
|
140
|
+
if turn == GeometricGrid::X
|
141
|
+
if xs == os
|
142
|
+
[GeometricGrid.new(grid.inspect), GeometricGrid::X]
|
143
|
+
elsif xs < os
|
144
|
+
[invert(grid), GeometricGrid::O]
|
145
|
+
else
|
146
|
+
raise ArgumentError, "#{grid} and #{turn} is not a valid combination, too many X's"
|
147
|
+
end
|
148
|
+
else
|
149
|
+
if xs == os
|
150
|
+
[invert(grid), GeometricGrid::X]
|
151
|
+
elsif xs > os
|
152
|
+
[GeometricGrid.new(grid.inspect), GeometricGrid::O]
|
153
|
+
else
|
154
|
+
raise ArgumentError, "#{grid} and #{turn} is not a valid combination, too many O's"
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def invert(grid)
|
160
|
+
inverted_grid = GeometricGrid.new
|
161
|
+
|
162
|
+
grid.each do |r, c, val|
|
163
|
+
inverted_grid[r, c] = GeometricGrid.other_token(val)
|
164
|
+
end
|
165
|
+
|
166
|
+
inverted_grid
|
167
|
+
end
|
168
|
+
|
169
|
+
def best_moves(grid, turn)
|
170
|
+
final_score = @scores[grid]
|
171
|
+
moves = []
|
65
172
|
|
66
|
-
|
67
|
-
|
173
|
+
grid.each_open do |r, c|
|
174
|
+
grid[r, c] = turn
|
68
175
|
|
69
|
-
|
70
|
-
grid.each_free do |r, c|
|
71
|
-
next_grid = grid.dup
|
72
|
-
next_grid[r, c] = player
|
176
|
+
moves << [r, c] if @scores[grid] == final_score
|
73
177
|
|
74
|
-
|
178
|
+
grid[r, c] = :e
|
75
179
|
end
|
180
|
+
|
181
|
+
moves
|
76
182
|
end
|
183
|
+
end
|
184
|
+
|
185
|
+
module MaxPlayer
|
186
|
+
|
187
|
+
def self.token
|
188
|
+
GeometricGrid::X
|
77
189
|
end
|
78
|
-
end
|
79
190
|
|
80
|
-
|
191
|
+
def self.other
|
192
|
+
MinPlayer
|
193
|
+
end
|
81
194
|
|
82
|
-
|
83
|
-
|
84
|
-
|
195
|
+
def self.score(type)
|
196
|
+
{ winner: 1, loser: -1, squashed: 0 }[type]
|
197
|
+
end
|
85
198
|
|
86
|
-
|
87
|
-
|
88
|
-
when :winner
|
89
|
-
1
|
90
|
-
when :loser
|
91
|
-
-1
|
92
|
-
when :squashed
|
93
|
-
0
|
199
|
+
def self.final_score(next_grids, scores)
|
200
|
+
next_grids.map { |grid| scores[grid] }.max
|
94
201
|
end
|
95
202
|
end
|
96
203
|
|
97
|
-
|
98
|
-
scores.max
|
99
|
-
end
|
100
|
-
end
|
204
|
+
module MinPlayer
|
101
205
|
|
102
|
-
|
206
|
+
def self.token
|
207
|
+
GeometricGrid::O
|
208
|
+
end
|
103
209
|
|
104
|
-
|
105
|
-
|
106
|
-
|
210
|
+
def self.other
|
211
|
+
MaxPlayer
|
212
|
+
end
|
107
213
|
|
108
|
-
|
109
|
-
|
110
|
-
when :winner
|
111
|
-
-1
|
112
|
-
when :loser
|
113
|
-
1
|
114
|
-
when :squashed
|
115
|
-
0
|
214
|
+
def self.score(type)
|
215
|
+
{ winner: -1, loser: 1, squashed: 0 }[type]
|
116
216
|
end
|
117
|
-
end
|
118
217
|
|
119
|
-
|
120
|
-
|
218
|
+
def self.final_score(next_grids, scores)
|
219
|
+
next_grids.map { |grid| scores[grid] }.min
|
220
|
+
end
|
121
221
|
end
|
122
222
|
end
|
123
|
-
|
124
|
-
class IllegalGridStatusError < StandardError; end
|
125
223
|
end
|