erics_tic_tac_toe 0.1.0 → 0.5.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/.gitignore +1 -0
- data/README.markdown +6 -7
- data/Rakefile +2 -1
- data/bin/tic_tac_toe +41 -2
- data/lib/tic_tac_toe.rb +5 -4
- data/lib/tic_tac_toe/board.rb +153 -0
- data/lib/tic_tac_toe/game.rb +70 -0
- data/lib/tic_tac_toe/game_types/terminal_game.rb +86 -0
- data/lib/tic_tac_toe/player.rb +13 -0
- data/lib/tic_tac_toe/players/computer_player.rb +26 -0
- data/lib/tic_tac_toe/players/human_player.rb +28 -0
- data/lib/tic_tac_toe/presentors/game_presenter.rb +16 -0
- data/lib/tic_tac_toe/presentors/player_presenter.rb +13 -0
- data/lib/tic_tac_toe/strategies/minimax_strategy.rb +91 -0
- data/lib/tic_tac_toe/strategies/three_by_three_strategy.rb +260 -0
- data/lib/{tic-tac-toe → tic_tac_toe}/version.rb +1 -1
- data/test/board_test.rb +40 -52
- data/test/game_presentor_test.rb +19 -0
- data/test/game_test.rb +79 -0
- data/test/player_presenter_test.rb +12 -0
- data/test/player_test.rb +57 -0
- data/test/potential_state_test.rb +53 -0
- data/test/solver_test.rb +169 -93
- data/test/terminal_game_test.rb +78 -0
- data/test/test_helper.rb +4 -0
- data/tic-tac-toe.gemspec +1 -1
- metadata +25 -11
- data/lib/tic-tac-toe/board.rb +0 -148
- data/lib/tic-tac-toe/game.rb +0 -62
- data/lib/tic-tac-toe/game_types/terminal_game.rb +0 -63
- data/lib/tic-tac-toe/solver.rb +0 -16
- data/lib/tic-tac-toe/strategies/threebythree_implementations/brute_force_implementation.rb +0 -197
- data/lib/tic-tac-toe/strategies/threebythree_stategy.rb +0 -30
- data/test/brute_force_implementation_test.rb +0 -25
@@ -0,0 +1,28 @@
|
|
1
|
+
module TicTacToe
|
2
|
+
|
3
|
+
class HumanPlayer
|
4
|
+
attr_reader :letter
|
5
|
+
attr_writer :move
|
6
|
+
|
7
|
+
def initialize(params)
|
8
|
+
@letter, @move = (params['letter'] || params[:letter]),
|
9
|
+
(params['move'] || params[:move])
|
10
|
+
end
|
11
|
+
|
12
|
+
def get_move(_board)
|
13
|
+
move = @move
|
14
|
+
@move = nil
|
15
|
+
move
|
16
|
+
end
|
17
|
+
|
18
|
+
def has_next_move?
|
19
|
+
!!@move
|
20
|
+
end
|
21
|
+
|
22
|
+
def type
|
23
|
+
Player::HUMAN
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module TicTacToe
|
2
|
+
# This implements the Minimax algorithum with AlphaBeta Prunning
|
3
|
+
# http://en.wikipedia.org/wiki/Minimax
|
4
|
+
# http://en.wikipedia.org/wiki/Alpha-beta_pruning
|
5
|
+
class MinimaxStrategy
|
6
|
+
|
7
|
+
attr_reader :board # Avoid getters, setters and properties
|
8
|
+
|
9
|
+
def initialize(board, letter)
|
10
|
+
@board, @letter = board, letter
|
11
|
+
end
|
12
|
+
|
13
|
+
def solve
|
14
|
+
raise "Can not solve a full board" if @board.full?
|
15
|
+
Minimax.new(@board, @letter).best_move
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# The game tree needs a evaluator for generating rankings,
|
20
|
+
# an initial game state,
|
21
|
+
# and a player
|
22
|
+
#
|
23
|
+
# This class uses 5 instance variables: @evaluator, @state,
|
24
|
+
# @depth, @alpha, @beta. Should be < 3
|
25
|
+
#
|
26
|
+
# This class has 63 lines. Should be <= 50
|
27
|
+
class Minimax
|
28
|
+
|
29
|
+
MAXDEPTH = 6
|
30
|
+
PositiveInfinity = +1.0/0.0
|
31
|
+
NegativeInfinity = -1.0/0.0
|
32
|
+
|
33
|
+
def initialize(board, player)
|
34
|
+
@start_board = board
|
35
|
+
@player = player
|
36
|
+
end
|
37
|
+
|
38
|
+
def best_move
|
39
|
+
@start_board.empty_positions.max_by do |column, row|
|
40
|
+
score(@start_board.clone.play_at(column, row, @player), next_turn(@player))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def score(board, whos_turn, depth=1,
|
47
|
+
alpha=NegativeInfinity, beta=PositiveInfinity)
|
48
|
+
|
49
|
+
if board.full? || board.solved?
|
50
|
+
return 1.0 / depth if board.winner == @player
|
51
|
+
return -1.0 if board.solved?
|
52
|
+
return 0
|
53
|
+
end
|
54
|
+
|
55
|
+
if whos_turn == @player
|
56
|
+
board.empty_positions.each do |column, row|
|
57
|
+
alpha = [
|
58
|
+
alpha,
|
59
|
+
next_score(board, column, row, whos_turn, depth, alpha, beta)
|
60
|
+
].max
|
61
|
+
break if beta <= alpha || depth >= MAXDEPTH
|
62
|
+
end
|
63
|
+
return alpha
|
64
|
+
end
|
65
|
+
|
66
|
+
board.empty_positions.each do |column, row|
|
67
|
+
beta = [
|
68
|
+
beta,
|
69
|
+
next_score(board, column, row, whos_turn, depth, alpha, beta)
|
70
|
+
].min
|
71
|
+
break if alpha >= beta || depth >= MAXDEPTH
|
72
|
+
end
|
73
|
+
beta
|
74
|
+
end
|
75
|
+
|
76
|
+
def next_score(board, column, row, whos_turn, depth, alpha, beta)
|
77
|
+
score( board.clone.play_at(column, row, whos_turn),
|
78
|
+
next_turn(whos_turn),
|
79
|
+
depth+1, alpha, beta
|
80
|
+
)
|
81
|
+
end
|
82
|
+
|
83
|
+
def next_turn(player)
|
84
|
+
case player
|
85
|
+
when X then O
|
86
|
+
when O then X
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
@@ -0,0 +1,260 @@
|
|
1
|
+
module TicTacToe
|
2
|
+
# Strategy used when playing on a 3x3 board
|
3
|
+
class ThreeByThreeStrategy
|
4
|
+
def initialize(board, letter)
|
5
|
+
@heuristic = ThreeByThree::Heuristic.new(board, letter)
|
6
|
+
end
|
7
|
+
|
8
|
+
# The strategy is from the Wikipedia article on Tic-Tac-Toe
|
9
|
+
# 1) Try to win
|
10
|
+
# 2) Try to block if they're about to win
|
11
|
+
# 3) Try to fork so you'll win next turn
|
12
|
+
# 4) Try to block their fork so they will not win next turn
|
13
|
+
# 5) Take the center if its not already taken
|
14
|
+
# 6) Play the opposite corner of your opponent
|
15
|
+
# 7) Play in an empty corner
|
16
|
+
# 8) Play in an empty side
|
17
|
+
def solve
|
18
|
+
[:win, :block, :fork, :block_fork,
|
19
|
+
:center, :opposite_corner, :empty_corner, :empty_side].each do |step|
|
20
|
+
move = @heuristic.send(step)
|
21
|
+
return move if move
|
22
|
+
end
|
23
|
+
|
24
|
+
raise "No possible moves to play!"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module ThreeByThree
|
29
|
+
# Brute Force Implementation for the Three by Three Strategy
|
30
|
+
# This implementation uses loops to try a change all of the board values and
|
31
|
+
# checking the result
|
32
|
+
#
|
33
|
+
# For example, win! is implemented by trying every cell, and checking if
|
34
|
+
# it was a winning solution
|
35
|
+
class Heuristic
|
36
|
+
|
37
|
+
attr_reader :board
|
38
|
+
|
39
|
+
def initialize(board, letter)
|
40
|
+
@board, @letter, @state = board, letter, PotentialState.new(board, letter)
|
41
|
+
@other_player = other_player
|
42
|
+
end
|
43
|
+
|
44
|
+
# Try placing letter at every available position
|
45
|
+
# If the board is solved, do that
|
46
|
+
def win
|
47
|
+
each_position do |row, column|
|
48
|
+
if @state.at(row, column).solved?
|
49
|
+
return [row, column]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
false
|
53
|
+
end
|
54
|
+
|
55
|
+
# Try placing the opponent's letter at every available position
|
56
|
+
# If the board is solved, block them at that position
|
57
|
+
def block(board = @board, letter = @letter)
|
58
|
+
state = PotentialState.new(board, other_player(letter))
|
59
|
+
each_position do |row, column|
|
60
|
+
if state.at(row, column).solved?
|
61
|
+
return [row, column]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
false
|
65
|
+
end
|
66
|
+
|
67
|
+
# Try placing the letter at every position.
|
68
|
+
# If there are now two winning solutions for next turn, go there
|
69
|
+
def fork
|
70
|
+
@state.each_forking_position do |row, column|
|
71
|
+
return [row, column]
|
72
|
+
end
|
73
|
+
false
|
74
|
+
end
|
75
|
+
|
76
|
+
# Try placing the opponent's letter at every position.
|
77
|
+
# If there are now two winning solutions for next turn, block them there
|
78
|
+
def block_fork
|
79
|
+
PotentialState.new(@board, @other_player).each_forking_position do |row, column|
|
80
|
+
|
81
|
+
# Simulate blocking the fork
|
82
|
+
temp_board = @board.clone
|
83
|
+
temp_board.play_at(row, column, @letter)
|
84
|
+
|
85
|
+
# Search for the elusive double fork
|
86
|
+
if PotentialState.new(temp_board, @other_player).forking_positions.any?
|
87
|
+
return force_a_block
|
88
|
+
end
|
89
|
+
|
90
|
+
return [row, column]
|
91
|
+
end
|
92
|
+
false
|
93
|
+
end
|
94
|
+
|
95
|
+
def center
|
96
|
+
return false if @board.get_cell(@board.size/2, @board.size/2)
|
97
|
+
[1,1]
|
98
|
+
end
|
99
|
+
|
100
|
+
# Cycle through all of the corners looking for the opponent's letter
|
101
|
+
# If one is found, place letter at the opposite corner
|
102
|
+
def opposite_corner
|
103
|
+
first = 0
|
104
|
+
last = @board.size - 1
|
105
|
+
corners.each_with_index do |corner, index|
|
106
|
+
if corner == @other_player
|
107
|
+
next if @board.get_cell(*opposite_corner_from_index(index).compact)
|
108
|
+
return opposite_corner_from_index(index)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
false
|
112
|
+
end
|
113
|
+
|
114
|
+
# Cycle though all of the corners, until one is found that is empty
|
115
|
+
def empty_corner
|
116
|
+
corners.each_with_index do |corner, index|
|
117
|
+
next if corner
|
118
|
+
return corner_from_index(index)
|
119
|
+
end
|
120
|
+
false
|
121
|
+
end
|
122
|
+
|
123
|
+
# Place letter at a random empty cell, at this point it should only be sides left
|
124
|
+
def empty_side
|
125
|
+
@board.empty_positions do |row, column|
|
126
|
+
return [row, column]
|
127
|
+
end
|
128
|
+
false
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def corners
|
134
|
+
[@board.get_cell(0, 0), # Top Left
|
135
|
+
@board.get_cell(@board.size-1, 0), # Top Right
|
136
|
+
@board.get_cell(@board.size-1, @board.size-1), # Bottom Right
|
137
|
+
@board.get_cell(0, @board.size-1)] # Bottom Left
|
138
|
+
end
|
139
|
+
|
140
|
+
def corner_from_index(index)
|
141
|
+
first = 0
|
142
|
+
last = @board.size - 1
|
143
|
+
case index
|
144
|
+
when 0 # Top Left
|
145
|
+
[first, first]
|
146
|
+
when 1 # Top Right
|
147
|
+
[last, first]
|
148
|
+
when 2 # Bottom Right
|
149
|
+
[last, last]
|
150
|
+
when 3 # Bottom Left
|
151
|
+
[first, last]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def opposite_corner_from_index(index)
|
156
|
+
first = 0
|
157
|
+
last = @board.size - 1
|
158
|
+
case index
|
159
|
+
when 0 # Top Left
|
160
|
+
[last, last]
|
161
|
+
when 1 # Top Right
|
162
|
+
[first, last]
|
163
|
+
when 2 # Bottom Right
|
164
|
+
[first, first]
|
165
|
+
when 3 # Bottom Left
|
166
|
+
[last, first]
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def other_player(letter = @letter)
|
171
|
+
letter == X ? O : X
|
172
|
+
end
|
173
|
+
|
174
|
+
def each_position(&block)
|
175
|
+
@board.size.times do |column|
|
176
|
+
@board.size.times do |row|
|
177
|
+
yield(row, column)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def force_a_block
|
183
|
+
# Force them to block without creating another fork
|
184
|
+
each_position do |row, column|
|
185
|
+
if @state.at(row, column).can_win_next_turn?
|
186
|
+
|
187
|
+
# Simulate forcing them to block
|
188
|
+
temp_board = @board.clone
|
189
|
+
temp_board.play_at(row, column, @letter)
|
190
|
+
temp_board.play_at(*block(temp_board, @other_player), @other_player)
|
191
|
+
|
192
|
+
# Did I just create another fork with that block?
|
193
|
+
next if PotentialState.new(temp_board, @other_player).fork_exists?
|
194
|
+
|
195
|
+
return [row, column]
|
196
|
+
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
# Represents a state of players move
|
202
|
+
# This is comprised of a board and letter (player)
|
203
|
+
class PotentialState
|
204
|
+
def initialize(board, letter)
|
205
|
+
@board, @letter = board, letter
|
206
|
+
end
|
207
|
+
|
208
|
+
def at(row, column)
|
209
|
+
new_board = @board.clone
|
210
|
+
new_board.play_at(row, column, @letter)
|
211
|
+
PotentialState.new(new_board, @letter)
|
212
|
+
end
|
213
|
+
|
214
|
+
def solved?
|
215
|
+
@board.solved?
|
216
|
+
end
|
217
|
+
|
218
|
+
def each_forking_position(&block)
|
219
|
+
forking_positions.each { |position| yield(*position) }
|
220
|
+
end
|
221
|
+
|
222
|
+
def fork_exists?
|
223
|
+
winning_positions_count >= 2
|
224
|
+
end
|
225
|
+
|
226
|
+
def can_win_next_turn?
|
227
|
+
each_position do |row, column|
|
228
|
+
return true if at(row, column).solved?
|
229
|
+
end
|
230
|
+
false
|
231
|
+
end
|
232
|
+
|
233
|
+
def forking_positions
|
234
|
+
positions = []
|
235
|
+
each_position do |row, column|
|
236
|
+
positions << [row, column] if at(row, column).fork_exists?
|
237
|
+
end
|
238
|
+
positions
|
239
|
+
end
|
240
|
+
|
241
|
+
private
|
242
|
+
|
243
|
+
def winning_positions_count
|
244
|
+
count = 0
|
245
|
+
each_position do |row, column|
|
246
|
+
count += 1 if at(row, column).solved?
|
247
|
+
end
|
248
|
+
count
|
249
|
+
end
|
250
|
+
|
251
|
+
def each_position(&block)
|
252
|
+
@board.size.times do |column|
|
253
|
+
@board.size.times do |row|
|
254
|
+
yield(row, column)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
data/test/board_test.rb
CHANGED
@@ -2,45 +2,64 @@ require 'test_helper'
|
|
2
2
|
|
3
3
|
class BoardTest < MiniTest::Unit::TestCase
|
4
4
|
def setup
|
5
|
-
TicTacToe::Board.instance_eval { attr_accessor :grid } # For testing purposes
|
6
5
|
@board = TicTacToe::Board.new
|
7
6
|
end
|
8
7
|
|
9
|
-
def
|
8
|
+
def test_grid_can_be_set
|
9
|
+
@board.grid = 'Foo'
|
10
|
+
assert_equal 'Foo', @board.grid
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_a_new_board_is_empty
|
10
14
|
# Board empty when created
|
11
15
|
assert @board.empty?
|
12
|
-
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_a_board_after_played_is_no_longer_empty
|
13
19
|
# Board not longer empty after letter placed
|
14
20
|
@board.play_at(0,0,'o')
|
15
21
|
refute @board.empty?
|
16
22
|
end
|
17
23
|
|
18
|
-
def
|
19
|
-
# Board not full when empty
|
24
|
+
def test_a_new_board_is_not_empty
|
20
25
|
refute @board.full?
|
26
|
+
end
|
21
27
|
|
28
|
+
def test_full
|
22
29
|
# Board full when no cells are nil
|
23
30
|
@board.grid = [ %w(x o x), %w(o x o), %w(x o x)]
|
24
31
|
assert @board.full?
|
25
32
|
end
|
26
33
|
|
27
|
-
def
|
34
|
+
def test_a_empty_board_is_not_only_one
|
28
35
|
# False when empty
|
29
36
|
refute @board.only_one?
|
30
|
-
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_a_board_knows_when_only_one_peice
|
31
40
|
# True when one
|
32
41
|
@board.grid = [ ['x',nil,nil], [nil,nil,nil], [nil,nil,nil] ]
|
33
42
|
assert @board.only_one?
|
43
|
+
end
|
34
44
|
|
45
|
+
def test_a_board_with_more_than_one_place_is_not_only_one
|
35
46
|
# False when more than one
|
36
47
|
@board.grid = [ ['x','x',nil], [nil,nil,nil], [nil,nil,nil] ]
|
37
48
|
refute @board.only_one?
|
38
49
|
end
|
39
50
|
|
40
|
-
def
|
51
|
+
def test_get_cell_returns_nil_when_nothing_there
|
41
52
|
# Returns nil when nothing in cell
|
42
53
|
assert_nil @board.get_cell(0, 0)
|
54
|
+
end
|
43
55
|
|
56
|
+
def test_play_at
|
57
|
+
# Cells are proper set and override nil values
|
58
|
+
@board.play_at(1, 1, 'x')
|
59
|
+
assert_equal 'x', @board.get_cell(1,1)
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_get_cell
|
44
63
|
# Returns the right letter after they've been played
|
45
64
|
@board.play_at(0,0,"o")
|
46
65
|
assert_equal "o", @board.get_cell(0, 0)
|
@@ -49,49 +68,7 @@ class BoardTest < MiniTest::Unit::TestCase
|
|
49
68
|
assert_equal "o", @board.get_cell(1, 2)
|
50
69
|
end
|
51
70
|
|
52
|
-
def
|
53
|
-
# Center cell is nil in empty grid
|
54
|
-
assert_nil @board.center_cell
|
55
|
-
|
56
|
-
# Returns the correct letter after its been placed
|
57
|
-
@board.play_at(1,1,'o')
|
58
|
-
assert_equal "o", @board.center_cell
|
59
|
-
|
60
|
-
# Does not override values after they've been placed
|
61
|
-
@board.center_cell = 'x'
|
62
|
-
assert_equal "o", @board.center_cell
|
63
|
-
|
64
|
-
# Can use helper when the center cell is nil
|
65
|
-
@board = TicTacToe::Board.new
|
66
|
-
@board.center_cell = 'x'
|
67
|
-
assert_equal "x", @board.center_cell
|
68
|
-
end
|
69
|
-
|
70
|
-
def test_corners
|
71
|
-
# All of the corners are nil in an empty grid
|
72
|
-
@board.corners.each { |corner| assert_nil corner }
|
73
|
-
|
74
|
-
# Will return the corners in the proper order (clockwise)
|
75
|
-
# 0 => Top Left
|
76
|
-
# 1 => Top Right
|
77
|
-
# 2 => Bottom Right
|
78
|
-
# 3 => Bottom Left
|
79
|
-
@board.play_at(0,0,'a')
|
80
|
-
@board.play_at(2,0,'b')
|
81
|
-
@board.play_at(2,2,'c')
|
82
|
-
@board.play_at(0,2,'d')
|
83
|
-
answers = %w( a b c d )
|
84
|
-
@board.corners.each_with_index { |corner, i| assert_equal answers[i], corner }
|
85
|
-
end
|
86
|
-
|
87
|
-
def test_play_at
|
88
|
-
# Cells are proper set and override nil values
|
89
|
-
assert_nil @board.get_cell(1,1)
|
90
|
-
@board.play_at(1, 1, 'x')
|
91
|
-
assert_equal 'x', @board.get_cell(1,1)
|
92
|
-
end
|
93
|
-
|
94
|
-
def test_can_not_overide_values
|
71
|
+
def test_can_not_override_values
|
95
72
|
assert_nil @board.get_cell(1,1)
|
96
73
|
@board.play_at(1, 1, 'x')
|
97
74
|
assert_equal 'x', @board.get_cell(1,1)
|
@@ -99,7 +76,7 @@ class BoardTest < MiniTest::Unit::TestCase
|
|
99
76
|
assert_equal 'x', @board.get_cell(1,1)
|
100
77
|
end
|
101
78
|
|
102
|
-
def
|
79
|
+
def test_solved_across
|
103
80
|
refute @board.solved?
|
104
81
|
|
105
82
|
@board.grid = [ %w(x x x), [nil, nil, nil], [nil, nil, nil]]
|
@@ -138,4 +115,15 @@ class BoardTest < MiniTest::Unit::TestCase
|
|
138
115
|
refute @board.solved?
|
139
116
|
end
|
140
117
|
|
118
|
+
def test_displays_correctly
|
119
|
+
assert_equal (<<-HEREDOC), @board.to_s
|
120
|
+
-----------
|
121
|
+
1 | 2 | 3 |
|
122
|
+
4 | 5 | 6 |
|
123
|
+
7 | 8 | 9 |
|
124
|
+
-----------
|
125
|
+
|
126
|
+
HEREDOC
|
127
|
+
end
|
128
|
+
|
141
129
|
end
|