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.
- 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
|