xo 0.0.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|