├── .codeclimate.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Guardfile ├── LICENSE ├── POSSIBLE_IMPROVEMENTS.md ├── README.md ├── Rakefile ├── benchmark ├── benchmark.sh ├── full_playout.rb ├── mcts_avg.rb ├── playout.rb ├── playout_micros.rb ├── profiling │ ├── full_playout.rb │ └── mcts.rb ├── results │ └── HISTORY.md ├── scoring.rb ├── scoring_micros.rb └── support │ ├── benchmark-ips.rb │ ├── benchmark-ips_shim.rb │ └── playout_help.rb ├── examples └── mcts_laziness.rb ├── exe └── rubykon ├── lib ├── benchmark │ ├── avg.rb │ └── avg │ │ ├── benchmark_suite.rb │ │ └── job.rb ├── mcts.rb ├── mcts │ ├── examples │ │ └── double_step.rb │ ├── mcts.rb │ ├── node.rb │ ├── playout.rb │ └── root.rb ├── rubykon.rb └── rubykon │ ├── board.rb │ ├── cli.rb │ ├── exceptions │ ├── exceptions.rb │ └── illegal_move_exception.rb │ ├── eye_detector.rb │ ├── game.rb │ ├── game_scorer.rb │ ├── game_state.rb │ ├── group.rb │ ├── group_tracker.rb │ ├── gtp_coordinate_converter.rb │ ├── move_validator.rb │ └── version.rb ├── rubykon.gemspec ├── samples_csv_parser.rb ├── setup_all.sh └── spec ├── benchmark ├── avg │ └── job_spec.rb ├── avg_spec.rb └── spec_helper.rb ├── mcts ├── examples │ └── double_step_spec.rb ├── mcts_spec.rb ├── node_spec.rb ├── root_spec.rb └── spec_helper.rb ├── rubykon ├── board_spec.rb ├── cli_spec.rb ├── eye_detector_spec.rb ├── game_scorer_spec.rb ├── game_spec.rb ├── game_state_spec.rb ├── group_tracker_spec.rb ├── gtp_coordinate_converter_spec.rb ├── help │ ├── fake_io.rb │ ├── group.rb │ ├── liberty_examples.rb │ ├── scoring.rb │ └── stone_factory.rb ├── move_validator_spec.rb └── spec_helper.rb └── spec_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | fixme: 4 | enabled: true 5 | rubocop: 6 | enabled: true 7 | ratings: 8 | paths: 9 | - "**.rb" 10 | exclude_paths: 11 | - spec/**/* 12 | - benchmark/**/* 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | profiling.html 3 | .bundle 4 | .jruby+truffle_bundle 5 | pkg/ 6 | .jruby-truffle-tool_bundle 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Don't require new lines between one-liner methods 2 | Style/EmptyLineBetweenDefs: 3 | AllowAdjacentOneLineDefs: true 4 | 5 | # We don't care if we do kind_of? or is_a? 6 | Style/ClassCheck: 7 | Enabled: false 8 | 9 | # Don't ever try to take my `inject` from me, reduce is ok too 10 | Style/CollectionMethods: 11 | PreferredMethods: 12 | inject: 'inject' 13 | 14 | # We don't have a strong opinion on how you use spaces inside braces 15 | Style/SpaceInsideHashLiteralBraces: 16 | Enabled: false 17 | 18 | # We like to be flexible if we want to add in interpolation 19 | Style/StringLiterals: 20 | Enabled: false 21 | 22 | # We like the flexibility of adding additional items to the hash 23 | Style/TrailingComma: 24 | Enabled: false 25 | 26 | # We don't care if you use %w or literal string arrays 27 | Style/WordArray: 28 | Enabled: false 29 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | rubykon 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.4.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.2.3 5 | - 2.3.3 6 | - 2.4.10 7 | - 2.5.8 8 | - 2.6.6 9 | - 2.7.1 10 | - jruby-9.1.17.0 11 | - jruby-9.2.11.1 12 | - jruby-head 13 | - ruby-head 14 | before_script: 15 | - bundle update 16 | matrix: 17 | allow_failures: 18 | - rvm: jruby-head 19 | - rvm: ruby-head 20 | fast_finish: true 21 | script: bundle exec rspec spec 22 | sudo: false 23 | cache: bundler 24 | addons: 25 | code_climate: 26 | repo_token: fab8afb587984cc2f6100be9c660e966ac3fb5e113458fb381267bfffcef15fe 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.1 (2015-11-17) 2 | Fixups to the CLI after the fast release. 3 | 4 | ### Bugfixes 5 | * fix wrong magic comment in gem executable 6 | * do not allow invalid moves in the CLI 7 | * implement `wdyt` command to ask rubykon what it is thinking 8 | * make FakeIO return nil on print/puts as it should be 9 | * Allow lower case move input (a19) 10 | 11 | ## 0.3 (2015-11-16) 12 | Implement full bot together with Monte Carlo Tree Search, as well as a more coarse grained benchmarking tool to benchmark full MCTS runs. Also add a CLI. Mostly a feature release. 13 | 14 | ### Performance 15 | * Faster and more reliable move selection 16 | * optimize neighbours_of by enumerating possibilities, raw but effective 17 | 18 | ### Features 19 | * Full Monte Carlo Tree Search implementation 20 | * Basic CLI implementation 21 | * benchmark/avg to do more coarse grained benchmarking 22 | * More readable string board representation 23 | * Added License (oops) 24 | * Added CoC 25 | 26 | ### Bugfixes 27 | * correctly count captures for score 28 | 29 | ## 0.2 (2015-10-03) 30 | Rewrote data representation to be smaller and do way less allocations. 31 | 32 | ### Performance 33 | * board is now a one dimensional array with each element corresponding to one cutting point (e.g. 1-1 is 0, 3-1 is 2, 1-2 is 19 (on 19x19). 34 | * no more stone class, the board just stores the color of the stone there. Instead of `Stone` objects, an identifier (see above) and its color are passed around. 35 | * what would be a ko move is now stored on the game, making checking faster and easier 36 | * dupping games is easier thanks to simpler data structures 37 | 38 | ### Bugfixes 39 | * captures are correctly included when scoring a game 40 | 41 | ## 0.1 (2015-09-25) 42 | Basic game version, able to perform random playouts and benchmark those. 43 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # all dev dependencies 4 | gem 'rspec', '~> 3.3' 5 | gem "benchmark-ips" 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: "bundle exec rspec" do 2 | require "guard/rspec/dsl" 3 | dsl = Guard::RSpec::Dsl.new(self) 4 | 5 | rspec = dsl.rspec 6 | watch(rspec.spec_helper) { rspec.spec_dir } 7 | watch(rspec.spec_support) { rspec.spec_dir } 8 | watch(rspec.spec_files) 9 | 10 | ruby = dsl.ruby 11 | dsl.watch_spec_files_for(ruby.lib_files) 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012-2015 Tobias Pfeiffer 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without restriction, 6 | including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, 8 | and to permit persons to whom the Software is furnished to do so, 9 | subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 15 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 16 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 17 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 18 | SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 20 | OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /POSSIBLE_IMPROVEMENTS.md: -------------------------------------------------------------------------------- 1 | # Possible Improvements 2 | 3 | Possible improvements to try out in the implementation :) 4 | 5 | ## Playout speed 6 | 7 | ### Board representation 8 | * Reuse stones instead of creating new ones (would probably require a separate move class) 9 | * just use symbols on the board instead of full fledged objects 10 | * use a one dimensional array as the board (can map back to original using modulo etc.) 11 | * use multiple bitmasks (or a bigger one) to represent board state (we have 3 states so a simple mask won't do) 12 | * neighbour_colors methods 13 | 14 | ### Group 15 | * remove references to stones/liberties from obsolete groups (when it is merged in another group or taken off the board) 16 | * in liberties, don't point to stones but point to groups (be careful as no group is nil... and that then is not good with hash lookups) 17 | 18 | ### Move gen 19 | * check self atari? 20 | 21 | ### Scoring 22 | * use more efficient scoring algorithm (michi floodfill like?) 23 | 24 | ### Move generation 25 | * sensibly choose moves and not just that random barrage... possiby using an Enumerator -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rubykon [![Gem Version](https://badge.fury.io/rb/rubykon.svg)](https://badge.fury.io/rb/rubykon)[![Build Status](https://secure.travis-ci.org/PragTob/rubykon.png?branch=master)](https://travis-ci.org/PragTob/rubykon)[![Code Climate](https://codeclimate.com/github/PragTob/Rubykon.png)](https://codeclimate.com/github/PragTob/Rubykon)[![Test Coverage](https://codeclimate.com/github/PragTob/Rubykon/badges/coverage.svg)](https://codeclimate.com/github/PragTob/Rubykon/coverage) 2 | A Go-Engine being built in Ruby. 3 | 4 | ## Status? 5 | 6 | _mostly not updated any more, if there's new work then it's focussed on bencharmks_ 7 | 8 | There is a CLI with which you can play, it does a full UCT MCTS. Still work to do on making move generation and scoring faster. Also there is no AMAF/RAVE implementation yet (which would make it a lot stronger) and it also does not use any expert knowledge right now. So still a lot to do, but it works. 9 | 10 | 11 | ## Sub gems 12 | Right now the `mcts` and `benchmark/avg` gem that I wrote for this are still embedded in here. They are bound to be broken out and released as separate gems to play with. If you want to use them now, just use rubykon and you can require `mcts` or `benchmark/avg` :) 13 | 14 | ## Why would you build a Go-Bot in Ruby? 15 | Cause it's fun. 16 | 17 | ## Setting up 18 | 19 | It should work with any standard ruby implementation. `bundle install` and you're ready to go. 20 | 21 | ## Benchmarking 22 | 23 | If you're here for the benchmarking, then there are a couple of useful scripts. 24 | Assuming you have [`asdf`](https://github.com/asdf-vm/asdf) with both the [ruby](https://github.com/asdf-vm/asdf-ruby) and [java](https://github.com/halcyon/asdf-java) plugins you can run `setup_all.sh` which installs all rubies and JVMs for the benchmark. 25 | 26 | You can then: 27 | 28 | ```shell 29 | cd benchmark/ 30 | benchmark.sh mcts_avg.rb 31 | ``` 32 | 33 | This runs the mcts_avg.rb (adjust timings as necessary) benchmark with all the configured ruby installations. This can take a _long_ while so you might want to comment out certain rubies/JVMs or entire sections. 34 | 35 | ## Contributing 36 | 37 | While not actively developped contributions are welcome. 38 | 39 | Especially performance related contributions should ideally come with before/after benchmark results. Not for all ruby versions mentioned in `benchmark.sh` but a fair subset of them. At best you'd run [`mcts_avg`](https://github.com/PragTob/rubykon/blob/main/benchmark/mcts_avg.rb) - feel free to adjust the warmup times to be massively smaller for implementations that don't need it ([see Warmup section for indications](https://pragtob.wordpress.com/2020/08/24/the-great-rubykon-benchmark-2020-cruby-vs-jruby-vs-truffleruby/)). 40 | 41 | Ideally it'd have benchmarks for: 42 | 43 | * recent CRuby (plus points: also with --jit) 44 | * recent JRuby with invokedynamic 45 | * recent truffleruby (ideally both native and jvm but one is enough) 46 | 47 | If that's too much ruby setup (I understand) feel free to PR and let me run the benchmarks for the missing implementations. Might take me a while though ;) 48 | 49 | The goal of this is to make sure two things: 50 | 51 | a.) We don't accidentally make performance worse (I had ideas for great optimizations that actually made it worse...) 52 | b.) We don't implement optimizations that benefit one implementation while making the others worse 53 | 54 | ## Blog Posts 55 | 56 | These days this is mostly used for writing performance blog posts. You can find all of them at https://pragtob.wordpress.com/tag/rubykon/ 57 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /benchmark/benchmark.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash --login 2 | 3 | set -e 4 | 5 | script_name=$1 6 | 7 | declare -a RUBIES=( "2.4.10" "2.5.8" "2.6.6" "2.7.1" "2.8.0-dev" "truffleruby-20.1.0" "truffleruby-1.0.0-rc16") 8 | 9 | for ruby in "${RUBIES[@]}" 10 | do 11 | echo Running $ruby 12 | asdf local ruby $ruby 13 | ruby -v 14 | ruby $script_name 15 | echo 16 | echo "--------------------------------------------------" 17 | echo 18 | done 19 | 20 | echo "-------------------------------------------" 21 | echo "| DONE WITH 'NORMAL' RUBIES |" 22 | echo "-------------------------------------------" 23 | 24 | asdf local ruby trufflerubyVM 25 | echo "Running GraalVM installed ruby --native" 26 | ruby --native -v 27 | ruby --native $script_name 28 | echo 29 | 30 | echo "Running GraalVM installed ruby --jvm" 31 | ruby --jvm -v 32 | ruby --jvm $script_name 33 | echo 34 | 35 | echo "-------------------------------------------" 36 | echo "| DONE WITH TuffleRuby VM |" 37 | echo "-------------------------------------------" 38 | 39 | declare -a MJITRUBIES=( "2.6.6" "2.7.1" "2.8.0-dev" ) 40 | 41 | for ruby in "${MJITRUBIES[@]}" 42 | do 43 | echo Running $ruby 44 | asdf local ruby $ruby 45 | ruby --jit -v 46 | ruby --jit $script_name 47 | echo 48 | echo "--------------------------------------------------" 49 | echo 50 | done 51 | 52 | echo "-------------------------------------------" 53 | echo "| DONE WITH MJIT RUBIES |" 54 | echo "-------------------------------------------" 55 | 56 | declare -a JRUBIES=("jruby-9.1.17.0" "jruby-9.2.11.1") 57 | declare -a JAVA8S=( "adoptopenjdk-8.0.265+1" "adoptopenjdk-8.0.265+1.openj9-0.21.0" "java-se-ri-8u41-b04" "corretto-8.265.01.1" "dragonwell-8.4.4" "graalvm-20.1.0+java8") 58 | 59 | 60 | for java in "${JAVA8S[@]}" 61 | do 62 | echo "-----------------" 63 | echo "Using Java $java" 64 | echo "-----------------" 65 | 66 | asdf local java $java 67 | java -version 68 | 69 | for ruby in "${JRUBIES[@]}" 70 | do 71 | asdf local ruby $ruby 72 | echo Running $ruby 73 | ruby -v 74 | ruby $script_name 75 | echo 76 | 77 | echo Running $ruby with --server -Xcompile.invokedynamic=true -J-Xmx1500m 78 | ruby --server -Xcompile.invokedynamic=true -J-Xmx1500m -v 79 | ruby --server -Xcompile.invokedynamic=true -J-Xmx1500m $script_name 80 | echo 81 | 82 | echo "--------------------------------------------------" 83 | echo 84 | done 85 | echo "-" 86 | echo 87 | echo 88 | done 89 | 90 | declare -a JAVA9PLUSS=( "adoptopenjdk-14.0.2+12" "adoptopenjdk-14.0.2+12.openj9-0.21.0" "java-se-ri-14+36" "corretto-11.0.8.10.1" "dragonwell-11.0.7.2+9" "graalvm-20.1.0+java11") 91 | 92 | 93 | for java in "${JAVA9PLUSS[@]}" 94 | do 95 | echo "-----------------" 96 | echo "Using Java $java" 97 | echo "-----------------" 98 | 99 | asdf local java $java 100 | java -version 101 | 102 | for ruby in "${JRUBIES[@]}" 103 | do 104 | asdf local ruby $ruby 105 | echo Running $ruby 106 | ruby -v 107 | ruby $script_name 108 | echo 109 | 110 | echo Running $ruby with --server -Xcompile.invokedynamic=true -J-Xmx1500m 111 | ruby --server -Xcompile.invokedynamic=true -J-Xmx1500m $script_name 112 | echo 113 | 114 | echo Running $ruby with --server -Xcompile.invokedynamic=true -J-Xmx1500m -J-XX:+UseParallelGC 115 | ruby --server -Xcompile.invokedynamic=true -J-Xmx1500m -J-XX:+UseParallelGC $script_name 116 | echo 117 | 118 | echo "--------------------------------------------------" 119 | echo 120 | done 121 | echo "-" 122 | echo 123 | echo 124 | done 125 | 126 | 127 | echo "-------------------------------------------" 128 | echo "| DONE WITH JRUBIES |" 129 | echo "-------------------------------------------" 130 | -------------------------------------------------------------------------------- /benchmark/full_playout.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative 'support/playout_help' 3 | require_relative 'support/benchmark-ips' 4 | 5 | Benchmark.ips do |benchmark| 6 | benchmark.config time: 180, warmup: 120 7 | 8 | benchmark.report '19x19 full playout (+ score)' do 9 | full_playout_for 19 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /benchmark/mcts_avg.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative '../lib/benchmark/avg' 3 | 4 | Benchmark.avg do |benchmark| 5 | game_state_19 = Rubykon::GameState.new Rubykon::Game.new(19) 6 | mcts = MCTS::MCTS.new 7 | 8 | benchmark.config warmup: 300, time: 120 9 | 10 | benchmark.report "19x19 1_000 iterations" do 11 | mcts.start game_state_19, 1_000 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /benchmark/playout.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative 'support/playout_help' 3 | require_relative 'support/benchmark-ips' 4 | 5 | Benchmark.ips do |benchmark| 6 | benchmark.report '9x9 playout' do 7 | playout_for 9 8 | end 9 | benchmark.report '13x13 playout' do 10 | playout_for 13 11 | end 12 | benchmark.report '19x19 playout' do 13 | playout_for 19 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /benchmark/playout_micros.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative 'support/playout_help' 3 | require_relative 'support/benchmark-ips' 4 | 5 | class Rubykon::GameState 6 | public :plausible_move? 7 | public :next_turn_color 8 | end 9 | 10 | class Rubykon::EyeDetector 11 | public :candidate_eye_color 12 | public :is_real_eye? 13 | end 14 | 15 | class Rubykon::MoveValidator 16 | public :no_suicide_move? 17 | end 18 | 19 | class Rubykon::GroupTracker 20 | public :color_to_neighbour 21 | public :create_own_group 22 | public :take_liberties_of_enemies 23 | public :join_group_of_friendly_stones 24 | public :add_liberties 25 | end 26 | 27 | 28 | Benchmark.ips do |benchmark| 29 | empty_game = Rubykon::GameState.new Rubykon::Game.new 19 30 | mid_game = empty_game.dup 31 | 200.times do 32 | mid_game.set_move mid_game.generate_move 33 | end 34 | finished_game = playout_for(19).game_state 35 | 36 | games = { 37 | empty_game => 'empty game', 38 | mid_game => 'mid game (200 moves played)', 39 | finished_game => 'finished game' 40 | } 41 | 42 | games.each do |game_state, description| 43 | 44 | game = game_state.game 45 | board = game.board 46 | 47 | benchmark.report "#{description}: finished?" do 48 | game_state.finished? 49 | end 50 | 51 | benchmark.report "#{description}: generate_move" do 52 | game_state.generate_move 53 | end 54 | 55 | color = game_state.next_turn_color 56 | 57 | benchmark.report "#{description}: plausible_move?" do 58 | identifier = rand(361) 59 | game_state.plausible_move?(identifier, color) 60 | end 61 | 62 | validator = Rubykon::MoveValidator.new 63 | 64 | benchmark.report "#{description}: valid?" do 65 | identifier = rand(361) 66 | validator.valid?(identifier, color, game) 67 | end 68 | 69 | benchmark.report "#{description}: no_suicide_move?" do 70 | identifier = rand(361) 71 | validator.no_suicide_move?(identifier, color, game) 72 | end 73 | 74 | eye_detector = Rubykon::EyeDetector.new 75 | 76 | benchmark.report "#{description}: is_eye?" do 77 | identifier = rand(361) 78 | eye_detector.is_eye?(identifier, board) 79 | end 80 | 81 | benchmark.report "#{description}: candidate_eye_color" do 82 | identifier = rand(361) 83 | eye_detector.candidate_eye_color(identifier, board) 84 | end 85 | 86 | candidate_identifier = rand(361) 87 | candidate_eye_color = eye_detector.candidate_eye_color(candidate_identifier, board) 88 | 89 | benchmark.report "#{description}: is_real_eye?" do 90 | eye_detector.is_real_eye?(candidate_identifier, board, candidate_eye_color) 91 | end 92 | 93 | benchmark.report "#{description}: diagonal_colors_of" do 94 | identifier = rand(361) 95 | board.diagonal_colors_of(identifier) 96 | end 97 | 98 | benchmark.report "#{description}: dup" do 99 | game_state.dup 100 | end 101 | 102 | benchmark.report "#{description}: set_valid_move" do 103 | game.dup.set_valid_move rand(361), color 104 | end 105 | 106 | benchmark.report "#{description}: assign" do 107 | group_tracker = game.group_tracker.dup 108 | group_tracker.assign(rand(361), color, board) 109 | end 110 | 111 | group_tracker = game.group_tracker.dup 112 | 113 | benchmark.report "#{description}: color_to_neighbour" do 114 | group_tracker.color_to_neighbour(board, rand(361)) 115 | end 116 | end 117 | 118 | 119 | # more rigorous setup, values gotta be right so we can't just take 120 | # our randomly played out boards. Also this doesn't make much sense 121 | # on the empty or finished board. 122 | 123 | game = Rubykon::Game.new(19) 124 | group_tracker = game.group_tracker 125 | 126 | stone1 = 55 127 | liberties_1 = { 128 | 54 => Rubykon::Board::EMPTY, 129 | 56 => 56, 130 | 36 => Rubykon::Board::EMPTY, 131 | 74 => Rubykon::Board::EMPTY, 132 | 57 => Rubykon::Board::EMPTY 133 | } 134 | group_1 = Rubykon::Group.new(stone1, [stone1], liberties_1, 4) 135 | group_tracker.stone_to_group[55] = 1 136 | group_tracker.groups[1] = group_1 137 | group_tracker.create_own_group(56) 138 | 139 | stone2 = 33 140 | liberties_2 = { 141 | 32 => Rubykon::Board::EMPTY, 142 | 34 => 34, 143 | 24 => Rubykon::Board::EMPTY, 144 | 54 => Rubykon::Board::EMPTY, 145 | 55 => Rubykon::Board::EMPTY 146 | } 147 | group_2 = Rubykon::Group.new(stone2, [stone2], liberties_2, 4) 148 | group_tracker.stone_to_group[33] = 2 149 | group_tracker.groups[2] = group_2 150 | group_tracker.create_own_group(34) 151 | 152 | # "small groups" as they have few liberties and stones assigned to them, 153 | # groups can easily have 20+ stones and even more liberties, but that'd 154 | # be even more setup :) 155 | benchmark.report 'connecting two small groups' do 156 | stones = [stone1, stone2] 157 | my_stone = 44 158 | group_tracker.dup.join_group_of_friendly_stones(stones, my_stone) 159 | end 160 | 161 | benchmark.report 'add_liberties' do 162 | liberties = [24, 78, 36, 79] 163 | group_tracker.add_liberties(liberties, stone1) 164 | end 165 | 166 | enemy_stones = [56, 34] 167 | liberties = { 168 | stone1 => stone1, 169 | 23 => Rubykon::Board::EMPTY, 170 | 73 => Rubykon::Board::EMPTY 171 | } 172 | enemy_group_1 = Rubykon::Group.new(enemy_stones[0], [enemy_stones[0]], liberties, 2) 173 | enemy_group_2 = Rubykon::Group.new(enemy_stones[1], [enemy_stones[1]], liberties.dup, 2) 174 | group_tracker.stone_to_group[enemy_stones[0]] = 3 175 | group_tracker.groups[3] = enemy_group_1 176 | 177 | group_tracker.stone_to_group[enemy_stones[1]] = 4 178 | group_tracker.groups[4] = enemy_group_2 179 | 180 | 181 | # Does not trigger enemy_group.caught? and removing the group. 182 | # That doesn't happen THAT often and it'd require to setup an according 183 | # board (with each test run) 184 | benchmark.report 'remove liberties of enemies' do 185 | 186 | group_tracker.dup.take_liberties_of_enemies(enemy_stones, stone1, game.board, :black) 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /benchmark/profiling/full_playout.rb: -------------------------------------------------------------------------------- 1 | # a simple script doing a full playout to use it with profiling tools 2 | # ruby-prof -p call_stack benchmark/profiling/full_playout.rb -f profiling_playout.html 3 | 4 | require_relative '../../lib/rubykon/' 5 | require_relative '../support/playout_help' 6 | 7 | playout_for 19 8 | -------------------------------------------------------------------------------- /benchmark/profiling/mcts.rb: -------------------------------------------------------------------------------- 1 | # ruby-prof -p call_stack benchmark/profiling/mcts.rb -f profiling_mcts.html 2 | 3 | require_relative '../../lib/rubykon' 4 | 5 | game_state = Rubykon::GameState.new 6 | mcts = MCTS::MCTS.new 7 | 8 | mcts.start game_state, 200 9 | -------------------------------------------------------------------------------- /benchmark/results/HISTORY.md: -------------------------------------------------------------------------------- 1 | ## 0.3 More Reliable playouts + new benchmarking 2 | 3 | Benchmarking is anew, with the help of the truffle/graal team a shim for benchmark/ips is in use that doesn't confuse the JIT as much yielding nice results. 4 | 5 | Moreover, a second more macro benchmark is in use that runs a whole actual MCTS with a predefined number of playouts. This is benchmarked using benchmark/avg I wrote to be more suitable for more macro benchmarks. Also it doesn't do anything inbetween warmup and measuring, so it is not confusing truffle as much. 6 | 7 | ### Playouts + Scoring 8 | 9 | 10 | ``` 11 | Running 1.9.3 with 12 | Using /home/tobi/.rvm/gems/ruby-1.9.3-p551 13 | ruby 1.9.3p551 (2014-11-13 revision 48407) [x86_64-linux] 14 | Calculating ------------------------------------- 15 | 9x9 full playout (+ score) 16 | 24.000 i/100ms 17 | 13x13 full playout (+ score) 18 | 10.000 i/100ms 19 | 19x19 full playout (+ score) 20 | 4.000 i/100ms 21 | ------------------------------------------------- 22 | 9x9 full playout (+ score) 23 | 252.308 (± 4.8%) i/s - 7.560k 24 | 13x13 full playout (+ score) 25 | 107.774 (±11.1%) i/s - 3.190k 26 | 19x19 full playout (+ score) 27 | 44.952 (± 8.9%) i/s - 1.344k 28 | 29 | 30 | Running jruby with 31 | Using /home/tobi/.rvm/gems/jruby-9.0.3.0 32 | jruby 9.0.3.0 (2.2.2) 2015-10-21 633c9aa OpenJDK 64-Bit Server VM 25.45-b02 on 1.8.0_45-internal-b14 +jit [linux-amd64] 33 | Calculating ------------------------------------- 34 | 9x9 full playout (+ score) 35 | 38.000 i/100ms 36 | 13x13 full playout (+ score) 37 | 17.000 i/100ms 38 | 19x19 full playout (+ score) 39 | 7.000 i/100ms 40 | ------------------------------------------------- 41 | 9x9 full playout (+ score) 42 | 405.833 (± 4.9%) i/s - 12.160k 43 | 13x13 full playout (+ score) 44 | 181.332 (± 5.5%) i/s - 5.423k 45 | 19x19 full playout (+ score) 46 | 73.479 (± 6.8%) i/s - 2.198k 47 | 48 | 49 | Running rbx-2.5.8 with 50 | Using /home/tobi/.rvm/gems/rbx-2.5.8 51 | rubinius 2.5.8 (2.1.0 bef51ae3 2015-11-08 3.4.2 JI) [x86_64-linux-gnu] 52 | Calculating ------------------------------------- 53 | 9x9 full playout (+ score) 54 | 18.000 i/100ms 55 | 13x13 full playout (+ score) 56 | 9.000 i/100ms 57 | 19x19 full playout (+ score) 58 | 4.000 i/100ms 59 | ------------------------------------------------- 60 | 9x9 full playout (+ score) 61 | 199.825 (± 4.0%) i/s - 5.994k 62 | 13x13 full playout (+ score) 63 | 92.732 (± 4.3%) i/s - 2.781k 64 | 19x19 full playout (+ score) 65 | 40.911 (± 4.9%) i/s - 1.224k 66 | 67 | 68 | Running jruby-9 with --server -Xcompile.invokedynamic=true -J-Xmx1500m 69 | Using /home/tobi/.rvm/gems/jruby-9.0.3.0 70 | jruby 9.0.3.0 (2.2.2) 2015-10-21 633c9aa OpenJDK 64-Bit Server VM 25.45-b02 on 1.8.0_45-internal-b14 +jit [linux-amd64] 71 | Calculating ------------------------------------- 72 | 9x9 full playout (+ score) 73 | 66.000 i/100ms 74 | 13x13 full playout (+ score) 75 | 32.000 i/100ms 76 | 19x19 full playout (+ score) 77 | 12.000 i/100ms 78 | ------------------------------------------------- 79 | 9x9 full playout (+ score) 80 | 713.264 (± 6.6%) i/s - 21.318k 81 | 13x13 full playout (+ score) 82 | 302.691 (±13.2%) i/s - 8.864k 83 | 19x19 full playout (+ score) 84 | 121.265 (±14.0%) i/s - 3.540k 85 | 86 | 87 | Running jruby-1 with 88 | Using /home/tobi/.rvm/gems/jruby-1.7.22 89 | jruby 1.7.22 (1.9.3p551) 2015-08-20 c28f492 on OpenJDK 64-Bit Server VM 1.8.0_45-internal-b14 +jit [linux-amd64] 90 | Calculating ------------------------------------- 91 | 9x9 full playout (+ score) 92 | 36.000 i/100ms 93 | 13x13 full playout (+ score) 94 | 16.000 i/100ms 95 | 19x19 full playout (+ score) 96 | 6.000 i/100ms 97 | ------------------------------------------------- 98 | 9x9 full playout (+ score) 99 | 353.815 (±12.7%) i/s - 10.404k 100 | 13x13 full playout (+ score) 101 | 163.234 (± 6.7%) i/s - 4.880k 102 | 19x19 full playout (+ score) 103 | 63.456 (±15.8%) i/s - 1.842k 104 | 105 | 106 | Running 2.2 with 107 | Using /home/tobi/.rvm/gems/ruby-2.2.3 108 | ruby 2.2.3p173 (2015-08-18 revision 51636) [x86_64-linux] 109 | Calculating ------------------------------------- 110 | 9x9 full playout (+ score) 111 | 30.000 i/100ms 112 | 13x13 full playout (+ score) 113 | 12.000 i/100ms 114 | 19x19 full playout (+ score) 115 | 5.000 i/100ms 116 | ------------------------------------------------- 117 | 9x9 full playout (+ score) 118 | 300.910 (± 7.6%) i/s - 8.970k 119 | 13x13 full playout (+ score) 120 | 131.262 (±12.2%) i/s - 3.864k 121 | 19x19 full playout (+ score) 122 | 55.403 (± 7.2%) i/s - 1.655k 123 | 124 | 125 | Using /home/tobi/.rvm/gems/ruby-2.2.3 with gemset rubykon 126 | Running truffle graal with enough heap space 127 | $ JAVACMD=../graalvm-jdk1.8.0/bin/java ../jruby/bin/jruby -X\+T -Xtruffle.core.load_path\=../jruby/truffle/src/main/ruby -r ./.jruby\+truffle_bundle/bundler/setup.rb -e puts\ RUBY_DESCRIPTION 128 | jruby 9.0.4.0-SNAPSHOT (2.2.2) 2015-11-08 fd2c179 OpenJDK 64-Bit Server VM 25.40-b25-internal-graal-0.7 on 1.8.0-internal-b132 +jit [linux-amd64] 129 | $ JAVACMD=../graalvm-jdk1.8.0/bin/java ../jruby/bin/jruby -X\+T -J-Xmx1500m -Xtruffle.core.load_path\=../jruby/truffle/src/main/ruby -r ./.jruby\+truffle_bundle/bundler/setup.rb benchmark/full_playout.rb 130 | Calculating ------------------------------------- 131 | 9x9 full playout (+ score) 132 | 39.000 i/100ms 133 | 13x13 full playout (+ score) 134 | 44.000 i/100ms 135 | 19x19 full playout (+ score) 136 | 16.000 i/100ms 137 | ------------------------------------------------- 138 | 9x9 full playout (+ score) 139 | 1.060k (± 16.2%) i/s - 30.654k 140 | 13x13 full playout (+ score) 141 | 460.080 (± 17.0%) i/s - 13.332k 142 | 19x19 full playout (+ score) 143 | 192.420 (± 14.0%) i/s - 5.632k 144 | 145 | ``` 146 | 147 | 148 | ### Full MCTS 149 | 150 | 151 | ``` 152 | Running 1.9.3 with 153 | Using /home/tobi/.rvm/gems/ruby-1.9.3-p551 154 | ruby 1.9.3p551 (2014-11-13 revision 48407) [x86_64-linux] 155 | Running your benchmark... 156 | -------------------------------------------------------------------------------- 157 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 158 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 159 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 160 | Benchmarking finished, here are your reports... 161 | 162 | Warm up results: 163 | -------------------------------------------------------------------------------- 164 | 9x9 10_000 iterations 0.97 i/min 61.79 s (avg) (± 6.59%) 165 | 13x13 2_000 iterations 2.1 i/min 28.6 s (avg) (± 0.92%) 166 | 19x19 1_000 iterations 1.74 i/min 34.47 s (avg) (± 0.36%) 167 | 168 | Runtime results: 169 | -------------------------------------------------------------------------------- 170 | 9x9 10_000 iterations 0.91 i/min 66.29 s (avg) (± 1.85%) 171 | 13x13 2_000 iterations 2.13 i/min 28.16 s (avg) (± 1.31%) 172 | 19x19 1_000 iterations 1.61 i/min 37.26 s (avg) (± 2.23%) 173 | -------------------------------------------------------------------------------- 174 | 175 | 176 | Running jruby with 177 | Using /home/tobi/.rvm/gems/jruby-9.0.3.0 178 | jruby 9.0.3.0 (2.2.2) 2015-10-21 633c9aa OpenJDK 64-Bit Server VM 25.45-b02 on 1.8.0_45-internal-b14 +jit [linux-amd64] 179 | Running your benchmark... 180 | -------------------------------------------------------------------------------- 181 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 182 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 183 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 184 | Benchmarking finished, here are your reports... 185 | 186 | Warm up results: 187 | -------------------------------------------------------------------------------- 188 | 9x9 10_000 iterations 1.84 i/min 32.63 s (avg) (± 5.13%) 189 | 13x13 2_000 iterations 4.33 i/min 13.86 s (avg) (± 2.28%) 190 | 19x19 1_000 iterations 3.62 i/min 16.56 s (avg) (± 5.43%) 191 | 192 | Runtime results: 193 | -------------------------------------------------------------------------------- 194 | 9x9 10_000 iterations 1.91 i/min 31.48 s (avg) (± 2.48%) 195 | 13x13 2_000 iterations 4.33 i/min 13.86 s (avg) (± 4.27%) 196 | 19x19 1_000 iterations 3.7 i/min 16.23 s (avg) (± 2.48%) 197 | -------------------------------------------------------------------------------- 198 | 199 | 200 | Running rbx-2.5.8 with 201 | Using /home/tobi/.rvm/gems/rbx-2.5.8 202 | rubinius 2.5.8 (2.1.0 bef51ae3 2015-11-08 3.4.2 JI) [x86_64-linux-gnu] 203 | Running your benchmark... 204 | -------------------------------------------------------------------------------- 205 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 206 | Finished measuring the run time for 9x9 10_000 iterations 207 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 208 | Finished measuring the run time for 13x13 2_000 iterations 209 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 210 | Finished measuring the run time for 19x19 1_000 iterations 211 | Benchmarking finished, here are your reports... 212 | 213 | Warm up results: 214 | -------------------------------------------------------------------------------- 215 | 9x9 10_000 iterations 1.0 i/min 59.76 s (avg) (± 5.83%) 216 | 13x13 2_000 iterations 2.48 i/min 24.21 s (avg) (± 0.88%) 217 | 19x19 1_000 iterations 2.12 i/min 28.27 s (avg) (± 1.55%) 218 | 219 | Runtime results: 220 | -------------------------------------------------------------------------------- 221 | 9x9 10_000 iterations 1.07 i/min 56.05 s (avg) (± 0.2%) 222 | 13x13 2_000 iterations 2.48 i/min 24.21 s (avg) (± 0.8%) 223 | 19x19 1_000 iterations 2.1 i/min 28.52 s (avg) (± 2.59%) 224 | -------------------------------------------------------------------------------- 225 | 226 | 227 | Running jruby-9 with --server -Xcompile.invokedynamic=true -J-Xmx1500m 228 | Using /home/tobi/.rvm/gems/jruby-9.0.3.0 229 | jruby 9.0.3.0 (2.2.2) 2015-10-21 633c9aa OpenJDK 64-Bit Server VM 25.45-b02 on 1.8.0_45-internal-b14 +jit [linux-amd64] 230 | Running your benchmark... 231 | -------------------------------------------------------------------------------- 232 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 233 | Finished measuring the run time for 9x9 10_000 iterations 234 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 235 | Finished measuring the run time for 13x13 2_000 iterations 236 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 237 | Finished measuring the run time for 19x19 1_000 iterations 238 | Benchmarking finished, here are your reports... 239 | 240 | Warm up results: 241 | -------------------------------------------------------------------------------- 242 | 9x9 10_000 iterations 3.53 i/min 17.02 s (avg) (± 15.86%) 243 | 13x13 2_000 iterations 8.59 i/min 6.99 s (avg) (± 1.21%) 244 | 19x19 1_000 iterations 6.96 i/min 8.62 s (avg) (± 1.65%) 245 | 246 | Runtime results: 247 | -------------------------------------------------------------------------------- 248 | 9x9 10_000 iterations 3.77 i/min 15.89 s (avg) (± 1.87%) 249 | 13x13 2_000 iterations 8.52 i/min 7.04 s (avg) (± 3.46%) 250 | 19x19 1_000 iterations 7.02 i/min 8.55 s (avg) (± 1.92%) 251 | -------------------------------------------------------------------------------- 252 | 253 | 254 | Running jruby-1 with 255 | Using /home/tobi/.rvm/gems/jruby-1.7.22 256 | jruby 1.7.22 (1.9.3p551) 2015-08-20 c28f492 on OpenJDK 64-Bit Server VM 1.8.0_45-internal-b14 +jit [linux-amd64] 257 | Running your benchmark... 258 | -------------------------------------------------------------------------------- 259 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 260 | Finished measuring the run time for 9x9 10_000 iterations 261 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 262 | Finished measuring the run time for 13x13 2_000 iterations 263 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 264 | Finished measuring the run time for 19x19 1_000 iterations 265 | Benchmarking finished, here are your reports... 266 | 267 | Warm up results: 268 | -------------------------------------------------------------------------------- 269 | 9x9 10_000 iterations 1.97 i/min 30.51 s (avg) (± 3.66%) 270 | 13x13 2_000 iterations 4.55 i/min 13.18 s (avg) (± 3.45%) 271 | 19x19 1_000 iterations 3.87 i/min 15.52 s (avg) (± 1.04%) 272 | 273 | Runtime results: 274 | -------------------------------------------------------------------------------- 275 | 9x9 10_000 iterations 2.05 i/min 29.32 s (avg) (± 1.21%) 276 | 13x13 2_000 iterations 4.57 i/min 13.13 s (avg) (± 2.73%) 277 | 19x19 1_000 iterations 3.94 i/min 15.23 s (avg) (± 1.61%) 278 | -------------------------------------------------------------------------------- 279 | 280 | 281 | Running 2.2 with 282 | Using /home/tobi/.rvm/gems/ruby-2.2.3 283 | ruby 2.2.3p173 (2015-08-18 revision 51636) [x86_64-linux] 284 | Running your benchmark... 285 | -------------------------------------------------------------------------------- 286 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 287 | Finished measuring the run time for 9x9 10_000 iterations 288 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 289 | Finished measuring the run time for 13x13 2_000 iterations 290 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 291 | Finished measuring the run time for 19x19 1_000 iterations 292 | Benchmarking finished, here are your reports... 293 | 294 | Warm up results: 295 | -------------------------------------------------------------------------------- 296 | 9x9 10_000 iterations 1.50 i/min 40.04 s (avg) (± 0.83%) 297 | 13x13 2_000 iterations 3.35 i/min 17.90 s (avg) (± 1.65%) 298 | 19x19 1_000 iterations 2.71 i/min 22.15 s (avg) (± 0.37%) 299 | 300 | Runtime results: 301 | -------------------------------------------------------------------------------- 302 | 9x9 10_000 iterations 1.47 i/min 40.68 s (avg) (± 2.28%) 303 | 13x13 2_000 iterations 3.29 i/min 18.21 s (avg) (± 0.44%) 304 | 19x19 1_000 iterations 2.72 i/min 22.09 s (avg) (± 1.05%) 305 | -------------------------------------------------------------------------------- 306 | 307 | 308 | Running truffle graal with enough heap space 309 | $ JAVACMD=../graalvm-jdk1.8.0/bin/java ../jruby/bin/jruby -X\+T -Xtruffle.core.load_path\=../jruby/truffle/src/main/ruby -r ./.jruby\+truffle_bundle/bundler/setup.rb -e puts\ RUBY_DESCRIPTION 310 | jruby 9.0.4.0-SNAPSHOT (2.2.2) 2015-11-08 fd2c179 OpenJDK 64-Bit Server VM 25.40-b25-internal-graal-0.7 on 1.8.0-internal-b132 +jit [linux-amd64] 311 | $ JAVACMD=../graalvm-jdk1.8.0/bin/java ../jruby/bin/jruby -X\+T -J-Xmx1500m -Xtruffle.core.load_path\=../jruby/truffle/src/main/ruby -r ./.jruby\+truffle_bundle/bundler/setup.rb benchmark/mcts_avg.rb 312 | Running your benchmark... 313 | -------------------------------------------------------------------------------- 314 | Finished warm up for 9x9 10_000 iterations, running the real bechmarks now 315 | Finished measuring the run time for 9x9 10_000 iterations 316 | Finished warm up for 13x13 2_000 iterations, running the real bechmarks now 317 | Finished measuring the run time for 13x13 2_000 iterations 318 | Finished warm up for 19x19 1_000 iterations, running the real bechmarks now 319 | Finished measuring the run time for 19x19 1_000 iterations 320 | Benchmarking finished, here are your reports... 321 | 322 | Warm up results: 323 | -------------------------------------------------------------------------------- 324 | 9x9 10_000 iterations 1.88 i/min 31.86 s (avg) (± 94.52%) 325 | 13x13 2_000 iterations 3.96 i/min 15.14 s (avg) (± 159.39%) 326 | 19x19 1_000 iterations 9.65 i/min 6.22 s (avg) (± 10.24%) 327 | 328 | Runtime results: 329 | -------------------------------------------------------------------------------- 330 | 9x9 10_000 iterations 5.04 i/min 11.90 s (avg) (± 7.95%) 331 | 13x13 2_000 iterations 13.86 i/min 4.33 s (avg) (± 15.73%) 332 | 19x19 1_000 iterations 9.49 i/min 6.32 s (avg) (± 8.33%) 333 | -------------------------------------------------------------------------------- 334 | ``` 335 | 336 | 337 | ## 0.2 (Simplified board representation) 338 | 339 | Notable is that these changes weren't done for performance reasons apparent in these benchmarks, as benchmark-ips does run GC so the lack of GC runs should not affect it. Maybe the benefit of creating less objects. 340 | 341 | Some ruby versions showed no notable differences (rbx, jruby 9k) while others (CRuby, jruby 1.7) showed nice gains. On 19x19 CRuby 2.2.3 went 25 --> 34, jruby 1.7 went 43 --> 54. 342 | 343 | ``` 344 | Running rbx with 345 | Using /home/tobi/.rvm/gems/rbx-2.5.2 346 | Calculating ------------------------------------- 347 | 9x9 full playout (+ score) 348 | 7.000 i/100ms 349 | 13x13 full playout (+ score) 350 | 4.000 i/100ms 351 | 19x19 full playout (+ score) 352 | 1.000 i/100ms 353 | ------------------------------------------------- 354 | 9x9 full playout (+ score) 355 | 117.110 (± 7.7%) i/s - 2.331k 356 | 13x13 full playout (+ score) 357 | 53.714 (± 7.4%) i/s - 1.068k 358 | 19x19 full playout (+ score) 359 | 23.817 (±12.6%) i/s - 467.000 360 | Running 1.9.3 with 361 | Using /home/tobi/.rvm/gems/ruby-1.9.3-p551 362 | Calculating ------------------------------------- 363 | 9x9 full playout (+ score) 364 | 15.000 i/100ms 365 | 13x13 full playout (+ score) 366 | 6.000 i/100ms 367 | 19x19 full playout (+ score) 368 | 2.000 i/100ms 369 | ------------------------------------------------- 370 | 9x9 full playout (+ score) 371 | 149.826 (± 6.0%) i/s - 3.000k 372 | 13x13 full playout (+ score) 373 | 66.382 (± 9.0%) i/s - 1.320k 374 | 19x19 full playout (+ score) 375 | 28.114 (±10.7%) i/s - 554.000 376 | Running jruby-dev-graal with -X+T -J-Xmx1500m 377 | Using /home/tobi/.rvm/gems/jruby-dev-graal 378 | Calculating ------------------------------------- 379 | 9x9 full playout (+ score) 380 | 1.000 i/100ms 381 | 13x13 full playout (+ score) 382 | 1.000 i/100ms 383 | 19x19 full playout (+ score) 384 | 1.000 i/100ms 385 | Calculating ------------------------------------- 386 | 9x9 full playout (+ score) 387 | 9.828 (± 40.7%) i/s - 158.000 388 | 13x13 full playout (+ score) 389 | 4.046 (± 24.7%) i/s - 70.000 390 | 19x19 full playout (+ score) 391 | 5.289 (± 37.8%) i/s - 87.000 392 | Running jruby with 393 | Using /home/tobi/.rvm/gems/jruby-9.0.1.0 394 | Calculating ------------------------------------- 395 | 9x9 full playout (+ score) 396 | 11.000 i/100ms 397 | 13x13 full playout (+ score) 398 | 10.000 i/100ms 399 | 19x19 full playout (+ score) 400 | 4.000 i/100ms 401 | ------------------------------------------------- 402 | 9x9 full playout (+ score) 403 | 243.322 (± 7.8%) i/s - 4.829k 404 | 13x13 full playout (+ score) 405 | 105.500 (± 6.6%) i/s - 2.100k 406 | 19x19 full playout (+ score) 407 | 45.046 (± 8.9%) i/s - 896.000 408 | Running jruby-1 with 409 | Using /home/tobi/.rvm/gems/jruby-1.7.22 410 | Calculating ------------------------------------- 411 | 9x9 full playout (+ score) 412 | 14.000 i/100ms 413 | 13x13 full playout (+ score) 414 | 12.000 i/100ms 415 | 19x19 full playout (+ score) 416 | 5.000 i/100ms 417 | ------------------------------------------------- 418 | 9x9 full playout (+ score) 419 | 279.079 (±11.8%) i/s - 5.488k 420 | 13x13 full playout (+ score) 421 | 128.978 (± 7.0%) i/s - 2.568k 422 | 19x19 full playout (+ score) 423 | 54.526 (± 9.2%) i/s - 1.085k 424 | Running 2.2 with 425 | Using /home/tobi/.rvm/gems/ruby-2.2.3 426 | Calculating ------------------------------------- 427 | 9x9 full playout (+ score) 428 | 18.000 i/100ms 429 | 13x13 full playout (+ score) 430 | 8.000 i/100ms 431 | 19x19 full playout (+ score) 432 | 3.000 i/100ms 433 | ------------------------------------------------- 434 | 9x9 full playout (+ score) 435 | 183.983 (± 4.9%) i/s - 3.672k 436 | 13x13 full playout (+ score) 437 | 80.525 (± 6.2%) i/s - 1.608k 438 | 19x19 full playout (+ score) 439 | 34.117 (± 8.8%) i/s - 678.000 440 | ``` 441 | 442 | ## 0.1 (first really naive implementation) 443 | 444 | ``` 445 | Running rbx with 446 | Using /home/tobi/.rvm/gems/rbx-2.5.2 447 | Calculating ------------------------------------- 448 | 9x9 full playout (+ score) 449 | 4.000 i/100ms 450 | 13x13 full playout (+ score) 451 | 3.000 i/100ms 452 | 19x19 full playout (+ score) 453 | 1.000 i/100ms 454 | ------------------------------------------------- 455 | 9x9 full playout (+ score) 456 | 112.237 (±11.6%) i/s - 2.212k 457 | 13x13 full playout (+ score) 458 | 52.475 (± 9.5%) i/s - 1.041k 459 | 19x19 full playout (+ score) 460 | 22.600 (±13.3%) i/s - 442.000 461 | Running 1.9.3 with 462 | Using /home/tobi/.rvm/gems/ruby-1.9.3-p551 463 | Calculating ------------------------------------- 464 | 9x9 full playout (+ score) 465 | 10.000 i/100ms 466 | 13x13 full playout (+ score) 467 | 4.000 i/100ms 468 | 19x19 full playout (+ score) 469 | 2.000 i/100ms 470 | ------------------------------------------------- 471 | 9x9 full playout (+ score) 472 | 111.529 (± 8.1%) i/s - 2.220k 473 | 13x13 full playout (+ score) 474 | 48.059 (±10.4%) i/s - 952.000 475 | 19x19 full playout (+ score) 476 | 19.788 (±15.2%) i/s - 390.000 477 | Running jruby-dev-graal with -X+T -J-Xmx1500m 478 | Using /home/tobi/.rvm/gems/jruby-dev-graal 479 | Calculating ------------------------------------- 480 | 9x9 full playout (+ score) 481 | 1.000 i/100ms 482 | 13x13 full playout (+ score) 483 | 1.000 i/100ms 484 | 19x19 full playout (+ score) 485 | 1.000 i/100ms 486 | Calculating ------------------------------------- 487 | 9x9 full playout (+ score) 488 | 5.787 (± 34.6%) i/s - 102.000 489 | 13x13 full playout (+ score) 490 | 3.598 (± 27.8%) i/s - 67.000 491 | 19x19 full playout (+ score) 492 | 1.849 (± 0.0%) i/s - 36.000 493 | Running jruby with 494 | Using /home/tobi/.rvm/gems/jruby-9.0.1.0 495 | Calculating ------------------------------------- 496 | 9x9 full playout (+ score) 497 | 9.000 i/100ms 498 | 13x13 full playout (+ score) 499 | 10.000 i/100ms 500 | 19x19 full playout (+ score) 501 | 4.000 i/100ms 502 | ------------------------------------------------- 503 | 9x9 full playout (+ score) 504 | 237.441 (±11.0%) i/s - 4.680k 505 | 13x13 full playout (+ score) 506 | 105.639 (± 9.5%) i/s - 2.090k 507 | 19x19 full playout (+ score) 508 | 44.741 (±11.2%) i/s - 884.000 509 | Running jruby-1 with 510 | Using /home/tobi/.rvm/gems/jruby-1.7.22 511 | Calculating ------------------------------------- 512 | 9x9 full playout (+ score) 513 | 11.000 i/100ms 514 | 13x13 full playout (+ score) 515 | 9.000 i/100ms 516 | 19x19 full playout (+ score) 517 | 4.000 i/100ms 518 | ------------------------------------------------- 519 | 9x9 full playout (+ score) 520 | 224.768 (±15.6%) i/s - 4.356k 521 | 13x13 full playout (+ score) 522 | 105.326 (± 7.6%) i/s - 2.097k 523 | 19x19 full playout (+ score) 524 | 43.576 (±11.5%) i/s - 864.000 525 | Running 2.2 with 526 | Using /home/tobi/.rvm/gems/ruby-2.2.3 527 | Calculating ------------------------------------- 528 | 9x9 full playout (+ score) 529 | 14.000 i/100ms 530 | 13x13 full playout (+ score) 531 | 6.000 i/100ms 532 | 19x19 full playout (+ score) 533 | 2.000 i/100ms 534 | ------------------------------------------------- 535 | 9x9 full playout (+ score) 536 | 139.838 (± 6.4%) i/s - 2.786k 537 | 13x13 full playout (+ score) 538 | 60.935 (± 8.2%) i/s - 1.212k 539 | 19x19 full playout (+ score) 540 | 25.423 (±11.8%) i/s - 502.000 541 | ``` 542 | -------------------------------------------------------------------------------- /benchmark/scoring.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative 'support/benchmark-ips' 3 | require_relative 'support/playout_help' 4 | 5 | Benchmark.ips do |benchmark| 6 | game_9 = playout_for(9).game_state.game 7 | game_13 = playout_for(13).game_state.game 8 | game_19 = playout_for(19).game_state.game 9 | scorer = Rubykon::GameScorer.new 10 | 11 | benchmark.report '9x9 scoring' do 12 | scorer.score game_9 13 | end 14 | benchmark.report '13x13 scoring' do 15 | scorer.score game_13 16 | end 17 | benchmark.report '19x19 scoring' do 18 | scorer.score game_19 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /benchmark/scoring_micros.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/rubykon' 2 | require_relative 'support/playout_help' 3 | require_relative 'support/benchmark-ips' 4 | 5 | class Rubykon::GameScorer 6 | public :score_empty_cutting_point 7 | public :find_candidate_color 8 | public :only_one_color_adjacent? 9 | end 10 | 11 | Benchmark.ips do |benchmark| 12 | 13 | board = Rubykon::Board.from <<-BOARD 14 | OOOOO-O-OOOOOOXXXXX 15 | O-OOOOOO-O-OOOX-X-X 16 | OO-OOXOXOOOO-OOXXXX 17 | -OOOXXXXXXOOOOOX-X- 18 | OOOOOXOXXXOOOOOOXXX 19 | OXXOOOOOXOOOO-OOOOX 20 | X-XOOOXXXOOOOOO-OOO 21 | XXXOXXXOXXOOOOOOO-O 22 | XX-XXXOOOOO-OOO-OOO 23 | X-XXXXOXXO-OOOOOOOO 24 | -XXXXXXXXXO-OOO-OOO 25 | XXXX-X-XXXXOOXOO-O- 26 | XX-XX-XX-XOOXXOOOOO 27 | -XXXXX-XXXXOX-XOOOO 28 | XXX-XXXXXXXXXXXXOXO 29 | XXXX-XXXXX-X-XXXOXO 30 | -XXXX-X-XXXXXXXXXXO 31 | X-XX-XXXX-X-XX-XOOO 32 | -XXXXX-XXXXXXXXXO-O 33 | BOARD 34 | scorer = Rubykon::GameScorer.new 35 | identifier = board.identifier_for(3, 3) 36 | 37 | 38 | 39 | benchmark.report 'score_empty_cp' do 40 | game_score = {Rubykon::Board::BLACK => 0, 41 | Rubykon::Board::WHITE => Rubykon::Game::DEFAULT_KOMI} 42 | scorer.score_empty_cutting_point(identifier, board, game_score) 43 | end 44 | 45 | benchmark.report 'Board#neighbour_colors_of' do 46 | board.neighbour_colors_of(identifier) 47 | end 48 | 49 | neighbour_colors = board.neighbour_colors_of(identifier) 50 | 51 | benchmark.report 'find_candidate_color' do 52 | scorer.find_candidate_color(neighbour_colors) 53 | end 54 | 55 | candidate_color = scorer.find_candidate_color(neighbour_colors) 56 | 57 | benchmark.report 'only_one_c_adj?' do 58 | scorer.only_one_color_adjacent?(neighbour_colors, candidate_color) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /benchmark/support/benchmark-ips.rb: -------------------------------------------------------------------------------- 1 | def truffle? # truffle can't do gem install 2 | defined?(RUBY_DESCRIPTION) && RUBY_DESCRIPTION.match(/graal/) 3 | end 4 | 5 | require 'benchmark/ips' 6 | 7 | # only loaded for truffle normally, as it has little to no effect on other 8 | # implementations I tested and only results in them running WAY longer. 9 | if truffle? 10 | require_relative 'benchmark-ips_shim' 11 | end 12 | -------------------------------------------------------------------------------- /benchmark/support/benchmark-ips_shim.rb: -------------------------------------------------------------------------------- 1 | # Shim modifying benchmark-ips to work better with truffle, 2 | # written by Chris Seaton and taken from: 3 | # https://gist.github.com/chrisseaton/1c4cb91f3c95ddcf2d1e 4 | # Note that this has very little effect on the performance of CRuby/JRuby. 5 | # I tried it out and results were ~2 to 4 % better which is well within 6 | # the tolerance. On a pre 0.3 version this moved truffle fromm 33 ips of 7 | # 19x19 gameplay up to 169. 8 | 9 | # This file modifies benchmark-ips to better accommodate the optimisation 10 | # characteristics of sophisticated implementations of Ruby that have a very 11 | # large difference between cold and warmed up performance, and that apply 12 | # optimisations such as value profiling or other speculation on runtime values. 13 | # Recommended to be used with a large (60s) warmup and (30s) measure time. This 14 | # has been modified to be the default. Note that on top of that, it now runs 15 | # warmup five times, so generating the report will be a lot slower than 16 | # before. 17 | 18 | # Code is modified from benchmark-ips 19 | 20 | # Copyright (c) 2015 Evan Phoenix 21 | # 22 | # Permission is hereby granted, free of charge, to any person obtaining a copy 23 | # of this software and associated documentation files (the 'Software'), to deal 24 | # in the Software without restriction, including without limitation the rights 25 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 26 | # copies of the Software, and to permit persons to whom the Software is 27 | # furnished to do so, subject to the following conditions: 28 | # 29 | # The above copyright notice and this permission notice shall be included in 30 | # all copies or substantial portions of the Software. 31 | # 32 | # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 33 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 34 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 35 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 36 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 37 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 38 | # SOFTWARE. 39 | 40 | module Benchmark 41 | module IPS 42 | class Job 43 | 44 | def run_warmup 45 | @list.each do |item| 46 | @suite.warming item.label, @warmup if @suite 47 | 48 | unless @quiet 49 | $stdout.print item.label_rjust 50 | end 51 | 52 | Timing.clean_env 53 | 54 | # Modification - run with different iteration parameters to defeat 55 | # value profiling and other speculation on runtime values. 56 | 57 | item.call_times 1 58 | item.call_times 2 59 | item.call_times 3 60 | 61 | # Modification - actual time to warm up - not measured 62 | 63 | target = Time.now + @warmup 64 | while Time.now < target 65 | item.call_times 1 66 | end 67 | 68 | before = Time.now 69 | target = Time.now + @warmup 70 | 71 | warmup_iter = 0 72 | 73 | while Time.now < target 74 | item.call_times(1) 75 | warmup_iter += 1 76 | end 77 | 78 | after = Time.now 79 | 80 | warmup_time_us = time_us before, after 81 | 82 | @timing[item] = cycles_per_100ms warmup_time_us, warmup_iter 83 | 84 | # Modification - warm up again with this new iteration value that we 85 | # haven't run before. 86 | 87 | cycles = @timing[item] 88 | target = Time.now + @warmup 89 | 90 | while Time.now < target 91 | item.call_times cycles 92 | end 93 | 94 | # Modification repeat the scaling again 95 | 96 | before = Time.now 97 | target = Time.now + @warmup 98 | 99 | warmup_iter = 0 100 | 101 | while Time.now < target 102 | item.call_times cycles 103 | warmup_iter += cycles 104 | end 105 | 106 | after = Time.now 107 | 108 | warmup_time_us = time_us before, after 109 | 110 | @timing[item] = cycles_per_100ms warmup_time_us, warmup_iter 111 | 112 | case Benchmark::IPS.options[:format] 113 | when :human 114 | $stdout.printf "%s i/100ms\n", Helpers.scale(@timing[item]) unless @quiet 115 | else 116 | $stdout.printf "%10d i/100ms\n", @timing[item] unless @quiet 117 | end 118 | 119 | @suite.warmup_stats warmup_time_us, @timing[item] if @suite 120 | 121 | # Modification - warm up again with this new iteration value that we 122 | # haven't run before. 123 | 124 | cycles = @timing[item] 125 | target = Time.now + @warmup 126 | 127 | while Time.now < target 128 | item.call_times cycles 129 | end 130 | end 131 | end 132 | 133 | alias_method :old_initialize, :initialize 134 | 135 | def initialize opts={} 136 | old_initialize opts 137 | @warmup = 60 138 | @time = 30 139 | end 140 | 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /benchmark/support/playout_help.rb: -------------------------------------------------------------------------------- 1 | def full_playout_for(size) 2 | playout_object_for(size).play 3 | end 4 | 5 | def playout_for(size) 6 | playout_object = playout_object_for(size) 7 | playout_object.playout 8 | playout_object 9 | end 10 | 11 | def playout_object_for(size) 12 | MCTS::Playout.new(Rubykon::GameState.new Rubykon::Game.new(size)) 13 | end 14 | -------------------------------------------------------------------------------- /examples/mcts_laziness.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/mcts' 2 | require_relative '../lib/mcts/examples/double_step' 3 | 4 | mcts = MCTS::MCTS.new 5 | 6 | 7 | [ 8 | {black: -4, white: 0}, {black: -3, white: 0}, {black: -2, white: 0}, 9 | {black: -1, white: 0}, {black: 0, white: 0}, {black: 0, white: -1}, 10 | {black: 0, white: -2}, {black: 0, white: -3}, 11 | {black: 0, white: -4}].each do |position| 12 | [2, 4, 8, 10, 16, 32, 64, 100].each do |playouts| 13 | results = Hash.new {0} 14 | double_step_game = MCTS::Examples::DoubleStep.new position[:black], position[:white] 15 | 10_000.times do |i| 16 | root = mcts.start double_step_game, playouts 17 | best_move = root.best_move 18 | results[best_move] += 1 19 | end 20 | puts "Distribution for #{playouts} playouts with a handicap of #{position[:black] - position[:white]}: #{results}" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /exe/rubykon: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../lib/rubykon' 3 | 4 | cli = Rubykon::CLI.new 5 | cli.start 6 | -------------------------------------------------------------------------------- /lib/benchmark/avg.rb: -------------------------------------------------------------------------------- 1 | require_relative 'avg/job' 2 | require_relative 'avg/benchmark_suite' 3 | 4 | module Benchmark 5 | module Avg 6 | def avg 7 | benchmark_suite = BenchmarkSuite.new 8 | yield benchmark_suite 9 | benchmark_suite.run 10 | end 11 | end 12 | 13 | extend Benchmark::Avg 14 | end 15 | -------------------------------------------------------------------------------- /lib/benchmark/avg/benchmark_suite.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Benchmark 3 | module Avg 4 | 5 | OUTPUT_WIDTH = 80 6 | LABEL_WIDTH = 30 7 | PADDING = 2 8 | METRICS_WIDTH = OUTPUT_WIDTH - LABEL_WIDTH 9 | 10 | class BenchmarkSuite 11 | 12 | def initialize 13 | @options = default_options 14 | @jobs = [] 15 | end 16 | 17 | def config(options) 18 | @options.merge! options 19 | end 20 | 21 | def report(label = "", &block) 22 | @jobs << Job.new(label, block) 23 | self 24 | end 25 | 26 | def run 27 | puts 'Running your benchmark...' 28 | divider 29 | each_job { |job| job.run @options[:warmup], @options[:time] } 30 | puts 'Benchmarking finished, here are your reports...' 31 | puts 32 | puts 'Warm up results:' 33 | divider 34 | each_job { |job| puts job.warmup_report } 35 | puts 36 | puts 'Runtime results:' 37 | divider 38 | each_job { |job| puts job.runtime_report } 39 | divider 40 | end 41 | 42 | private 43 | def default_options 44 | { 45 | warmup: 30, 46 | time: 60, 47 | } 48 | end 49 | 50 | def divider 51 | puts '-' * OUTPUT_WIDTH 52 | end 53 | 54 | def each_job(&proc) 55 | @jobs.each &proc 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/benchmark/avg/job.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Benchmark 3 | module Avg 4 | class Job 5 | PRECISION = 2 6 | 7 | def initialize(label, block) 8 | @label = label 9 | @block = block 10 | @warmup_samples = [] 11 | @run_samples = [] 12 | @warming_up = true 13 | end 14 | 15 | def run(warmup_time, run_time) 16 | warmup_finish = Time.now + warmup_time 17 | measure_until(@warmup_samples, warmup_finish) 18 | finish_warmup 19 | 20 | suite_finish = Time.now + run_time 21 | measure_until(@run_samples, suite_finish) 22 | finish_measure 23 | end 24 | 25 | 26 | def warmup_report 27 | report @warmup_samples 28 | end 29 | 30 | def runtime_report 31 | report @run_samples 32 | end 33 | 34 | private 35 | def measure_until(samples, finish_time) 36 | while Time.now < finish_time do 37 | measure_block(samples) 38 | end 39 | end 40 | 41 | def measure_block(samples) 42 | start = Time.now 43 | @block.call 44 | finish = Time.now 45 | samples << (finish - start) 46 | end 47 | 48 | def finish_warmup 49 | @warming_up = false 50 | puts "Finished warm up for #{@label}, running the real bechmarks now" 51 | end 52 | 53 | def finish_measure 54 | puts "Finished measuring the run time for #{@label}" 55 | end 56 | 57 | def report(samples) 58 | p samples 59 | times = extract_times(samples) 60 | label = @label.ljust(LABEL_WIDTH - PADDING) + padding_space 61 | metrics = "#{round(times[:ipm])} i/min" << padding_space 62 | metrics << "#{round(times[:avg])} s (avg)" << padding_space 63 | metrics << "(± #{round(times[:standard_deviation_percent])}%)" 64 | label + metrics 65 | end 66 | 67 | def round(number) 68 | # not Float#round to also get numbers like 3.20 (static number after ,) 69 | sprintf("%.#{PRECISION}f", number) 70 | end 71 | 72 | def padding_space 73 | ' ' * PADDING 74 | end 75 | 76 | def extract_times(samples) 77 | times = {} 78 | times[:total] = samples.inject(:+) 79 | iterations = samples.size 80 | times[:avg] = times[:total] / iterations 81 | times[:ipm] = iterations / (times[:total] / 60) 82 | total_variane = samples.inject(0) do |total, time| 83 | total + ((time - times[:avg]) ** 2) 84 | end 85 | times[:variance] = total_variane / iterations 86 | times[:standard_deviation] = Math.sqrt times[:variance] 87 | times[:standard_deviation_percent] = 88 | 100.0 * (times[:standard_deviation] / times[:avg]) 89 | times 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/mcts.rb: -------------------------------------------------------------------------------- 1 | require_relative 'mcts/node' 2 | require_relative 'mcts/root' 3 | require_relative 'mcts/playout' 4 | require_relative 'mcts/mcts' 5 | 6 | module MCTS 7 | UCT_BIAS_FACTOR = 2 8 | DEFAULT_PLAYOUTS = 1_000 9 | end 10 | 11 | 12 | -------------------------------------------------------------------------------- /lib/mcts/examples/double_step.rb: -------------------------------------------------------------------------------- 1 | module MCTS 2 | module Examples 3 | class DoubleStep 4 | 5 | FINAL_POSITION = 6 6 | STEPS = [1, 2] 7 | 8 | attr_reader :black, :white 9 | 10 | def initialize(black = 0, white = 0, n = 0) 11 | @black = black 12 | @white = white 13 | @move_count = n 14 | end 15 | 16 | def finished? 17 | @white >= FINAL_POSITION || @black >= FINAL_POSITION 18 | end 19 | 20 | def generate_move 21 | STEPS.sample 22 | end 23 | 24 | def set_move(move) 25 | steps = move 26 | case next_turn_color 27 | when :white 28 | @white += steps 29 | else 30 | @black += steps 31 | end 32 | @move_count += 1 33 | end 34 | 35 | def dup 36 | self.class.new @black, @white, @move_count 37 | end 38 | 39 | def won?(color) 40 | fail "Game not finished" unless finished? 41 | case color 42 | when :black 43 | @black > @white 44 | else 45 | @white > @black 46 | end 47 | end 48 | 49 | def all_valid_moves 50 | if finished? 51 | [] 52 | else 53 | [1, 2] 54 | end 55 | end 56 | 57 | def last_turn_color 58 | @move_count.odd? ? :black : :white 59 | end 60 | 61 | private 62 | def next_turn_color 63 | @move_count.even? ? :black : :white 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/mcts/mcts.rb: -------------------------------------------------------------------------------- 1 | module MCTS 2 | class MCTS 3 | def start(game_state, playouts = DEFAULT_PLAYOUTS) 4 | root = Root.new(game_state) 5 | 6 | playouts.times do |i| 7 | root.explore_tree 8 | end 9 | root 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/mcts/node.rb: -------------------------------------------------------------------------------- 1 | module MCTS 2 | class Node 3 | attr_reader :parent, :move, :wins, :visits, :children, :game_state 4 | 5 | def initialize(game_state, move, parent) 6 | @parent = parent 7 | @game_state = game_state 8 | @move = move 9 | @wins = 0.0 10 | @visits = 0 11 | @children = [] 12 | @untried_moves = game_state.all_valid_moves 13 | @leaf = game_state.finished? || @untried_moves.empty? 14 | end 15 | 16 | def uct_value 17 | win_percentage + UCT_BIAS_FACTOR * Math.sqrt(Math.log(parent.visits)/@visits) 18 | end 19 | 20 | def win_percentage 21 | @wins/@visits 22 | end 23 | 24 | def root? 25 | false 26 | end 27 | 28 | def leaf? 29 | @leaf 30 | end 31 | 32 | def uct_select_child 33 | children.max_by &:uct_value 34 | end 35 | 36 | # maybe get a maximum depth or soemthing in 37 | def expand 38 | move = @untried_moves.pop 39 | create_child(move) 40 | end 41 | 42 | def rollout 43 | playout = Playout.new(@game_state) 44 | playout.play 45 | end 46 | 47 | def won 48 | @visits += 1 49 | @wins += 1 50 | end 51 | 52 | def lost 53 | @visits += 1 54 | end 55 | 56 | def backpropagate(won) 57 | node = self 58 | node.update_won won 59 | until node.root? do 60 | won = !won # switching players perspective 61 | node = node.parent 62 | node.update_won(won) 63 | end 64 | end 65 | 66 | def untried_moves? 67 | !@untried_moves.empty? 68 | end 69 | 70 | def update_won(won) 71 | if won 72 | self.won 73 | else 74 | self.lost 75 | end 76 | end 77 | 78 | private 79 | 80 | def create_child(move) 81 | game_state = @game_state.dup 82 | game_state.set_move(move) 83 | child = Node.new game_state, move, self 84 | @children << child 85 | child 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/mcts/playout.rb: -------------------------------------------------------------------------------- 1 | module MCTS 2 | class Playout 3 | 4 | attr_reader :game_state 5 | 6 | def initialize(game_state) 7 | @game_state = game_state.dup 8 | end 9 | 10 | def play 11 | my_color = @game_state.last_turn_color 12 | playout 13 | @game_state.won?(my_color) 14 | end 15 | 16 | def playout 17 | until @game_state.finished? 18 | @game_state.set_move @game_state.generate_move 19 | end 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /lib/mcts/root.rb: -------------------------------------------------------------------------------- 1 | module MCTS 2 | class Root < Node 3 | def initialize(game_state) 4 | super game_state, nil, nil 5 | end 6 | 7 | def root? 8 | true 9 | end 10 | 11 | def best_child 12 | children.max_by &:win_percentage 13 | end 14 | 15 | def best_move 16 | best_child.move 17 | end 18 | 19 | def explore_tree 20 | selected_node = select 21 | playout_node = if selected_node.leaf? 22 | selected_node 23 | else 24 | selected_node.expand 25 | end 26 | won = playout_node.rollout 27 | playout_node.backpropagate(won) 28 | end 29 | 30 | def update_won(won) 31 | # logic reversed as the node accumulates its children and has no move 32 | # of its own 33 | if won 34 | self.lost 35 | else 36 | self.won 37 | end 38 | end 39 | 40 | private 41 | def select 42 | node = self 43 | until node.untried_moves? || node.leaf? do 44 | node = node.uct_select_child 45 | end 46 | node 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/rubykon.rb: -------------------------------------------------------------------------------- 1 | require_relative 'mcts' 2 | 3 | require_relative 'rubykon/group' 4 | require_relative 'rubykon/group_tracker' 5 | require_relative 'rubykon/game' 6 | require_relative 'rubykon/board' 7 | require_relative 'rubykon/move_validator' 8 | require_relative 'rubykon/eye_detector' 9 | require_relative 'rubykon/game_scorer' 10 | require_relative 'rubykon/exceptions/exceptions' 11 | require_relative 'rubykon/game_state' 12 | require_relative 'rubykon/gtp_coordinate_converter' 13 | require_relative 'rubykon/cli' 14 | -------------------------------------------------------------------------------- /lib/rubykon/board.rb: -------------------------------------------------------------------------------- 1 | # Board it acts a bit like a giant 2 dimensional array - but one based 2 | # not zero based 3 | module Rubykon 4 | class Board 5 | include Enumerable 6 | 7 | BLACK = :black 8 | WHITE = :white 9 | EMPTY = nil 10 | 11 | attr_reader :size, :board 12 | 13 | # weird constructor for dup 14 | def initialize(size, board = create_board(size)) 15 | @size = size 16 | @board = board 17 | end 18 | 19 | def each 20 | @board.each_with_index do |color, identifier| 21 | yield identifier, color 22 | end 23 | end 24 | 25 | def cutting_point_count 26 | @board.size 27 | end 28 | 29 | def [](identifier) 30 | @board[identifier] 31 | end 32 | 33 | def []=(identifier, color) 34 | @board[identifier] = color 35 | end 36 | 37 | # this method is rather raw and explicit, it gets called a lot 38 | def neighbours_of(identifier) 39 | x = identifier % size 40 | y = identifier / size 41 | right = identifier + 1 42 | below = identifier + @size 43 | left = identifier - 1 44 | above = identifier - @size 45 | board_edge = @size - 1 46 | not_on_x_edge = x > 0 && x < board_edge 47 | not_on_y_edge = y > 0 && y < board_edge 48 | 49 | if not_on_x_edge && not_on_y_edge 50 | [[right, @board[right]], [below, @board[below]], 51 | [left, @board[left]], [above, @board[above]]] 52 | else 53 | handle_edge_cases(x, y, above, below, left, right, board_edge, 54 | not_on_x_edge, not_on_y_edge) 55 | end 56 | end 57 | 58 | def neighbour_colors_of(identifier) 59 | neighbours_of(identifier).map {|identifier, color| color} 60 | end 61 | 62 | def diagonal_colors_of(identifier) 63 | diagonal_coordinates(identifier).inject([]) do |res, n_identifier| 64 | res << self[n_identifier] if on_board?(n_identifier) 65 | res 66 | end 67 | end 68 | 69 | def on_edge?(identifier) 70 | x, y = x_y_from identifier 71 | x == 1 || x == size || y == 1 || y == size 72 | end 73 | 74 | def on_board?(identifier) 75 | identifier >= 0 && identifier < @board.size 76 | end 77 | 78 | COLOR_TO_CHARACTER = {BLACK => ' X', WHITE => ' O', EMPTY => ' .'} 79 | CHARACTER_TO_COLOR = COLOR_TO_CHARACTER.invert 80 | LEGACY_CONVERSION = {'X' => ' X', 'O' => ' O', '-' => ' .'} 81 | CHARS_PER_GLYPH = 2 82 | 83 | def ==(other_board) 84 | board == other_board.board 85 | end 86 | 87 | def to_s 88 | @board.each_slice(@size).map do |row| 89 | row_chars = row.map do |color| 90 | COLOR_TO_CHARACTER.fetch(color) 91 | end 92 | row_chars.join 93 | end.join("\n") << "\n" 94 | end 95 | 96 | def dup 97 | self.class.new @size, @board.dup 98 | end 99 | 100 | MAKE_IT_OUT_OF_BOUNDS = 1000 101 | 102 | def identifier_for(x, y) 103 | return nil if x.nil? || y.nil? 104 | x = MAKE_IT_OUT_OF_BOUNDS if x > @size || x < 1 105 | (y - 1) * @size + (x - 1) 106 | end 107 | 108 | def x_y_from(identifier) 109 | x = (identifier % (@size)) + 1 110 | y = (identifier / (@size)) + 1 111 | [x, y] 112 | end 113 | 114 | def self.from(string) 115 | new_board = new(string.index("\n") / CHARS_PER_GLYPH) 116 | each_move_from(string) do |index, color| 117 | new_board[index] = color 118 | end 119 | new_board 120 | end 121 | 122 | def self.each_move_from(string) 123 | glyphs = string.tr("\n", '').chars.each_slice(CHARS_PER_GLYPH).map(&:join) 124 | relevant_glyphs = glyphs.select do |glyph| 125 | CHARACTER_TO_COLOR.has_key?(glyph) 126 | end 127 | relevant_glyphs.each_with_index do |glyph, index| 128 | yield index, CHARACTER_TO_COLOR.fetch(glyph) 129 | end 130 | end 131 | 132 | def self.convert(old_board_string) 133 | old_board_string.gsub /[XO-]/, LEGACY_CONVERSION 134 | end 135 | 136 | private 137 | 138 | def create_board(size) 139 | Array.new(size * size, EMPTY) 140 | end 141 | 142 | def handle_edge_cases(x, y, above, below, left, right, board_edge, not_on_x_edge, not_on_y_edge) 143 | left_edge = x == 0 144 | right_edge = x == board_edge 145 | top_edge = y == 0 146 | bottom_edge = y == board_edge 147 | if left_edge && not_on_y_edge 148 | [[right, @board[right]], [below, @board[below]], 149 | [above, @board[above]]] 150 | elsif right_edge && not_on_y_edge 151 | [[below, @board[below]], 152 | [left, @board[left]], [above, @board[above]]] 153 | elsif top_edge && not_on_x_edge 154 | [[right, @board[right]], [below, @board[below]], 155 | [left, @board[left]]] 156 | elsif bottom_edge && not_on_x_edge 157 | [[right, @board[right]], 158 | [left, @board[left]], [above, @board[above]]] 159 | else 160 | handle_corner_case(above, below, left, right, bottom_edge, left_edge, right_edge, top_edge) 161 | end 162 | end 163 | 164 | def handle_corner_case(above, below, left, right, bottom_edge, left_edge, right_edge, top_edge) 165 | if left_edge && top_edge 166 | [[right, @board[right]], [below, @board[below]]] 167 | elsif left_edge && bottom_edge 168 | [[above, @board[above]], [right, @board[right]]] 169 | elsif right_edge && top_edge 170 | [[left, @board[left]], [below, @board[below]]] 171 | elsif right_edge && bottom_edge 172 | [[left, @board[left]], [above, @board[above]]] 173 | end 174 | end 175 | 176 | def diagonal_coordinates(identifier) 177 | x = identifier % size 178 | if x == 0 179 | [identifier + 1 - @size, identifier + 1 + @size] 180 | elsif x == size - 1 181 | [identifier - 1 - @size, identifier - 1 + @size] 182 | else 183 | [identifier - 1 - @size, identifier - 1 + @size, 184 | identifier + 1 - @size, identifier + 1 + @size] 185 | end 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/rubykon/cli.rb: -------------------------------------------------------------------------------- 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 | GTP_COORDINATE = /^[A-Z]\d\d?$/ 9 | MOVE_CONSIDERATIONS_COUNT = 10 10 | 11 | def initialize(output = $stdout, input = $stdin) 12 | @output = output 13 | @input = input 14 | @state = :init 15 | @move_validator = MoveValidator.new 16 | @root = nil 17 | end 18 | 19 | def start 20 | @output.puts 'Please enter a board size (common sizes are 9, 13, and 19)' 21 | size = get_digit_input 22 | @output.puts <<-PLAYOUTS 23 | Please enter the number of playouts you'd like rubykon to make! 24 | More playouts means rubykon is stronger, but also takes longer. 25 | For 9x9 10000 is an acceptable value, for 19x19 1000 already take a long time (but still plays bad). 26 | PLAYOUTS 27 | playouts = get_digit_input 28 | init_game(size, playouts) 29 | game_loop 30 | end 31 | 32 | private 33 | def get_digit_input 34 | input = get_input 35 | until input.match /^\d\d*$/ 36 | @output.puts "Input has to be a number. Please try again!" 37 | input = get_input 38 | end 39 | input 40 | end 41 | 42 | def get_input 43 | @output.print '> ' 44 | input = @input.gets.chomp 45 | exit_if_desired(input) 46 | input 47 | end 48 | 49 | def exit_if_desired(input) 50 | quit if input.match EXIT 51 | end 52 | 53 | def quit 54 | @output.puts "too bad, bye bye!" 55 | exit 56 | end 57 | 58 | def init_game(size, playouts) 59 | board_size = size.to_i 60 | @output.puts "Great starting a #{board_size}x#{board_size} game with #{playouts} playouts" 61 | @game = Game.new board_size 62 | @game_state = GameState.new @game 63 | @mcts = MCTS::MCTS.new 64 | @board = @game.board 65 | @gtp_converter = GTPCoordinateConverter.new(@board) 66 | @playouts = playouts.to_i 67 | end 68 | 69 | def game_loop 70 | print_board 71 | while true 72 | if bot_turn? 73 | bot_move 74 | else 75 | human_input 76 | end 77 | end 78 | end 79 | 80 | def bot_turn? 81 | @game.next_turn_color == Board::BLACK 82 | end 83 | 84 | def print_board 85 | @output.puts labeled_board 86 | end 87 | 88 | def labeled_board 89 | rows = [] 90 | x_labels = X_LABEL_PADDING + CHAR_LABELS.take(@board.size).join(' ') 91 | rows << x_labels 92 | board_rows = @board.to_s.split("\n").each_with_index.map do |row, i| 93 | y_label = "#{@board.size - i}".rjust(Y_LABEL_WIDTH) 94 | y_label + row + y_label 95 | end 96 | rows += board_rows 97 | rows << x_labels 98 | rows.join "\n" 99 | end 100 | 101 | def bot_move 102 | @output.puts 'Rubykon is thinking...' 103 | @root = @mcts.start @game_state, @playouts 104 | move = @root.best_move 105 | make_move(move) 106 | end 107 | 108 | def human_input 109 | input = ask_for_input.upcase 110 | case input 111 | when GTP_COORDINATE 112 | human_move(input) 113 | when 'WDYT'.freeze 114 | print_move_considerations 115 | else 116 | invalid_input 117 | end 118 | end 119 | 120 | def ask_for_input 121 | @output.puts "Make a move in the form XY, e.g. A19, D7 as the labels indicate!" 122 | @output.puts 'Or ask rubykon what it is thinking with "wdyt"' 123 | get_input 124 | end 125 | 126 | def human_move(input) 127 | move = move_from_input(input) 128 | if @move_validator.valid?(*move, @game_state.game) 129 | make_move(move) 130 | else 131 | retry_input 132 | end 133 | end 134 | 135 | def retry_input 136 | @output.puts 'That was an invalid move, please try again!' 137 | human_input 138 | end 139 | 140 | def print_move_considerations 141 | best_children = @root.children.sort_by(&:win_percentage).reverse 142 | top_children = best_children.take(MOVE_CONSIDERATIONS_COUNT) 143 | moves_to_win_percentage = top_children.map do |child| 144 | "#{@gtp_converter.to(child.move.first)} => #{child.win_percentage * 100}%" 145 | end.join "\n" 146 | @output.puts moves_to_win_percentage 147 | end 148 | 149 | def move_from_input(input) 150 | identifier = @gtp_converter.from(input) 151 | [identifier, :white] 152 | end 153 | 154 | def make_move(move) 155 | @game_state.set_move move 156 | print_board 157 | @output.puts "#{move.last} played at #{@gtp_converter.to(move.first)}" 158 | @output.puts "#{@game.next_turn_color}'s turn to move!'" 159 | end 160 | 161 | def invalid_input 162 | puts "Sorry, didn't catch that!" 163 | end 164 | 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/rubykon/exceptions/exceptions.rb: -------------------------------------------------------------------------------- 1 | require_relative 'illegal_move_exception' 2 | -------------------------------------------------------------------------------- /lib/rubykon/exceptions/illegal_move_exception.rb: -------------------------------------------------------------------------------- 1 | module Rubykon 2 | class IllegalMoveException < ::RuntimeError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/rubykon/eye_detector.rb: -------------------------------------------------------------------------------- 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 28 | -------------------------------------------------------------------------------- /lib/rubykon/game.rb: -------------------------------------------------------------------------------- 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 116 | -------------------------------------------------------------------------------- /lib/rubykon/game_scorer.rb: -------------------------------------------------------------------------------- 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 63 | -------------------------------------------------------------------------------- /lib/rubykon/game_state.rb: -------------------------------------------------------------------------------- 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 94 | -------------------------------------------------------------------------------- /lib/rubykon/group.rb: -------------------------------------------------------------------------------- 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 100 | -------------------------------------------------------------------------------- /lib/rubykon/group_tracker.rb: -------------------------------------------------------------------------------- 1 | module Rubykon 2 | class GroupTracker 3 | 4 | attr_reader :groups, :stone_to_group 5 | 6 | def initialize(groups = {}, stone_to_group = {}) 7 | @groups = groups 8 | @stone_to_group = stone_to_group 9 | end 10 | 11 | def assign(identifier, color, board) 12 | neighbours_by_color = color_to_neighbour(board, identifier) 13 | join_group_of_friendly_stones(neighbours_by_color[color], identifier) 14 | create_own_group(identifier) unless group_id_of(identifier) 15 | add_liberties(neighbours_by_color[Board::EMPTY], identifier) 16 | take_liberties_of_enemies(neighbours_by_color[Game.other_color(color)], identifier, board, color) 17 | end 18 | 19 | def liberty_count_at(identifier) 20 | group_of(identifier).liberty_count 21 | end 22 | 23 | def group_id_of(identifier) 24 | @stone_to_group[identifier] 25 | end 26 | 27 | def group_of(identifier) 28 | group(group_id_of(identifier)) 29 | end 30 | 31 | def group(id) 32 | @groups[id] 33 | end 34 | 35 | def stone_joins_group(stone_identifier, group_identifier) 36 | @stone_to_group[stone_identifier] = group_identifier 37 | end 38 | 39 | def dup 40 | self.class.new(dup_groups, @stone_to_group.dup) 41 | end 42 | 43 | private 44 | def color_to_neighbour(board, identifier) 45 | neighbors = board.neighbours_of(identifier) 46 | hash = neighbors.inject({}) do |hash, (n_identifier, color)| 47 | (hash[color] ||= []) << n_identifier 48 | hash 49 | end 50 | hash.default = [] 51 | hash 52 | end 53 | 54 | def take_liberties_of_enemies(enemy_neighbours, identifier, board, capturer_color) 55 | my_group = group_of(identifier) 56 | captures = enemy_neighbours.inject([]) do |caught, enemy_identifier| 57 | enemy_group = group_of(enemy_identifier) 58 | remove_liberties(enemy_identifier, enemy_group, identifier, my_group) 59 | collect_captured_groups(caught, enemy_group) 60 | end 61 | remove_caught_groups(board, capturer_color, captures) 62 | end 63 | 64 | def remove_liberties(enemy_identifier, enemy_group, identifier, my_group) 65 | enemy_group.remove_liberty(identifier) 66 | # this needs to be the identifier and not the group, as groups 67 | # might get merged 68 | my_group.add_enemy_group_at(enemy_identifier) 69 | end 70 | 71 | def collect_captured_groups(caught, enemy_group) 72 | if enemy_group.caught? && !caught.include?(enemy_group) 73 | caught << enemy_group 74 | end 75 | caught 76 | end 77 | 78 | def remove_caught_groups(board, capturer_color, caught) 79 | captures = caught.inject([]) do |captures, enemy_group| 80 | captures + remove(enemy_group, board) 81 | end 82 | captures 83 | end 84 | 85 | def remove(enemy_group, board) 86 | regain_liberties_from_capture(enemy_group) 87 | delete_group(enemy_group) 88 | remove_captured_stones(board, enemy_group) 89 | end 90 | 91 | def remove_captured_stones(board, enemy_group) 92 | captured_stones = enemy_group.stones 93 | captured_stones.each do |identifier| 94 | @stone_to_group.delete identifier 95 | board[identifier] = Board::EMPTY 96 | end 97 | captured_stones 98 | end 99 | 100 | def delete_group(enemy_group) 101 | @groups.delete(enemy_group.identifier) 102 | end 103 | 104 | def regain_liberties_from_capture(enemy_group) 105 | neighboring_groups_of(enemy_group).each do |neighbor_group| 106 | neighbor_group.gain_liberties_from_capture_of(enemy_group, self) 107 | end 108 | end 109 | 110 | def neighboring_groups_of(enemy_group) 111 | enemy_group.liberties.map do |identifier, _| 112 | group_of(identifier) 113 | end.compact.uniq 114 | end 115 | 116 | def add_liberties(liberties, identifier) 117 | liberties.each do |liberty_identifier| 118 | group_of(identifier).add_liberty(liberty_identifier) 119 | end 120 | end 121 | 122 | def join_group_of_friendly_stones(friendly_stones, identifier) 123 | friendly_stones.each do |f_identifier, _color| 124 | friendly_group = group_of(f_identifier) 125 | friendly_group.connect identifier, group_of(identifier), self 126 | end 127 | end 128 | 129 | def create_own_group(identifier) 130 | # we can use the identifier of the stone, as it should not be taken 131 | # (it may have been taken before, but for that stone to be played the 132 | # other group would have had to be captured before) 133 | @groups[identifier] = Group.new(identifier) 134 | @stone_to_group[identifier] = identifier 135 | end 136 | 137 | def dup_groups 138 | @groups.inject({}) do |dupped, (key, group)| 139 | dupped[key] = group.dup 140 | dupped 141 | end 142 | end 143 | end 144 | end -------------------------------------------------------------------------------- /lib/rubykon/gtp_coordinate_converter.rb: -------------------------------------------------------------------------------- 1 | module Rubykon 2 | class GTPCoordinateConverter 3 | 4 | X_CHARS = ('A'..'Z').reject { |c| c == 'I'.freeze } 5 | 6 | def initialize(board) 7 | @board = board 8 | end 9 | 10 | def from(string) 11 | x = string[0] 12 | y = string[1..-1] 13 | x_index = X_CHARS.index(x) + 1 14 | y_index = @board.size - y.to_i + 1 15 | @board.identifier_for(x_index, y_index) 16 | end 17 | 18 | def to(index) 19 | x, y = @board.x_y_from(index) 20 | x_char = X_CHARS[x - 1] 21 | y_index = @board.size - y + 1 22 | "#{x_char}#{y_index}" 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /lib/rubykon/move_validator.rb: -------------------------------------------------------------------------------- 1 | module Rubykon 2 | class MoveValidator 3 | 4 | def valid?(identifier, color, game) 5 | board = game.board 6 | no_double_move?(color, game) && 7 | (Game.pass?(identifier) || 8 | (move_on_board?(identifier, board) && 9 | spot_unoccupied?(identifier, board) && 10 | no_suicide_move?(identifier, color, game) && 11 | no_ko_move?(identifier, game))) 12 | end 13 | 14 | def trusted_valid?(identifier, color, game) 15 | board = game.board 16 | spot_unoccupied?(identifier, board) && 17 | no_ko_move?(identifier, game) && 18 | no_suicide_move?(identifier, color, game) 19 | 20 | end 21 | 22 | private 23 | def no_double_move?(color, game) 24 | color == game.next_turn_color 25 | end 26 | 27 | def move_on_board?(identifier, board) 28 | board.on_board?(identifier) 29 | end 30 | 31 | def spot_unoccupied?(identifier, board) 32 | board[identifier] == Board::EMPTY 33 | end 34 | 35 | def no_suicide_move?(identifier, color, game) 36 | enemy_color = Game.other_color(color) 37 | board = game.board 38 | board_neighbours_of = board.neighbours_of(identifier) 39 | p identifier if board_neighbours_of.nil? 40 | board_neighbours_of.any? do |n_identifier, n_color| 41 | (n_color == Board::EMPTY) || 42 | (n_color == color) && (liberties_at(n_identifier, game) > 1) || 43 | (n_color == enemy_color) && (liberties_at(n_identifier, game) <= 1) 44 | end 45 | end 46 | 47 | def liberties_at(identifier, game) 48 | game.group_tracker.liberty_count_at(identifier) 49 | end 50 | 51 | def no_ko_move?(identifier, game) 52 | identifier != game.ko 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rubykon/version.rb: -------------------------------------------------------------------------------- 1 | module Rubykon 2 | VERSION = "0.3.1" 3 | end -------------------------------------------------------------------------------- /rubykon.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'rubykon/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rubykon" 8 | spec.version = Rubykon::VERSION 9 | spec.authors = ["Tobias Pfeiffer"] 10 | spec.email = ["pragtob@gmail.com"] 11 | 12 | spec.summary = %q{An AI to play Go using Monte Carlo Tree Search.} 13 | spec.description = %q{An AI to play Go using Monte Carlo Tree Search. Currently includes the mcts gem and benchmark/avg. Works on all major ruby versions.} 14 | spec.homepage = "https://github.com/PragTob/rubykon" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | end 22 | -------------------------------------------------------------------------------- /samples_csv_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'csv' 4 | 5 | def rewrite_samples(path) 6 | csv = CSV.read(path, headers: true) 7 | data = build_structure(csv) 8 | write_new_file(data) 9 | end 10 | 11 | def build_structure(csv) 12 | csv.map do |row| 13 | name = row["Ruby"] 14 | name += " #{row["VM"]}" if row["VM"] && row["VM"] != "-" 15 | # don't do this at home kids (or with unknown input) 16 | times = eval row["warmup times"] 17 | times += eval row["run times"] 18 | [name, times] 19 | end 20 | end 21 | 22 | def write_new_file(data) 23 | CSV.open("samples.csv", "w") do |csv| 24 | names = data.map { |name, _| name} 25 | csv << names 26 | times = data.map { |_, times| times } 27 | max_times = times.map(&:size).max 28 | max_times.times do |i| 29 | row_times = times.map {|all_times| all_times[i]} 30 | csv << row_times 31 | end 32 | end 33 | end 34 | 35 | 36 | rewrite_samples "Rubykon Benchmarks 2020-08.csv" 37 | -------------------------------------------------------------------------------- /setup_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash --login 2 | 3 | declare -a RUBIES=( "2.4.10" "2.5.8" "2.6.6" "2.7.1" "2.8.0-dev" "jruby-9.1.17.0" "jruby-9.2.11.1" "truffleruby-20.1.0" "truffleruby-1.0.0-rc16") 4 | 5 | asdf plugin-update ruby 6 | 7 | for ruby in "${RUBIES[@]}" 8 | do 9 | echo Running $ruby 10 | asdf install ruby $ruby 11 | asdf local ruby $ruby 12 | ruby -v 13 | gem install bundler 14 | 15 | # the install of jruby 9.2 seems to need this 16 | asdf reshim ruby $ruby 17 | bundle install 18 | bundle exec rspec 19 | echo 20 | echo 21 | done 22 | 23 | # Also get me some javas 24 | 25 | 26 | declare -a JVMS=( "adoptopenjdk-8.0.265+1" "adoptopenjdk-8.0.265+1.openj9-0.21.0" "adoptopenjdk-14.0.2+12" "adoptopenjdk-14.0.2+12.openj9-0.21.0" "java-se-ri-8u41-b04" "java-se-ri-14+36" "corretto-8.265.01.1" "corretto-11.0.8.10.1" "dragonwell-8.4.4" "dragonwell-11.0.7.2+9" "graalvm-20.1.0+java8" "graalvm-20.1.0+java11") 27 | 28 | asdf plugin-update java 29 | 30 | for java in "${JVMS[@]}" 31 | do 32 | echo Installing $java 33 | asdf install java $java 34 | 35 | echo 36 | echo 37 | done 38 | 39 | # Setup GraalVM non native 40 | 41 | asdf local java graalvm-20.1.0+java11 42 | gu install ruby 43 | ~/.asdf/installs/java/graalvm-20.1.0+java11/languages/ruby/lib/truffle/post_install_hook.sh 44 | ruby_home=$(/home/tobi/.asdf/installs/java/graalvm-20.1.0+java11/languages/ruby/bin/ruby -e 'print RbConfig::CONFIG["prefix"]') 45 | ln -s "$ruby_home" "$HOME/.asdf/installs/ruby/trufflerubyVM" 46 | asdf reshim ruby trufflerubyVM 47 | asdf local ruby trufflerubyVM 48 | ruby -v 49 | ruby --jvm -v 50 | -------------------------------------------------------------------------------- /spec/benchmark/avg/job_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | 3 | module Benchmark::Avg 4 | RSpec.describe Job do 5 | let(:block) { proc {sleep 0.01}} 6 | let(:label) {'some label'} 7 | subject(:job) {described_class.new label, block} 8 | 9 | it { is_expected.not_to be_nil} 10 | 11 | describe 'reports' do 12 | let(:fake_io) {FakeIO.new} 13 | before :each do 14 | $stdout = fake_io 15 | job.run 0.02, 0.03 16 | $stdout = STDOUT 17 | end 18 | 19 | it "prints a note about the warm up phase being over" do 20 | expect(fake_io.output).to match /finish.+warm up.*#{label}/i 21 | end 22 | 23 | it "prints a note about the runtime phase being over" do 24 | expect(fake_io.output).to match /finish.+measur.+run.*#{label}/i 25 | end 26 | 27 | shared_examples_for 'static run time report' do 28 | it "gets the reportright" do 29 | expect(subject).to include label 30 | expect(subject).to match /(6[012345]\d\d|5[456789]\d\d)\.\d* i\/min/ 31 | expect(subject).to match /0\.01\d* s/ 32 | expect(subject).to match /([012345678]\.\d*|0)%/ 33 | end 34 | end 35 | 36 | describe '#warmup_report' do 37 | subject(:warmup_report) { job.warmup_report} 38 | 39 | it_behaves_like "static run time report" 40 | end 41 | 42 | describe '#runtime_report' do 43 | subject(:runtime_report) { job.runtime_report} 44 | 45 | it_behaves_like "static run time report" 46 | end 47 | 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/benchmark/avg_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | # also covers BenchmarkSuite 4 | RSpec.describe Benchmark::Avg do 5 | let(:fake_io) {FakeIO.new} 6 | let(:fake_job) {create_fake_job} 7 | before :each do 8 | $stdout = fake_io 9 | end 10 | 11 | after :each do 12 | $stdout = STDOUT 13 | end 14 | 15 | describe 'A run with just one job' do 16 | before :each do 17 | allow(Benchmark::Avg::Job).to receive(:new).and_return fake_job 18 | end 19 | 20 | let!(:benchmark) do 21 | Benchmark.avg do |benchmark| 22 | benchmark.report 'Label' do 23 | # something 24 | end 25 | end 26 | end 27 | 28 | it "tries to create a job with the right label" do 29 | expect(Benchmark::Avg::Job).to have_received(:new).with('Label', anything) 30 | end 31 | 32 | it 'uses defaults for time' do 33 | expect(fake_job).to have_received(:run).with(30, 60) 34 | end 35 | 36 | it "prints the reports" do 37 | expect(fake_io).to match /warm up report 1/i 38 | expect(fake_io).to match /runtime report 1/i 39 | end 40 | 41 | it 'says that the reports are ready' do 42 | expect(fake_io).to match /reports/i 43 | end 44 | 45 | describe 'configuring via #config' do 46 | let!(:benchmark) do 47 | Benchmark.avg do |benchmark| 48 | benchmark.config warmup: 120, time: 150 49 | benchmark.report 'Label' do 50 | # something 51 | end 52 | end 53 | end 54 | 55 | it 'uses the configured times' do 56 | expect(fake_job).to have_received(:run).with(120, 150) 57 | end 58 | end 59 | end 60 | 61 | describe "Running multiple jobs" do 62 | 63 | let(:fake_job_2) {create_fake_job(2)} 64 | 65 | before :each do 66 | allow(Benchmark::Avg::Job).to receive(:new).and_return fake_job, 67 | fake_job_2 68 | end 69 | 70 | let!(:benchmark) do 71 | Benchmark.avg do |benchmark| 72 | benchmark.config warmup: 34, time: 77 73 | benchmark.report 'Label' do 74 | # something 75 | end 76 | benchmark.report 'Label 2' do 77 | # someting 2 78 | end 79 | end 80 | end 81 | 82 | it "calls all the jobs with the appropriate times" do 83 | expect(fake_job).to have_received(:run).with 34, 77 84 | expect(fake_job_2).to have_received(:run).with 34, 77 85 | end 86 | 87 | it "prints the reports" do 88 | expect(fake_io).to match /warm up report 1/i 89 | expect(fake_io).to match /runtime report 1/i 90 | expect(fake_io).to match /warm up report 2/i 91 | expect(fake_io).to match /runtime report 2/i 92 | end 93 | 94 | it "created jobs with the right labels" do 95 | expect(Benchmark::Avg::Job).to have_received(:new).with("Label", anything) 96 | expect(Benchmark::Avg::Job).to have_received(:new).with("Label 2", anything) 97 | end 98 | end 99 | 100 | def create_fake_job(i = 1) 101 | double 'fake job', run: nil, 102 | warmup_report: "Warm up Report #{i}", 103 | runtime_report: "Runtime Report #{i}" 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/benchmark/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require_relative '../rubykon/help/fake_io' 3 | 4 | require_relative '../../lib/benchmark/avg' 5 | -------------------------------------------------------------------------------- /spec/mcts/examples/double_step_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | 3 | module MCTS::Examples 4 | RSpec.describe DoubleStep do 5 | describe "#initialize" do 6 | subject {DoubleStep.new} 7 | 8 | it "is not finished" do 9 | expect(subject).not_to be_finished 10 | end 11 | 12 | it "has the right position" do 13 | expect(subject.black).to eq(0) 14 | expect(subject.white).to eq(0) 15 | end 16 | end 17 | 18 | describe "#all_valid_moves" do 19 | it "returns 1 and 2" do 20 | expect(subject.all_valid_moves).to contain_exactly 1, 2 21 | end 22 | 23 | context "finished game" do 24 | 25 | end 26 | end 27 | 28 | describe "#set_move" do 29 | before :each do 30 | subject.set_move(2) 31 | end 32 | 33 | it "sets the move for the starting color (black)" do 34 | expect(subject.black).to eq 2 35 | end 36 | 37 | it "returns the correct color for last turn" do 38 | expect(subject.last_turn_color).to eq :black 39 | end 40 | 41 | it "does not touch the moves of the other color" do 42 | expect(subject.white).to eq 0 43 | end 44 | 45 | describe "and another move" do 46 | before :each do 47 | subject.set_move(1) 48 | end 49 | 50 | it "sets the move for white" do 51 | expect(subject.white).to eq 1 52 | end 53 | 54 | it "returns the correct color for last turn" do 55 | expect(subject.last_turn_color).to eq :white 56 | end 57 | 58 | it "does not touch the moves of the other color" do 59 | expect(subject.black).to eq 2 60 | end 61 | 62 | it "then changes back to the original color" do 63 | subject.set_move(1) 64 | expect(subject.black).to eq 3 65 | expect(subject.white).to eq 1 66 | end 67 | end 68 | end 69 | 70 | describe "generate_move" do 71 | it "generates one or two" do 72 | expect([1, 2]).to include subject.generate_move 73 | end 74 | end 75 | 76 | describe "finished?" do 77 | it "is finished once black reaches the 6th field" do 78 | 3.times do 79 | subject.set_move(2) 80 | subject.set_move(1) 81 | end 82 | expect(subject).to be_finished 83 | expect(subject).to be_won(:black) 84 | expect(subject).not_to be_won(:white) 85 | end 86 | 87 | it "is finished once black reaches the 6th field" do 88 | 3.times do 89 | subject.set_move(1) 90 | subject.set_move(2) 91 | end 92 | expect(subject).to be_finished 93 | expect(subject).not_to be_won(:black) 94 | expect(subject).to be_won(:white) 95 | end 96 | end 97 | 98 | describe "#dup" do 99 | it "correctly dups the data and applies changes individually" do 100 | subject.set_move(2) 101 | dup = subject.dup 102 | subject.set_move(1) 103 | dup.set_move(2) 104 | expect(subject.black).to eq(2) 105 | expect(subject.white).to eq(1) 106 | expect(dup.black).to eq(2) 107 | expect(dup.white).to eq(2) 108 | end 109 | end 110 | 111 | describe "introducing a handicap" do 112 | it "works" do 113 | game = described_class.new(4, 0) 114 | game.set_move(2) 115 | expect(game).to be_finished 116 | expect(game).to be_won(:black) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/mcts/mcts_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module MCTS 4 | RSpec.describe MCTS do 5 | subject {MCTS.new } 6 | let(:double_step) {Examples::DoubleStep.new} 7 | let(:root) {subject.start(double_step, times)} 8 | let(:times) {100} 9 | 10 | it "returns the best move (2)" do 11 | expect(root.best_move).to eq 2 12 | end 13 | 14 | it "creates 2 children" do 15 | expect(root.children.size).to eq 2 16 | end 17 | 18 | it "made 1000 visits to the root" do 19 | expect(root.visits).to eq times 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/mcts/node_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module MCTS 4 | RSpec.describe Node do 5 | let(:game_state) {double 'game_state', all_valid_moves: [], finished?: false} 6 | let(:move) {double 'move'} 7 | let(:root) {Root.new game_state} 8 | subject {Node.new game_state, move, root} 9 | 10 | it {is_expected.not_to be_root} 11 | 12 | describe 'initialization' do 13 | it "has 0 visits" do 14 | expect(subject.visits).to eq 0 15 | end 16 | 17 | it "has 0 wins" do 18 | expect(subject.wins).to eq 0 19 | end 20 | 21 | it "has the right parent" do 22 | expect(subject.parent).to be root 23 | end 24 | 25 | it "has the right move" do 26 | expect(subject.move).to be move 27 | end 28 | end 29 | 30 | describe "#won" do 31 | it "increases the visit and wins count" do 32 | subject.won 33 | expect(subject.wins).to eq 1 34 | expect(subject.visits).to eq 1 35 | end 36 | end 37 | 38 | describe "#lost" do 39 | it "increases visits and does not increase wins" do 40 | subject.lost 41 | expect(subject.wins).to eq 0 42 | expect(subject.visits).to eq 1 43 | end 44 | end 45 | 46 | describe "#win_average" do 47 | it "is still 0 after losing" do 48 | subject.lost 49 | expect(subject.win_percentage).to eq 0 50 | end 51 | 52 | it "is one after it won" do 53 | subject.won 54 | expect(subject.win_percentage).to eq 1 55 | end 56 | 57 | it "is 0.5 after a win and a los" do 58 | subject.won 59 | subject.lost 60 | expect(subject.win_percentage).to be_within(0.01).of 0.5 61 | end 62 | 63 | it "is 0.33 after a win and two losses" do 64 | subject.won 65 | subject.lost 66 | subject.lost 67 | expect(subject.win_percentage).to be_within(0.01).of 0.33 68 | end 69 | end 70 | 71 | def create_test_node(wins, visits, parent) 72 | node = Node.new game_state, move, parent 73 | wins.times do node.won end 74 | (visits - wins).times do node.lost end 75 | node 76 | end 77 | 78 | describe '#uct_value' do 79 | it "gets the uct value right" do 80 | parent = create_test_node(0, 40, nil) 81 | node = create_test_node(5, 7, parent) 82 | expect(node.uct_value).to be_within(0.01).of 2.166 83 | end 84 | end 85 | 86 | describe '#uct_select_child' do 87 | it "selects the right child" do 88 | parent = create_test_node 0, 30, nil 89 | child_1 = create_test_node(0, 15, parent) 90 | child_2 = create_test_node(5, 7, parent) 91 | child_3 = create_test_node(4, 8, parent) 92 | allow(parent).to receive(:children).and_return [child_1, child_2, child_3] 93 | expect(parent.uct_select_child).to be child_2 94 | end 95 | end 96 | 97 | describe '#expand' do 98 | let(:game_state) {double('game_state', dup: dupped, 99 | all_valid_moves: [move_2]).as_null_object} 100 | let(:dupped) do 101 | mine = double('dupped').as_null_object 102 | end 103 | let(:move) {double 'move'} 104 | let(:move_2) {double 'move_2'} 105 | let(:node) {Node.new game_state, move, root} 106 | 107 | it "returns the child of the node" do 108 | expect(node.expand.parent).to be node 109 | end 110 | 111 | it "leads to no untried_moves" do 112 | node.expand 113 | expect(node).not_to be_untried_moves 114 | end 115 | 116 | it "the child has the one previously untried move as move" do 117 | child = node.expand 118 | expect(child.move).to be move_2 119 | end 120 | 121 | it "the child is in the children of the parent node" do 122 | child = node.expand 123 | expect(node.children).to eq [child] 124 | end 125 | 126 | end 127 | 128 | describe '#backpropagate' do 129 | let!(:child_1) {create_test_node(2, 4, root)} 130 | let!(:child_2) {create_test_node 1, 3, root} 131 | let!(:child_1_1) {create_test_node 2, 3, child_1} 132 | let!(:child_1_2) {create_test_node 0, 1, child_1} 133 | 134 | before :each do 135 | 3.times do root.won end 136 | 4.times do root.lost end 137 | end 138 | 139 | describe "winning at child_1_1" do 140 | 141 | before :each do 142 | child_1_1.backpropagate true 143 | end 144 | 145 | it "updates the node itself" do 146 | expect(child_1_1.wins).to eq 3 147 | expect(child_1_1.visits).to eq 4 148 | end 149 | 150 | it "results in a loss for the parent" do 151 | expect(child_1.wins).to eq 2 152 | expect(child_1.visits).to eq 5 153 | end 154 | 155 | it "results in a loss in the root (root accumulates level beneath it)" do 156 | expect(root.wins).to eq 3 157 | expect(root.visits).to eq 8 158 | end 159 | 160 | it "does not touch its own sibiling" do 161 | expect(child_1_2.visits).to eq 1 162 | end 163 | 164 | it "does nto touch its parents sibiling" do 165 | expect(child_2.visits).to eq 3 166 | end 167 | end 168 | 169 | describe 'winning child_1_2_1 gets a loss in the root' do 170 | let!(:child_1) {create_test_node(2, 4, root)} 171 | let!(:child_2) {create_test_node 1, 3, root} 172 | let!(:child_1_1) {create_test_node 2, 2, child_1} 173 | let!(:child_1_2) {create_test_node 1, 2, child_1} 174 | let!(:child_1_2_1) {create_test_node(0, 1, child_1_2)} 175 | 176 | before :each do 177 | child_1_2_1.backpropagate true 178 | end 179 | 180 | it "updates the node itself" do 181 | expect(child_1_2_1.wins).to eq 1 182 | expect(child_1_2_1.visits).to eq 2 183 | end 184 | 185 | it "propagates the change to its parent as a loss" do 186 | expect(child_1_2.wins).to eq 1 187 | expect(child_1_2.visits).to eq 3 188 | end 189 | 190 | it "propagates the change to the parent's parent as a win" do 191 | expect(child_1.wins).to eq 3 192 | expect(child_1.visits).to eq 5 193 | end 194 | 195 | it "propagates the change to the root as a win" do 196 | expect(root.wins).to eq 4 197 | expect(root.visits).to eq 8 198 | end 199 | 200 | end 201 | 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /spec/mcts/root_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module MCTS 4 | RSpec.describe Root do 5 | let(:game_state) {double 'game_state', dup: dupped, 6 | all_valid_moves: [move_1, move_2], 7 | finished?: false} 8 | let(:dupped) {double('dupped', dup: duppie).as_null_object} 9 | let(:duppie) {double('duppie',finished?: true, won?: true, dup: dupped2).as_null_object} 10 | let(:dupped2) {double("dupped2", dup: duppie2).as_null_object} 11 | let(:duppie2) {double('duppie2', finished?: true, won?: false).as_null_object} 12 | let(:move_1) {double 'move 1'} 13 | let(:move_2) {double 'move 2'} 14 | subject {Root.new game_state} 15 | 16 | it {is_expected.to be_root} 17 | 18 | describe '#explore_tree' do 19 | 20 | before :each do 21 | subject.explore_tree 22 | end 23 | 24 | it "creates a child" do 25 | expect(subject.children.size).to eq 1 26 | end 27 | 28 | it "the child has move 1 as a move" do 29 | expect(subject.children.first.move).to be move_2 30 | end 31 | 32 | it "gives the root a visit" do 33 | expect(subject.visits).to eq 1 34 | expect(subject.wins).to eq 1 35 | end 36 | 37 | it "selects it as the best node" do 38 | expect(subject.best_child).to eq subject.children.first 39 | end 40 | 41 | describe 'one more expand' do 42 | 43 | let(:game_state) do 44 | mine = double 'game_state', all_valid_moves: [move_1, move_2], 45 | finished?: false 46 | allow(mine).to receive(:dup).and_return(dupped, dupped2) 47 | mine 48 | end 49 | 50 | before :each do 51 | subject.explore_tree 52 | end 53 | 54 | it "creates a child" do 55 | expect(subject.children.size).to eq 2 56 | end 57 | 58 | it "the child has move 1 as a move" do 59 | expect(subject.children[1].move).to be move_1 60 | end 61 | 62 | it "gives the root a visit" do 63 | expect(subject.visits).to eq 2 64 | expect(subject.wins).to eq 1 65 | end 66 | 67 | it "selects the other still as the best node" do 68 | expect(subject.best_child).to eq subject.children.first 69 | end 70 | 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/mcts/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | 3 | require_relative '../../lib/mcts' 4 | require_relative '../../lib/mcts/examples/double_step' 5 | -------------------------------------------------------------------------------- /spec/rubykon/board_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | module Rubykon 3 | describe Board do 4 | 5 | let(:board) {Board.new(19)} 6 | 7 | context 'setting and retrieving LOOKUP' do 8 | it 'has the empty symbol for every LOOKUP' do 9 | all_empty = board.all? do |identifier, color| 10 | color == Board::EMPTY 11 | end 12 | expect(all_empty).to be true 13 | end 14 | 15 | it 'can retrive the empty values via #[]' do 16 | expect(board[1]).to eq Board::EMPTY 17 | end 18 | 19 | it "gives the initially set stones the right coordinates" do 20 | expect(board[board.identifier_for(1, 1)]).to eq Board::EMPTY 21 | expect(board[board.identifier_for(1, 7)]).to eq Board::EMPTY 22 | expect(board[board.identifier_for(7, 1)]).to eq Board::EMPTY 23 | expect(board[board.identifier_for(19, 19)]).to eq Board::EMPTY 24 | expect(board[board.identifier_for(3, 5)]).to eq Board::EMPTY 25 | end 26 | 27 | it 'can set values with []= and geht them with []' do 28 | board[34] = :test 29 | expect(board[34]).to be :test 30 | end 31 | 32 | it 'sets and gets with and without identifiers' do 33 | identifier = board.identifier_for(5, 7) 34 | board[identifier] = :special 35 | expect(board[identifier]).to eq :special 36 | end 37 | end 38 | 39 | describe "identifier conversion" do 40 | 41 | it "works fine on the first line" do 42 | expect(board.identifier_for(1, 1)).to eq 0 43 | end 44 | 45 | it "works fine at the last spot" do 46 | expect(board.identifier_for(19, 19)).to eq 360 47 | end 48 | 49 | it "is a reversible operation" do 50 | identifier = board.identifier_for(7, 9) 51 | expect(identifier).to eq 158 52 | x, y = board.x_y_from(identifier) 53 | expect(x).to eq 7 54 | expect(y).to eq 9 55 | end 56 | 57 | it "handles passing moves" do 58 | x, y, _color = StoneFactory.pass 59 | expect(board.identifier_for(x, y)).to eq nil 60 | end 61 | end 62 | 63 | describe "#neighbours_of and neighbour_colors_of" do 64 | it "returns the stones of the neighbouring fields" do 65 | board = Rubykon::Board.from <<-String 66 | . X . 67 | O . X 68 | . . . 69 | String 70 | identifier = board.identifier_for(2, 2) 71 | expect(board.neighbours_of(identifier)).to contain_exactly( 72 | [board.identifier_for(2, 1), :black], 73 | [board.identifier_for(3, 2), :black], 74 | [board.identifier_for(1, 2), :white], 75 | [board.identifier_for(2, 3), Board::EMPTY]) 76 | expect(board.neighbour_colors_of(identifier)).to contain_exactly( 77 | :black, :black, :white, 78 | Board::EMPTY) 79 | end 80 | 81 | 82 | it "returns fewer stones when on the edge" do 83 | board = Rubykon::Board.from <<-String 84 | . . X 85 | . O . 86 | . . . 87 | String 88 | identifier = board.identifier_for(2, 1) 89 | expect(board.neighbours_of(identifier)).to contain_exactly( 90 | [board.identifier_for(3, 1), :black], 91 | [board.identifier_for(2, 2), :white], 92 | [board.identifier_for(1, 1), Board::EMPTY]) 93 | expect(board.neighbour_colors_of(identifier)).to contain_exactly( 94 | :black, :white, 95 | Board::EMPTY) 96 | end 97 | 98 | it "on the other edge" do 99 | board = Rubykon::Board.from <<-String 100 | X . . 101 | . O . 102 | . . . 103 | String 104 | identifier = board.identifier_for(1, 2) 105 | expect(board.neighbours_of(identifier)).to contain_exactly( 106 | [board.identifier_for(1, 1), :black], 107 | [board.identifier_for(2, 2), :white], 108 | [board.identifier_for(1, 3), Board::EMPTY]) 109 | expect(board.neighbour_colors_of(identifier)).to contain_exactly( 110 | :black, :white, 111 | Board::EMPTY) 112 | end 113 | 114 | 115 | it "returns fewer stones when in the corner" do 116 | board = Rubykon::Board.from <<-String 117 | . X . 118 | . . . 119 | . . . 120 | String 121 | identifier = board.identifier_for(1, 1) 122 | expect(board.neighbours_of(identifier)).to contain_exactly( 123 | [board.identifier_for(2, 1), :black], 124 | [board.identifier_for(1, 2), Board::EMPTY]) 125 | expect(board.neighbour_colors_of(identifier)).to contain_exactly( 126 | :black, Board::EMPTY) 127 | end 128 | end 129 | 130 | describe "#diagonal_colors_of" do 131 | it "returns the colors in the diagonal fields" do 132 | board = Board.from <<-BOARD 133 | O . X 134 | . . . 135 | X . . 136 | BOARD 137 | expect(board.diagonal_colors_of(board.identifier_for(2, 2))).to contain_exactly :white, 138 | :black, 139 | :black, 140 | Board::EMPTY 141 | end 142 | 143 | it "does not contain the neighbors" do 144 | board = Board.from <<-BOARD 145 | . X . 146 | O . X 147 | . O . 148 | BOARD 149 | expect(board.diagonal_colors_of(board.identifier_for(2, 2))).to contain_exactly Board::EMPTY, 150 | Board::EMPTY, 151 | Board::EMPTY, 152 | Board::EMPTY 153 | end 154 | 155 | it "works on the edge" do 156 | board = Board.from <<-BOARD 157 | . . . 158 | O . X 159 | . . . 160 | BOARD 161 | expect(board.diagonal_colors_of(board.identifier_for(2, 1))).to contain_exactly :white, 162 | :black 163 | end 164 | 165 | it "works on the edge 2" do 166 | board = Board.from <<-BOARD 167 | . X . 168 | . . . 169 | . O . 170 | BOARD 171 | expect(board.diagonal_colors_of(board.identifier_for(1, 2))).to contain_exactly :white, 172 | :black 173 | end 174 | 175 | it "works in the corner" do 176 | board = Board.from <<-BOARD 177 | . . . 178 | . X . 179 | . . . 180 | BOARD 181 | expect(board.diagonal_colors_of(board.identifier_for(1, 1))).to contain_exactly :black 182 | end 183 | end 184 | 185 | describe "on_edge?" do 186 | let(:board) {Board.new 5} 187 | 188 | it "is false for coordinates close to the edge" do 189 | expect(board.on_edge?(board.identifier_for(2, 2))).to be_falsey 190 | expect(board.on_edge?(board.identifier_for(4, 4))).to be_falsey 191 | end 192 | 193 | it "is true if one coordinate is 1" do 194 | expect(board.on_edge?(board.identifier_for(1, 3))).to be_truthy 195 | expect(board.on_edge?(board.identifier_for(2, 1))).to be_truthy 196 | expect(board.on_edge?(board.identifier_for(1, 1))).to be_truthy 197 | end 198 | 199 | it "is true if one coordinate is boardsize" do 200 | expect(board.on_edge?(board.identifier_for(2, 5))).to be_truthy 201 | expect(board.on_edge?(board.identifier_for(5, 1))).to be_truthy 202 | expect(board.on_edge?(board.identifier_for(5, 5))).to be_truthy 203 | end 204 | end 205 | 206 | describe '#==' do 207 | it "is true for two empty boards" do 208 | expect(Rubykon::Board.new(5) == Rubykon::Board.new(5)).to be true 209 | end 210 | 211 | it "is false when the board size is different" do 212 | expect(Rubykon::Board.new(6) == Rubykon::Board.new(5)).to be false 213 | end 214 | 215 | it "is equal to itself" do 216 | board = Rubykon::Board.new 5 217 | expect(board == board).to be true 218 | end 219 | 220 | it "is false if one of the boards has a move played" do 221 | board = Rubykon::Board.new 5 222 | other_board = Rubykon::Board.new 5 223 | board[1] = :muh 224 | expect(board == other_board).to be false 225 | end 226 | 227 | it "is true if both boards has a move played" do 228 | board = Rubykon::Board.new 5 229 | other_board = Rubykon::Board.new 5 230 | board[1] = :black 231 | other_board[1] = :black 232 | expect(board == other_board).to be true 233 | end 234 | 235 | it "is false if both boards have a move played but different colors" do 236 | board = Rubykon::Board.new 5 237 | other_board = Rubykon::Board.new 5 238 | board[1] = :white 239 | other_board[1] = :black 240 | expect(board == other_board).to be false 241 | end 242 | end 243 | 244 | describe '#String conversions' do 245 | let(:board) {Rubykon::Board.new 7} 246 | 247 | it "correctly outputs an empty board" do 248 | expected = <<-BOARD 249 | . . . . . . . 250 | . . . . . . . 251 | . . . . . . . 252 | . . . . . . . 253 | . . . . . . . 254 | . . . . . . . 255 | . . . . . . . 256 | BOARD 257 | 258 | board_string = board.to_s 259 | expect(board_string).to eq expected 260 | expect(Rubykon::Board.from board_string).to eq board 261 | end 262 | 263 | it "correctly outputs a board with a black move" do 264 | board[board.identifier_for(4, 4)] = :black 265 | expected = <<-BOARD 266 | . . . . . . . 267 | . . . . . . . 268 | . . . . . . . 269 | . . . X . . . 270 | . . . . . . . 271 | . . . . . . . 272 | . . . . . . . 273 | BOARD 274 | board_string = board.to_s 275 | expect(board_string).to eq expected 276 | expect(Rubykon::Board.from board_string).to eq board 277 | end 278 | 279 | it "correctly outputs a board with a white move" do 280 | board[board.identifier_for(4, 4)] = :white 281 | expected = <<-BOARD 282 | . . . . . . . 283 | . . . . . . . 284 | . . . . . . . 285 | . . . O . . . 286 | . . . . . . . 287 | . . . . . . . 288 | . . . . . . . 289 | BOARD 290 | board_string = board.to_s 291 | expect(board_string).to eq expected 292 | expect(Rubykon::Board.from board_string).to eq board 293 | end 294 | 295 | it "correctly outputs multiple moves played" do 296 | board[board.identifier_for(1, 1)] = :white 297 | board[board.identifier_for(7, 7)] = :black 298 | board[board.identifier_for(1, 7)] = :white 299 | board[board.identifier_for(7, 1)] = :black 300 | board[board.identifier_for(5, 5)] = :white 301 | board[board.identifier_for(3, 3)] = :black 302 | 303 | expected = <<-BOARD 304 | O . . . . . X 305 | . . . . . . . 306 | . . X . . . . 307 | . . . . . . . 308 | . . . . O . . 309 | . . . . . . . 310 | O . . . . . X 311 | BOARD 312 | board_string = board.to_s 313 | expect(board_string).to eq expected 314 | expect(Rubykon::Board.from board_string).to eq board 315 | end 316 | 317 | describe '.convert' do 318 | it "makes the conversion" do 319 | legacy = <<-BOARD 320 | O-----X 321 | ------- 322 | --X---- 323 | ------- 324 | ----O-- 325 | ------- 326 | O-----X 327 | BOARD 328 | expect(Board.convert(legacy)).to eq <<-BOARD 329 | O . . . . . X 330 | . . . . . . . 331 | . . X . . . . 332 | . . . . . . . 333 | . . . . O . . 334 | . . . . . . . 335 | O . . . . . X 336 | BOARD 337 | end 338 | end 339 | 340 | end 341 | 342 | end 343 | end -------------------------------------------------------------------------------- /spec/rubykon/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | require 'stringio' 3 | 4 | module Rubykon 5 | RSpec.describe CLI do 6 | subject {described_class.new} 7 | context 'stubbed out MCTS' do 8 | let(:fake_root) {double 'fake_root', best_move: [33, :black], 9 | children: children} 10 | let(:children) do 11 | (0..9).map {double 'child', move: [33, :black], win_percentage: 0.5} 12 | end 13 | 14 | before :each do 15 | allow_any_instance_of(MCTS::MCTS).to receive(:start).and_return(fake_root) 16 | end 17 | 18 | describe 'choosing a board' do 19 | # input has to go in before starting, otherwise we are stuck waiting 20 | it "displays a message prompting the user to choose a game type" do 21 | output = FakeIO.each_input ['exit'] do 22 | subject.start 23 | end 24 | 25 | expect(output).to match /board size/ 26 | expect(output).to match /9.*13.*19/ 27 | end 28 | 29 | it "waits for some input of a board size" do 30 | output = FakeIO.each_input %w(9 100 exit) do 31 | subject.start 32 | end 33 | expect(output).to match /starting.*9x9/ 34 | end 35 | 36 | it "keeps prompting until a number was entered" do 37 | output = FakeIO.each_input %w(h9 19 100 exit) do 38 | subject.start 39 | end 40 | expect(output).to match /number.*try again/i 41 | expect(output).to match /starting/i 42 | end 43 | 44 | it "prints a board with nice labels" do 45 | output = FakeIO.each_input %w(19 100 exit) do 46 | subject.start 47 | end 48 | 49 | nice_board = <<-BOARD 50 | A B C D E F G H J K L M N O P Q R S T 51 | 19 . . . . . . . . . . . . . . . . . . . 19 52 | 18 . . . . . . . . . . . . . . . . . . . 18 53 | 17 . . . . . . . . . . . . . . . . . . . 17 54 | 16 . . . . . . . . . . . . . . . . . . . 16 55 | 15 . . . . . . . . . . . . . . . . . . . 15 56 | 14 . . . . . . . . . . . . . . . . . . . 14 57 | 13 . . . . . . . . . . . . . . . . . . . 13 58 | 12 . . . . . . . . . . . . . . . . . . . 12 59 | 11 . . . . . . . . . . . . . . . . . . . 11 60 | 10 . . . . . . . . . . . . . . . . . . . 10 61 | 9 . . . . . . . . . . . . . . . . . . . 9 62 | 8 . . . . . . . . . . . . . . . . . . . 8 63 | 7 . . . . . . . . . . . . . . . . . . . 7 64 | 6 . . . . . . . . . . . . . . . . . . . 6 65 | 5 . . . . . . . . . . . . . . . . . . . 5 66 | 4 . . . . . . . . . . . . . . . . . . . 4 67 | 3 . . . . . . . . . . . . . . . . . . . 3 68 | 2 . . . . . . . . . . . . . . . . . . . 2 69 | 1 . . . . . . . . . . . . . . . . . . . 1 70 | A B C D E F G H J K L M N O P Q R S T 71 | BOARD 72 | 73 | expect(output).to include nice_board 74 | end 75 | end 76 | 77 | describe 'enter playputs' do 78 | it "asks for the number of playouts" do 79 | output = FakeIO.each_input %w(9 1000 exit) do 80 | subject.start 81 | end 82 | expect(output).to match /number.*playouts/i 83 | expect(output).to match /1000 playout/i 84 | end 85 | end 86 | 87 | describe 'entering a move' do 88 | it "makes a whole test through all the things" do 89 | output = FakeIO.each_input %w(9 100 A9 exit) do 90 | subject.start 91 | end 92 | 93 | expect(output).to match /O . . . . . . . ./ 94 | expect(output).to match /starting/i 95 | end 96 | 97 | it "can handle lower/uppercase case input" do 98 | output = FakeIO.each_input %w(9 100 a9 exit) do 99 | subject.start 100 | end 101 | 102 | expect(output).to match /O . . . . . . . ./ 103 | expect(output).not_to match /invalid/i 104 | end 105 | 106 | it "rejects moves that are not on the board" do 107 | output = FakeIO.each_input %w(9 100 A10 A9 exit) do 108 | subject.start 109 | end 110 | 111 | expect(output).to match /invalid move/i 112 | expect(output).to match /O . . . . . . . ./ 113 | end 114 | 115 | it "rejects moves that are set where there's only a move" do 116 | output = FakeIO.each_input %w(9 100 A9 A9 D9 exit) do 117 | subject.start 118 | end 119 | 120 | expect(output).to match /invalid move/i 121 | expect(output).to match /O . . O . . . . ./ 122 | end 123 | 124 | it "doesn't blow up on invalid input" do 125 | output = FakeIO.each_input %w(9 100 adslkadla A9 exit) do 126 | subject.start 127 | end 128 | 129 | expect(output).to match /sorry/i 130 | expect(output).to match /O . . . . . . . ./ 131 | end 132 | end 133 | end 134 | 135 | context 'real MCTS' do 136 | it "does not blow up (but we take a very small board" do 137 | output = FakeIO.each_input %w(2 100 B1 exit) do 138 | subject.start 139 | end 140 | 141 | expect(output).to match /thinking/ 142 | expect(output).to match /black/ 143 | expect(output).to match /white/ 144 | end 145 | 146 | describe "wdyt" do 147 | it "prints the win percentages" do 148 | output = FakeIO.each_input %w(9 10 wdyt exit) do 149 | subject.start 150 | end 151 | 152 | expect(output).to match /\=> \d?\d\.\d\d*%/ 153 | end 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/rubykon/eye_detector_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe EyeDetector do 5 | subject{described_class.new} 6 | 7 | describe "obviously not eyes" do 8 | let(:board) {Board.new 5} 9 | 10 | 11 | it "is false for an empty cutting point" do 12 | expect_no_eye(3, 3, board) 13 | end 14 | 15 | it "is false for an empty cutting point at the edge" do 16 | expect_no_eye 3, 1, board 17 | end 18 | 19 | it "is false for the corner" do 20 | expect_no_eye 1, 1, board 21 | end 22 | 23 | it "is false when one of the star shapes is another color" do 24 | board = Board.from <<-BOARD 25 | . X . 26 | X . X 27 | . O . 28 | BOARD 29 | expect_no_eye 2, 2, board 30 | end 31 | end 32 | 33 | describe "false eyes" do 34 | 35 | it "is false when two diagonals are occupied by the enemy" do 36 | board = Board.from <<-BOARD 37 | . . . . . 38 | . . X O . 39 | . X . X . 40 | . . X O . 41 | . . . . . 42 | BOARD 43 | expect_no_eye 3, 3, board 44 | end 45 | 46 | it "is false when two diagonals are occupied by the enemy (diagonal)" do 47 | board = Board.from <<-BOARD 48 | . . . . . 49 | . . X O . 50 | . X . X . 51 | . O X . . 52 | . . . . . 53 | BOARD 54 | expect_no_eye 3, 3, board 55 | end 56 | 57 | it "is false when three diagonals are occupied by the enemy" do 58 | board = Board.from <<-BOARD 59 | . . . . . 60 | . O X O . 61 | . X . X . 62 | . . X O . 63 | . . . . . 64 | BOARD 65 | expect_no_eye 3, 3, board 66 | end 67 | 68 | it "is false when four diagonals are occupied by the enemy" do 69 | board = Board.from <<-BOARD 70 | . . . . . 71 | . O X O . 72 | . X . X . 73 | . O X O . 74 | . . . . . 75 | BOARD 76 | expect_no_eye 3, 3, board 77 | end 78 | 79 | it "is false on the edge when just one diagonal is occupied" do 80 | board = Board.from <<-BOARD 81 | . X . X . 82 | . . X O . 83 | . . . . . 84 | . . . . . 85 | . . . . . 86 | BOARD 87 | expect_no_eye 3, 1, board 88 | end 89 | 90 | it "is false in the corner with the diagonal occupied" do 91 | board = Board.from <<-BOARD 92 | . X . 93 | X O . 94 | . . . 95 | BOARD 96 | expect_no_eye 1, 1, board 97 | end 98 | 99 | end 100 | 101 | describe "real eyes" do 102 | 103 | it "is real for a star shape" do 104 | board = Board.from <<-BOARD 105 | . X . 106 | X . X 107 | . X . 108 | BOARD 109 | expect_eye 2, 2, board 110 | end 111 | 112 | it "is real for a star shape with one diagonal occupied by enemy" do 113 | board = Board.from <<-BOARD 114 | . . . . . 115 | . . X O . 116 | . X . X . 117 | . . X . . 118 | . . . . . 119 | BOARD 120 | expect_eye 3, 3, board 121 | end 122 | 123 | it "is real on the edge" do 124 | board = Board.from <<-BOARD 125 | X . X 126 | . X . 127 | . . . 128 | BOARD 129 | expect_eye 2, 1, board 130 | end 131 | 132 | it "is real in the corner" do 133 | board = Board.from <<-BOARD 134 | . X . 135 | X . . 136 | . . . 137 | BOARD 138 | expect_eye 1, 1, board 139 | end 140 | 141 | end 142 | 143 | def expect_eye(x, y, board) 144 | expect(subject.is_eye?(board.identifier_for(x, y), board)).to be_truthy 145 | end 146 | 147 | def expect_no_eye(x, y, board) 148 | expect(subject.is_eye?(board.identifier_for(x, y), board)).to be_falsey 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/rubykon/game_scorer_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe GameScorer do 5 | let(:game) {Game.from board_string} 6 | let(:scorer) {described_class.new} 7 | let(:score) {scorer.score game} 8 | let(:black_score) {score[:black]} 9 | let(:white_score) {score[:white]} 10 | let(:winner) {score[:winner]} 11 | 12 | describe "empty board" do 13 | let(:game) {Game.new 9} 14 | 15 | it_behaves_like "correctly scored", :black => 0, 16 | :white => Game::DEFAULT_KOMI 17 | end 18 | 19 | describe "it correctly scores a tiny finished game" do 20 | 21 | let(:board_string) do 22 | <<-BOARD 23 | . X X O . 24 | X X O . O 25 | . X O O . 26 | X X O O O 27 | X X X X O 28 | BOARD 29 | end 30 | 31 | it_behaves_like "correctly scored", :black => 13, 32 | :white => 12 + Game::DEFAULT_KOMI 33 | 34 | it "gets the right winner" do 35 | expect(winner).to eq :white 36 | end 37 | end 38 | 39 | describe "it correctly scores a 9x9 board" do 40 | let(:board_string) do 41 | <<-BOARD 42 | . X X O . O . O O 43 | X . X O O . O O O 44 | X X O O . O X X X 45 | O X O . O O X X X 46 | O O O O O O X X X 47 | . O O X X X X O O 48 | O O X . X X X O . 49 | O X . X . X X O O 50 | O X X . X X X O . 51 | BOARD 52 | end 53 | 54 | it_behaves_like "correctly scored", :black => 39, 55 | :white => 42 + Game::DEFAULT_KOMI 56 | end 57 | 58 | describe "game won slightly by komi" do 59 | let(:board_string) do 9 60 | <<-BOARD 61 | . X X O . O . O O 62 | X . X O O . O O O 63 | X X O O . O X X X 64 | O X O . O O X X X 65 | O O O O O O X X X 66 | O O O X X X X O O 67 | X X X . X X X O . 68 | X X . X . X X O O 69 | X X X . X X X O . 70 | BOARD 71 | end 72 | 73 | it_behaves_like "correctly scored", :black => 43, 74 | :white => 38 + Game::DEFAULT_KOMI 75 | it "gets the right winner" do 76 | expect(winner).to eq :white 77 | end 78 | 79 | context "with a different komi" do 80 | before :each do 81 | game.komi = 0.5 82 | end 83 | 84 | it_behaves_like "correctly scored", :black => 43, 85 | :white => 38.5 86 | 87 | it "gets the right winner" do 88 | expect(winner).to eq :black 89 | end 90 | 91 | end 92 | 93 | context 'it takes prisoners into account' do 94 | let(:captures) {{black: 6, white: 4}} 95 | 96 | before :each do 97 | allow(game).to receive(:captures).and_return captures 98 | end 99 | 100 | it_behaves_like "correctly scored", :black => 43 + 6, 101 | :white => 38 + Game::DEFAULT_KOMI + 4 102 | 103 | it "gets the right winner" do 104 | expect(winner).to eq :black 105 | end 106 | end 107 | end 108 | 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/rubykon/game_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe Rubykon::Game do 5 | let(:game) {described_class.new} 6 | let(:validator) {MoveValidator.new} 7 | 8 | context 'creation' do 9 | subject {game} 10 | it {is_expected.not_to be_nil} 11 | 12 | it 'has a default size of 19' do 13 | expect(game.board.size).to eq(19) 14 | end 15 | 16 | it 'has a move_count of 0' do 17 | expect(game.move_count).to eq 0 18 | end 19 | 20 | it 'has no moves playd' do 21 | expect(game).to be_no_moves_played 22 | end 23 | 24 | it 'can be created with another size' do 25 | size = 13 26 | expect(Rubykon::Game.new(size).board.size).to eq size 27 | end 28 | 29 | it 'can retrieve the board' do 30 | expect(game.board).not_to be_nil 31 | end 32 | end 33 | 34 | describe "next_turn_color" do 35 | it "is black for starters" do 36 | expect(game.next_turn_color).to eq Board::BLACK 37 | end 38 | 39 | it "is white after a black move" do 40 | game.play! *StoneFactory.build(color: Board::BLACK) 41 | expect(game.next_turn_color).to eq Board::WHITE 42 | end 43 | 44 | it "is black again after a white move" do 45 | game.play! *StoneFactory.build(color: Board::BLACK) 46 | game.play! *StoneFactory.build(x: 4, y: 5, color: Board::WHITE) 47 | expect(game.next_turn_color).to eq Board::BLACK 48 | end 49 | end 50 | 51 | describe "#finished?" do 52 | it "an empty game is not over" do 53 | expect(game).not_to be_finished 54 | end 55 | 56 | it "a game with one pass is not over" do 57 | game.play! *StoneFactory.pass(:black) 58 | expect(game).not_to be_finished 59 | end 60 | 61 | it "a game with two passes is over" do 62 | game.play! *StoneFactory.pass(:black) 63 | game.play! *StoneFactory.pass(:white) 64 | expect(game).to be_finished 65 | end 66 | end 67 | 68 | describe ".from" do 69 | let(:string) do 70 | <<-GAME 71 | X . . . O 72 | . . X . . 73 | X . . . . 74 | . . . . . 75 | . X . . O 76 | GAME 77 | end 78 | 79 | let(:new_game) {Game.from string} 80 | let(:board) {new_game.board} 81 | let(:group_tracker) {new_game.group_tracker} 82 | 83 | it "sets the right number of moves" do 84 | expect(new_game.move_count).to eq 6 85 | end 86 | 87 | it "assigns the stones a group" do 88 | expect(group_from(1, 1)).not_to be_nil 89 | end 90 | 91 | it "does not assign a group to the empty fields" do 92 | expect(group_tracker.stone_to_group).not_to have_key(board.identifier_for(2, 2)) 93 | end 94 | 95 | it "has stones in all the right places" do 96 | expect(board_at(1, 1)).to eq :black 97 | expect(board_at(5, 1)).to eq :white 98 | expect(board_at(3, 2)).to eq :black 99 | expect(board_at(1, 3)).to eq :black 100 | expect(board_at(2, 5)).to eq :black 101 | expect(board_at(5, 5)).to eq :white 102 | expect(board_at(2, 2)).to eq Board::EMPTY 103 | expect(board_at(1, 4)).to eq Board::EMPTY 104 | end 105 | end 106 | 107 | describe 'playing moves' do 108 | 109 | let(:game) {Game.from board_string} 110 | let(:board) {game.board} 111 | let(:group_tracker) {game.group_tracker} 112 | 113 | describe 'play!' do 114 | let(:game) {Game.new 5} 115 | 116 | it "plays moves" do 117 | game.play!(2, 2, :black) 118 | expect(board_at(2, 2)).to eq :black 119 | end 120 | 121 | it "raises if the move is invalid" do 122 | expect do 123 | game.play!(0, 0, :black) 124 | end.to raise_error(IllegalMoveException) 125 | end 126 | end 127 | 128 | describe 'capturing stones' do 129 | let(:captures) {game.captures} 130 | let(:identifier) {board.identifier_for(capturer[0], capturer[1])} 131 | let(:color) {capturer.last} 132 | 133 | 134 | before :each do 135 | game.set_valid_move identifier, color 136 | end 137 | 138 | describe 'simple star capture' do 139 | let(:board_string) do 140 | <<-BOARD 141 | . . . 142 | X O X 143 | . X . 144 | BOARD 145 | end 146 | let(:capturer) {[2, 1, :black]} 147 | 148 | it "removes the captured stone from the board" do 149 | expect(board_at(2,2)).to eq Board::EMPTY 150 | end 151 | 152 | it "the stone made one capture" do 153 | expect(game.captures[:black]).to eq 1 154 | end 155 | 156 | it_behaves_like "has liberties at position", 2, 1, 3 157 | it_behaves_like "has liberties at position", 1, 2, 3 158 | it_behaves_like "has liberties at position", 2, 3, 3 159 | it_behaves_like "has liberties at position", 3, 2, 3 160 | end 161 | 162 | describe 'turtle capture' do 163 | let(:board_string) do 164 | <<-BOARD 165 | . . . . . 166 | . O O . . 167 | O X X . . 168 | . O O O . 169 | . . . . . 170 | BOARD 171 | end 172 | let(:capturer) {[4, 3, :white]} 173 | 174 | it "removes the two stones from the board" do 175 | expect(board_at(2, 3)).to eq Board::EMPTY 176 | expect(board_at(3, 3)).to eq Board::EMPTY 177 | end 178 | 179 | it "the board looks cleared afterwards" do 180 | expect(board.to_s).to eq <<-BOARD 181 | . . . . . 182 | . O O . . 183 | O . . O . 184 | . O O O . 185 | . . . . . 186 | BOARD 187 | end 188 | 189 | it "has 2 captures" do 190 | expect(captures[:white]).to eq 2 191 | end 192 | 193 | it "black can move into that space again (left)" do 194 | force_next_move_to_be :black, game 195 | should_be_valid_move [2, 3, :black], game 196 | end 197 | 198 | it "black can move into that space again (right)" do 199 | force_next_move_to_be :black, game 200 | should_be_valid_move [3, 3, :black], game 201 | end 202 | 203 | it_behaves_like "has liberties at position", 1, 3, 3 204 | it_behaves_like "has liberties at position", 2, 2, 6 205 | it_behaves_like "has liberties at position", 4, 3, 9 206 | 207 | describe "black playing left in the space" do 208 | before :each do 209 | force_next_move_to_be :black, game 210 | game.play! 2, 3, :black 211 | end 212 | 213 | it_behaves_like "has liberties at position", 2, 3, 1 214 | it_behaves_like "has liberties at position", 1, 3, 2 215 | it_behaves_like "has liberties at position", 2, 2, 5 216 | it_behaves_like "has liberties at position", 2, 4, 8 217 | end 218 | 219 | describe "black playing right in the space" do 220 | before :each do 221 | force_next_move_to_be :black, game 222 | game.play! 3, 3, :black 223 | end 224 | 225 | it_behaves_like "has liberties at position", 3, 3, 1 226 | it_behaves_like "has liberties at position", 1, 3, 3 227 | it_behaves_like "has liberties at position", 2, 2, 5 228 | it_behaves_like "has liberties at position", 2, 4, 8 229 | end 230 | end 231 | 232 | describe 'capturing two distinct groups' do 233 | let(:board_string) do 234 | <<-BOARD 235 | . . . . . 236 | O O . O O 237 | X X . X X 238 | O O . O O 239 | . . . . . 240 | BOARD 241 | let(:capturer) {[3, 3, :white]} 242 | 243 | it "makes 4 captures" do 244 | expect(captures[:white]).to eq 4 245 | end 246 | 247 | it "removes the captured stones" do 248 | [board_at(1, 3), board_at(2, 3), 249 | board_at(4, 3), board_at(5, 3)].each do |field| 250 | expect(field).to eq Board::EMPTY 251 | end 252 | end 253 | 254 | it_behaves_like "has liberties at position", 1, 2, 5 255 | it_behaves_like "has liberties at position", 3, 2, 5 256 | it_behaves_like "has liberties at position", 3, 3, 4 257 | it_behaves_like "has liberties at position", 1, 4, 5 258 | it_behaves_like "has liberties at position", 3, 4, 5 259 | 260 | end 261 | end 262 | end 263 | 264 | describe 'Playing moves on a board (old board move integration)' do 265 | let(:game) {Game.new board_size} 266 | let(:board) {game.board} 267 | let(:board_size) {19} 268 | let(:simple_x) {1} 269 | let(:simple_y) {1} 270 | let(:simple_color) {:black} 271 | 272 | describe 'A simple move' do 273 | 274 | before :each do 275 | game.play! simple_x, simple_y, simple_color 276 | end 277 | 278 | it 'lets the board retrieve the move at that position' do 279 | expect(board_at(simple_x, simple_y)).to eq simple_color 280 | end 281 | 282 | it 'sets the move_count to 1' do 283 | expect(game.move_count).to eq 1 284 | end 285 | 286 | it 'should have played moves' do 287 | expect(game).not_to be_no_moves_played 288 | end 289 | 290 | it 'returns a truthy value' do 291 | legal_move = StoneFactory.build x: simple_x + 2, color: :white 292 | expect(game.play(*legal_move)).to eq(true) 293 | end 294 | 295 | it "can play a pass move" do 296 | pass = StoneFactory.pass(:white) 297 | expect(game.play *pass).to be true 298 | end 299 | end 300 | 301 | describe 'A couple of moves' do 302 | let(:moves) do 303 | [ StoneFactory.build(x: 3, y: 7, color: :black), 304 | StoneFactory.build(x: 5, y: 7, color: :white), 305 | StoneFactory.build(x: 3, y: 10, color: :black) 306 | ] 307 | end 308 | 309 | before :each do 310 | moves.each {|move| game.play *move} 311 | end 312 | 313 | it 'sets the move_count to the number of moves played' do 314 | expect(game.move_count).to eq moves.size 315 | end 316 | end 317 | 318 | describe 'Illegal moves' do 319 | it 'is illegal to play moves with a greater x than the board size' do 320 | illegal_move = StoneFactory.build(x: board_size + 1) 321 | expect(game.play(*illegal_move)).to eq(false) 322 | end 323 | 324 | it 'is illegal to play moves with a greater y than the board size' do 325 | illegal_move = StoneFactory.build(y: board_size + 1) 326 | expect(game.play(*illegal_move)).to eq(false) 327 | end 328 | end 329 | end 330 | end 331 | 332 | describe '#dup' do 333 | 334 | let(:dupped) {game.dup} 335 | let(:move1) {StoneFactory.build(x: 1, y:1, color: :black)} 336 | let(:move2) {StoneFactory.build x: 3, y:1, color: :white} 337 | let(:move3) {StoneFactory.build x: 5, y:1, color: :black} 338 | let(:board) {game.board} 339 | 340 | before :each do 341 | dupped.play! *move1 342 | dupped.play! *move2 343 | dupped.play! *move3 344 | end 345 | 346 | describe "empty game" do 347 | let(:game) {Game.new 5} 348 | 349 | it "does not change the board" do 350 | expect(board.to_s).to eq <<-BOARD 351 | . . . . . 352 | . . . . . 353 | . . . . . 354 | . . . . . 355 | . . . . . 356 | BOARD 357 | end 358 | 359 | it "has zero moves played" do 360 | expect(game.move_count).to eq 0 361 | end 362 | 363 | it "changes the board for the copy" do 364 | expect(dupped.board.to_s).to eq <<-BOARD 365 | X . O . X 366 | . . . . . 367 | . . . . . 368 | . . . . . 369 | . . . . . 370 | BOARD 371 | end 372 | 373 | it "has moves played for the copy" do 374 | expect(dupped.move_count).to eq 3 375 | end 376 | end 377 | 378 | describe "game with some moves" do 379 | let(:game) do 380 | Game.from board_string 381 | end 382 | let(:board_string) do 383 | <<-BOARD 384 | . . . . . 385 | O . X . X 386 | O . O . O 387 | . . . . . 388 | . . . . . 389 | BOARD 390 | end 391 | let(:group_tracker) {game.group_tracker} 392 | let(:dupped_tracker) {dupped.group_tracker} 393 | let(:identifier_5_2) {board.identifier_for(5, 2)} 394 | 395 | describe "not changing the original" do 396 | it "is still the same board" do 397 | expect(game.board.to_s).to eq board_string 398 | end 399 | 400 | it "still has the old move_count" do 401 | expect(game.move_count).to eq 6 402 | end 403 | 404 | it "does not modify the group of the stones" do 405 | expect(group_from(5, 2).stones.size).to eq 1 406 | end 407 | 408 | it "color at same position can be different" do 409 | expect(board_at(5,1)).not_to eq from_board_at(dupped.board, 5, 1) 410 | end 411 | 412 | it "the group points to the right liberties" do 413 | identifier_5_1 = board.identifier_for(5, 1) 414 | expect(group_from(5, 2).liberties.fetch(identifier_5_1)).to eq Board::EMPTY 415 | dupped_5_2_group = dupped_tracker.group_of(identifier_5_2) 416 | expect(dupped_5_2_group.liberties).not_to have_key(identifier_5_1) 417 | end 418 | 419 | it "does not register the new stones" do 420 | group = group_from(1, 2) 421 | expect(group.liberties.fetch(board.identifier_for(1, 1))).to eq Board::EMPTY 422 | expect(group.liberty_count).to eq 4 423 | end 424 | end 425 | 426 | describe "the dupped entity has the changes" do 427 | 428 | let(:group) {dupped_tracker.group_of(identifier_5_2)} 429 | 430 | it "has a move count of 9" do 431 | expect(dupped.move_count).to eq 9 432 | end 433 | 434 | it "has the new moves" do 435 | expect(dupped.board.to_s).to eq <<-BOARD 436 | X . O . X 437 | O . X . X 438 | O . O . O 439 | . . . . . 440 | . . . . . 441 | BOARD 442 | end 443 | 444 | it "handles groups" do 445 | expect(group.stones.size).to eq 2 446 | end 447 | 448 | it "has the right group liberties" do 449 | expect(group.liberties.fetch(board.identifier_for(4, 2))).to eq Board::EMPTY 450 | identifier = board.identifier_for(5, 3) 451 | group_id = dupped_tracker.group_id_of(identifier) 452 | expect(group.liberties[identifier]).to eq group_id 453 | end 454 | 455 | it "registers new stones" do 456 | group = dupped_tracker.group_of(board.identifier_for(1, 2)) 457 | identifier_1_1 = board.identifier_for(1, 1) 458 | expect(group.liberties.fetch(identifier_1_1)).to eq dupped_tracker.group_id_of(identifier_1_1) 459 | expect(group.liberty_count).to eq 3 460 | end 461 | end 462 | 463 | end 464 | end 465 | 466 | describe 'regressions' do 467 | describe 'weird missing liberties' do 468 | let(:game) {Game.new} 469 | let(:board) {game.board} 470 | let(:moves) do 471 | [[223, :black], [251, :white], [312, :black], [175, :white], [115, :black], [326, :white], [337, :black], [98, :white], [206, :black], [255, :white], [50, :black], [129, :white], [344, :black], [41, :white], [275, :black], [17, :white], [194, :black], [348, :white], [8, :black], [333, :white], [226, :black], [163, :white], [342, :black], [82, :white], [15, :black], [61, :white], [358, :black], [249, :white], [134, :black], [77, :white], [215, :black], [55, :white], [14, :black], [47, :white], [102, :black], [261, :white], [196, :black], [153, :white], [86, :black], [110, :white], [188, :black], [260, :white], [10, :black], [277, :white], [85, :black], [92, :white], [142, :black], [119, :white], [20, :black], [307, :white], [285, :black], [76, :white], [325, :black], [286, :white], [244, :black], [48, :white], [243, :black], [140, :white], [252, :black], [357, :white], [78, :black], [310, :white], [339, :black], [158, :white], [302, :black], [355, :white], [259, :black], [108, :white], [65, :black], [31, :white], [349, :black], [356, :white], [187, :black], [318, :white], [317, :black], [271, :white], [208, :black], [247, :white], [182, :black], [330, :white], [238, :black], [220, :white], [293, :black], [23, :white], [193, :black], [128, :white], [43, :black], [311, :white], [107, :black], [218, :white], [227, :black], [351, :white], [323, :black], [30, :white], [316, :black], [121, :white], [18, :black], [276, :white], [132, :black], [75, :white], [161, :black], [168, :white], [272, :black], [79, :white], [137, :black], [209, :white], [336, :black], [253, :white], [57, :black], [63, :white], [246, :black], [174, :white], [87, :black], [83, :white], [33, :black], [54, :white], [234, :black], [169, :white], [262, :black], [89, :white], [343, :black], [322, :white], [125, :black], [228, :white], [186, :black], [141, :white], [100, :black], [151, :white], [155, :black], [224, :white], [122, :black], [353, :white], [217, :black], [211, :white], [265, :black], [280, :white], [4, :black], [324, :white], [314, :black], [60, :white], [112, :black], [12, :white], [266, :black], [219, :white], [6, :black], [292, :white], [162, :black], [279, :white], [210, :black], [64, :white], [28, :black], [148, :white], [69, :black], [106, :white], [334, :black], [327, :white], [321, :black], [338, :white], [46, :black], [73, :white], [281, :black], [29, :white], [296, :black], [191, :white], [350, :black], [284, :white], [95, :black], [5, :white], [213, :black], [222, :white], [154, :black], [164, :white], [21, :black], [133, :white], [221, :black], [167, :white], [38, :black], [360, :white], [13, :black], [67, :white], [19, :black], [239, :white], [214, :black], [256, :white], [9, :black], [2, :white], [190, :black], [99, :white], [53, :black], [305, :white], [295, :black], [16, :white], [72, :black], [308, :white], [240, :black], [335, :white], [195, :black], [143, :white], [236, :black], [149, :white], [212, :black], [254, :white], [301, :black], [282, :white], [172, :black], [199, :white], [319, :black], [264, :white], [200, :black], [147, :white], [178, :black], [231, :white], [62, :black], [130, :white], [294, :black], [304, :white], [152, :black], [273, :white], [71, :black], [139, :white], [68, :black], [80, :white], [202, :black], [216, :white], [300, :black], [116, :white], [138, :black], [181, :white], [27, :black], [166, :white], [303, :black], [204, :white], [329, :black], [315, :white], [177, :black], [248, :white], [3, :black], [309, :white], [51, :black], [328, :white], [32, :black], [298, :white], [93, :black], [0, :white], [105, :black], [118, :white], [136, :black], [245, :white], [159, :black], [37, :white], [267, :black], [81, :white], [291, :black], [49, :white], [347, :black], [88, :white], [274, :black], [120, :white], [173, :black], [278, :white], [359, :black], [131, :white], [345, :black], [263, :white], [306, :black], [35, :white], [233, :black], [70, :white], [257, :black], [189, :white], [288, :black], [103, :white], [287, :black], [74, :white], [242, :black], [225, :white], [24, :black], [42, :white], [297, :black], [84, :white], [299, :black], [235, :white], [66, :black], [160, :white], [58, :black], [332, :white], [305, :black], [340, :white], [22, :black], [124, :white], [96, :black], [258, :white], [39, :black], [270, :white], [232, :black], [146, :white], [11, :black], [31, :white], [90, :black], [230, :white], [170, :black], [250, :white], [45, :black], [135, :white], [67, :black], [59, :white], [49, :black], [26, :white], [201, :black], [289, :white], [12, :black], [180, :white], [192, :black], [156, :white], [334, :black], [40, :white], [269, :black], [117, :white], [203, :black], [150, :white], [184, :black], [165, :white], [315, :black], [123, :white], [94, :black], [113, :white], [198, :black], [331, :white], [354, :black], [132, :white], [341, :black], [29, :white], [161, :black], [197, :white], [229, :black], [30, :white], [7, :black], [268, :white], [109, :black], [191, :white], [350, :black], [210, :white], [357, :black], [76, :white], [34, :black], [179, :white], [189, :black], [355, :white], [126, :black], [313, :white], [97, :black], [171, :white], [47, :black]] 472 | end 473 | 474 | before :each do 475 | moves.each do |identifier, color| 476 | game.play! *board.x_y_from(identifier), color 477 | end 478 | end 479 | 480 | it "does not allow the suicide move" do 481 | expect(MoveValidator.new.valid?(5, :white, game)).to be_falsey 482 | end 483 | 484 | it "has the right liberty)count for the neighboring group" do 485 | expect(game.group_tracker.group_of(4).liberty_count).to eq 3 486 | end 487 | 488 | end 489 | end 490 | 491 | end 492 | end 493 | -------------------------------------------------------------------------------- /spec/rubykon/game_state_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe GameState do 5 | let(:original_game) {GameState.new} 6 | let(:playouter) {MCTS::Playout.new original_game} 7 | 8 | describe "#play" do 9 | let!(:played_out) do 10 | playouter.play 11 | playouter.game_state 12 | end 13 | 14 | it "gets right scores" do 15 | result = played_out.score 16 | expect(result[:black]).to be > 0 17 | expect(result[:white]).to be > 0 18 | end 19 | 20 | 21 | it "sets a lot of moves... " do 22 | expect(played_out.game.move_count).to be >= 150 23 | end 24 | 25 | describe "not modifying the original" do 26 | it "makes no moves" do 27 | expect(original_game.game.no_moves_played?).to be_truthy 28 | end 29 | 30 | it "the associated board is empty" do 31 | board_empty = original_game.game.board.all? do |_, color| 32 | color == Board::EMPTY 33 | end 34 | expect(board_empty).to be_truthy 35 | end 36 | end 37 | end 38 | 39 | describe "full MCTS playout" do 40 | before :all do 41 | # this is rather expensive and no mutating operations are used 42 | # => before :all and instance variables for spec perf ++ 43 | @original_game = GameState.new Game.new(9) 44 | @root = MCTS::MCTS.new.start(@original_game, 100) 45 | end 46 | 47 | it "creates the right number of children" do 48 | expect(@root.children.size).to eq @original_game.game.board.cutting_point_count 49 | end 50 | 51 | it "has some kind of win_percentage" do 52 | expect(@root.win_percentage).to be_between(0, 1).exclusive 53 | end 54 | 55 | it "has 500 visits" do 56 | expect(@root.visits).to eq 100 57 | end 58 | 59 | it "can select the best move" do 60 | expect(@root.best_move).not_to be_nil 61 | end 62 | 63 | it "does not touch the original game" do 64 | expect(@original_game.game.move_count).to eq 0 65 | end 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/rubykon/group_tracker_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe GroupTracker do 5 | 6 | let(:overseer) {described_class.new} 7 | let(:group) do 8 | { 9 | id: 0, 10 | stones: [0] 11 | } 12 | end 13 | let(:other_group) {GroupTracker.new other_stone} 14 | 15 | describe '#assign (integration style)' do 16 | let(:game) {Game.from board_string} 17 | let(:board) {game.board} 18 | let(:group_tracker) {game.group_tracker} 19 | let(:group) {group_tracker.group_of(identifier)} 20 | let(:identifier) {board.identifier_for(*coords)} 21 | let(:other_group) do 22 | group_from(*other_coords) 23 | end 24 | let(:another_group) do 25 | group_from(*another_coords) 26 | end 27 | let(:coords) {[connector[0], connector[1]]} 28 | let(:color) {connector.last} 29 | 30 | def liberties_at(*identifiers) 31 | identifiers.inject({}) do |hash, string_notation| 32 | x, y = string_notation.split('-').map &:to_i 33 | identifier = board.identifier_for(x, y) 34 | hash[identifier] = Board::EMPTY 35 | hash 36 | end 37 | end 38 | 39 | describe 'group of a lonely stone' do 40 | let(:board_string) do 41 | <<-BOARD 42 | . . . 43 | . X . 44 | . . . 45 | BOARD 46 | end 47 | 48 | let(:coords) {[2, 2]} 49 | 50 | it "has 4 liberties" do 51 | expect(group.liberty_count).to eq 4 52 | end 53 | 54 | it "has the stone" do 55 | expect(group.stones).to contain_exactly(identifier) 56 | end 57 | 58 | it "correctly references those liberties" do 59 | expect(group.liberties).to eq liberties_at '2-1', '1-2', '3-2', '2-3' 60 | end 61 | end 62 | 63 | describe 'group of a lonely stone on the edge' do 64 | let(:board_string) do 65 | <<-BOARD 66 | . X . 67 | . . . 68 | . . . 69 | BOARD 70 | end 71 | let(:coords) {[2, 1]} 72 | 73 | it "has 4 liberties" do 74 | expect(group.liberty_count).to eq 3 75 | end 76 | 77 | it "correctly references those liberties" do 78 | expect(group.liberties).to eq liberties_at '1-1', '2-2', '3-1' 79 | end 80 | end 81 | 82 | describe 'group of a lonely stone in the corner' do 83 | let(:board_string) do 84 | <<-BOARD 85 | X . . 86 | . . . 87 | . . . 88 | BOARD 89 | end 90 | 91 | let(:coords) {[1, 1]} 92 | 93 | 94 | it "has 4 liberties" do 95 | expect(group.liberty_count).to eq 2 96 | end 97 | 98 | it "correctly references those liberties" do 99 | expect(group.liberties).to eq liberties_at '2-1', '1-2' 100 | end 101 | end 102 | 103 | describe 'group of two' do 104 | let(:board_string) do 105 | <<-BOARD 106 | . . . . 107 | . X X . 108 | . . . . 109 | . . . . 110 | BOARD 111 | end 112 | let(:coords) {[2, 2]} 113 | 114 | it "has 4 liberties" do 115 | expect(group.liberty_count).to eq 6 116 | end 117 | 118 | it "correctly references those liberties" do 119 | expect(group.liberties).to eq liberties_at '1-2', '2-1', '3-1', 120 | '4-2', '2-3', '3-3' 121 | end 122 | end 123 | 124 | describe 'connecting through a connect move' do 125 | 126 | before :each do 127 | game.set_valid_move identifier, color 128 | end 129 | 130 | describe 'merging two groups with multiple stones' do 131 | let(:board_string) do 132 | <<-BOARD 133 | . . . . . 134 | . . . . . 135 | X X . X X 136 | . . . . . 137 | . . . . . 138 | BOARD 139 | end 140 | 141 | let(:connector) {[3, 3, :black]} 142 | let(:all_stone_coords) do 143 | [[1, 3], [2, 3], [3, 3], [4, 3], [5, 3]] 144 | end 145 | 146 | let(:all_stones) do 147 | all_stone_coords.map do |x, y| 148 | board.identifier_for(x, y) 149 | end 150 | end 151 | 152 | let(:all_stone_group_ids) do 153 | all_stones.map do |identifier| 154 | group_tracker.group_id_of(identifier) 155 | end 156 | end 157 | 158 | it "has 10 liberties" do 159 | expect(group.liberty_count).to eq 10 160 | end 161 | 162 | it "all stones belong to the same group" do 163 | all_stone_group_ids.each do |group_id| 164 | expect(group_id).to eq group.identifier 165 | end 166 | end 167 | 168 | it "group knows all the stones" do 169 | expect(group.stones).to match_array all_stones 170 | end 171 | 172 | it "does not think that the connector is a liberty" do 173 | expect(group.liberties).not_to have_key(identifier) 174 | end 175 | end 176 | 177 | describe 'connecting groups with a shared liberty' do 178 | let(:board_string) do 179 | <<-BOARD 180 | . . . . 181 | . . X . 182 | . X . . 183 | . . . . 184 | BOARD 185 | end 186 | 187 | let(:connector) {[3, 3, :black]} 188 | 189 | it 'has 7 liberties' do 190 | expect(group.liberty_count).to eq 7 191 | end 192 | 193 | it "does not think that the connector is a liberty" do 194 | expect(group.liberties).not_to have_key(identifier) 195 | end 196 | end 197 | 198 | describe 'group with multiple shared liberties' do 199 | let(:board_string) do 200 | <<-BOARD 201 | . . . . . 202 | X X X X X 203 | . . . . . 204 | X X X X X 205 | . . . . . 206 | BOARD 207 | end 208 | let(:connector) {[3, 3, :black]} 209 | let(:other_coords) {[1, 4]} 210 | 211 | it "has 14 liberties" do 212 | expect(group.liberty_count).to eq 14 213 | end 214 | 215 | it "reports the right group for connected stones" do 216 | expect(other_group).to eq group 217 | end 218 | end 219 | 220 | describe "joining stones of the same group" do 221 | let(:board_string) do 222 | <<-BOARD 223 | . . . . . 224 | X X X X X 225 | X . . . . 226 | X X X X X 227 | . . . . . 228 | BOARD 229 | end 230 | let(:connector) {[5,3, :black]} 231 | 232 | it "has the right liberty count of 13" do 233 | expect(group.liberty_count).to eq 13 234 | end 235 | end 236 | 237 | end 238 | 239 | describe "taking away liberties" do 240 | describe "simple taking away" do 241 | let(:board_string) do 242 | <<-BOARD 243 | . . . 244 | . X . 245 | . O . 246 | BOARD 247 | end 248 | let(:other_coords) {[2, 2]} 249 | let(:another_coords) {[2, 3]} 250 | 251 | it "gives the black stone 3 liberties" do 252 | expect(other_group.liberty_count).to eq 3 253 | end 254 | 255 | it "gives the white stone two liberties" do 256 | expect(another_group.liberty_count).to eq 2 257 | end 258 | end 259 | 260 | describe "before capture" do 261 | let(:board_string) do 262 | <<-BOARD 263 | . . . 264 | O X O 265 | . O . 266 | BOARD 267 | end 268 | 269 | let(:white_stone_coords) {[[1, 2], [3, 2], [2, 3]]} 270 | let(:white_stone_groups) do 271 | white_stone_coords.map {|x, y| group_from(x, y)} 272 | end 273 | let(:other_coords) {[2, 2]} 274 | 275 | it "leaves the black stone just one liberty" do 276 | expect(other_group.liberty_count).to eq 1 277 | end 278 | 279 | it "the white stones have all different groups" do 280 | expect(white_stone_groups.uniq.size).to eq 3 281 | end 282 | 283 | it "the white stones all have 2 liberties" do 284 | white_stone_groups.each do |group| 285 | expect(group.liberty_count).to eq 2 286 | end 287 | end 288 | end 289 | 290 | describe "the tricky shared liberty situation" do 291 | let(:board_string) do 292 | <<-BOARD 293 | . . . . . 294 | X X X X X 295 | . O . . . 296 | X X X X X 297 | . . . . . 298 | BOARD 299 | end 300 | 301 | let(:connector) {[3, 3, :black]} 302 | let(:other_coords) {[2, 3]} 303 | let(:another_coords) {[3, 3]} 304 | 305 | before :each do 306 | game.set_valid_move identifier, color 307 | end 308 | 309 | it "the white group has just one liberty" do 310 | expect(other_group.liberty_count).to eq 1 311 | end 312 | 313 | it "the black group has 13 liberties" do 314 | expect(another_group.liberty_count).to eq 13 315 | end 316 | end 317 | 318 | describe "taking a liberty from a shared liberty group" do 319 | let(:board_string) do 320 | <<-BOARD 321 | . . . . . 322 | X X X X X 323 | . . X . . 324 | X X X X X 325 | . . . . . 326 | BOARD 327 | end 328 | 329 | let(:taker) {[2, 3, :white]} 330 | let(:taker_group) {group_from taker[0], taker[1]} 331 | let(:other_coords) {[3, 3]} 332 | before :each do 333 | game.set_valid_move board.identifier_for(taker[0], taker[1]), taker[2] 334 | end 335 | 336 | it "the white group has just one liberty" do 337 | expect(taker_group.liberty_count).to eq 1 338 | end 339 | 340 | it "the black group has 13 liberties" do 341 | expect(other_group.liberty_count).to eq 13 342 | end 343 | end 344 | end 345 | 346 | describe "captures" do 347 | describe "multiple stones next to the capturing stone" do 348 | let(:board_string) do 349 | <<-BOARD 350 | . X X . . 351 | X O O X . 352 | . . O X . 353 | . . X . . 354 | BOARD 355 | end 356 | 357 | before :each do 358 | game.set_valid_move board.identifier_for(2, 3), :black 359 | end 360 | 361 | it "clears the caught stones" do 362 | expect(board_at(2, 2)).to eq Board::EMPTY 363 | expect(board_at(3, 2)).to eq Board::EMPTY 364 | expect(board_at(3, 3)).to eq Board::EMPTY 365 | end 366 | 367 | it "gives surrounding stones their liberties back" do 368 | expect(group_from(2, 1).liberty_count).to eq 4 369 | expect(group_from(2, 3).liberty_count).to eq 4 370 | end 371 | 372 | it "has liberties where the enemy stones used to be" do 373 | liberties = group_from(2, 3).liberties 374 | expect(liberties.size).to eq 4 375 | expect(liberties.fetch(board.identifier_for(2, 2))).to eq Board::EMPTY 376 | expect(liberties.fetch(board.identifier_for(3, 3))).to eq Board::EMPTY 377 | 378 | end 379 | 380 | it "increases the captures count" do 381 | expect(game.captures[:black]).to eq 3 382 | end 383 | end 384 | 385 | describe "capturing 2 birds with one stone" do 386 | let(:board_string) do 387 | <<-BOARD 388 | . . . . . 389 | X X . X X 390 | O O . O O 391 | X X . X X 392 | . . . . . 393 | BOARD 394 | end 395 | 396 | before :each do 397 | game.set_valid_move board.identifier_for(3, 3), :black 398 | end 399 | 400 | it "clears the caught stones" do 401 | expect(board_at(1, 3)).to eq Board::EMPTY 402 | expect(board_at(2, 3)).to eq Board::EMPTY 403 | expect(board_at(4, 3)).to eq Board::EMPTY 404 | expect(board_at(5, 3)).to eq Board::EMPTY 405 | end 406 | 407 | it "gives surrounding stones their liberties back" do 408 | expect(group_from(2, 2).liberty_count).to eq 5 409 | expect(group_from(3, 3).liberty_count).to eq 4 410 | expect(group_from(4, 4).liberty_count).to eq 5 411 | end 412 | 413 | it "has liberties where the enemy stones used to be" do 414 | liberties = group_from(3, 3).liberties 415 | expect(liberties.size).to eq 4 416 | expect(liberties.fetch(board.identifier_for(2, 3))).to eq Board::EMPTY 417 | expect(liberties.fetch(board.identifier_for(4, 3))).to eq Board::EMPTY 418 | 419 | end 420 | 421 | it "increases the captures count" do 422 | expect(game.captures[:black]).to eq 4 423 | end 424 | end 425 | 426 | describe "capturing a stone after the assigned group of a neighbor changed" do 427 | let(:board_string) do 428 | <<-BOARD 429 | X . X O 430 | . . . . 431 | . . . . 432 | . . . . 433 | BOARD 434 | end 435 | 436 | let(:group) {group_from(1, 1)} 437 | 438 | before :each do 439 | game.set_valid_move(board.identifier_for(2, 1), :black) #connects 440 | game.set_valid_move(board.identifier_for(4, 2), :black) #captures 441 | end 442 | 443 | it "group has 4 liberties" do 444 | expect(group.liberty_count).to eq 4 445 | end 446 | 447 | it "group has a liberty where the white stone used to be" do 448 | expect(group.liberties.fetch(board.identifier_for(4, 1))).to eq Board::EMPTY 449 | end 450 | 451 | end 452 | 453 | end 454 | 455 | describe "huge integration examples as well as regressions" do 456 | describe "integration" do 457 | let(:board_string) do 458 | <<-BOARD 459 | X O O O O O O O O X . . . . . . . X . 460 | . . X X X X X X X . . . . . . . . O O 461 | . . . . . . . . . . X . . . . . O X X 462 | . . . . . . . . O O O O O . . . O X . 463 | . . . . . . . X O X . X O X . . O X X 464 | . . . . . . . . O X X X O X . . O X . 465 | . . . . . . . . O O O O O . . . O X X 466 | . . . . . . . . . . . . . . . . . O O 467 | . . . . . . . . . . . . . . . . . . . 468 | . . . X . . . . . . . . . . . O . . . 469 | . . . . . . . . . . . . . . . X O . . 470 | . . . . . . . . . . . . . . . X O . . 471 | . . . . . . . . . . . . . . . O . . . 472 | . . X . . . . . . . . . . . . . . . . 473 | . X . . . . . . . . . . . . . . O . . 474 | . O X X . . . . . . . . . . . X . O . 475 | . O O O X X X . . X . . . X . . X O . 476 | . . . . O O . . . . . . . . . . . X . 477 | . . . . . . . . . . . . . . . . . . . 478 | BOARD 479 | end 480 | 481 | it_behaves_like "has liberties at position", 1, 1, 1 482 | it_behaves_like "has liberties at position", 2, 1, 1 483 | it_behaves_like "has liberties at position", 18, 1, 2 484 | it_behaves_like "has liberties at position", 10, 1, 2 485 | it_behaves_like "has liberties at position", 3, 2, 9 486 | it_behaves_like "has liberties at position", 19, 2, 2 487 | it_behaves_like "has liberties at position", 11, 3, 3 488 | it_behaves_like "has liberties at position", 19, 3, 2 489 | it_behaves_like "has liberties at position", 9, 4, 15 490 | it_behaves_like "has liberties at position", 10, 5, 1 491 | it_behaves_like "has liberties at position", 14, 5, 4 492 | it_behaves_like "has liberties at position", 16, 11, 2 493 | it_behaves_like "has liberties at position", 17, 11, 4 494 | it_behaves_like "has liberties at position", 3, 14, 4 495 | it_behaves_like "has liberties at position", 2, 15, 3 496 | it_behaves_like "has liberties at position", 2, 16, 5 497 | it_behaves_like "has liberties at position", 3, 16, 3 498 | it_behaves_like "has liberties at position", 5, 17, 5 499 | it_behaves_like "has liberties at position", 17, 17, 3 500 | it_behaves_like "has liberties at position", 18, 17, 4 501 | end 502 | end 503 | end 504 | end 505 | end 506 | -------------------------------------------------------------------------------- /spec/rubykon/gtp_coordinate_converter_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | RSpec.describe GTPCoordinateConverter do 5 | 6 | subject {described_class.new(board)} 7 | let(:board) {Board.new size} 8 | 9 | shared_examples_for 'converting' do |gtp_string, index| 10 | describe '#from' do 11 | it "converts from #{gtp_string} to #{index}" do 12 | expect(subject.from(gtp_string)).to eq index 13 | end 14 | end 15 | 16 | describe '#to' do 17 | it "converts from #{index} to #{gtp_string}" do 18 | expect(subject.to(index)).to eq gtp_string 19 | end 20 | end 21 | end 22 | 23 | context '19x19' do 24 | let(:size) {19} 25 | 26 | it_behaves_like 'converting', 'A19', 0 27 | it_behaves_like 'converting', 'T1', 360 28 | it_behaves_like 'converting', 'D12', 136 29 | end 30 | 31 | context '9x9' do 32 | let(:size) {9} 33 | 34 | it_behaves_like 'converting', 'A9', 0 35 | it_behaves_like 'converting', 'J1', 80 36 | it_behaves_like 'converting', 'D7', 21 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/rubykon/help/fake_io.rb: -------------------------------------------------------------------------------- 1 | # adapted from: 2 | # http://dalibornasevic.com/posts/39-simple-way-to-test-io-in-ruby 3 | class FakeIO 4 | 5 | attr_reader :input, :output 6 | 7 | def initialize(input = []) 8 | @input = input 9 | @output = "" 10 | end 11 | 12 | def gets 13 | @input.shift.to_s 14 | end 15 | 16 | def print(string = '') 17 | @output << string 18 | nil 19 | end 20 | 21 | alias_method :write, :print 22 | 23 | def puts(string = '') 24 | print "#{string}\n" 25 | end 26 | 27 | def match(regex_or_so) 28 | @output.match regex_or_so 29 | end 30 | 31 | def self.each_input(input) 32 | fake_io = new(input) 33 | $stdin = fake_io 34 | $stdout = fake_io 35 | 36 | yield 37 | 38 | fake_io.output 39 | rescue SystemExit 40 | # it's cool to exit, it's what we want to do at some point. 41 | fake_io.output 42 | 43 | ensure 44 | $stdin = STDIN 45 | $stdout = STDOUT 46 | 47 | end 48 | end -------------------------------------------------------------------------------- /spec/rubykon/help/group.rb: -------------------------------------------------------------------------------- 1 | def group_from(x, y) 2 | group_tracker.group_of(board.identifier_for(x, y)) 3 | end 4 | 5 | def board_at(x, y) 6 | from_board_at(board, x, y) 7 | end 8 | 9 | def from_board_at(board, x, y) 10 | board[board.identifier_for(x, y)] 11 | end 12 | 13 | def force_next_move_to_be(color, game) 14 | return if game.next_turn_color == color 15 | game.set_valid_move nil, Rubykon::Game.other_color(color) 16 | end 17 | 18 | def should_be_invalid_move(move, game) 19 | move_validate_should_return(false, move, game) 20 | end 21 | 22 | def should_be_valid_move(move, game) 23 | move_validate_should_return(true, move, game) 24 | end 25 | 26 | def move_validate_should_return(bool, move, game) 27 | identifier = game.board.identifier_for(move[0], move[1]) 28 | color = move[2] 29 | expect(validator.valid?(identifier, color, game)).to be bool 30 | end -------------------------------------------------------------------------------- /spec/rubykon/help/liberty_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "has liberties at position" do |x, y, expected| 2 | it "the group at #{x}-#{y} has #{expected} liberties" do 3 | identifier = board.identifier_for(x, y) 4 | expect(group_tracker.liberty_count_at(identifier)).to eq expected 5 | end 6 | end -------------------------------------------------------------------------------- /spec/rubykon/help/scoring.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "correctly scored" do |expected_score| 2 | it "gets the right score for black" do 3 | 4 | expect(black_score).to eq expected_score[:black] 5 | end 6 | 7 | it "gets the right score for white" do 8 | expect(white_score).to eq (expected_score[:white]) 9 | end 10 | end -------------------------------------------------------------------------------- /spec/rubykon/help/stone_factory.rb: -------------------------------------------------------------------------------- 1 | # A simple factory generating valid moves for board sizes starting 9 2 | module Rubykon 3 | module StoneFactory 4 | extend self 5 | 6 | DEFAULT_X = 5 7 | DEFAULT_Y = 9 8 | DEFAULT_COLOR = :black 9 | 10 | def build(options = {}) 11 | x = options.fetch(:x, DEFAULT_X) 12 | y = options.fetch(:y, DEFAULT_Y) 13 | color = options.fetch(:color, DEFAULT_COLOR) 14 | [x, y, color] 15 | end 16 | 17 | def pass(color = DEFAULT_COLOR) 18 | build x: nil, y: nil, color: color 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/rubykon/move_validator_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | module Rubykon 4 | describe MoveValidator do 5 | 6 | let(:validator) {MoveValidator.new} 7 | let(:board_size) {19} 8 | let(:game) {Game.new board_size} 9 | let(:baord) {game.board} 10 | 11 | it 'can be created' do 12 | expect(validator).not_to be_nil 13 | end 14 | 15 | describe 'legal moves' do 16 | it 'is accepts normal moves' do 17 | should_be_valid_move StoneFactory.build, game 18 | end 19 | 20 | it 'accepts 1-1' do 21 | should_be_valid_move (StoneFactory.build x: 1, y: 1), game 22 | end 23 | 24 | it 'accepts the move in the top right corner (19-19)' do 25 | should_be_valid_move StoneFactory.build(x: board_size, 26 | y: board_size), 27 | game 28 | end 29 | 30 | it 'accepts a different color after the first move was played' do 31 | game.play! *StoneFactory.build(color: :black, x: 1, y: 1) 32 | should_be_valid_move (StoneFactory.build color: :white), game 33 | end 34 | 35 | it 'also works correctly with bigger boards' do 36 | game = Game.new 37 37 | should_be_valid_move (StoneFactory.build x: 37, y: 37), game 38 | end 39 | 40 | it "allows for pass moves" do 41 | should_be_valid_move StoneFactory.pass, game 42 | end 43 | end 44 | 45 | describe 'Moves illegal of their own' do 46 | it 'is illegal with negative x and y' do 47 | move = StoneFactory.build x: -3, y: -4 48 | should_be_invalid_move move, game 49 | end 50 | 51 | it 'is illegal with negative x' do 52 | move = StoneFactory.build x: -1 53 | should_be_invalid_move move, game 54 | end 55 | 56 | it 'is illegal with negative y' do 57 | move = StoneFactory.build y: -1 58 | should_be_invalid_move move, game 59 | end 60 | 61 | it 'is illegal with x set to 0' do 62 | move = StoneFactory.build x: 0 63 | should_be_invalid_move move, game 64 | end 65 | 66 | it 'is illegal with y set to 0' do 67 | move = StoneFactory.build y: 0 68 | should_be_invalid_move move, game 69 | end 70 | end 71 | 72 | describe 'Moves illegal in the context of a board' do 73 | it 'is illegal with x bigger than the board size' do 74 | move = StoneFactory.build x: board_size + 1 75 | should_be_invalid_move move, game 76 | end 77 | 78 | it 'is illegal with y bigger than the board size' do 79 | move = StoneFactory.build y: board_size + 1 80 | should_be_invalid_move move, game 81 | end 82 | 83 | it 'is illegal to set a stone at a position already occupied by a stone' do 84 | move = StoneFactory.build x: 1, y: 1 85 | game.play *move 86 | should_be_invalid_move move, game 87 | end 88 | 89 | it 'also works for other board sizes' do 90 | game = Game.new 5 91 | should_be_invalid_move (StoneFactory.build x: 6), game 92 | end 93 | end 94 | 95 | describe 'suicide moves' do 96 | it "is forbidden" do 97 | game = Game.from <<-BOARD 98 | . X . 99 | X . X 100 | . X . 101 | BOARD 102 | force_next_move_to_be :white, game 103 | should_be_invalid_move [2, 2, :white], game 104 | end 105 | 106 | it "is forbidden in the corner as well" do 107 | game = Game.from <<-BOARD 108 | . X . 109 | X . . 110 | . . . 111 | BOARD 112 | force_next_move_to_be :white, game 113 | should_be_invalid_move [1, 1, :white], game 114 | end 115 | 116 | it "is forbidden when it robs a friendly group of its last liberty" do 117 | game = Game.from <<-BOARD 118 | O X . . 119 | O X . . 120 | O X . . 121 | . X . . 122 | BOARD 123 | force_next_move_to_be :white, game 124 | should_be_invalid_move [1, 4, :white], game 125 | end 126 | 127 | it "is valid if the group still has liberties with the move" do 128 | game = Game.from <<-BOARD 129 | O X . . 130 | O X . . 131 | O X . . 132 | . . . . 133 | BOARD 134 | force_next_move_to_be :white, game 135 | should_be_valid_move [1, 4, :white], game 136 | end 137 | 138 | it "is valid if it captures the group" do 139 | game = Game.from <<-BOARD 140 | O X O . 141 | O X O . 142 | O X O . 143 | . X O . 144 | BOARD 145 | force_next_move_to_be :white, game 146 | should_be_valid_move [1, 4, :white], game 147 | end 148 | 149 | it "is allowed when it captures a stone first (e.g. no suicide)" do 150 | game = Game.from <<-BOARD 151 | . . . . 152 | . X O . 153 | X . X O 154 | . X O . 155 | BOARD 156 | force_next_move_to_be :white, game 157 | should_be_valid_move [2, 3, :white], game 158 | end 159 | end 160 | 161 | describe 'KO' do 162 | 163 | let(:game) {Game.from board_string} 164 | 165 | let(:board_string) do 166 | <<-BOARD 167 | . X O . 168 | X . X O 169 | . X O . 170 | . . . . 171 | BOARD 172 | end 173 | let(:white_ko_capture) {StoneFactory.build x: 2, y: 2, color: :white} 174 | let(:black_ko_capture) {StoneFactory.build x: 3, y: 2, color: :black} 175 | let(:black_tenuki) {StoneFactory.build x: 1, y: 4, color: :black} 176 | let(:white_closes) {StoneFactory.build x: 3, y: 2, color: :white} 177 | let(:white_tenuki) {StoneFactory.build x: 2, y: 4, color: :white} 178 | 179 | before :each do 180 | force_next_move_to_be :white, game 181 | end 182 | 183 | it 'is a valid move for white at 2-2' do 184 | should_be_valid_move white_ko_capture, game 185 | end 186 | 187 | describe "white caputres ko" do 188 | 189 | before :each do 190 | game.play! *white_ko_capture 191 | end 192 | 193 | it 'is an invalid move to catch back for black' do 194 | should_be_invalid_move black_ko_capture, game 195 | end 196 | 197 | it "black can tenuki" do 198 | should_be_valid_move black_tenuki, game 199 | end 200 | 201 | describe "black tenuki" do 202 | 203 | before :each do 204 | game.play! *black_tenuki 205 | end 206 | 207 | it "white can close the ko" do 208 | should_be_valid_move white_closes, game 209 | end 210 | 211 | it "white can tenuki" do 212 | should_be_valid_move white_tenuki, game 213 | end 214 | 215 | describe "white tenuki" do 216 | before :each do 217 | game.play! *white_tenuki 218 | end 219 | 220 | it "black can capture" do 221 | should_be_valid_move black_ko_capture, game 222 | end 223 | end 224 | end 225 | end 226 | 227 | end 228 | 229 | describe "double move" do 230 | it "is not valid for the same color to move two times" do 231 | move_1 = StoneFactory.build x: 2, y: 2, color: :black 232 | move_2 = StoneFactory.build x: 1, y: 1, color: :black 233 | game.play! *move_1 234 | should_be_invalid_move move_2, game 235 | end 236 | end 237 | 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /spec/rubykon/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | 3 | require_relative '../../lib/rubykon' 4 | require_relative 'help/stone_factory' 5 | require_relative 'help/liberty_examples' 6 | require_relative 'help/scoring' 7 | require_relative 'help/group' 8 | require_relative 'help/fake_io' 9 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PragTob/rubykon/62b62965241fe4f83db305390db2500c7271bab3/spec/spec_helper.rb --------------------------------------------------------------------------------