xo 1.0.0 → 1.1.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 +8 -8
- data/Gemfile.lock +3 -1
- data/README.md +59 -23
- data/bin/xo +22 -27
- data/lib/xo.rb +1 -1
- data/lib/xo/ai/max_player.rb +22 -0
- data/lib/xo/ai/min_player.rb +22 -0
- data/lib/xo/ai/minimax.rb +47 -108
- data/lib/xo/ai/player.rb +44 -0
- data/lib/xo/engine.rb +32 -234
- data/lib/xo/engine/game_context.rb +28 -0
- data/lib/xo/engine/game_over.rb +33 -0
- data/lib/xo/engine/game_state.rb +21 -0
- data/lib/xo/engine/init.rb +24 -0
- data/lib/xo/engine/playing.rb +88 -0
- data/lib/xo/evaluator.rb +50 -66
- data/lib/xo/grid.rb +41 -93
- data/lib/xo/version.rb +1 -1
- data/spec/engine_spec.rb +323 -0
- data/spec/{xo/evaluator_spec.rb → evaluator_spec.rb} +25 -10
- data/spec/{xo/ai/geometric_grid_spec.rb → geometric_grid_spec.rb} +1 -3
- data/spec/{xo/grid_spec.rb → grid_spec.rb} +13 -16
- data/spec/{xo/ai/minimax_spec.rb → minimax_spec.rb} +0 -0
- data/xo.gemspec +3 -1
- metadata +36 -14
- data/lib/xo/ai.rb +0 -2
- data/spec/xo/engine_spec.rb +0 -331
data/lib/xo/evaluator.rb
CHANGED
@@ -4,25 +4,18 @@ require 'xo/grid'
|
|
4
4
|
|
5
5
|
module XO
|
6
6
|
|
7
|
-
#
|
8
|
-
# answer the following questions:
|
7
|
+
# {Evaluator} is a {http://en.wikipedia.org/wiki/Singleton_pattern Singleton} that defines an {#analyze} method than can be used to examine a grid to answer the following questions:
|
9
8
|
#
|
10
|
-
# 1. Is it a valid grid? A grid is considered valid if it possible for
|
11
|
-
# two players, taking turns, to reach the given grid configuration.
|
9
|
+
# 1. Is it a valid grid? A grid is considered valid if it possible for two players, taking turns, to reach the given grid configuration.
|
12
10
|
# 2. Is there a winner/loser or is the grid squashed?
|
13
11
|
# 3. Who is the winner/loser?
|
14
12
|
# 4. Which positions make up the winning row, column and/or diagonal?
|
15
|
-
#
|
16
|
-
# The Evaluator class is a Singleton class and can be used as follows:
|
17
|
-
#
|
18
|
-
# @example
|
19
|
-
# Evaluator.instance.analyze(Grid.new('xo'), Grid::X)
|
20
13
|
class Evaluator
|
21
14
|
include Singleton
|
22
15
|
|
23
|
-
#
|
16
|
+
# Examines the given grid assuming that the given token is the one that was last placed on it.
|
24
17
|
#
|
25
|
-
#
|
18
|
+
# The following return values are possible:
|
26
19
|
#
|
27
20
|
# - If everything is fine, then
|
28
21
|
#
|
@@ -30,11 +23,11 @@ module XO
|
|
30
23
|
#
|
31
24
|
# - If the game is over and the given token is in a winning position, then
|
32
25
|
#
|
33
|
-
# { status: :game_over, type: :winner, details: [{ where: :
|
26
|
+
# { status: :game_over, type: :winner, details: [{ where: :where, index: :index, positions: :positions }] }
|
34
27
|
#
|
35
28
|
# - If the game is over and the other token is in a winning position, then
|
36
29
|
#
|
37
|
-
# { status: :game_over, type: :loser, details: [{ where: :
|
30
|
+
# { status: :game_over, type: :loser, details: [{ where: :where, index: :index, positions: :positions }] }
|
38
31
|
#
|
39
32
|
# - If the game is over due to a squashed grid, then
|
40
33
|
#
|
@@ -48,14 +41,13 @@ module XO
|
|
48
41
|
#
|
49
42
|
# { status: :invalid_grid, type: :two_winners }
|
50
43
|
#
|
51
|
-
# Legend
|
44
|
+
# *Legend:*
|
52
45
|
#
|
53
|
-
# -
|
54
|
-
# -
|
55
|
-
# -
|
46
|
+
# - *:where* is one of :row, :column, :diagonal
|
47
|
+
# - *:index* is one of 1, 2, 3 if :where is :row or :column and one of 1, 2 if :where is :diagonal
|
48
|
+
# - *:positions* is a 3 element array having the row, column values of the winning position
|
56
49
|
#
|
57
|
-
# Notice that the :details key is an
|
58
|
-
# example:
|
50
|
+
# Notice that the :details key is an Array since it is possible to win a game in two different ways. For example:
|
59
51
|
#
|
60
52
|
# x | o | x
|
61
53
|
# ---+---+---
|
@@ -63,15 +55,13 @@ module XO
|
|
63
55
|
# ---+---+---
|
64
56
|
# x | o | x
|
65
57
|
#
|
66
|
-
#
|
67
|
-
#
|
68
|
-
# @param grid [Grid] the grid to be analyzed
|
58
|
+
# @param grid [Grid] the grid to be examined
|
69
59
|
# @param token [Grid::X, Grid::O] the token that was last placed on the grid
|
70
60
|
# @raise [ArgumentError] unless token is either {Grid::X} or {Grid::O}
|
71
61
|
# @return [Hash]
|
72
62
|
def analyze(grid, token)
|
73
63
|
check_token(token)
|
74
|
-
|
64
|
+
initialize_analyzer(grid, token)
|
75
65
|
perform_analysis
|
76
66
|
end
|
77
67
|
|
@@ -79,17 +69,17 @@ module XO
|
|
79
69
|
#
|
80
70
|
# @example
|
81
71
|
# g = Grid.new('xoxxo')
|
82
|
-
# xs, os = Evaluator.
|
72
|
+
# xs, os = Evaluator.xos(g)
|
83
73
|
# puts xs # => 3
|
84
74
|
# puts os # => 2
|
85
75
|
#
|
86
76
|
# @return [Array(Integer, Integer)]
|
87
|
-
def xos(grid)
|
77
|
+
def self.xos(grid)
|
88
78
|
xs = os = 0
|
89
79
|
|
90
|
-
grid.each do |_, _,
|
91
|
-
xs += 1 if
|
92
|
-
os += 1 if
|
80
|
+
grid.each do |_, _, k|
|
81
|
+
xs += 1 if k == Grid::X
|
82
|
+
os += 1 if k == Grid::O
|
93
83
|
end
|
94
84
|
|
95
85
|
[xs, os]
|
@@ -103,30 +93,22 @@ module XO
|
|
103
93
|
raise ArgumentError, "illegal token #{token}" unless Grid.is_token?(token)
|
104
94
|
end
|
105
95
|
|
106
|
-
def
|
107
|
-
@grid
|
108
|
-
@token
|
96
|
+
def initialize_analyzer(grid, token)
|
97
|
+
@grid = grid
|
98
|
+
@token = token
|
109
99
|
@winners = {}
|
110
100
|
end
|
111
101
|
|
112
102
|
def perform_analysis
|
113
|
-
return { status: :invalid_grid, type: :too_many_moves_ahead }
|
103
|
+
return { status: :invalid_grid, type: :too_many_moves_ahead } if two_or_more_moves_ahead?
|
114
104
|
|
115
105
|
find_winners
|
116
106
|
|
117
|
-
if two_winners?
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
{ status: :game_over, type: :loser, details: winners[other_token] }
|
123
|
-
else
|
124
|
-
if grid.full?
|
125
|
-
{ status: :game_over, type: :squashed }
|
126
|
-
else
|
127
|
-
{ status: :ok }
|
128
|
-
end
|
129
|
-
end
|
107
|
+
return { status: :invalid_grid, type: :two_winners } if two_winners?
|
108
|
+
return { status: :game_over, type: :winner, details: winners[token] } if winners[token]
|
109
|
+
return { status: :game_over, type: :loser, details: winners[other_token] } if winners[other_token]
|
110
|
+
return { status: :game_over, type: :squashed } if grid.full?
|
111
|
+
return { status: :ok }
|
130
112
|
end
|
131
113
|
|
132
114
|
def two_or_more_moves_ahead?
|
@@ -134,34 +116,32 @@ module XO
|
|
134
116
|
end
|
135
117
|
|
136
118
|
def moves_ahead
|
137
|
-
xs, os = xos(grid)
|
119
|
+
xs, os = self.class.xos(grid)
|
138
120
|
|
139
121
|
(xs - os).abs
|
140
122
|
end
|
141
123
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
c = grid[*w[:positions][2]]
|
124
|
+
WINNING_POSITIONS = [
|
125
|
+
{ where: :row, index: 1, positions: [[1, 1], [1, 2], [1, 3]] },
|
126
|
+
{ where: :row, index: 2, positions: [[2, 1], [2, 2], [2, 3]] },
|
127
|
+
{ where: :row, index: 3, positions: [[3, 1], [3, 2], [3, 3]] },
|
147
128
|
|
148
|
-
|
149
|
-
|
150
|
-
|
129
|
+
{ where: :column, index: 1, positions: [[1, 1], [2, 1], [3, 1]] },
|
130
|
+
{ where: :column, index: 2, positions: [[1, 2], [2, 2], [3, 2]] },
|
131
|
+
{ where: :column, index: 3, positions: [[1, 3], [2, 3], [3, 3]] },
|
151
132
|
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
{ where: :row, index: 2, positions: [[2, 1], [2, 2], [2, 3]] },
|
156
|
-
{ where: :row, index: 3, positions: [[3, 1], [3, 2], [3, 3]] },
|
133
|
+
{ where: :diagonal, index: 1, positions: [[1, 1], [2, 2], [3, 3]] },
|
134
|
+
{ where: :diagonal, index: 2, positions: [[1, 3], [2, 2], [3, 1]] }
|
135
|
+
]
|
157
136
|
|
158
|
-
|
159
|
-
|
160
|
-
|
137
|
+
def find_winners
|
138
|
+
WINNING_POSITIONS.each do |w|
|
139
|
+
x = grid[*w[:positions][0]]
|
140
|
+
y = grid[*w[:positions][1]]
|
141
|
+
z = grid[*w[:positions][2]]
|
161
142
|
|
162
|
-
|
163
|
-
|
164
|
-
]
|
143
|
+
add_winner(x, w.dup) if winning_combination(x, y, z)
|
144
|
+
end
|
165
145
|
end
|
166
146
|
|
167
147
|
def add_winner(token, details)
|
@@ -172,8 +152,12 @@ module XO
|
|
172
152
|
end
|
173
153
|
end
|
174
154
|
|
155
|
+
def winning_combination(x, y, z)
|
156
|
+
Grid.is_token?(x) && x == y && y == z
|
157
|
+
end
|
158
|
+
|
175
159
|
def two_winners?
|
176
|
-
winners
|
160
|
+
winners.keys.length == 2
|
177
161
|
end
|
178
162
|
|
179
163
|
def other_token
|
data/lib/xo/grid.rb
CHANGED
@@ -12,103 +12,54 @@ module XO
|
|
12
12
|
# 2 | |
|
13
13
|
# ---+---+---
|
14
14
|
# 3 | |
|
15
|
-
#
|
16
|
-
# It is important to note that if a position stores anything other than
|
17
|
-
# {X} or {O} then that position is considered to be open.
|
18
15
|
class Grid
|
19
16
|
|
20
17
|
X = :x
|
21
18
|
O = :o
|
22
19
|
|
20
|
+
EMPTY = :e
|
21
|
+
|
23
22
|
ROWS = 3
|
24
23
|
COLS = 3
|
25
24
|
|
26
25
|
N = ROWS * COLS
|
27
26
|
|
28
|
-
# Determines whether or not position (r, c) is such that 1 <= r <= 3 and 1 <= c <= 3.
|
29
|
-
#
|
30
|
-
# @param r [Integer] the row
|
31
|
-
# @param c [Integer] the column
|
32
|
-
# @return [Boolean] true iff the position is contained within a 3x3 grid
|
33
27
|
def self.contains?(r, c)
|
34
28
|
r.between?(1, ROWS) && c.between?(1, COLS)
|
35
29
|
end
|
36
30
|
|
37
|
-
|
38
|
-
|
39
|
-
# @param val [Object]
|
40
|
-
# @return [Boolean] true iff val is {X} or {O}
|
41
|
-
def self.is_token?(val)
|
42
|
-
val == X || val == O
|
31
|
+
def self.is_token?(k)
|
32
|
+
k == X || k == O
|
43
33
|
end
|
44
34
|
|
45
|
-
|
46
|
-
|
47
|
-
# @param val [Object]
|
48
|
-
# @return [Object] {X} given {O}, {O} given {X} or the original value
|
49
|
-
def self.other_token(val)
|
50
|
-
val == X ? O : (val == O ? X : val)
|
35
|
+
def self.other_token(k)
|
36
|
+
k == X ? O : (k == O ? X : k)
|
51
37
|
end
|
52
38
|
|
53
|
-
attr_reader :grid
|
54
|
-
private :grid
|
55
|
-
|
56
|
-
# Creates a new empty grid by default. You can also create a
|
57
|
-
# prepopulated grid by passing in a string representation.
|
58
|
-
#
|
59
|
-
# @example
|
60
|
-
# g = Grid.new('xo ox o')
|
61
39
|
def initialize(g = '')
|
62
40
|
@grid = from_string(g)
|
63
41
|
end
|
64
42
|
|
65
|
-
# Creates a copy of the given grid. Use #dup to get your copy.
|
66
|
-
#
|
67
|
-
# @example
|
68
|
-
# g = Grid.new
|
69
|
-
# g_copy = g.dup
|
70
|
-
#
|
71
|
-
# @param orig [Grid] the original grid
|
72
|
-
# @return [Grid] a copy
|
73
43
|
def initialize_copy(orig)
|
74
44
|
@grid = orig.instance_variable_get(:@grid).dup
|
75
45
|
end
|
76
46
|
|
77
|
-
# Determines whether or not there are any tokens on the grid.
|
78
|
-
#
|
79
|
-
# @return [Boolean] true iff there are no tokens on the grid
|
80
47
|
def empty?
|
81
|
-
grid.all? { |
|
48
|
+
grid.all? { |k| !self.class.is_token?(k) }
|
82
49
|
end
|
83
50
|
|
84
|
-
# Determines whether or not every position on the grid has a token?
|
85
|
-
#
|
86
|
-
# @return [Boolean] true iff every position on the grid has a token
|
87
51
|
def full?
|
88
|
-
grid.all? { |
|
52
|
+
grid.all? { |k| self.class.is_token?(k) }
|
89
53
|
end
|
90
54
|
|
91
|
-
|
92
|
-
#
|
93
|
-
# @param r [Integer] the row
|
94
|
-
# @param c [Integer] the column
|
95
|
-
# @param val [Object]
|
96
|
-
# @raise [IndexError] if the position is off the grid
|
97
|
-
# @return [Object] the value it was given
|
98
|
-
def []=(r, c, val)
|
55
|
+
def []=(r, c, k)
|
99
56
|
if self.class.contains?(r, c)
|
100
|
-
grid[idx(r, c)] =
|
57
|
+
grid[idx(r, c)] = normalize(k)
|
101
58
|
else
|
102
59
|
raise IndexError, "position (#{r}, #{c}) is off the grid"
|
103
60
|
end
|
104
61
|
end
|
105
62
|
|
106
|
-
# Retrieves the value at the given position (r, c).
|
107
|
-
#
|
108
|
-
# @param r [Integer] the row
|
109
|
-
# @param c [Integer] the column
|
110
|
-
# @raise [IndexError] if the position is off the grid
|
111
|
-
# @return [Object]
|
112
63
|
def [](r, c)
|
113
64
|
if self.class.contains?(r, c)
|
114
65
|
grid[idx(r, c)]
|
@@ -117,29 +68,20 @@ module XO
|
|
117
68
|
end
|
118
69
|
end
|
119
70
|
|
120
|
-
# Determines whether or not position (r, c) contains a token.
|
121
|
-
#
|
122
|
-
# @param r [Integer] the row
|
123
|
-
# @param c [Integer] the column
|
124
|
-
# @raise [IndexError] if the position is off the grid
|
125
|
-
# @return true iff the position does not contain a token
|
126
71
|
def open?(r, c)
|
127
72
|
!self.class.is_token?(self[r, c])
|
128
73
|
end
|
129
74
|
|
130
|
-
# Removes all tokens from the grid.
|
131
75
|
def clear
|
132
|
-
grid.fill(
|
133
|
-
|
134
|
-
self
|
76
|
+
grid.fill(EMPTY)
|
135
77
|
end
|
136
78
|
|
137
|
-
#
|
79
|
+
# Iterates over all the positions of this grid from left to right and top to bottom.
|
138
80
|
#
|
139
81
|
# @example
|
140
82
|
# g = Grid.new
|
141
|
-
# g.each do |r, c,
|
142
|
-
# puts "(#{r}, #{c}) -> #{
|
83
|
+
# g.each do |r, c, k|
|
84
|
+
# puts "(#{r}, #{c}) -> #{k}"
|
143
85
|
# end
|
144
86
|
def each
|
145
87
|
(1..ROWS).each do |r|
|
@@ -149,7 +91,7 @@ module XO
|
|
149
91
|
end
|
150
92
|
end
|
151
93
|
|
152
|
-
#
|
94
|
+
# Iterates over all the open positions of this grid from left to right and top to bottom.
|
153
95
|
#
|
154
96
|
# @example
|
155
97
|
# g = Grid.new
|
@@ -164,16 +106,14 @@ module XO
|
|
164
106
|
self.each { |r, c, _| yield(r, c) if open?(r, c) }
|
165
107
|
end
|
166
108
|
|
167
|
-
# Returns a string representation of
|
168
|
-
# for debugging.
|
109
|
+
# Returns a string representation of this grid which can be useful for debugging.
|
169
110
|
def inspect
|
170
|
-
grid.map { |
|
111
|
+
grid.map { |k| t(k) }.join
|
171
112
|
end
|
172
113
|
|
173
|
-
# Returns a string representation of
|
174
|
-
# for display.
|
114
|
+
# Returns a string representation of this grid which can be useful for display.
|
175
115
|
def to_s
|
176
|
-
g = grid.map { |
|
116
|
+
g = grid.map { |k| t(k) }
|
177
117
|
|
178
118
|
[" #{g[0]} | #{g[1]} | #{g[2]} ",
|
179
119
|
"---+---+---",
|
@@ -184,21 +124,23 @@ module XO
|
|
184
124
|
|
185
125
|
private
|
186
126
|
|
187
|
-
|
188
|
-
g = g.to_s
|
189
|
-
l = g.length
|
127
|
+
attr_reader :grid
|
190
128
|
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
g[0..N-1]
|
195
|
-
else
|
196
|
-
g
|
129
|
+
def from_string(s)
|
130
|
+
adjust_length(s, N).split('').map do |ch|
|
131
|
+
normalize(ch.to_sym)
|
197
132
|
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def adjust_length(s, n)
|
136
|
+
l = s.length
|
198
137
|
|
199
|
-
|
200
|
-
|
201
|
-
|
138
|
+
if l < n
|
139
|
+
s + ' ' * (n - l)
|
140
|
+
elsif l > n
|
141
|
+
s[0..n-1]
|
142
|
+
else
|
143
|
+
s
|
202
144
|
end
|
203
145
|
end
|
204
146
|
|
@@ -217,8 +159,14 @@ module XO
|
|
217
159
|
COLS * (r - 1) + (c - 1)
|
218
160
|
end
|
219
161
|
|
220
|
-
|
221
|
-
|
162
|
+
NORMALIZED_TO_STRING_MAP = { X => 'x', O => 'o', EMPTY => ' ' }
|
163
|
+
|
164
|
+
def t(k)
|
165
|
+
NORMALIZED_TO_STRING_MAP[normalize(k)]
|
166
|
+
end
|
167
|
+
|
168
|
+
def normalize(k)
|
169
|
+
self.class.is_token?(k) ? k : EMPTY
|
222
170
|
end
|
223
171
|
end
|
224
172
|
end
|
data/lib/xo/version.rb
CHANGED
data/spec/engine_spec.rb
ADDED
@@ -0,0 +1,323 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
class EngineObserver
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@events = []
|
7
|
+
end
|
8
|
+
|
9
|
+
def last_event
|
10
|
+
@events.last
|
11
|
+
end
|
12
|
+
|
13
|
+
def update(event)
|
14
|
+
@events << event
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module XO
|
19
|
+
|
20
|
+
describe Engine do
|
21
|
+
|
22
|
+
let (:engine) { Engine.new }
|
23
|
+
let (:engine_observer) { EngineObserver.new }
|
24
|
+
|
25
|
+
describe "how the engine works" do
|
26
|
+
|
27
|
+
before { engine.add_observer(engine_observer) }
|
28
|
+
|
29
|
+
describe "in the Init state" do
|
30
|
+
|
31
|
+
it "is in the Init state" do
|
32
|
+
engine.current_state.must_equal Init
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "#start" do
|
36
|
+
|
37
|
+
describe "for valid input" do
|
38
|
+
|
39
|
+
before { engine.start(Grid::X) }
|
40
|
+
|
41
|
+
it "is X's turn" do
|
42
|
+
engine.turn.must_equal Grid::X
|
43
|
+
end
|
44
|
+
|
45
|
+
it "has an empty grid" do
|
46
|
+
engine.grid.must_be :empty?
|
47
|
+
end
|
48
|
+
|
49
|
+
it "transitions to the Playing state" do
|
50
|
+
engine.current_state.must_equal Playing
|
51
|
+
end
|
52
|
+
|
53
|
+
it "triggers an event" do
|
54
|
+
engine_observer.last_event[:name].must_equal :game_started
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe "for invalid input" do
|
59
|
+
|
60
|
+
it "raises ArgumentError" do
|
61
|
+
proc { engine.start(:nobody) }.must_raise ArgumentError
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
it "raises StateDesignPattern::IllegalStateException for the actions stop, play and continue_playing" do
|
67
|
+
proc { engine.stop }.must_raise StateDesignPattern::IllegalStateException
|
68
|
+
proc { engine.play(1, 1) }.must_raise StateDesignPattern::IllegalStateException
|
69
|
+
proc { engine.continue_playing(Grid::X) }.must_raise StateDesignPattern::IllegalStateException
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe "in the Playing state" do
|
74
|
+
|
75
|
+
before { engine.start(Grid::O) }
|
76
|
+
|
77
|
+
it "is in the Playing state" do
|
78
|
+
engine.current_state.must_equal Playing
|
79
|
+
end
|
80
|
+
|
81
|
+
describe "#play" do
|
82
|
+
|
83
|
+
describe "when the move is out of bounds" do
|
84
|
+
|
85
|
+
before { engine.play(1, 0) }
|
86
|
+
|
87
|
+
it "remains in the Playing state" do
|
88
|
+
engine.current_state.must_equal Playing
|
89
|
+
end
|
90
|
+
|
91
|
+
it "triggers an event" do
|
92
|
+
last_event = engine_observer.last_event
|
93
|
+
|
94
|
+
last_event[:name].must_equal :invalid_move
|
95
|
+
last_event[:type].must_equal :out_of_bounds
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe "when the move is on an occupied position" do
|
100
|
+
|
101
|
+
before { engine.play(1, 1).play(1, 1) }
|
102
|
+
|
103
|
+
it "remains in the Playing state" do
|
104
|
+
engine.current_state.must_equal Playing
|
105
|
+
end
|
106
|
+
|
107
|
+
it "triggers an event" do
|
108
|
+
last_event = engine_observer.last_event
|
109
|
+
|
110
|
+
last_event[:name].must_equal :invalid_move
|
111
|
+
last_event[:type].must_equal :occupied
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
describe "when the move results in a win" do
|
116
|
+
|
117
|
+
before do
|
118
|
+
engine.play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
|
119
|
+
end
|
120
|
+
|
121
|
+
it "transitions to the GameOver state" do
|
122
|
+
engine.current_state.must_equal GameOver
|
123
|
+
end
|
124
|
+
|
125
|
+
it "triggers an event" do
|
126
|
+
last_event = engine_observer.last_event
|
127
|
+
|
128
|
+
last_event[:name].must_equal :game_over
|
129
|
+
last_event[:type].must_equal :winner
|
130
|
+
last_event[:last_move].must_equal({ turn: Grid::O, r: 1, c: 3 })
|
131
|
+
last_event[:details][0][:where].must_equal :row
|
132
|
+
last_event[:details][0][:index].must_equal 1
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
describe "when the move results in a squashed game" do
|
137
|
+
|
138
|
+
before do
|
139
|
+
engine
|
140
|
+
.play(1, 1).play(2, 2)
|
141
|
+
.play(3, 3).play(1, 2)
|
142
|
+
.play(3, 2).play(3, 1)
|
143
|
+
.play(1, 3).play(2, 3)
|
144
|
+
.play(2, 1)
|
145
|
+
end
|
146
|
+
|
147
|
+
it "transitions to the GameOver state" do
|
148
|
+
engine.current_state.must_equal GameOver
|
149
|
+
end
|
150
|
+
|
151
|
+
it "triggers an event" do
|
152
|
+
last_event = engine_observer.last_event
|
153
|
+
|
154
|
+
last_event[:name].must_equal :game_over
|
155
|
+
last_event[:type].must_equal :squashed
|
156
|
+
last_event[:last_move].must_equal({ turn: Grid::O, r: 2, c: 1 })
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
describe "when the move just advances the game" do
|
161
|
+
|
162
|
+
before { engine.play(1, 1) }
|
163
|
+
|
164
|
+
it "sets O at position (1, 1)" do
|
165
|
+
engine.grid[1, 1].must_equal Grid::O
|
166
|
+
end
|
167
|
+
|
168
|
+
it "remains in the Playing state" do
|
169
|
+
engine.current_state.must_equal Playing
|
170
|
+
end
|
171
|
+
|
172
|
+
it "triggers an event" do
|
173
|
+
last_event = engine_observer.last_event
|
174
|
+
|
175
|
+
last_event[:name].must_equal :next_turn
|
176
|
+
last_event[:last_move].must_equal({ turn: Grid::O, r: 1, c: 1 })
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
describe "#stop" do
|
182
|
+
|
183
|
+
before { engine.play(2, 2).play(3, 1).stop }
|
184
|
+
|
185
|
+
it "resets the game" do
|
186
|
+
engine.turn.must_equal :nobody
|
187
|
+
engine.grid.must_be :empty?
|
188
|
+
end
|
189
|
+
|
190
|
+
it "transitions to the Init state" do
|
191
|
+
engine.current_state.must_equal Init
|
192
|
+
end
|
193
|
+
|
194
|
+
it "triggers an event" do
|
195
|
+
engine_observer.last_event[:name].must_equal :game_stopped
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
it "raises StateDesignPattern::IllegalStateException for the actions start and continue_playing" do
|
200
|
+
proc { engine.start(Grid::X) }.must_raise StateDesignPattern::IllegalStateException
|
201
|
+
proc { engine.continue_playing(Grid::X) }.must_raise StateDesignPattern::IllegalStateException
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
describe "in the GameOver state" do
|
206
|
+
|
207
|
+
describe "due to a win" do
|
208
|
+
|
209
|
+
before do
|
210
|
+
engine
|
211
|
+
.start(Grid::X)
|
212
|
+
.play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
|
213
|
+
end
|
214
|
+
|
215
|
+
it "is in the GameOver state" do
|
216
|
+
engine.current_state.must_equal GameOver
|
217
|
+
end
|
218
|
+
|
219
|
+
describe "#continue_playing" do
|
220
|
+
|
221
|
+
before { engine.continue_playing }
|
222
|
+
|
223
|
+
it "is X's turn" do
|
224
|
+
engine.turn.must_equal Grid::X
|
225
|
+
end
|
226
|
+
|
227
|
+
it "has an empty grid" do
|
228
|
+
engine.grid.must_be :empty?
|
229
|
+
end
|
230
|
+
|
231
|
+
it "transitions to the Playing state" do
|
232
|
+
engine.current_state.must_equal Playing
|
233
|
+
end
|
234
|
+
|
235
|
+
it "triggers an event" do
|
236
|
+
last_event = engine_observer.last_event
|
237
|
+
|
238
|
+
last_event[:name].must_equal :game_started
|
239
|
+
last_event[:type].must_equal :continue_playing
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
describe "due to a squashed game" do
|
245
|
+
|
246
|
+
before do
|
247
|
+
engine
|
248
|
+
.start(Grid::X)
|
249
|
+
.play(1, 1).play(2, 2)
|
250
|
+
.play(3, 3).play(1, 2)
|
251
|
+
.play(3, 2).play(3, 1)
|
252
|
+
.play(1, 3).play(2, 3)
|
253
|
+
.play(2, 1)
|
254
|
+
end
|
255
|
+
|
256
|
+
it "is in the GameOver state" do
|
257
|
+
engine.current_state.must_equal GameOver
|
258
|
+
end
|
259
|
+
|
260
|
+
describe "#continue_playing" do
|
261
|
+
|
262
|
+
before { engine.continue_playing }
|
263
|
+
|
264
|
+
it "is O's turn" do
|
265
|
+
engine.turn.must_equal Grid::O
|
266
|
+
end
|
267
|
+
|
268
|
+
it "has an empty grid" do
|
269
|
+
engine.grid.must_be :empty?
|
270
|
+
end
|
271
|
+
|
272
|
+
it "transitions to the Playing state" do
|
273
|
+
engine.current_state.must_equal Playing
|
274
|
+
end
|
275
|
+
|
276
|
+
it "triggers an event" do
|
277
|
+
last_event = engine_observer.last_event
|
278
|
+
|
279
|
+
last_event[:name].must_equal :game_started
|
280
|
+
last_event[:type].must_equal :continue_playing
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
describe "#stop" do
|
286
|
+
|
287
|
+
before do
|
288
|
+
engine
|
289
|
+
.start(Grid::X)
|
290
|
+
.play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
|
291
|
+
.stop
|
292
|
+
end
|
293
|
+
|
294
|
+
it "resets the game" do
|
295
|
+
engine.turn.must_equal :nobody
|
296
|
+
engine.grid.must_be :empty?
|
297
|
+
end
|
298
|
+
|
299
|
+
it "transitions to the Init state" do
|
300
|
+
engine.current_state.must_equal Init
|
301
|
+
end
|
302
|
+
|
303
|
+
it "triggers an event" do
|
304
|
+
engine_observer.last_event[:name].must_equal :game_stopped
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
describe "what happens when calling illegal actions" do
|
309
|
+
before do
|
310
|
+
engine
|
311
|
+
.start(Grid::X)
|
312
|
+
.play(1, 1).play(2, 1).play(1, 2).play(2, 2).play(1, 3)
|
313
|
+
end
|
314
|
+
|
315
|
+
it "raises StateDesignPattern::IllegalStateException for the actions start and play" do
|
316
|
+
proc { engine.start(Grid::X) }.must_raise StateDesignPattern::IllegalStateException
|
317
|
+
proc { engine.play(1, 1) }.must_raise StateDesignPattern::IllegalStateException
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|