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.
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
@@ -1,4 +1,2 @@
1
+ require 'xo/ai/geometric_grid'
1
2
  require 'xo/ai/minimax'
2
- require 'xo/ai/expert'
3
- require 'xo/ai/novice'
4
- require 'xo/ai/advanced_beginner'
@@ -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 'ostruct'
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
- module XO::AI
75
+ private
5
76
 
6
- def self.minimax(grid, player)
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
- OpenStruct.new(start_state: state, moves: moves)
11
- end
79
+ def initialize
80
+ init_search
81
+ build_search_tree
82
+ end
12
83
 
13
- class GameState
84
+ def init_search
85
+ @the_grid = GeometricGrid.new
86
+ @scores = {}
87
+ end
14
88
 
15
- attr_reader :grid, :player, :move, :next_states
89
+ def build_search_tree(player = MaxPlayer)
90
+ return if has_score?
16
91
 
17
- def initialize(grid, player, move = nil)
18
- @grid = grid.dup
19
- @player = player
20
- @move = move
92
+ analyze_grid(player)
21
93
 
22
- generate_next_states
23
- end
94
+ if terminal?
95
+ set_score(player)
96
+ else
97
+ next_grids = []
24
98
 
25
- def result
26
- @result ||= XO::Evaluator.analyze(grid, player)
27
- end
99
+ the_grid.each_open do |r, c|
100
+ the_grid[r, c] = player.token
101
+ next_grids << the_grid.dup
28
102
 
29
- def is_terminal?
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
- def scores
41
- next_states.map(&:score)
42
- end
105
+ the_grid[r, c] = :e
106
+ end
43
107
 
44
- def score
45
- if is_terminal?
46
- terminal_score
47
- else
48
- non_terminal_score
49
- end
50
- end
108
+ set_final_score(player, next_grids)
109
+ end
110
+ end
51
111
 
52
- def terminal_score
53
- raise NotImplementedError
54
- end
112
+ def has_score?
113
+ scores.key?(the_grid)
114
+ end
55
115
 
56
- def non_terminal_score
57
- raise NotImplementedError
58
- end
116
+ def analyze_grid(player)
117
+ @results = Evaluator.instance.analyze(the_grid, player.token)
118
+ end
59
119
 
60
- def next_game_state(next_grid, other_player, move)
61
- raise NotImplementedError
62
- end
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
- private
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
- def generate_next_states
67
- @next_states = []
173
+ grid.each_open do |r, c|
174
+ grid[r, c] = turn
68
175
 
69
- unless is_terminal?
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
- @next_states << next_game_state(next_grid, XO.other_player(player), XO::Position.new(r, c))
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
- class MaxGameState < GameState
191
+ def self.other
192
+ MinPlayer
193
+ end
81
194
 
82
- def next_game_state(next_grid, other_player, move)
83
- MinGameState.new(next_grid, other_player, move)
84
- end
195
+ def self.score(type)
196
+ { winner: 1, loser: -1, squashed: 0 }[type]
197
+ end
85
198
 
86
- def terminal_score
87
- case result[:type]
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
- def non_terminal_score
98
- scores.max
99
- end
100
- end
204
+ module MinPlayer
101
205
 
102
- class MinGameState < GameState
206
+ def self.token
207
+ GeometricGrid::O
208
+ end
103
209
 
104
- def next_game_state(next_grid, other_player, move)
105
- MaxGameState.new(next_grid, other_player, move)
106
- end
210
+ def self.other
211
+ MaxPlayer
212
+ end
107
213
 
108
- def terminal_score
109
- case result[:type]
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
- def non_terminal_score
120
- scores.min
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