xo 0.0.1 → 1.0.0

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