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
checksums.yaml
CHANGED
@@ -1,7 +1,15 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
NTMyNzA0ODhkYmI4OGE5ZjM3ZDc3YjZkNDA2NGQ0ZWZjMWI3ODYwYQ==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
OWNlNjMxM2JmMDEzMTQ0YWJjNjdjMDUzYmQ0ZDQ3YmY3OWUzMDk4OA==
|
5
7
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
OGY3M2IyNWE0Y2YzYzZkMzlhMmEzNzIyODg3YTFiNzg1MzFiMjZkZDBiMjZl
|
10
|
+
OWMzYzllZjVhYTVjOWM1NTYxY2MyYWQ3ODdlZDhmNzUxMjNlNTA0N2VhYzQ2
|
11
|
+
MjE4OWJhNGE0NTk3YTFiYTU3MWViMjAxODcxZGQ0OGFiYjAwMGU=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
ZjllZDA0YzY0MDYzMTVlY2EzMzlkZmE4NjU2ZjEzNDYwOTJiM2EwNDYyNmI0
|
14
|
+
YTRlZWRlOWM0N2MyNjFkM2FhYmU0MTJlNzA5NDVhMjFmZjk0OTQ0NTMzYzYx
|
15
|
+
Yzk2ZjFmNzAyY2JlNzY0OWM3OTEyMTNkYjkyOTBiMTcwNGJjZDQ=
|
data/.gitignore
CHANGED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
xo (0.0.1)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
coveralls (0.7.0)
|
10
|
+
multi_json (~> 1.3)
|
11
|
+
rest-client
|
12
|
+
simplecov (>= 0.7)
|
13
|
+
term-ansicolor
|
14
|
+
thor
|
15
|
+
docile (1.1.3)
|
16
|
+
mime-types (2.2)
|
17
|
+
minitest (5.3.3)
|
18
|
+
multi_json (1.10.0)
|
19
|
+
rake (10.3.1)
|
20
|
+
redcarpet (3.1.1)
|
21
|
+
rest-client (1.6.7)
|
22
|
+
mime-types (>= 1.16)
|
23
|
+
simplecov (0.8.2)
|
24
|
+
docile (~> 1.1.0)
|
25
|
+
multi_json
|
26
|
+
simplecov-html (~> 0.8.0)
|
27
|
+
simplecov-html (0.8.0)
|
28
|
+
term-ansicolor (1.3.0)
|
29
|
+
tins (~> 1.0)
|
30
|
+
thor (0.19.1)
|
31
|
+
tins (1.1.0)
|
32
|
+
yard (0.8.7.4)
|
33
|
+
|
34
|
+
PLATFORMS
|
35
|
+
ruby
|
36
|
+
|
37
|
+
DEPENDENCIES
|
38
|
+
coveralls (~> 0.7)
|
39
|
+
minitest (~> 5.3)
|
40
|
+
rake (~> 10.3)
|
41
|
+
redcarpet (~> 3.1)
|
42
|
+
xo!
|
43
|
+
yard (~> 0.8)
|
data/README.md
CHANGED
@@ -1,55 +1,120 @@
|
|
1
1
|
# xo
|
2
2
|
|
3
|
+
[](http://badge.fury.io/rb/xo)
|
4
|
+
[](https://travis-ci.org/dwayne/xo)
|
5
|
+
[](https://coveralls.io/r/dwayne/xo)
|
6
|
+
[](https://codeclimate.com/github/dwayne/xo)
|
7
|
+
|
3
8
|
A [Ruby](http://www.ruby-lang.org/en/) library for [Tic-tac-toe](http://en.wikipedia.org/wiki/Tic-tac-toe).
|
4
9
|
|
5
|
-
|
10
|
+
The code is well documented and fully tested, so please have a [read of the documentation](http://rubydoc.info/github/dwayne/xo) and have a [look at the tests](https://github.com/dwayne/xo/tree/master/spec/xo).
|
11
|
+
|
12
|
+
My implementation of the [Minimax algorithm](http://en.wikipedia.org/wiki/Minimax#Minimax_algorithm_with_alternate_moves) might be a little different than what you've seen before. It uses symmetry to significantly reduce the search space and in so doing we get good [performance out of the algorithm](#Performance_of_the_Minimax_Algorithm). However, I still want to get it under 1 second. I'd love to get your thoughts on how I can make it happen. Have a [look](https://github.com/dwayne/xo/blob/master/lib/xo/ai/minimax.rb#L23).
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
```
|
17
|
+
$ gem install xo
|
18
|
+
```
|
19
|
+
|
20
|
+
## Example usage
|
21
|
+
|
22
|
+
Managing the grid yourself.
|
6
23
|
|
7
24
|
```ruby
|
8
|
-
require 'benchmark'
|
9
25
|
require 'xo'
|
10
26
|
|
11
|
-
|
12
|
-
|
27
|
+
include XO
|
28
|
+
|
29
|
+
g = Grid.new('xx oo')
|
30
|
+
|
31
|
+
puts g # => x | x |
|
32
|
+
# ---+---+---
|
33
|
+
# o | o |
|
34
|
+
# ---+---+---
|
35
|
+
# | |
|
36
|
+
|
37
|
+
Evaluator.analyze(g, Grid::X) # => { status: :ok }
|
38
|
+
|
39
|
+
g[1, 3] = Grid::X
|
40
|
+
Evaluator.analyze(g, Grid::X) # => { status: :game_over,
|
41
|
+
# type: :winner,
|
42
|
+
# details: [{
|
43
|
+
# where: :row,
|
44
|
+
# index: 1,
|
45
|
+
# positions: [[1, 1], [1, 2], [1, 3]]
|
46
|
+
# }]
|
47
|
+
# }
|
48
|
+
|
49
|
+
Evaluator.analyze(g, Grid::O) # => { status: :game_over,
|
50
|
+
# type: :loser,
|
51
|
+
# details: [{
|
52
|
+
# where: :row,
|
53
|
+
# index: 1,
|
54
|
+
# positions: [[1, 1], [1, 2], [1, 3]]
|
55
|
+
# }]
|
56
|
+
# }
|
57
|
+
```
|
58
|
+
|
59
|
+
The problem with managing the grid yourself is that there is nothing stopping you from making bad moves. For example, playing twice.
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
g = Grid.new('xx')
|
63
|
+
Evaluator.analyze(g, Grid::O) # => { status: :invalid_grid,
|
64
|
+
# type: :too_many_moves_ahead
|
65
|
+
# }
|
66
|
+
```
|
13
67
|
|
14
|
-
|
15
|
-
# => 0.000000 0.000000 0.000000 ( 0.000463)
|
16
|
-
# => O(1) time due to caching
|
68
|
+
To avoid such situations, let the engine handle game play. Once you tell it who plays first, then it ensures that the game play follows the rules of [Tic-tac-toe](http://en.wikipedia.org/wiki/Tic-tac-toe).
|
17
69
|
|
18
|
-
|
19
|
-
|
70
|
+
```ruby
|
71
|
+
e = Engine.new
|
72
|
+
e.start(Grid::O).play(2, 1).play(1, 1).play(2, 2).play(1, 2).play(2, 3)
|
73
|
+
e.last_event # => { name: :game_over,
|
74
|
+
# type: :winner,
|
75
|
+
# last_move: { turn: :o, r: 2, c: 3 },
|
76
|
+
# details: [{
|
77
|
+
# where: :row,
|
78
|
+
# index: 2,
|
79
|
+
# positions: [[2, 1], [2, 2], [2, 3]]
|
80
|
+
# }]
|
81
|
+
# }
|
82
|
+
```
|
20
83
|
|
21
|
-
|
22
|
-
# => 0.000000 0.000000 0.000000 ( 0.000216)
|
23
|
-
# => O(1) time due to caching
|
84
|
+
I also built a [Tic-tac-toe](http://en.wikipedia.org/wiki/Tic-tac-toe) command-line client that uses the library. See how everything comes together by viewing its implementation right [here](https://github.com/dwayne/xo/blob/master/bin/xo).
|
24
85
|
|
25
|
-
|
26
|
-
g[1, 3] = :o
|
86
|
+
To use the [client](https://github.com/dwayne/xo/blob/master/bin/xo) just type,
|
27
87
|
|
28
|
-
|
29
|
-
|
30
|
-
# => Worst-case time, performance only improves from here on as the grid gets filled
|
88
|
+
```
|
89
|
+
$ xo
|
31
90
|
```
|
32
91
|
|
33
|
-
|
92
|
+
on the command-line after installing the gem.
|
34
93
|
|
35
|
-
|
94
|
+
## Performance of the Minimax Algorithm
|
36
95
|
|
37
|
-
|
38
|
-
|
96
|
+
```ruby
|
97
|
+
require 'benchmark'
|
98
|
+
require 'xo/ai/minimax'
|
99
|
+
|
100
|
+
puts Benchmark.measure { XO::AI::Minimax.instance }
|
101
|
+
# => 3.090000 0.000000 3.090000 ( 3.091686)
|
102
|
+
```
|
103
|
+
|
104
|
+
## Testing
|
39
105
|
|
40
|
-
|
106
|
+
You can run:
|
41
107
|
|
42
|
-
|
43
|
-
|
44
|
-
3. Write an example Tic-tac-toe command-line game client.
|
108
|
+
- All specs: `bundle exec rake`, or
|
109
|
+
- A specific spec: `bundle exec ruby -Ilib -Ispec spec/path_to_spec_file.rb`
|
45
110
|
|
46
|
-
|
111
|
+
## Contributing
|
47
112
|
|
48
113
|
If you'd like to contribute a feature or bugfix: Thanks! To make sure your fix/feature has a high chance of being included, please read the following guidelines:
|
49
114
|
|
50
115
|
1. Post a [pull request](https://github.com/dwayne/xo/compare/).
|
51
116
|
2. Make sure there are tests! I will not accept any patch that is not tested. It's a rare time when explicit tests aren't needed. If you have questions about writing tests for xo, please open a [GitHub issue](https://github.com/dwayne/xo/issues/new).
|
52
117
|
|
53
|
-
|
118
|
+
## License
|
54
119
|
|
55
120
|
xo is Copyright © 2014 Dwayne R. Crooks. It is free software, and may be redistributed under the terms specified in the MIT-LICENSE file.
|
data/Rakefile
CHANGED
data/bin/xo
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# A Tic-tac-toe game client based on xo.
|
4
|
+
|
5
|
+
require 'xo'
|
6
|
+
include XO
|
7
|
+
|
8
|
+
AUTHOR = "Dwayne Crooks"
|
9
|
+
EMAIL = "me@dwaynecrooks.com"
|
10
|
+
|
11
|
+
class Player
|
12
|
+
|
13
|
+
def moves(grid, turn)
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def all_open_moves(grid)
|
20
|
+
moves = []
|
21
|
+
|
22
|
+
grid.each_open do |r, c|
|
23
|
+
moves << [r, c]
|
24
|
+
end
|
25
|
+
|
26
|
+
moves
|
27
|
+
end
|
28
|
+
|
29
|
+
def all_smart_moves(grid, turn)
|
30
|
+
AI::Minimax.instance.moves(grid, turn)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class Easy < Player
|
35
|
+
|
36
|
+
def moves(grid, turn)
|
37
|
+
all_open_moves(grid)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class Medium < Player
|
42
|
+
|
43
|
+
def moves(grid, turn)
|
44
|
+
smart_moves = all_smart_moves(grid, turn)
|
45
|
+
dumb_moves = all_open_moves(grid) - smart_moves
|
46
|
+
|
47
|
+
dumb_moves.empty? ? smart_moves : (rand < 0.75 ? smart_moves : dumb_moves)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
class Hard < Player
|
52
|
+
|
53
|
+
def moves(grid, turn)
|
54
|
+
all_smart_moves(grid, turn)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class Human
|
59
|
+
|
60
|
+
def initialize(humans)
|
61
|
+
@humans = humans
|
62
|
+
end
|
63
|
+
|
64
|
+
def get_move(grid, turn)
|
65
|
+
puts grid
|
66
|
+
puts
|
67
|
+
|
68
|
+
puts "Enter your move in the format r, c."
|
69
|
+
prompt_for_move(grid, turn)
|
70
|
+
end
|
71
|
+
|
72
|
+
def handle_next_turn(grid)
|
73
|
+
puts grid
|
74
|
+
puts
|
75
|
+
end
|
76
|
+
|
77
|
+
def handle_game_over(grid, event)
|
78
|
+
case event[:type]
|
79
|
+
when :winner
|
80
|
+
puts "Congratulations! #{@humans == 2 ? event[:last_move][:turn] : 'You'} won."
|
81
|
+
when :squashed
|
82
|
+
puts "Sorry! Game squashed."
|
83
|
+
end
|
84
|
+
|
85
|
+
puts
|
86
|
+
end
|
87
|
+
|
88
|
+
def handle_invalid_move(event)
|
89
|
+
puts "Sorry! You cannot make that move."
|
90
|
+
|
91
|
+
case event[:type]
|
92
|
+
when :occupied
|
93
|
+
puts "That position is occupied."
|
94
|
+
when :out_of_bounds
|
95
|
+
puts "That position is not on the board."
|
96
|
+
end
|
97
|
+
|
98
|
+
puts
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def prompt_for_move(grid, turn)
|
104
|
+
print "> "
|
105
|
+
|
106
|
+
position = gets.chomp.split(',').map(&:strip).map(&:to_i)
|
107
|
+
|
108
|
+
if position.length == 2
|
109
|
+
puts
|
110
|
+
position
|
111
|
+
else
|
112
|
+
prompt_for_move(grid, turn)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
class Computer
|
118
|
+
|
119
|
+
def initialize(humans, ai)
|
120
|
+
@humans = humans
|
121
|
+
@ai = ai
|
122
|
+
end
|
123
|
+
|
124
|
+
def get_move(grid, turn)
|
125
|
+
puts "Waiting for the computer to play..." if @humans == 1
|
126
|
+
|
127
|
+
r, c = @ai.moves(grid, turn).shuffle[0]
|
128
|
+
|
129
|
+
if @humans == 1
|
130
|
+
puts "The computer played at #{r}, #{c}."
|
131
|
+
puts
|
132
|
+
end
|
133
|
+
|
134
|
+
[r, c]
|
135
|
+
end
|
136
|
+
|
137
|
+
def handle_next_turn(grid)
|
138
|
+
end
|
139
|
+
|
140
|
+
def handle_game_over(grid, event)
|
141
|
+
if @humans == 0
|
142
|
+
case event[:type]
|
143
|
+
when :winner
|
144
|
+
puts "#{event[:last_move][:turn]} wins!"
|
145
|
+
when :squashed
|
146
|
+
puts "Game squashed!"
|
147
|
+
end
|
148
|
+
elsif @humans == 1
|
149
|
+
puts grid
|
150
|
+
puts
|
151
|
+
|
152
|
+
case event[:type]
|
153
|
+
when :winner
|
154
|
+
puts "Sorry! You lost."
|
155
|
+
when :squashed
|
156
|
+
puts "Sorry! Game squashed."
|
157
|
+
end
|
158
|
+
|
159
|
+
puts
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def handle_invalid_move(event)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
class Game
|
168
|
+
|
169
|
+
def initialize(players)
|
170
|
+
@players = players
|
171
|
+
@engine = Engine.new
|
172
|
+
end
|
173
|
+
|
174
|
+
def start(turn = false)
|
175
|
+
if turn
|
176
|
+
@engine.start(turn)
|
177
|
+
else
|
178
|
+
event = @engine.last_event
|
179
|
+
|
180
|
+
case event[:type]
|
181
|
+
when :winner
|
182
|
+
@engine.continue_playing(@engine.turn)
|
183
|
+
when :squashed
|
184
|
+
@engine.continue_playing(@engine.next_turn)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def run
|
190
|
+
run_one_turn while @engine.state != :game_over
|
191
|
+
end
|
192
|
+
|
193
|
+
private
|
194
|
+
|
195
|
+
def run_one_turn
|
196
|
+
turn = @engine.turn
|
197
|
+
player = @players[turn]
|
198
|
+
|
199
|
+
r, c = player.get_move(@engine.grid, turn)
|
200
|
+
|
201
|
+
event = @engine.play(r, c).last_event
|
202
|
+
|
203
|
+
case event[:name]
|
204
|
+
when :next_turn
|
205
|
+
player.handle_next_turn(@engine.grid)
|
206
|
+
when :game_over
|
207
|
+
player.handle_game_over(@engine.grid, event)
|
208
|
+
when :invalid_move
|
209
|
+
player.handle_invalid_move(event)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def welcome
|
215
|
+
heading = "Welcome to xo! A Tic-tac-toe game client created by #{AUTHOR}."
|
216
|
+
|
217
|
+
puts heading
|
218
|
+
puts "=" * heading.length
|
219
|
+
puts
|
220
|
+
|
221
|
+
puts "NOTE: You can exit the game at anytime by pressing CTRL-C."
|
222
|
+
puts
|
223
|
+
end
|
224
|
+
|
225
|
+
def ask_for_difficulty_level
|
226
|
+
puts "Please select your difficulty level:"
|
227
|
+
puts "1. Easy"
|
228
|
+
puts "2. Medium"
|
229
|
+
puts "3. Hard"
|
230
|
+
|
231
|
+
get_option({ '1' => Easy, '2' => Medium, '3' => Hard })
|
232
|
+
end
|
233
|
+
|
234
|
+
def ask_for_token
|
235
|
+
puts "Do you want to play as x or o?"
|
236
|
+
|
237
|
+
get_option({ 'x' => Grid::X, 'o' => Grid::O })
|
238
|
+
end
|
239
|
+
|
240
|
+
def ask_to_play_first
|
241
|
+
puts "Do you want to play first (y or n)?"
|
242
|
+
|
243
|
+
get_option({ 'y' => true, 'n' => false })
|
244
|
+
end
|
245
|
+
|
246
|
+
def ask_to_play_again
|
247
|
+
puts "Do you want to play again (y or n)?"
|
248
|
+
|
249
|
+
get_option({ 'y' => true, 'n' => false })
|
250
|
+
end
|
251
|
+
|
252
|
+
def get_option(options)
|
253
|
+
print "> "
|
254
|
+
|
255
|
+
selection = gets.chomp
|
256
|
+
|
257
|
+
if options.key?(selection)
|
258
|
+
puts
|
259
|
+
options[selection]
|
260
|
+
else
|
261
|
+
get_option(options)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def say_goodbye
|
266
|
+
puts "Thank you for playing."
|
267
|
+
puts "Bye!"
|
268
|
+
end
|
269
|
+
|
270
|
+
def terminate
|
271
|
+
puts
|
272
|
+
puts
|
273
|
+
say_goodbye
|
274
|
+
exit
|
275
|
+
end
|
276
|
+
|
277
|
+
def set_up_ctrl_c_handler
|
278
|
+
trap("INT") {
|
279
|
+
terminate
|
280
|
+
}
|
281
|
+
end
|
282
|
+
|
283
|
+
def main
|
284
|
+
set_up_ctrl_c_handler
|
285
|
+
|
286
|
+
welcome
|
287
|
+
|
288
|
+
level = ask_for_difficulty_level
|
289
|
+
|
290
|
+
human_token = ask_for_token
|
291
|
+
computer_token = Grid.other_token(human_token)
|
292
|
+
|
293
|
+
play_first = ask_to_play_first
|
294
|
+
|
295
|
+
players = {}
|
296
|
+
players[human_token] = Human.new(1)
|
297
|
+
players[computer_token] = Computer.new(1, level.new)
|
298
|
+
|
299
|
+
game = Game.new(players)
|
300
|
+
game.start(play_first ? human_token : computer_token)
|
301
|
+
|
302
|
+
loop do
|
303
|
+
game.run
|
304
|
+
|
305
|
+
play_again = ask_to_play_again
|
306
|
+
break unless play_again
|
307
|
+
|
308
|
+
game.start
|
309
|
+
end
|
310
|
+
|
311
|
+
say_goodbye
|
312
|
+
end
|
313
|
+
|
314
|
+
main
|