xo 0.0.1

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f4e9916b4a489801329e2b8713a883a06ebef55f
4
+ data.tar.gz: 83ea35a8950526c5ab4a10587ac151ce3523f6d6
5
+ SHA512:
6
+ metadata.gz: ff7f19ed461e17028efaeda45010b6faf967103640d004b80da343f179dd4dbf307577aeebfb38bcfeda61f33c01b45657ea5ceadd911c7ca134496d6ff532b5
7
+ data.tar.gz: e31c7c082b239cd9e3efc10edb36e9e92197e383b4da15f84f8a9d6520fa55c72ccbaa743d3e0aa1fe224f4260bf2c8e4327b57d564eaa1aea8148dd310aa06b
@@ -0,0 +1 @@
1
+ *.gem
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Dwayne R. Crooks
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,55 @@
1
+ # xo
2
+
3
+ A [Ruby](http://www.ruby-lang.org/en/) library for [Tic-tac-toe](http://en.wikipedia.org/wiki/Tic-tac-toe).
4
+
5
+ # Performance of the Minimax Algorithm
6
+
7
+ ```ruby
8
+ require 'benchmark'
9
+ require 'xo'
10
+
11
+ # Empty grid
12
+ g = XO::Grid.new
13
+
14
+ puts Benchmark.measure { XO::AI.minimax(g, :x) }
15
+ # => 0.000000 0.000000 0.000000 ( 0.000463)
16
+ # => O(1) time due to caching
17
+
18
+ # One spot taken
19
+ g[1, 1] = :x
20
+
21
+ puts Benchmark.measure { XO::AI.minimax(g, :o) }
22
+ # => 0.000000 0.000000 0.000000 ( 0.000216)
23
+ # => O(1) time due to caching
24
+
25
+ # Two spots taken
26
+ g[1, 3] = :o
27
+
28
+ puts Benchmark.measure { XO::AI.minimax(g, :x) }
29
+ # => 0.690000 0.000000 0.690000 ( 0.695095)
30
+ # => Worst-case time, performance only improves from here on as the grid gets filled
31
+ ```
32
+
33
+ # Testing
34
+
35
+ You can run:
36
+
37
+ - All tests: `rake test`
38
+ - One test: `ruby -Ilib -Ispec spec/path_to_spec_file.rb`
39
+
40
+ # TODO
41
+
42
+ 1. Write documentation.
43
+ 2. Show example usage.
44
+ 3. Write an example Tic-tac-toe command-line game client.
45
+
46
+ # Contributing
47
+
48
+ 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
+
50
+ 1. Post a [pull request](https://github.com/dwayne/xo/compare/).
51
+ 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
+
53
+ # License
54
+
55
+ xo is Copyright © 2014 Dwayne R. Crooks. It is free software, and may be redistributed under the terms specified in the MIT-LICENSE file.
@@ -0,0 +1,7 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.pattern = 'spec/**/*_spec.rb'
6
+ t.libs.push 'spec'
7
+ end
@@ -0,0 +1,25 @@
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
+ require 'xo/grid'
23
+ require 'xo/evaluator'
24
+ require 'xo/engine'
25
+ require 'xo/ai'
@@ -0,0 +1,4 @@
1
+ require 'xo/ai/minimax'
2
+ require 'xo/ai/expert'
3
+ require 'xo/ai/novice'
4
+ require 'xo/ai/advanced_beginner'
@@ -0,0 +1,17 @@
1
+ require 'xo/ai/expert'
2
+
3
+ module XO::AI
4
+
5
+ class AdvancedBeginner < Expert
6
+
7
+ def self.get_moves(grid, player)
8
+ smart_moves = super(grid, player)
9
+ dumb_moves = all_moves - smart_moves
10
+
11
+ # if there are no dumb moves then we have no choice but to make a smart move
12
+ # otherwise, 75% of the time we'll make a smart move and the other 25% of the
13
+ # time we'll make a dumb move
14
+ dumb_moves.empty? ? smart_moves : (rand < 0.75 ? smart_moves : dumb_moves)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,64 @@
1
+ require 'xo/grid'
2
+ require 'xo/evaluator'
3
+ require 'xo/ai'
4
+
5
+ module XO::AI
6
+
7
+ class Expert
8
+
9
+ def self.suggest_moves(grid, player)
10
+ result = XO::Evaluator.analyze(grid, player)
11
+
12
+ case result[:status]
13
+ when :ok
14
+ get_moves(grid, player)
15
+ when :game_over
16
+ []
17
+ else
18
+ raise IllegalGridStatusError
19
+ end
20
+ end
21
+
22
+ def self.get_moves(grid, player)
23
+ if moves = MOVES_CACHE[grid] || MOVES_CACHE[invert_grid(grid)]
24
+ moves.map { |pos| XO::Position.new(*pos) }
25
+ else
26
+ XO::AI.minimax(grid, player).moves
27
+ end
28
+ end
29
+
30
+ def self.all_moves(grid)
31
+ grid.enum_for(:each_free).map { |r, c| XO::Position.new(r, c) }
32
+ end
33
+
34
+ private
35
+
36
+ def self.invert_grid(grid)
37
+ (new_grid = grid.dup).each do |r, c, val|
38
+ new_grid[r, c] = XO::other_token(val)
39
+ end
40
+ end
41
+
42
+ def self.one_x_grid(r, c)
43
+ XO::Grid.new.tap do |grid|
44
+ grid[r, c] = :x
45
+ end
46
+ end
47
+
48
+ MOVES_CACHE = {
49
+ XO::Grid.new => [[1, 1], [1, 2], [1, 3], [2, 1], [2, 2], [2, 3], [3, 1], [3, 2], [3, 3]],
50
+
51
+ one_x_grid(1, 1) => [[2, 2]],
52
+ one_x_grid(1, 3) => [[2, 2]],
53
+ one_x_grid(3, 1) => [[2, 2]],
54
+ one_x_grid(3, 3) => [[2, 2]],
55
+
56
+ one_x_grid(1, 2) => [[1, 1], [1, 3], [2, 2], [3, 2]],
57
+ one_x_grid(2, 1) => [[1, 1], [2, 2], [2, 3], [3, 1]],
58
+ one_x_grid(2, 3) => [[1, 3], [2, 1], [2, 2], [3, 3]],
59
+ one_x_grid(3, 2) => [[1, 2], [2, 2], [3, 1], [3, 3]],
60
+
61
+ one_x_grid(2, 2) => [[1, 1], [1, 3], [3, 1], [3, 3]]
62
+ }
63
+ end
64
+ end
@@ -0,0 +1,125 @@
1
+ require 'ostruct'
2
+ require 'xo/evaluator'
3
+
4
+ module XO::AI
5
+
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)
9
+
10
+ OpenStruct.new(start_state: state, moves: moves)
11
+ end
12
+
13
+ class GameState
14
+
15
+ attr_reader :grid, :player, :move, :next_states
16
+
17
+ def initialize(grid, player, move = nil)
18
+ @grid = grid.dup
19
+ @player = player
20
+ @move = move
21
+
22
+ generate_next_states
23
+ end
24
+
25
+ def result
26
+ @result ||= XO::Evaluator.analyze(grid, player)
27
+ end
28
+
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
39
+
40
+ def scores
41
+ next_states.map(&:score)
42
+ end
43
+
44
+ def score
45
+ if is_terminal?
46
+ terminal_score
47
+ else
48
+ non_terminal_score
49
+ end
50
+ end
51
+
52
+ def terminal_score
53
+ raise NotImplementedError
54
+ end
55
+
56
+ def non_terminal_score
57
+ raise NotImplementedError
58
+ end
59
+
60
+ def next_game_state(next_grid, other_player, move)
61
+ raise NotImplementedError
62
+ end
63
+
64
+ private
65
+
66
+ def generate_next_states
67
+ @next_states = []
68
+
69
+ unless is_terminal?
70
+ grid.each_free do |r, c|
71
+ next_grid = grid.dup
72
+ next_grid[r, c] = player
73
+
74
+ @next_states << next_game_state(next_grid, XO.other_player(player), XO::Position.new(r, c))
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ class MaxGameState < GameState
81
+
82
+ def next_game_state(next_grid, other_player, move)
83
+ MinGameState.new(next_grid, other_player, move)
84
+ end
85
+
86
+ def terminal_score
87
+ case result[:type]
88
+ when :winner
89
+ 1
90
+ when :loser
91
+ -1
92
+ when :squashed
93
+ 0
94
+ end
95
+ end
96
+
97
+ def non_terminal_score
98
+ scores.max
99
+ end
100
+ end
101
+
102
+ class MinGameState < GameState
103
+
104
+ def next_game_state(next_grid, other_player, move)
105
+ MaxGameState.new(next_grid, other_player, move)
106
+ end
107
+
108
+ def terminal_score
109
+ case result[:type]
110
+ when :winner
111
+ -1
112
+ when :loser
113
+ 1
114
+ when :squashed
115
+ 0
116
+ end
117
+ end
118
+
119
+ def non_terminal_score
120
+ scores.min
121
+ end
122
+ end
123
+
124
+ class IllegalGridStatusError < StandardError; end
125
+ end
@@ -0,0 +1,11 @@
1
+ require 'xo/ai/expert'
2
+
3
+ module XO::AI
4
+
5
+ class Novice < Expert
6
+
7
+ def self.get_moves(grid, player)
8
+ all_moves
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,137 @@
1
+ require 'observer'
2
+ require 'ostruct'
3
+
4
+ require 'xo/grid'
5
+ require 'xo/evaluator'
6
+
7
+ module XO
8
+
9
+ class Engine
10
+ include Observable
11
+
12
+ attr_reader :turn, :state
13
+
14
+ def initialize
15
+ @grid = Grid.new
16
+ @turn = :nobody
17
+ @state = :idle
18
+ end
19
+
20
+ def grid
21
+ @grid.dup
22
+ end
23
+
24
+ def next_turn
25
+ XO.other_player(turn)
26
+ end
27
+
28
+ def start(player)
29
+ raise ArgumentError, "unknown player #{player}" unless XO.is_player?(player)
30
+
31
+ case state
32
+ when :idle
33
+ handle_start(player)
34
+ else
35
+ raise NotImplementedError
36
+ end
37
+
38
+ self
39
+ end
40
+
41
+ def stop
42
+ case state
43
+ when :playing, :game_over
44
+ handle_stop
45
+ else
46
+ raise NotImplementedError
47
+ end
48
+
49
+ self
50
+ end
51
+
52
+ def play(r, c)
53
+ case state
54
+ when :playing
55
+ handle_play(r.to_i, c.to_i)
56
+ else
57
+ raise NotImplementedError
58
+ end
59
+
60
+ self
61
+ end
62
+
63
+ def continue_playing(player)
64
+ raise ArgumentError, "unknown player #{player}" unless XO.is_player?(player)
65
+
66
+ case state
67
+ when :game_over
68
+ handle_continue_playing(player)
69
+ else
70
+ raise NotImplementedError
71
+ end
72
+
73
+ self
74
+ end
75
+
76
+ private
77
+
78
+ attr_writer :turn, :state
79
+
80
+ def handle_start(player)
81
+ self.turn = player
82
+ self.state = :playing
83
+ @grid.clear
84
+
85
+ send_event(:game_started, who: player)
86
+ end
87
+
88
+ def handle_stop
89
+ self.state = :idle
90
+
91
+ send_event(:game_stopped)
92
+ end
93
+
94
+ def handle_play(r, c)
95
+ if Grid.contains?(r, c)
96
+ if @grid.free?(r, c)
97
+ @grid[r, c] = turn
98
+ last_played_at = OpenStruct.new(row: r, col: c)
99
+
100
+ result = Evaluator.analyze(@grid, turn)
101
+
102
+ case result[:status]
103
+ when :ok
104
+ self.turn = next_turn
105
+ send_event(:next_turn, who: turn, last_played_at: last_played_at)
106
+ when :game_over
107
+ self.state = :game_over
108
+
109
+ case result[:type]
110
+ when :winner
111
+ send_event(:game_over, type: :winner, who: turn, last_played_at: last_played_at, details: result[:details])
112
+ when :squashed
113
+ send_event(:game_over, type: :squashed, who: turn, last_played_at: last_played_at)
114
+ end
115
+ end
116
+ else
117
+ send_event(:invalid_move, type: :occupied)
118
+ end
119
+ else
120
+ send_event(:invalid_move, type: :out_of_bounds)
121
+ end
122
+ end
123
+
124
+ def handle_continue_playing(player)
125
+ self.turn = player
126
+ self.state = :playing
127
+ @grid.clear
128
+
129
+ send_event(:continue_playing, who: player)
130
+ end
131
+
132
+ def send_event(name, message = {})
133
+ changed
134
+ notify_observers({ event: name }.merge(message))
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,108 @@
1
+ module XO
2
+
3
+ module Evaluator
4
+
5
+ def self.analyze(grid, player)
6
+ @grid = grid
7
+ @player = player
8
+
9
+ perform_analysis
10
+ end
11
+
12
+ private
13
+
14
+ class << self
15
+ attr_reader :grid, :player, :winners
16
+ end
17
+
18
+ def self.perform_analysis
19
+ return { status: :error, type: :too_many_moves_ahead } if two_or_more_moves_ahead?
20
+
21
+ find_winners
22
+
23
+ if two_winners?
24
+ { status: :error, type: :two_winners }
25
+ elsif winners[player]
26
+ { status: :game_over, type: :winner, details: winners[player] }
27
+ elsif winners[other_player]
28
+ { status: :game_over, type: :loser, details: winners[other_player] }
29
+ else
30
+ if grid.full?
31
+ { status: :game_over, type: :squashed }
32
+ else
33
+ { status: :ok }
34
+ end
35
+ end
36
+ end
37
+
38
+ def self.two_or_more_moves_ahead?
39
+ moves_ahead >= 2
40
+ end
41
+
42
+ def self.moves_ahead
43
+ xs = os = 0
44
+
45
+ grid.each do |_, _, val|
46
+ xs += 1 if val == XO::X
47
+ os += 1 if val == XO::O
48
+ end
49
+
50
+ (xs - os).abs
51
+ end
52
+
53
+ def self.find_winners
54
+ @winners = {}
55
+
56
+ # check rows
57
+ if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[1, 2] && grid[1, 2] == grid[1, 3]
58
+ add_winner(grid[1, 1], { where: :row, index: 1, positions: [[1, 1], [1, 2], [1, 3]] })
59
+ end
60
+
61
+ if XO.is_token?(grid[2, 1]) && grid[2, 1] == grid[2, 2] && grid[2, 2] == grid[2, 3]
62
+ add_winner(grid[2, 1], { where: :row, index: 2, positions: [[2, 1], [2, 2], [2, 3]] })
63
+ end
64
+
65
+ if XO.is_token?(grid[3, 1]) && grid[3, 1] == grid[3, 2] && grid[3, 2] == grid[3, 3]
66
+ add_winner(grid[3, 1], { where: :row, index: 3, positions: [[3, 1], [3, 2], [3, 3]] })
67
+ end
68
+
69
+ # check columns
70
+ if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[2, 1] && grid[2, 1] == grid[3, 1]
71
+ add_winner(grid[1, 1], { where: :column, index: 1, positions: [[1, 1], [2, 1], [3, 1]] })
72
+ end
73
+
74
+ if XO.is_token?(grid[1, 2]) && grid[1, 2] == grid[2, 2] && grid[2, 2] == grid[3, 2]
75
+ add_winner(grid[1, 2], { where: :column, index: 2, positions: [[1, 2], [2, 2], [3, 2]] })
76
+ end
77
+
78
+ if XO.is_token?(grid[1, 3]) && grid[1, 3] == grid[2, 3] && grid[2, 3] == grid[3, 3]
79
+ add_winner(grid[1, 3], { where: :column, index: 3, positions: [[1, 3], [2, 3], [3, 3]] })
80
+ end
81
+
82
+ # check diagonals
83
+ if XO.is_token?(grid[1, 1]) && grid[1, 1] == grid[2, 2] && grid[2, 2] == grid[3, 3]
84
+ add_winner(grid[1, 1], { where: :diagonal, index: 1, positions: [[1, 1], [2, 2], [3, 3]] })
85
+ end
86
+
87
+ if XO.is_token?(grid[1, 3]) && grid[1, 3] == grid[2, 2] && grid[2, 2] == grid[3, 1]
88
+ add_winner(grid[1, 3], { where: :diagonal, index: 2, positions: [[1, 3], [2, 2], [3, 1]] })
89
+ end
90
+ end
91
+
92
+ def self.add_winner(player, details)
93
+ if winners.key?(player)
94
+ winners[player] << details
95
+ else
96
+ winners[player] = [details]
97
+ end
98
+ end
99
+
100
+ def self.two_winners?
101
+ winners[XO::X] && winners[XO::O]
102
+ end
103
+
104
+ def self.other_player
105
+ XO.other_player(player)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,95 @@
1
+ module XO
2
+
3
+ class Grid
4
+
5
+ ROWS = 3
6
+ COLS = 3
7
+
8
+ def self.contains?(r, c)
9
+ r.between?(1, ROWS) && c.between?(1, COLS)
10
+ end
11
+
12
+ def initialize
13
+ @grid = Array.new(ROWS * COLS, :e)
14
+ end
15
+
16
+ def initialize_copy(orig)
17
+ @grid = orig.instance_variable_get(:@grid).dup
18
+ end
19
+
20
+ def []=(r, c, val)
21
+ if self.class.contains?(r, c)
22
+ grid[idx(r, c)] = val
23
+ else
24
+ raise IndexError, "position (#{r}, #{c}) is off the grid"
25
+ end
26
+ end
27
+
28
+ def [](r, c)
29
+ if self.class.contains?(r, c)
30
+ grid[idx(r, c)]
31
+ else
32
+ raise IndexError, "position (#{r}, #{c}) is off the grid"
33
+ end
34
+ end
35
+
36
+ def empty?
37
+ grid.all? { |val| !XO.is_token?(val) }
38
+ end
39
+
40
+ def full?
41
+ grid.all? { |val| XO.is_token?(val) }
42
+ end
43
+
44
+ def free?(r, c)
45
+ !XO.is_token?(self[r, c])
46
+ end
47
+
48
+ def clear
49
+ grid.fill(:e)
50
+ end
51
+
52
+ def each
53
+ (1..ROWS).each do |r|
54
+ (1..COLS).each do |c|
55
+ yield(r, c, self[r, c])
56
+ end
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ def each_free
63
+ self.each { |r, c, _| yield(r, c) if free?(r, c) }
64
+ end
65
+
66
+ def ==(other)
67
+ return false unless other.instance_of?(self.class)
68
+ grid == other.instance_variable_get(:@grid)
69
+ end
70
+ alias_method :eql?, :==
71
+
72
+ def hash
73
+ grid.hash
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :grid
79
+
80
+ # Computes the 0-based index of position (r, c) on a 3x3 grid.
81
+ #
82
+ # c 1 2 3
83
+ # r
84
+ # 1 0 | 1 | 2
85
+ # ---+---+---
86
+ # 2 3 | 4 | 5
87
+ # ---+---+---
88
+ # 3 6 | 7 | 8
89
+ #
90
+ # For e.g. idx(2, 3) is 5.
91
+ def idx(r, c)
92
+ COLS * (r - 1) + (c - 1)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,3 @@
1
+ module XO
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,4 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest/spec'
3
+
4
+ require 'xo'
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ module XO
4
+
5
+ describe AI do
6
+
7
+ describe 'minimax' do
8
+
9
+ let(:grid) { Grid.new }
10
+
11
+ describe 'immediate wins' do
12
+
13
+ it 'should return (1, 3)' do
14
+ grid[1, 1] = grid[1, 2] = :x
15
+ grid[2, 1] = grid[2, 2] = :o
16
+
17
+ moves = AI.minimax(grid, :x).moves
18
+
19
+ moves.size.must_equal 1
20
+ [moves[0].row, moves[0].column].must_equal [1, 3]
21
+ end
22
+
23
+ it 'should return (1, 3), (3, 2) and (3, 3)' do
24
+ grid[2, 1] = grid[2, 3] = grid[3, 1] = :x
25
+ grid[1, 1] = grid[1, 2] = grid[2, 2] = :o
26
+
27
+ moves = AI.minimax(grid, :o).moves
28
+
29
+ moves.size.must_equal 3
30
+ [moves[0].row, moves[0].column].must_equal [1, 3]
31
+ [moves[1].row, moves[1].column].must_equal [3, 2]
32
+ [moves[2].row, moves[2].column].must_equal [3, 3]
33
+ end
34
+ end
35
+
36
+ describe 'blocking moves' do
37
+
38
+ it 'should return (2, 1)' do
39
+ grid[1, 1] = grid[3, 1] = :x
40
+ grid[2, 2] = :o
41
+
42
+ moves = AI.minimax(grid, :o).moves
43
+
44
+ moves.size.must_equal 1
45
+ [moves[0].row, moves[0].column].must_equal [2, 1]
46
+ end
47
+ end
48
+
49
+ describe 'smart moves' do
50
+
51
+ it 'should return (1, 3)' do
52
+ grid[1, 1] = grid[3, 1] = :x
53
+ grid[2, 1] = grid[3, 3] = :o
54
+
55
+ moves = AI.minimax(grid, :x).moves
56
+
57
+ moves.size.must_equal 1
58
+ [moves[0].row, moves[0].column].must_equal [1, 3]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+
3
+ module XO
4
+
5
+ describe Engine do
6
+
7
+ let (:engine) { Engine.new }
8
+
9
+ describe 'initial state' do
10
+
11
+ it 'has an empty grid' do
12
+ engine.grid.empty?.must_equal true
13
+ end
14
+
15
+ it "is nobody's turn" do
16
+ engine.turn.must_equal :nobody
17
+ end
18
+
19
+ it 'is in the idle state' do
20
+ engine.state.must_equal :idle
21
+ end
22
+ end
23
+
24
+ describe '#grid' do
25
+
26
+ it 'returns a copy' do
27
+ grid = engine.grid
28
+ grid[1, 1] = X
29
+
30
+ # FIXME: How else can I test this requirement? I don't like that the test
31
+ # depends on knowing the name of the internal private instance variable.
32
+ engine.instance_variable_get(:@grid).empty?.must_equal true
33
+ end
34
+ end
35
+
36
+ describe 'a single round of play' do
37
+
38
+ it 'works as follows' do
39
+ observer = Object.new
40
+
41
+ def observer.handle_event(e)
42
+ if e[:event] == :game_over && e[:type] == :winner
43
+ @winner = e[:who]
44
+ end
45
+ end
46
+
47
+ engine.add_observer(observer, :handle_event)
48
+
49
+ engine.start(X).play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
50
+
51
+ observer.instance_variable_get(:@winner).must_equal X
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,90 @@
1
+ require 'spec_helper'
2
+
3
+ module XO
4
+
5
+ describe Evaluator do
6
+
7
+ describe 'analyze' do
8
+
9
+ let (:grid) { Grid.new }
10
+
11
+ describe 'error statuses' do
12
+
13
+ it 'returns too many moves ahead' do
14
+ grid[1, 1] = grid[1, 2] = grid[1, 3] = X
15
+ grid[2, 1] = O
16
+
17
+ result = { status: :error, type: :too_many_moves_ahead }
18
+
19
+ Evaluator.analyze(grid, X).must_equal result
20
+ Evaluator.analyze(grid, O).must_equal result
21
+ end
22
+
23
+ it 'returns two winners' do
24
+ grid[1, 1] = grid[1, 2] = grid[1, 3] = X
25
+ grid[2, 1] = grid[2, 2] = grid[2, 3] = O
26
+
27
+ result = { status: :error, type: :two_winners }
28
+
29
+ Evaluator.analyze(grid, X).must_equal result
30
+ Evaluator.analyze(grid, O).must_equal result
31
+ end
32
+ end
33
+
34
+ describe 'game over statuses' do
35
+
36
+ describe 'wins and losses' do
37
+
38
+ it 'returns a win/loss in the first row' do
39
+ grid[1, 1] = grid[1, 2] = grid[1, 3] = X
40
+ grid[2, 1] = grid[2, 2] = O
41
+
42
+ result = {
43
+ status: :game_over,
44
+ type: :winner,
45
+ details: [{
46
+ where: :row,
47
+ index: 1,
48
+ positions: [[1, 1], [1, 2], [1, 3]]
49
+ }]
50
+ }
51
+
52
+ Evaluator.analyze(grid, X).must_equal result
53
+
54
+ result[:type] = :loser
55
+ Evaluator.analyze(grid, O).must_equal result
56
+ end
57
+
58
+ # TODO: Test the winners/losers in the other rows, the columns and the diagonals.
59
+ end
60
+
61
+ describe 'squashed' do
62
+
63
+ it 'returns squashed' do
64
+ grid[1, 1] = grid[1, 2] = grid[2, 3] = grid[3, 1] = grid[3, 3] = X
65
+ grid[1, 3] = grid[2, 1] = grid[2, 2] = grid[3, 2] = O
66
+
67
+ result = { status: :game_over, type: :squashed }
68
+
69
+ Evaluator.analyze(grid, X).must_equal result
70
+ Evaluator.analyze(grid, O).must_equal result
71
+ end
72
+ end
73
+ end
74
+
75
+ describe 'ok status' do
76
+
77
+ it 'returns ok' do
78
+ result = { status: :ok }
79
+
80
+ Evaluator.analyze(grid, X).must_equal result
81
+ Evaluator.analyze(grid, O).must_equal result
82
+
83
+ grid[1, 1] = X
84
+ Evaluator.analyze(grid, X).must_equal result
85
+ Evaluator.analyze(grid, O).must_equal result
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,146 @@
1
+ require 'spec_helper'
2
+
3
+ module XO
4
+
5
+ describe Grid do
6
+
7
+ it 'has 3 rows' do
8
+ Grid::ROWS.must_equal 3
9
+ end
10
+
11
+ it 'has 3 columns' do
12
+ Grid::COLS.must_equal 3
13
+ end
14
+
15
+ it 'contains all r, c where r is in {1, 2, 3} and c is in {1, 2, 3}' do
16
+ (1..3).each do |r|
17
+ (1..3).each do |c|
18
+ Grid.contains?(r, c).must_equal true
19
+ end
20
+ end
21
+ end
22
+
23
+ it 'does not contain any r, c where either r is not in {1, 2, 3} or c is not in {1, 2, 3}' do
24
+ [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4],
25
+ [1, 0], [1, 4],
26
+ [2, 0], [2, 4],
27
+ [3, 0], [3, 4],
28
+ [4, 0], [4, 1], [4, 2], [4, 3], [4, 4]].each do |pos|
29
+ Grid.contains?(*pos).must_equal false
30
+ end
31
+ end
32
+
33
+ let(:grid) { Grid.new }
34
+
35
+ describe 'a new grid' do
36
+
37
+ it 'is empty' do
38
+ grid.empty?.must_equal true
39
+ end
40
+ end
41
+
42
+ describe '#dup' do
43
+
44
+ it 'creates a copy' do
45
+ grid[1, 1] = X
46
+
47
+ grid_copy = grid.dup
48
+ grid_copy[1, 1] = O
49
+
50
+ grid[1, 1].must_equal X
51
+ end
52
+ end
53
+
54
+ describe '#empty?' do
55
+
56
+ it 'returns false when at least one position has a token' do
57
+ grid[1, 1] = X
58
+ grid.empty?.must_equal false
59
+ end
60
+ end
61
+
62
+ describe '#[]=' do
63
+
64
+ it 'raises IndexError when a token is placed at a position it does not contain' do
65
+ proc { grid[0, 0] = X }.must_raise IndexError
66
+ end
67
+ end
68
+
69
+ describe '#[]' do
70
+
71
+ it 'raises IndexError when given a position it does not contain' do
72
+ proc { grid[4, 4] }.must_raise IndexError
73
+ end
74
+ end
75
+
76
+ describe '#full?' do
77
+
78
+ before do
79
+ (1..3).each do |r|
80
+ (1..3).each do |c|
81
+ grid[r, c] = O
82
+ end
83
+ end
84
+ end
85
+
86
+ it 'returns true when every position has a token' do
87
+ grid.full?.must_equal true
88
+ end
89
+
90
+ it 'returns false when at least one position does not have a token' do
91
+ grid[1, 1] = :e
92
+ grid.full?.must_equal false
93
+ end
94
+ end
95
+
96
+ describe '#free?' do
97
+
98
+ it 'returns true when no token is at the given position' do
99
+ grid.free?(2, 2).must_equal true
100
+ end
101
+
102
+ it 'returns false when a token is at the given position' do
103
+ grid[3, 1] = O
104
+ grid.free?(3, 1).must_equal false
105
+ end
106
+
107
+ it 'raises IndexError when given a position the grid does not contain' do
108
+ proc { grid.free?(0, 4) }.must_raise IndexError
109
+ end
110
+ end
111
+
112
+ describe '#clear' do
113
+
114
+ it 'empties the grid' do
115
+ grid[1, 1] = X
116
+ grid[1, 3] = O
117
+ grid[3, 2] = X
118
+
119
+ grid.clear
120
+
121
+ grid.empty?.must_equal true
122
+ end
123
+ end
124
+
125
+ describe '#each' do
126
+
127
+ it "visits every position and yields a block that takes the position's row, column and value" do
128
+ grid[1, 1] = O
129
+ grid[2, 2] = X
130
+ grid[3, 3] = O
131
+
132
+ visited = {}
133
+
134
+ grid.each do |r, c, val|
135
+ # ensure that the value for the position is correct
136
+ grid[r, c].must_equal val
137
+
138
+ # keep track of every position we visit
139
+ visited[[r, c]] = val
140
+ end
141
+
142
+ visited.keys.size.must_equal(Grid::ROWS * Grid::COLS)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'xo/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'xo'
8
+ spec.version = XO::VERSION
9
+ spec.author = 'Dwayne R. Crooks'
10
+ spec.email = ['me@dwaynecrooks.com']
11
+ spec.summary = %q{A Ruby library for Tic-tac-toe.}
12
+ spec.description = %q{A Ruby library that can be used to develop Tic-tac-toe game clients.}
13
+ spec.homepage = 'https://github.com/dwayne/xo'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'rake', '~> 10.1', '>= 10.1.0'
22
+ spec.add_development_dependency 'minitest', '~> 5.3', '>= 5.3.2'
23
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Dwayne R. Crooks
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-04-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '10.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 10.1.0
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '10.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 10.1.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: minitest
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.3'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 5.3.2
43
+ type: :development
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '5.3'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 5.3.2
53
+ description: A Ruby library that can be used to develop Tic-tac-toe game clients.
54
+ email:
55
+ - me@dwaynecrooks.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - ".gitignore"
61
+ - LICENSE.txt
62
+ - README.md
63
+ - Rakefile
64
+ - lib/xo.rb
65
+ - lib/xo/ai.rb
66
+ - lib/xo/ai/advanced_beginner.rb
67
+ - lib/xo/ai/expert.rb
68
+ - lib/xo/ai/minimax.rb
69
+ - lib/xo/ai/novice.rb
70
+ - lib/xo/engine.rb
71
+ - lib/xo/evaluator.rb
72
+ - lib/xo/grid.rb
73
+ - lib/xo/version.rb
74
+ - spec/spec_helper.rb
75
+ - spec/xo/ai/minimax_spec.rb
76
+ - spec/xo/engine_spec.rb
77
+ - spec/xo/evaluator_spec.rb
78
+ - spec/xo/grid_spec.rb
79
+ - xo.gemspec
80
+ homepage: https://github.com/dwayne/xo
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubyforge_project:
100
+ rubygems_version: 2.2.2
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: A Ruby library for Tic-tac-toe.
104
+ test_files:
105
+ - spec/spec_helper.rb
106
+ - spec/xo/ai/minimax_spec.rb
107
+ - spec/xo/engine_spec.rb
108
+ - spec/xo/evaluator_spec.rb
109
+ - spec/xo/grid_spec.rb