rubykon 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +11 -0
- data/CHANGELOG.md +32 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +7 -0
- data/Guardfile +12 -0
- data/LICENSE +22 -0
- data/POSSIBLE_IMPROVEMENTS.md +25 -0
- data/README.md +36 -0
- data/Rakefile +6 -0
- data/benchmark/benchmark.sh +22 -0
- data/benchmark/full_playout.rb +17 -0
- data/benchmark/mcts_avg.rb +23 -0
- data/benchmark/playout.rb +15 -0
- data/benchmark/playout_micros.rb +188 -0
- data/benchmark/profiling/full_playout.rb +7 -0
- data/benchmark/profiling/mcts.rb +6 -0
- data/benchmark/results/HISTORY.md +541 -0
- data/benchmark/scoring.rb +20 -0
- data/benchmark/scoring_micros.rb +60 -0
- data/benchmark/support/benchmark-ips.rb +11 -0
- data/benchmark/support/benchmark-ips_shim.rb +143 -0
- data/benchmark/support/playout_help.rb +13 -0
- data/examples/mcts_laziness.rb +22 -0
- data/exe/rubykon +5 -0
- data/lib/benchmark/avg.rb +14 -0
- data/lib/benchmark/avg/benchmark_suite.rb +59 -0
- data/lib/benchmark/avg/job.rb +92 -0
- data/lib/mcts.rb +11 -0
- data/lib/mcts/examples/double_step.rb +68 -0
- data/lib/mcts/mcts.rb +13 -0
- data/lib/mcts/node.rb +88 -0
- data/lib/mcts/playout.rb +22 -0
- data/lib/mcts/root.rb +49 -0
- data/lib/rubykon.rb +13 -0
- data/lib/rubykon/board.rb +188 -0
- data/lib/rubykon/cli.rb +122 -0
- data/lib/rubykon/exceptions/exceptions.rb +1 -0
- data/lib/rubykon/exceptions/illegal_move_exception.rb +4 -0
- data/lib/rubykon/eye_detector.rb +27 -0
- data/lib/rubykon/game.rb +115 -0
- data/lib/rubykon/game_scorer.rb +62 -0
- data/lib/rubykon/game_state.rb +93 -0
- data/lib/rubykon/group.rb +99 -0
- data/lib/rubykon/group_tracker.rb +144 -0
- data/lib/rubykon/gtp_coordinate_converter.rb +25 -0
- data/lib/rubykon/move_validator.rb +55 -0
- data/lib/rubykon/version.rb +3 -0
- data/rubykon.gemspec +21 -0
- metadata +97 -0
data/lib/rubykon/cli.rb
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class CLI
|
3
|
+
|
4
|
+
EXIT = /exit/i
|
5
|
+
CHAR_LABELS = GTPCoordinateConverter::X_CHARS
|
6
|
+
X_LABEL_PADDING = ' '.freeze * 4
|
7
|
+
Y_LABEL_WIDTH = 3
|
8
|
+
|
9
|
+
def initialize(output = $stdout, input = $stdin)
|
10
|
+
@output = output
|
11
|
+
@input = input
|
12
|
+
@state = :init
|
13
|
+
end
|
14
|
+
|
15
|
+
def start
|
16
|
+
@output.puts 'Please enter a board size (common sizes are 9, 13, and 19)'
|
17
|
+
size = get_digit_input
|
18
|
+
@output.puts <<-PLAYOUTS
|
19
|
+
Please enter the number of playouts you'd like rubykon to make!
|
20
|
+
More playouts means rubykon is stronger, but also takes longer.
|
21
|
+
For 9x9 10000 is an acceptable value, for 19x19 1000 already take a long time.
|
22
|
+
PLAYOUTS
|
23
|
+
playouts = get_digit_input
|
24
|
+
init_game(size, playouts)
|
25
|
+
game_loop
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def get_digit_input
|
30
|
+
input = get_input
|
31
|
+
until input.match /^\d\d*$/
|
32
|
+
@output.puts "Input has to be a number. Please try again!"
|
33
|
+
input = get_input
|
34
|
+
end
|
35
|
+
input
|
36
|
+
end
|
37
|
+
|
38
|
+
def get_input
|
39
|
+
@output.print '> '
|
40
|
+
input = @input.gets.chomp
|
41
|
+
exit_if_desired(input)
|
42
|
+
input
|
43
|
+
end
|
44
|
+
|
45
|
+
def exit_if_desired(input)
|
46
|
+
quit if input.match EXIT
|
47
|
+
end
|
48
|
+
|
49
|
+
def quit
|
50
|
+
@output.puts "too bad, bye bye!"
|
51
|
+
exit
|
52
|
+
end
|
53
|
+
|
54
|
+
def init_game(size, playouts)
|
55
|
+
board_size = size.to_i
|
56
|
+
@output.puts "Great starting a #{board_size}x#{board_size} game with #{playouts} playouts"
|
57
|
+
@game = Game.new board_size
|
58
|
+
@game_state = GameState.new @game
|
59
|
+
@mcts = MCTS::MCTS.new
|
60
|
+
@board = @game.board
|
61
|
+
@gtp_converter = GTPCoordinateConverter.new(@board)
|
62
|
+
@playouts = playouts.to_i
|
63
|
+
end
|
64
|
+
|
65
|
+
def game_loop
|
66
|
+
print_board
|
67
|
+
while true
|
68
|
+
if bot_turn?
|
69
|
+
bot_move
|
70
|
+
else
|
71
|
+
human_move
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def bot_turn?
|
77
|
+
@game.next_turn_color == Board::BLACK
|
78
|
+
end
|
79
|
+
|
80
|
+
def print_board
|
81
|
+
@output.puts labeled_board
|
82
|
+
end
|
83
|
+
|
84
|
+
def labeled_board
|
85
|
+
rows = []
|
86
|
+
x_labels = X_LABEL_PADDING + CHAR_LABELS.take(@board.size).join(' ')
|
87
|
+
rows << x_labels
|
88
|
+
board_rows = @board.to_s.split("\n").each_with_index.map do |row, i|
|
89
|
+
y_label = "#{@board.size - i}".rjust(Y_LABEL_WIDTH)
|
90
|
+
y_label + row + y_label
|
91
|
+
end
|
92
|
+
rows += board_rows
|
93
|
+
rows << x_labels
|
94
|
+
rows.join "\n"
|
95
|
+
end
|
96
|
+
|
97
|
+
def bot_move
|
98
|
+
@output.puts 'Rubykon is thinking...'
|
99
|
+
root = @mcts.start @game_state, @playouts
|
100
|
+
move = root.best_move
|
101
|
+
best_children = root.children.sort_by(&:win_percentage).reverse.take(10)
|
102
|
+
@output.puts best_children.map {|child| "#{@gtp_converter.to(child.move.first)} => #{child.win_percentage}"}.join "\n"
|
103
|
+
make_move(move)
|
104
|
+
end
|
105
|
+
|
106
|
+
def human_move
|
107
|
+
@output.puts "Make a move in the form XY, e.g. A19, D7 as the labels indicate!"
|
108
|
+
coords = get_input
|
109
|
+
identifier = @gtp_converter.from(coords)
|
110
|
+
move = [identifier, :white]
|
111
|
+
make_move(move)
|
112
|
+
end
|
113
|
+
|
114
|
+
def make_move(move)
|
115
|
+
@game_state.set_move move
|
116
|
+
print_board
|
117
|
+
@output.puts "#{move.last} played at #{@gtp_converter.to(move.first)}"
|
118
|
+
@output.puts "#{@game.next_turn_color}'s turn to move!'"
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require_relative 'illegal_move_exception'
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class EyeDetector
|
3
|
+
def is_eye?(identifier, board)
|
4
|
+
candidate_eye_color = candidate_eye_color(identifier, board)
|
5
|
+
return false unless candidate_eye_color
|
6
|
+
is_real_eye?(identifier, board, candidate_eye_color)
|
7
|
+
end
|
8
|
+
|
9
|
+
def candidate_eye_color(identifier, board)
|
10
|
+
neighbor_colors = board.neighbour_colors_of(identifier)
|
11
|
+
candidate_eye_color = neighbor_colors.first
|
12
|
+
return false if candidate_eye_color == Board::EMPTY
|
13
|
+
if neighbor_colors.all? {|color| color == candidate_eye_color}
|
14
|
+
candidate_eye_color
|
15
|
+
else
|
16
|
+
nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
def is_real_eye?(identifier, board, candidate_eye_color)
|
22
|
+
enemy_color = Game.other_color(candidate_eye_color)
|
23
|
+
enemy_count = board.diagonal_colors_of(identifier).count(enemy_color)
|
24
|
+
(enemy_count < 1) || (!board.on_edge?(identifier) && enemy_count < 2)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/rubykon/game.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class Game
|
3
|
+
attr_reader :board, :group_tracker, :move_count, :ko, :captures
|
4
|
+
attr_accessor :komi
|
5
|
+
|
6
|
+
DEFAULT_KOMI = 6.5
|
7
|
+
|
8
|
+
# the freakish constructor is here so that we can have a decent dup
|
9
|
+
def initialize(size = 19, komi = DEFAULT_KOMI, board = Board.new(size),
|
10
|
+
move_count = 0, consecutive_passes = 0,
|
11
|
+
ko = nil, captures = initial_captures,
|
12
|
+
move_validator = MoveValidator.new,
|
13
|
+
group_tracker = GroupTracker.new)
|
14
|
+
@board = board
|
15
|
+
@komi = komi
|
16
|
+
@move_count = move_count
|
17
|
+
@consecutive_passes = consecutive_passes
|
18
|
+
@ko = ko
|
19
|
+
@captures = captures
|
20
|
+
@move_validator = move_validator
|
21
|
+
@group_tracker = group_tracker
|
22
|
+
end
|
23
|
+
|
24
|
+
def play(x, y, color)
|
25
|
+
identifier = @board.identifier_for(x, y)
|
26
|
+
if valid_move?(identifier, color)
|
27
|
+
set_valid_move(identifier, color)
|
28
|
+
true
|
29
|
+
else
|
30
|
+
false
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def play!(x, y, color)
|
35
|
+
raise IllegalMoveException unless play(x, y, color)
|
36
|
+
end
|
37
|
+
|
38
|
+
def no_moves_played?
|
39
|
+
@move_count == 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def next_turn_color
|
43
|
+
move_count.even? ? Board::BLACK : Board::WHITE
|
44
|
+
end
|
45
|
+
|
46
|
+
def finished?
|
47
|
+
@consecutive_passes >= 2
|
48
|
+
end
|
49
|
+
|
50
|
+
def set_valid_move(identifier, color)
|
51
|
+
@move_count += 1
|
52
|
+
if Game.pass?(identifier)
|
53
|
+
@consecutive_passes += 1
|
54
|
+
else
|
55
|
+
set_move(color, identifier)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def safe_set_move(identifier, color)
|
60
|
+
return if color == Board::EMPTY
|
61
|
+
set_valid_move(identifier, color)
|
62
|
+
end
|
63
|
+
|
64
|
+
def dup
|
65
|
+
self.class.new @size, @komi, @board.dup, @move_count, @consecutive_passes,
|
66
|
+
@ko, @captures.dup, @move_validator, @group_tracker.dup
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.other_color(color)
|
70
|
+
if color == :black
|
71
|
+
:white
|
72
|
+
else
|
73
|
+
:black
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.pass?(identifier)
|
78
|
+
identifier.nil?
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.from(string)
|
82
|
+
game = new(string.index("\n") / Board::CHARS_PER_GLYPH)
|
83
|
+
Board.each_move_from(string) do |identifier, color|
|
84
|
+
game.safe_set_move(identifier, color)
|
85
|
+
end
|
86
|
+
game
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
def initial_captures
|
91
|
+
{Board::BLACK => 0, Board::WHITE => 0}
|
92
|
+
end
|
93
|
+
|
94
|
+
def valid_move?(identifier, color)
|
95
|
+
@move_validator.valid?(identifier, color, self)
|
96
|
+
end
|
97
|
+
|
98
|
+
def set_move(color, identifier)
|
99
|
+
@board[identifier] = color
|
100
|
+
potential_eye = EyeDetector.new.candidate_eye_color(identifier, @board)
|
101
|
+
captures = @group_tracker.assign(identifier, color, board)
|
102
|
+
determine_ko_move(captures, potential_eye)
|
103
|
+
@captures[color] += captures.size
|
104
|
+
@consecutive_passes = 0
|
105
|
+
end
|
106
|
+
|
107
|
+
def determine_ko_move(captures, potential_eye)
|
108
|
+
if captures.size == 1 && potential_eye
|
109
|
+
@ko = captures[0]
|
110
|
+
else
|
111
|
+
@ko = nil
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class GameScorer
|
3
|
+
def score(game)
|
4
|
+
game_score = {Board::BLACK => 0, Board::WHITE => game.komi}
|
5
|
+
score_board(game, game_score)
|
6
|
+
add_captures(game, game_score)
|
7
|
+
determine_winner(game_score)
|
8
|
+
game_score
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
def score_board(game, game_score)
|
13
|
+
board = game.board
|
14
|
+
board.each do |identifier, color|
|
15
|
+
if color == Board::EMPTY
|
16
|
+
score_empty_cutting_point(identifier, board, game_score)
|
17
|
+
else
|
18
|
+
game_score[color] += 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def score_empty_cutting_point(identifier, board, game_score)
|
24
|
+
neighbor_colors = board.neighbour_colors_of(identifier)
|
25
|
+
candidate_color = find_candidate_color(neighbor_colors)
|
26
|
+
return unless candidate_color
|
27
|
+
if only_one_color_adjacent?(neighbor_colors, candidate_color)
|
28
|
+
game_score[candidate_color] += 1
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def find_candidate_color(neighbor_colors)
|
33
|
+
neighbor_colors.find do |color|
|
34
|
+
color != Board::EMPTY
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def only_one_color_adjacent?(neighbor_colors, candidate_color)
|
39
|
+
enemy_color = Game.other_color(candidate_color)
|
40
|
+
neighbor_colors.all? do |color|
|
41
|
+
color != enemy_color
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_captures(game, game_score)
|
46
|
+
game_score[Board::BLACK] += game.captures[Board::BLACK]
|
47
|
+
game_score[Board::WHITE] += game.captures[Board::WHITE]
|
48
|
+
end
|
49
|
+
|
50
|
+
def determine_winner(game_score)
|
51
|
+
game_score[:winner] = if black_won?(game_score)
|
52
|
+
Board::BLACK
|
53
|
+
else
|
54
|
+
Board::WHITE
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def black_won?(game_score)
|
59
|
+
game_score[Board::BLACK] > game_score[Board::WHITE]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class GameState
|
3
|
+
|
4
|
+
attr_reader :game
|
5
|
+
|
6
|
+
def initialize(game = Game.new,
|
7
|
+
validator = MoveValidator.new,
|
8
|
+
eye_detector = EyeDetector.new)
|
9
|
+
@game = game
|
10
|
+
@validator = validator
|
11
|
+
@eye_detector = eye_detector
|
12
|
+
end
|
13
|
+
|
14
|
+
def finished?
|
15
|
+
@game.finished?
|
16
|
+
end
|
17
|
+
|
18
|
+
def generate_move
|
19
|
+
generate_random_move
|
20
|
+
end
|
21
|
+
|
22
|
+
def set_move(move)
|
23
|
+
identifier = move.first
|
24
|
+
color = move.last
|
25
|
+
@game.set_valid_move identifier, color
|
26
|
+
end
|
27
|
+
|
28
|
+
def dup
|
29
|
+
self.class.new @game.dup, @validator, @eye_detector
|
30
|
+
end
|
31
|
+
|
32
|
+
def won?(color)
|
33
|
+
score[:winner] == color
|
34
|
+
end
|
35
|
+
|
36
|
+
def all_valid_moves
|
37
|
+
color = @game.next_turn_color
|
38
|
+
@game.board.inject([]) do |valid_moves, (identifier, _field_color)|
|
39
|
+
valid_moves << [identifier, color] if plausible_move?(identifier, color)
|
40
|
+
valid_moves
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def score
|
45
|
+
@score ||= GameScorer.new.score(@game)
|
46
|
+
end
|
47
|
+
|
48
|
+
def last_turn_color
|
49
|
+
Game.other_color(next_turn_color)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
def generate_random_move
|
54
|
+
color = @game.next_turn_color
|
55
|
+
cp_count = @game.board.cutting_point_count
|
56
|
+
start_point = rand(cp_count)
|
57
|
+
identifier = start_point
|
58
|
+
passes = 0
|
59
|
+
|
60
|
+
until searched_whole_board?(identifier, passes, start_point) ||
|
61
|
+
plausible_move?(identifier, color) do
|
62
|
+
if identifier >= cp_count - 1
|
63
|
+
identifier = 0
|
64
|
+
passes += 1
|
65
|
+
else
|
66
|
+
identifier += 1
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
if searched_whole_board?(identifier, passes, start_point)
|
71
|
+
pass_move(color)
|
72
|
+
else
|
73
|
+
[identifier, color]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def searched_whole_board?(identifier, passes, start_point)
|
78
|
+
passes > 0 && identifier >= start_point
|
79
|
+
end
|
80
|
+
|
81
|
+
def pass_move(color)
|
82
|
+
[nil, color]
|
83
|
+
end
|
84
|
+
|
85
|
+
def next_turn_color
|
86
|
+
@game.next_turn_color
|
87
|
+
end
|
88
|
+
|
89
|
+
def plausible_move?(identifier, color)
|
90
|
+
@validator.trusted_valid?(identifier, color, @game) && !@eye_detector.is_eye?(identifier, @game.board)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Rubykon
|
2
|
+
class Group
|
3
|
+
|
4
|
+
attr_reader :identifier, :stones, :liberties, :liberty_count
|
5
|
+
|
6
|
+
NOT_SET = :not_set
|
7
|
+
|
8
|
+
def initialize(id, stones = [id], liberties = {}, liberty_count = 0)
|
9
|
+
@identifier = id
|
10
|
+
@stones = stones
|
11
|
+
@liberties = liberties
|
12
|
+
@liberty_count = liberty_count
|
13
|
+
end
|
14
|
+
|
15
|
+
def connect(stone_identifier, stone_group, group_tracker)
|
16
|
+
return if stone_group == self
|
17
|
+
if stone_group
|
18
|
+
merge(stone_group, group_tracker)
|
19
|
+
else
|
20
|
+
add_stone(stone_identifier, group_tracker)
|
21
|
+
end
|
22
|
+
remove_connector_liberty(stone_identifier)
|
23
|
+
end
|
24
|
+
|
25
|
+
def gain_liberties_from_capture_of(captured_group, group_tracker)
|
26
|
+
new_liberties = @liberties.select do |_identifier, stone_identifier|
|
27
|
+
group_tracker.group_id_of(stone_identifier) == captured_group.identifier
|
28
|
+
end
|
29
|
+
new_liberties.each do |identifier, _group_id|
|
30
|
+
add_liberty(identifier)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def dup
|
35
|
+
self.class.new @identifier, @stones.dup, @liberties.dup, @liberty_count
|
36
|
+
end
|
37
|
+
|
38
|
+
def add_liberty(identifier)
|
39
|
+
return if already_counted_as_liberty?(identifier, Board::EMPTY)
|
40
|
+
@liberties[identifier] = Board::EMPTY
|
41
|
+
@liberty_count += 1
|
42
|
+
end
|
43
|
+
|
44
|
+
def remove_liberty(identifier)
|
45
|
+
return if already_counted_as_liberty?(identifier, identifier)
|
46
|
+
@liberties[identifier] = identifier
|
47
|
+
@liberty_count -= 1
|
48
|
+
end
|
49
|
+
|
50
|
+
def caught?
|
51
|
+
@liberty_count <= 0
|
52
|
+
end
|
53
|
+
|
54
|
+
def add_enemy_group_at(enemy_identifier)
|
55
|
+
liberties[enemy_identifier] = enemy_identifier
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def merge(other_group, group_tracker)
|
61
|
+
merge_stones(other_group, group_tracker)
|
62
|
+
merge_liberties(other_group)
|
63
|
+
end
|
64
|
+
|
65
|
+
def merge_stones(other_group, group_tracker)
|
66
|
+
other_group.stones.each do |identifier|
|
67
|
+
add_stone(identifier, group_tracker)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def merge_liberties(other_group)
|
72
|
+
@liberty_count += other_group.liberty_count
|
73
|
+
@liberties.merge!(other_group.liberties) do |_key, my_identifier, other_identifier|
|
74
|
+
if shared_liberty?(my_identifier, other_identifier)
|
75
|
+
@liberty_count -= 1
|
76
|
+
end
|
77
|
+
my_identifier
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def add_stone(identifier, group_tracker)
|
82
|
+
group_tracker.stone_joins_group(identifier, @identifier)
|
83
|
+
@stones << identifier
|
84
|
+
end
|
85
|
+
|
86
|
+
def shared_liberty?(my_identifier, other_identifier)
|
87
|
+
my_identifier == Board::EMPTY || other_identifier == Board::EMPTY
|
88
|
+
end
|
89
|
+
|
90
|
+
def remove_connector_liberty(identifier)
|
91
|
+
@liberties.delete(identifier)
|
92
|
+
@liberty_count -= 1
|
93
|
+
end
|
94
|
+
|
95
|
+
def already_counted_as_liberty?(identifier, value)
|
96
|
+
@liberties.fetch(identifier, NOT_SET) == value
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|