rubykon 0.3.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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +11 -0
  6. data/CHANGELOG.md +32 -0
  7. data/CODE_OF_CONDUCT.md +13 -0
  8. data/Gemfile +7 -0
  9. data/Guardfile +12 -0
  10. data/LICENSE +22 -0
  11. data/POSSIBLE_IMPROVEMENTS.md +25 -0
  12. data/README.md +36 -0
  13. data/Rakefile +6 -0
  14. data/benchmark/benchmark.sh +22 -0
  15. data/benchmark/full_playout.rb +17 -0
  16. data/benchmark/mcts_avg.rb +23 -0
  17. data/benchmark/playout.rb +15 -0
  18. data/benchmark/playout_micros.rb +188 -0
  19. data/benchmark/profiling/full_playout.rb +7 -0
  20. data/benchmark/profiling/mcts.rb +6 -0
  21. data/benchmark/results/HISTORY.md +541 -0
  22. data/benchmark/scoring.rb +20 -0
  23. data/benchmark/scoring_micros.rb +60 -0
  24. data/benchmark/support/benchmark-ips.rb +11 -0
  25. data/benchmark/support/benchmark-ips_shim.rb +143 -0
  26. data/benchmark/support/playout_help.rb +13 -0
  27. data/examples/mcts_laziness.rb +22 -0
  28. data/exe/rubykon +5 -0
  29. data/lib/benchmark/avg.rb +14 -0
  30. data/lib/benchmark/avg/benchmark_suite.rb +59 -0
  31. data/lib/benchmark/avg/job.rb +92 -0
  32. data/lib/mcts.rb +11 -0
  33. data/lib/mcts/examples/double_step.rb +68 -0
  34. data/lib/mcts/mcts.rb +13 -0
  35. data/lib/mcts/node.rb +88 -0
  36. data/lib/mcts/playout.rb +22 -0
  37. data/lib/mcts/root.rb +49 -0
  38. data/lib/rubykon.rb +13 -0
  39. data/lib/rubykon/board.rb +188 -0
  40. data/lib/rubykon/cli.rb +122 -0
  41. data/lib/rubykon/exceptions/exceptions.rb +1 -0
  42. data/lib/rubykon/exceptions/illegal_move_exception.rb +4 -0
  43. data/lib/rubykon/eye_detector.rb +27 -0
  44. data/lib/rubykon/game.rb +115 -0
  45. data/lib/rubykon/game_scorer.rb +62 -0
  46. data/lib/rubykon/game_state.rb +93 -0
  47. data/lib/rubykon/group.rb +99 -0
  48. data/lib/rubykon/group_tracker.rb +144 -0
  49. data/lib/rubykon/gtp_coordinate_converter.rb +25 -0
  50. data/lib/rubykon/move_validator.rb +55 -0
  51. data/lib/rubykon/version.rb +3 -0
  52. data/rubykon.gemspec +21 -0
  53. metadata +97 -0
@@ -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,4 @@
1
+ module Rubykon
2
+ class IllegalMoveException < ::RuntimeError
3
+ end
4
+ end
@@ -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
@@ -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