rubykon 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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