├── .yardopts ├── spec ├── fixtures │ └── probs_fair_die_6.dat ├── dice_spec.rb ├── map_rule_spec.rb ├── reroll_rule_spec.rb ├── helpers.rb ├── die_spec.rb ├── parser_spec.rb ├── die_result_spec.rb ├── complex_die_spec.rb ├── readme_spec.rb ├── bunch_spec.rb └── probability_spec.rb ├── Gemfile ├── lib ├── games_dice │ ├── version.rb │ ├── constants.rb │ ├── marshal.rb │ ├── bunch_helpers.rb │ ├── map_rule.rb │ ├── die.rb │ ├── reroll_rule.rb │ ├── dice.rb │ ├── complex_die.rb │ ├── die_result.rb │ ├── bunch.rb │ ├── complex_die_helpers.rb │ └── parser.rb └── games_dice.rb ├── .travis.yml ├── ext └── games_dice │ ├── extconf.rb │ ├── games_dice.c │ ├── probabilities.h │ └── probabilities.c ├── .gitignore ├── .rubocop.yml ├── Rakefile ├── LICENSE.txt ├── games_dice.gemspec ├── CHANGELOG.md └── README.md /.yardopts: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | ext/**/*.c 3 | --no-private 4 | - 5 | README.md 6 | LICENSE.txt 7 | CHANGELOG.md 8 | -------------------------------------------------------------------------------- /spec/fixtures/probs_fair_die_6.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilslater/games_dice/HEAD/spec/fixtures/probs_fair_die_6.dat -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Dependencies are in games_dice.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/games_dice/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # Current version of the gem. 5 | VERSION = '0.4.1' 6 | end 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | before_install: 4 | - gem install bundler 5 | 6 | os: 7 | - linux 8 | 9 | rvm: 10 | - "2.7.1" 11 | - "3.0.2" 12 | -------------------------------------------------------------------------------- /ext/games_dice/extconf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # ext/games_dice/extconf.rb 4 | 5 | require 'mkmf' 6 | 7 | create_makefile('games_dice/games_dice') 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | *.bundle 5 | .config 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6.0 3 | NewCops: enable 4 | 5 | Layout/LineLength: 6 | Max: 120 7 | 8 | Metrics/BlockLength: 9 | IgnoredMethods: 10 | - context 11 | - describe 12 | - shared_examples 13 | - define 14 | 15 | 16 | -------------------------------------------------------------------------------- /ext/games_dice/games_dice.c: -------------------------------------------------------------------------------- 1 | // ext/games_dice/games_dice.c 2 | 3 | #include 4 | #include "probabilities.h" 5 | 6 | // To hold the module object 7 | VALUE GamesDice = Qnil; 8 | 9 | void Init_games_dice() { 10 | GamesDice = rb_define_module("GamesDice"); 11 | init_probabilities_class(); 12 | } 13 | -------------------------------------------------------------------------------- /lib/games_dice/constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # Reasons for making a reroll, and text explanation symbols for them 5 | REROLL_TYPES = { 6 | basic: ',', 7 | reroll_add: '+', 8 | reroll_subtract: '-', 9 | reroll_replace: '|', 10 | reroll_use_best: '/', 11 | reroll_use_worst: '\\' 12 | # These are not yet implemented: 13 | # :reroll_new_die => '*', 14 | # :reroll_new_keeper => '*', 15 | }.freeze 16 | end 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rake/extensiontask' 6 | require 'yard' 7 | 8 | def can_compile_extensions 9 | return false if RUBY_DESCRIPTION =~ /jruby/ 10 | 11 | true 12 | end 13 | 14 | desc 'GamesDice unit tests' 15 | RSpec::Core::RakeTask.new(:test) do |t| 16 | t.pattern = 'spec/*_spec.rb' 17 | t.verbose = false 18 | end 19 | 20 | YARD::Rake::YardocTask.new do |t| 21 | t.files = ['lib/**/*.rb'] 22 | end 23 | 24 | gemspec = Gem::Specification.load('games_dice.gemspec') 25 | Rake::ExtensionTask.new do |ext| 26 | ext.name = 'games_dice' 27 | ext.source_pattern = '*.{c,h}' 28 | ext.ext_dir = 'ext/games_dice' 29 | ext.lib_dir = 'lib/games_dice' 30 | ext.gem_spec = gemspec 31 | end 32 | 33 | task :delete_compiled_ext do |_t| 34 | `rm lib/games_dice/games_dice.*` 35 | end 36 | 37 | task pure_test: %i[delete_compiled_ext test] 38 | 39 | if can_compile_extensions 40 | task default: %i[compile test] 41 | else 42 | task default: [:test] 43 | end 44 | -------------------------------------------------------------------------------- /spec/dice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::Dice do 6 | describe 'dice scheme' do 7 | before :each do 8 | srand(67_809) 9 | end 10 | 11 | describe '1d10+2' do 12 | let(:dice) { GamesDice::Dice.new([{ sides: 10, ndice: 1 }], 2) } 13 | 14 | it 'should simulate rolling a ten-sided die, and adding two to each result' do 15 | [5, 4, 10, 10, 7, 5, 9].each do |expected_total| 16 | expect(dice.roll).to eql expected_total 17 | expect(dice.result).to eql expected_total 18 | end 19 | end 20 | end 21 | 22 | describe '2d6+6' do 23 | let(:dice) { GamesDice::Dice.new([{ sides: 6, ndice: 2 }], 6) } 24 | 25 | it 'should simulate rolling two six-sided dice and adding six to the result' do 26 | [15, 12, 17, 15, 13, 13, 16].each do |expected_total| 27 | expect(dice.roll).to eql expected_total 28 | expect(dice.result).to eql expected_total 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Neil Slater 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/games_dice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'games_dice/version' 4 | require 'games_dice/constants' 5 | require 'games_dice/die' 6 | require 'games_dice/die_result' 7 | require 'games_dice/reroll_rule' 8 | require 'games_dice/map_rule' 9 | require 'games_dice/complex_die' 10 | require 'games_dice/bunch' 11 | require 'games_dice/dice' 12 | require 'games_dice/parser' 13 | require 'games_dice/games_dice' 14 | require 'games_dice/marshal' 15 | 16 | # GamesDice is a library for simulating dice combinations used in dice and board games. 17 | module GamesDice 18 | # Creates an instance of GamesDice::Dice from a string description. 19 | # @param [String] dice_description Uses a variation of common game notation, examples: '1d6', '3d8+1d4+7', '5d10k2' 20 | # @param [#rand] prng Optional random number generator, default is to use Ruby's built-in #rand() 21 | # @return [GamesDice::Dice] A new dice object. 22 | # 23 | def self.create(dice_description, prng = nil) 24 | parsed = parser.parse(dice_description) 25 | parsed[:bunches].each { |bunch| bunch.merge!(prng: prng) } if prng 26 | GamesDice::Dice.new(parsed[:bunches], parsed[:offset]) 27 | end 28 | 29 | def self.parser 30 | @parser ||= GamesDice::Parser.new 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/games_dice/marshal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # This class models probability distributions for dice systems. 5 | # 6 | # An object of this class represents a single distribution, which might be the result of a complex 7 | # combination of dice. 8 | # 9 | # @example Distribution for a six-sided die 10 | # probs = GamesDice::Probabilities.for_fair_die( 6 ) 11 | # probs.min # => 1 12 | # probs.max # => 6 13 | # probs.expected # => 3.5 14 | # probs.p_ge( 4 ) # => 0.5 15 | # 16 | # @example Adding two distributions 17 | # pd6 = GamesDice::Probabilities.for_fair_die( 6 ) 18 | # probs = GamesDice::Probabilities.add_distributions( pd6, pd6 ) 19 | # probs.min # => 2 20 | # probs.max # => 12 21 | # probs.expected # => 7.0 22 | # probs.p_ge( 10 ) # => 0.16666666666666669 23 | # 24 | class Probabilities 25 | # @!visibility private 26 | # Adds support for Marshal, via to_h and from_h methods 27 | def marshal_dump 28 | to_h 29 | end 30 | 31 | # @!visibility private 32 | def self._load(buf) 33 | # Use of Marshal for general-purpose object serialisation is discouraged. However, this class does support 34 | # it for backwards-compatibility. 35 | # rubocop:disable Security/MarshalLoad 36 | h = Marshal.load buf 37 | # rubocop:enable Security/MarshalLoad 38 | from_h h 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /ext/games_dice/probabilities.h: -------------------------------------------------------------------------------- 1 | // ext/games_dice/probabilities.h 2 | 3 | // definitions for NewProbabilities class 4 | 5 | #ifndef PROBABILITIES_H 6 | #define PROBABILITIES_H 7 | 8 | #include 9 | 10 | void init_probabilities_class(); 11 | 12 | typedef struct _pd { 13 | int offset; 14 | int slots; 15 | double *probs; 16 | double *cumulative; 17 | } ProbabilityList; 18 | 19 | inline int pl_min( ProbabilityList *pl ); 20 | 21 | inline int pl_max( ProbabilityList *pl ); 22 | 23 | ProbabilityList *pl_add_distributions( ProbabilityList *pl_a, ProbabilityList *pl_b ); 24 | 25 | ProbabilityList *pl_add_distributions_mult( int mul_a, ProbabilityList *pl_a, int mul_b, ProbabilityList *pl_b ); 26 | 27 | inline double pl_p_eql( ProbabilityList *pl, int target ); 28 | 29 | inline double pl_p_gt( ProbabilityList *pl, int target ); 30 | 31 | inline double pl_p_lt( ProbabilityList *pl, int target ); 32 | 33 | inline double pl_p_le( ProbabilityList *pl, int target ); 34 | 35 | inline double pl_p_ge( ProbabilityList *pl, int target ); 36 | 37 | inline double pl_expected( ProbabilityList *pl ); 38 | 39 | ProbabilityList *pl_given_ge( ProbabilityList *pl, int target ); 40 | 41 | ProbabilityList *pl_given_le( ProbabilityList *pl, int target ); 42 | 43 | ProbabilityList *pl_repeat_sum( ProbabilityList *pl, int n ); 44 | 45 | ProbabilityList *pl_repeat_n_sum_k( ProbabilityList *pl, int n, int k, int kbest ); 46 | 47 | #endif 48 | -------------------------------------------------------------------------------- /spec/map_rule_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::MapRule do 6 | describe '#new' do 7 | it 'should accept self-consistent operator/value pairs as a trigger' do 8 | GamesDice::MapRule.new(5, :>, 1) 9 | GamesDice::MapRule.new((1..5), :member?, 17) 10 | end 11 | 12 | it 'should reject inconsistent operator/value pairs for a trigger' do 13 | expect(-> { GamesDice::MapRule.new(5, :member?, -1) }).to raise_error(ArgumentError) 14 | expect(-> { GamesDice::MapRule.new((1..5), :>, 12) }).to raise_error(ArgumentError) 15 | end 16 | 17 | it 'should reject non-Integer map results' do 18 | expect(-> { GamesDice::MapRule.new(5, :>, :reroll_again) }).to raise_error(TypeError) 19 | expect(-> { GamesDice::MapRule.new((1..5), :member?, 'foo') }).to raise_error(TypeError) 20 | end 21 | end 22 | 23 | describe '#map_from' do 24 | it 'should return the mapped value for a match' do 25 | rule = GamesDice::MapRule.new(5, :>, -1) 26 | expect(rule.map_from(4)).to eql(-1) 27 | 28 | rule = GamesDice::MapRule.new((1..5), :member?, 3) 29 | expect(rule.map_from(4)).to eql 3 30 | end 31 | 32 | it 'should return nil for no match' do 33 | rule = GamesDice::MapRule.new(5, :>, -1) 34 | expect(rule.map_from(6)).to be_nil 35 | 36 | rule = GamesDice::MapRule.new((1..5), :member?, 3) 37 | expect(rule.map_from(6)).to be_nil 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/reroll_rule_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::RerollRule do 6 | describe '#new' do 7 | it 'should accept self-consistent operator/value pairs as a trigger' do 8 | GamesDice::RerollRule.new(5, :>, :reroll_subtract) 9 | GamesDice::RerollRule.new((1..5), :member?, :reroll_replace) 10 | end 11 | 12 | it 'should reject inconsistent operator/value pairs for a trigger' do 13 | expect(-> { GamesDice::RerollRule.new(5, :member?, :reroll_subtract) }).to raise_error(ArgumentError) 14 | expect(-> { GamesDice::RerollRule.new((1..5), :>, :reroll_replace) }).to raise_error(ArgumentError) 15 | end 16 | 17 | it 'should reject bad re-roll types' do 18 | expect(-> { GamesDice::RerollRule.new(5, :>, :reroll_again) }).to raise_error(ArgumentError) 19 | expect(-> { GamesDice::RerollRule.new((1..5), :member?, 42) }).to raise_error(ArgumentError) 20 | end 21 | end 22 | 23 | describe '#applies?' do 24 | it 'should return true if a trigger condition is met' do 25 | rule = GamesDice::RerollRule.new(5, :>, :reroll_subtract) 26 | expect(rule.applies?(4)).to be true 27 | 28 | rule = GamesDice::RerollRule.new((1..5), :member?, :reroll_subtract) 29 | expect(rule.applies?(4)).to be true 30 | end 31 | 32 | it 'should return false if a trigger condition is not met' do 33 | rule = GamesDice::RerollRule.new(5, :>, :reroll_subtract) 34 | expect(rule.applies?(7)).to be false 35 | 36 | rule = GamesDice::RerollRule.new((1..5), :member?, :reroll_subtract) 37 | expect(rule.applies?(6)).to be false 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /games_dice.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'English' 4 | lib = File.expand_path('lib', __dir__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require 'games_dice/version' 7 | 8 | Gem::Specification.new do |gem| 9 | gem.name = 'games_dice' 10 | gem.version = GamesDice::VERSION 11 | gem.authors = ['Neil Slater'] 12 | gem.email = ['slobo777@gmail.com'] 13 | gem.description = <<~GEMDESC 14 | A library for simulating dice. Use it to construct dice-rolling systems used in role-playing and board games. 15 | GEMDESC 16 | gem.summary = <<~GEMSUMM 17 | Simulates and explains dice rolls from simple "1d6" to complex "roll 7 ten-sided dice, take best 3, 18 | results of 10 roll again and add on". 19 | GEMSUMM 20 | gem.homepage = 'https://github.com/neilslater/games_dice' 21 | gem.license = 'MIT' 22 | 23 | gem.required_ruby_version = '>= 2.6.0' 24 | gem.add_development_dependency 'coveralls', '>= 0.6.7' 25 | gem.add_development_dependency 'json', '>= 1.7.7' 26 | gem.add_development_dependency 'rake', '>= 1.9.1' 27 | gem.add_development_dependency 'rake-compiler', '>= 0.8.3' 28 | gem.add_development_dependency 'rspec', '>= 2.13.0' 29 | gem.add_development_dependency 'rubocop', '>= 1.2.1' 30 | gem.add_development_dependency 'yard', '>= 0.8.6' 31 | 32 | # Red Carpet renders README.md, and is optional even when developing the gem. 33 | # However, it has a C extension, which will not work in JRuby. This only affects the gem build process, so 34 | # is only really used in environments like Travis, and is safe to wrap like this in the gemspec. 35 | gem.add_development_dependency 'redcarpet', '>=3.5.1' if RUBY_DESCRIPTION !~ /jruby/ 36 | 37 | gem.add_dependency 'parslet', '>= 1.5.0' 38 | 39 | gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 40 | gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } 41 | gem.extensions = gem.files.grep(%r{/extconf\.rb$}) 42 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 43 | gem.require_paths = ['lib'] 44 | end 45 | -------------------------------------------------------------------------------- /spec/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # games_dice/spec/helpers.rb 4 | require 'pathname' 5 | 6 | require 'games_dice' 7 | 8 | def fixture(name) 9 | "#{__dir__}/fixtures/#{name}" 10 | end 11 | 12 | # TestPRNG tests short predictable series 13 | class TestPRNG 14 | def initialize 15 | @numbers = [0.123, 0.234, 0.345, 0.999, 0.876, 0.765, 0.543, 0.111, 0.333, 0.777] 16 | end 17 | 18 | def rand(num) 19 | Integer(num * @numbers.pop) 20 | end 21 | end 22 | 23 | # TestPRNGMax checks behaviour of re-rolls 24 | class TestPRNGMax 25 | def rand(num) 26 | Integer(num) - 1 27 | end 28 | end 29 | 30 | # TestPRNGMin checks behaviour of re-rolls 31 | class TestPRNGMin 32 | def rand(_num) 33 | 1 34 | end 35 | end 36 | 37 | # A valid distribution is: 38 | # A hash 39 | # Keys are all Integers 40 | # Values are all positive Floats, between 0.0 and 1.0 41 | # Sum of values is 1.0 42 | RSpec::Matchers.define :be_valid_distribution do 43 | match do |given| 44 | @error = nil 45 | if !given.is_a?(Hash) 46 | @error = "distribution should be a Hash, but it is a #{given.class}" 47 | elsif given.keys.any? { |k| !k.is_a?(Integer) } 48 | bad_key = given.keys.first { |k| !k.is_a?(Integer) } 49 | @error = "all keys should be Integers, but found '#{bad_key.inspect}' which is a #{bad_key.class}" 50 | elsif given.values.any? { |v| !v.is_a?(Float) } 51 | bad_value = given.values.find { |v| !v.is_a?(Float) } 52 | @error = "all values should be Floats, but found '#{bad_value.inspect}' which is a #{bad_value.class}" 53 | elsif given.values.any? { |v| v < 0.0 || v > 1.0 } 54 | bad_value = given.values.find { |v| v < 0.0 || v > 1.0 } 55 | @error = "all values should be in range (0.0..1.0), but found #{bad_value}" 56 | elsif (1.0 - given.values.inject(:+)).abs > 1e-6 57 | total_probs = given.values.inject(:+) 58 | @error = "sum of values should be 1.0, but got #{total_probs}" 59 | end 60 | !@error 61 | end 62 | 63 | failure_message do |_given| 64 | @error || 'Distribution is valid and complete' 65 | end 66 | 67 | failure_message_when_negated do |_given| 68 | @error || 'Distribution is valid and complete' 69 | end 70 | 71 | description do |_given| 72 | 'a hash describing a complete probability distribution of integer results' 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/games_dice/bunch_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | class Bunch 5 | # @!visibility private 6 | # Private extension methods for GamesDice::Bunch keep rules 7 | module KeepHelpers 8 | private 9 | 10 | def keep_mode_from_hash(options) 11 | @keep_mode = options[:keep_mode] 12 | case @keep_mode 13 | when nil 14 | @keep_mode = nil 15 | when :keep_best, :keep_worst 16 | @keep_number = Integer(options[:keep_number] || 1) 17 | else 18 | raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}" 19 | end 20 | end 21 | 22 | def find_used_dice_due_to_keep_mode(used_dice, unused_dice = []) 23 | full_dice = result_details.sort_by(&:total) 24 | case @keep_mode 25 | when :keep_best 26 | used_dice = full_dice[-@keep_number..] 27 | unused_dice = full_dice[0..full_dice.length - 1 - @keep_number] 28 | when :keep_worst 29 | used_dice = full_dice[0..(@keep_number - 1)] 30 | unused_dice = full_dice[@keep_number..(full_dice.length - 1)] 31 | end 32 | 33 | [used_dice, unused_dice] 34 | end 35 | 36 | def explain_kept_dice(used_dice) 37 | separator = @single_die.maps ? ', ' : ' + ' 38 | ". Keep: #{used_dice.map(&:explain_total).join(separator)}" 39 | end 40 | end 41 | 42 | # @!visibility private 43 | # Private extension methods for GamesDice::Bunch explaining 44 | module ExplainHelpers 45 | private 46 | 47 | def build_explanation(used_dice) 48 | if @keep_mode || @single_die.maps 49 | explanation = explain_with_keep_or_map(used_dice) 50 | else 51 | explanation = used_dice.map(&:explain_value).join(' + ') 52 | explanation += " = #{@result}" if @ndice > 1 53 | end 54 | 55 | explanation 56 | end 57 | 58 | def explain_with_keep_or_map(used_dice) 59 | explanation = result_details.map(&:explain_value).join(', ') 60 | explanation += explain_kept_dice(used_dice) if @keep_mode 61 | explanation += ". Successes: #{@result}" if @single_die.maps 62 | explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1 63 | 64 | explanation 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # GamesDice Changelog 2 | 3 | ## 0.4.1 ( Unreleased ) 4 | 5 | * Tidy up documentation and address some Rubocop offences. 6 | 7 | ## 0.4.0 ( 19 September 2021 ) 8 | 9 | * Dropping support for older Ruby versions (< 2.6) 10 | * Native extensions are no longer optional, to reduce maintenance overhead. Effectively dropping support for JRuby. 11 | * Tidy up code for maintainability (Rubocop and RSpec) 12 | 13 | ## 0.3.11 ( 26 May 2014 ) 14 | 15 | * Bugfix. Actually use custom PRNG passed to GamesDice.create or GamesDice::Bunch.new 16 | * Dropping support for Ruby 1.8.* versions (it may still work if you restrict parslet version) 17 | 18 | ## 0.3.10 ( 29 July 2013 ) 19 | 20 | * Non-functional changes to improve code quality metrics on CodeClimate 21 | * Altered specs to improve accuracy of coverage metrics on Coveralls 22 | 23 | ## 0.3.9 ( 23 July 2013 ) 24 | 25 | * New methods for inspecting and iterating over potential values in GamesDice::Die 26 | * Code metric integration and badges for github 27 | * Non-functional changes to improve code quality metrics on CodeClimate 28 | 29 | ## 0.3.7 ( 17 July 2013 ) 30 | 31 | * Compatibility between pure Ruby and native extension code when handling bad method params 32 | * Added this changelog to documentation 33 | 34 | ## 0.3.6 ( 15 July 2013 ) 35 | 36 | * Extension building skipped, with fallback to pure Ruby, for JRuby compatibility 37 | 38 | ## 0.3.5 ( 14 July 2013 ) 39 | 40 | * Adjust C code to avoid warnings about C90 compatibility (warnings seen on Travis) 41 | * Note MIT license in gemspec 42 | * Add class method GamesDice::Probabilities.implemented_in 43 | 44 | ## 0.3.3 ( 11 July 2013 ) 45 | 46 | * Standardised code for Ruby 1.8.7 compatibility in GamesDice::Probabilities 47 | * Bug fix for probability calculations where distributions are added with mulipliers e.g. '2d6 - 1d8' 48 | 49 | ## 0.3.2 ( 10 July 2013 ) 50 | 51 | * Bug fix for Ruby 1.8.7 compatibility in GamesDice::Probabilities 52 | 53 | ## 0.3.1 ( 10 July 2013 ) 54 | 55 | * Bug fix for Ruby 1.8.7 compatibility in GamesDice::Probabilities 56 | 57 | ## 0.3.0 ( 10 July 2013 ) 58 | 59 | * Implemented GamesDice::Probabilities as native extension 60 | 61 | ## 0.2.4 ( 18 June 2013 ) 62 | 63 | * Minor speed improvements to GamesDice::Probabilities 64 | 65 | ## 0.2.3 ( 12 June 2013 ) 66 | 67 | * More YARD documentation 68 | 69 | ## 0.2.2 ( 10 June 2013 ) 70 | 71 | * Extended YARD documentation 72 | 73 | ## 0.2.1 ( 5 June 2013 ) 74 | 75 | * Started basic YARD documentation 76 | 77 | ## 0.2.0 ( 30 May 2013 ) 78 | 79 | * First version with a complete feature set 80 | -------------------------------------------------------------------------------- /spec/die_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::Die do 6 | before do 7 | # Set state of default PRNG 8 | srand(4567) 9 | end 10 | 11 | describe '#new' do 12 | it 'should return an object that represents e.g. a six-sided die' do 13 | die = GamesDice::Die.new(6) 14 | expect(die.min).to eql 1 15 | expect(die.max).to eql 6 16 | expect(die.sides).to eql 6 17 | end 18 | 19 | it 'should accept any object with a rand(Integer) method as the second param' do 20 | prng = TestPRNG.new 21 | die = GamesDice::Die.new(20, prng) 22 | [16, 7, 3, 11, 16, 18, 20, 7].each do |expected| 23 | expect(die.roll).to eql expected 24 | expect(die.result).to eql expected 25 | end 26 | end 27 | end 28 | 29 | describe '#roll and #result' do 30 | it "should return results based on Ruby's internal rand() by default" do 31 | die = GamesDice::Die.new(10) 32 | [5, 4, 10, 4, 7, 8, 1, 9].each do |expected| 33 | expect(die.roll).to eql expected 34 | expect(die.result).to eql expected 35 | end 36 | end 37 | end 38 | 39 | describe '#min and #max' do 40 | it 'should calculate correct min, max' do 41 | die = GamesDice::Die.new(20) 42 | expect(die.min).to eql 1 43 | expect(die.max).to eql 20 44 | end 45 | end 46 | 47 | describe '#probabilities' do 48 | it "should return the die's probability distribution as a GamesDice::Probabilities object" do 49 | die = GamesDice::Die.new(6) 50 | probs = die.probabilities 51 | expect(probs).to be_a GamesDice::Probabilities 52 | 53 | expect(probs.to_h).to be_valid_distribution 54 | 55 | expect(probs.p_eql(1)).to be_within(1e-10).of 1 / 6.0 56 | expect(probs.p_eql(2)).to be_within(1e-10).of 1 / 6.0 57 | expect(probs.p_eql(3)).to be_within(1e-10).of 1 / 6.0 58 | expect(probs.p_eql(4)).to be_within(1e-10).of 1 / 6.0 59 | expect(probs.p_eql(5)).to be_within(1e-10).of 1 / 6.0 60 | expect(probs.p_eql(6)).to be_within(1e-10).of 1 / 6.0 61 | 62 | expect(probs.expected).to be_within(1e-10).of 3.5 63 | end 64 | end 65 | 66 | describe '#all_values' do 67 | it 'should return array with one result value per side' do 68 | die = GamesDice::Die.new(8) 69 | expect(die.all_values).to eql [1, 2, 3, 4, 5, 6, 7, 8] 70 | end 71 | end 72 | 73 | describe '#each_value' do 74 | it 'should iterate through all sides of the die' do 75 | die = GamesDice::Die.new(10) 76 | arr = [] 77 | die.each_value { |x| arr << x } 78 | expect(arr).to eql [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/games_dice/map_rule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # This class models rules that convert numbers shown on a die to values used in a game. A 5 | # common use for this is to count "successes" - dice that score a certain number or higher. 6 | # 7 | # An object of the class represents a single rule, such as "count a die result of 5 or more as 1 8 | # _success_". 9 | # 10 | # @example A rule for counting successes 11 | # rule = GamesDice::MapRule.new( 6, :<=, 1, 'Success' ) 12 | # # Test how the rule applies . . . 13 | # rule.map_from 4 # => nil 14 | # rule.map_from 6 # => 1 15 | # 16 | # @example A rule for counting "fumbles" which reduce total successes 17 | # rule = GamesDice::MapRule.new( 1, :==, -1, 'Fumble' ) 18 | # # Test how the rule applies . . . 19 | # rule.map_from 7 # => nil 20 | # rule.map_from 1 # => -1 21 | # 22 | class MapRule 23 | # Creates new instance of GamesDice::MapRule. The rule will be assessed as 24 | # trigger_value.send( trigger_op, x ) 25 | # where x is the Integer value shown on a die. 26 | # @param [Integer,Range,Object] trigger_value Any object is allowed, but typically an Integer 27 | # @param [Symbol] trigger_op A method of trigger_value that takes an Integer param and returns Boolean 28 | # @param [Integer] mapped_value The value to use in place of the trigger value 29 | # @param [String] mapped_name Name of mapped value, for use in descriptions 30 | # @return [GamesDice::MapRule] 31 | def initialize(trigger_value, trigger_op, mapped_value = 0, mapped_name = '') 32 | unless trigger_value.respond_to?(trigger_op) 33 | raise ArgumentError, 34 | "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}" 35 | end 36 | 37 | @trigger_value = trigger_value 38 | @trigger_op = trigger_op 39 | raise TypeError unless mapped_value.is_a? Numeric 40 | 41 | @mapped_value = Integer(mapped_value) 42 | @mapped_name = mapped_name.to_s 43 | end 44 | 45 | # Trigger operation. How the rule is assessed against #trigger_value. 46 | # @return [Symbol] Method name to be sent to #trigger_value 47 | attr_reader :trigger_op 48 | 49 | # Trigger value. An object that will use #trigger_op to assess a die result for a reroll. 50 | # @return [Integer,Range,Object] Object that receives (#trigger_op, die_result) 51 | attr_reader :trigger_value 52 | 53 | # Value that a die will use after the value has been mapped. 54 | # @return [Integer] 55 | attr_reader :mapped_value 56 | 57 | # Name for mapped value, used in explanations. 58 | # @return [String] 59 | attr_reader :mapped_name 60 | 61 | # Assesses the rule against a die result value. 62 | # @param [Integer] test_value Value that is result of rolling a single die. 63 | # @return [Integer,nil] Replacement value, or nil if this rule doesn't apply 64 | def map_from(test_value) 65 | op_result = @trigger_value.send(@trigger_op, test_value) 66 | return nil unless op_result 67 | return @mapped_value if op_result == true 68 | 69 | op_result 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/games_dice/die.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # This class models the simplest, most-familiar kind of die. 5 | # 6 | # An object of the class represents a basic die that rolls 1..#sides, with equal weighting for each value. 7 | # 8 | # @example Create a 6-sided die, and roll it 9 | # d = GamesDice::Die.new( 6 ) 10 | # d.roll # => Integer in range 1..6 11 | # d.result # => same Integer value as just returned by d.roll 12 | # 13 | # @example Create a 10-sided die, that rolls using a monkey-patch to SecureRandom 14 | # module SecureRandom 15 | # def self.rand n 16 | # random_number( n ) 17 | # end 18 | # end 19 | # d = GamesDice::Die.new( 10, SecureRandom ) 20 | # d.roll # => (secure) Integer in range 1..10 21 | # d.result # => same Integer value as just returned by d.roll 22 | # 23 | class Die 24 | # Creates new instance of GamesDice::Die 25 | # @param [Integer] sides the number of sides 26 | # @param [#rand] prng random number generator, GamesDice::Die will use Ruby's built-in #rand() by default 27 | # @return [GamesDice::Die] 28 | def initialize(sides, prng = nil) 29 | @sides = Integer(sides) 30 | raise ArgumentError, "sides value #{sides} is too low, it must be 1 or greater" if @sides < 1 31 | raise ArgumentError, 'prng does not support the rand() method' if prng && !prng.respond_to?(:rand) 32 | 33 | @prng = prng 34 | @result = nil 35 | end 36 | 37 | # @return [Integer] number of sides on simulated die 38 | attr_reader :sides 39 | 40 | # @return [Integer] result of last call to #roll, nil if no call made yet 41 | attr_reader :result 42 | 43 | # @return [Object] random number generator as supplied to constructor, may be nil 44 | attr_reader :prng 45 | 46 | # @!attribute [r] min 47 | # @return [Integer] minimum possible result from a call to #roll 48 | def min 49 | 1 50 | end 51 | 52 | # @!attribute [r] max 53 | # @return [Integer] maximum possible result from a call to #roll 54 | def max 55 | @sides 56 | end 57 | 58 | # Calculates probability distribution for this die. 59 | # @return [GamesDice::Probabilities] probability distribution of the die 60 | def probabilities 61 | @probabilities ||= GamesDice::Probabilities.for_fair_die(@sides) 62 | end 63 | 64 | # Simulates rolling the die 65 | # @return [Integer] selected value between 1 and #sides inclusive 66 | def roll 67 | @result = if @prng 68 | @prng.rand(@sides) + 1 69 | else 70 | rand(@sides) + 1 71 | end 72 | end 73 | 74 | # Iterates through all possible results on die. 75 | # @yieldparam [Integer] result A potential result from the die 76 | # @return [GamesDice::Die] this object 77 | def each_value(&block) 78 | (1..@sides).each(&block) 79 | self 80 | end 81 | 82 | # @return [Array] All potential results from the die 83 | def all_values 84 | (1..@sides).to_a 85 | end 86 | 87 | # @!attribute [r] rerolls 88 | # Rules for when to re-roll this die. 89 | # @return [nil] always nil, available for interface equivalence with GamesDice::ComplexDie 90 | def rerolls 91 | nil 92 | end 93 | 94 | # @!attribute [r] maps 95 | # Rules for when to map return value of this die. 96 | # @return [nil] always nil, available for interface equivalence with GamesDice::ComplexDie 97 | def maps 98 | nil 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/games_dice/reroll_rule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # This class models a variety of game rules that cause dice to be re-rolled. 5 | # 6 | # An object of the class represents a single rule, such as "re-roll a result of 1 7 | # and use the new value". 8 | # 9 | # @example A rule for "exploding" dice 10 | # rule = GamesDice::RerollRule.new( 6, :<=, :reroll_add ) 11 | # # Test whether the rule applies . . . 12 | # rule.applies? 4 # => false 13 | # rule.applies? 6 # => true 14 | # rule.type # => :reroll_add 15 | # 16 | # @example A rule for re-rolling and taking best value if first attempt is lower than a threshold 17 | # rule = GamesDice::RerollRule.new( 11, :>, :reroll_use_best, 1 ) 18 | # # Test whether the rule applies . . . 19 | # rule.applies? 4 # => true 20 | # rule.applies? 14 # => false 21 | # rule.type # => :reroll_use_best 22 | # 23 | class RerollRule 24 | # Creates new instance of GamesDice::RerollRule. The rule will be assessed as 25 | # trigger_value.send( trigger_op, x ) 26 | # where x is the Integer value shown on a die. 27 | # @param [Integer,Range,Object] trigger_value Any object is allowed, but typically an Integer 28 | # @param [Symbol] trigger_op A method of trigger_value that takes an Integer param and returns Boolean 29 | # @param [Symbol] type The type of reroll 30 | # @param [Integer] limit Maximum number of times this rule can be applied to a single die 31 | # @return [GamesDice::RerollRule] 32 | def initialize(trigger_value, trigger_op, type, limit = 1000) 33 | unless trigger_value.respond_to?(trigger_op) 34 | raise ArgumentError, 35 | "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}" 36 | end 37 | 38 | raise ArgumentError, "Unrecognised reason for a re-roll #{type}" unless GamesDice::REROLL_TYPES.key?(type) 39 | 40 | @trigger_value = trigger_value 41 | @trigger_op = trigger_op 42 | @type = type 43 | @limit = limit ? Integer(limit) : 1000 44 | @limit = 1 if @type == :reroll_subtract 45 | end 46 | 47 | # Trigger operation. How the rule is assessed against #trigger_value. 48 | # @return [Symbol] Method name to be sent to #trigger_value 49 | attr_reader :trigger_op 50 | 51 | # Trigger value. An object that will use #trigger_op to assess a die result for a reroll. 52 | # @return [Integer,Range,Object] Object that receives (#trigger_op, die_result) 53 | attr_reader :trigger_value 54 | 55 | # The reroll behaviour that this rule triggers. 56 | # @return [Symbol] A category for the re-roll, declares how it should be processed 57 | # The following values are supported: 58 | # +:reroll_add+:: add result of reroll to running total, and ignore :reroll_subtract for this die 59 | # +reroll_subtract+:: subtract result of reroll from running total, reverse sense of any further :reroll_add results 60 | # +:reroll_replace+:: use the new value in place of existing value for the die 61 | # +:reroll_use_best+:: use the new value if it is higher than the existing value 62 | # +:reroll_use_worst+:: use the new value if it is higher than the existing value 63 | attr_reader :type 64 | 65 | # Maximum to number of times that this rule can be applied to a single die. 66 | # @return [Integer] A number of rolls. 67 | attr_reader :limit 68 | 69 | # Assesses the rule against a die result value. 70 | # @param [Integer] test_value Value that is result of rolling a single die. 71 | # @return [Boolean] Whether the rule applies. 72 | def applies?(test_value) 73 | @trigger_value.send(@trigger_op, test_value) ? true : false 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::Parser do 6 | describe '#parse' do 7 | let(:parser) { GamesDice::Parser.new } 8 | 9 | it 'should parse simple dice sums' do 10 | variations = { 11 | '1d6' => { bunches: [{ ndice: 1, sides: 6, multiplier: 1 }], offset: 0 }, 12 | '2d8-1d4' => { bunches: [{ ndice: 2, sides: 8, multiplier: 1 }, { ndice: 1, sides: 4, multiplier: -1 }], 13 | offset: 0 }, 14 | '+ 2d10 - 1d4 ' => { 15 | bunches: [{ ndice: 2, sides: 10, multiplier: 1 }, 16 | { ndice: 1, sides: 4, multiplier: -1 }], offset: 0 17 | }, 18 | ' + 3d6 + 12 ' => { bunches: [{ ndice: 3, sides: 6, multiplier: 1 }], offset: 12 }, 19 | '-7 + 2d4 + 1 ' => { bunches: [{ ndice: 2, sides: 4, multiplier: 1 }], offset: -6 }, 20 | '- 3 + 7d20 - 1 ' => { bunches: [{ ndice: 7, sides: 20, multiplier: 1 }], offset: -4 }, 21 | ' - 2d4' => { bunches: [{ ndice: 2, sides: 4, multiplier: -1 }], offset: 0 }, 22 | '3d12+5+2d8+1d6' => { 23 | bunches: [{ ndice: 3, sides: 12, multiplier: 1 }, { ndice: 2, sides: 8, multiplier: 1 }, 24 | { ndice: 1, sides: 6, multiplier: 1 }], offset: 5 25 | } 26 | } 27 | 28 | variations.each do |input, expected_output| 29 | expect(parser.parse(input)).to eql expected_output 30 | end 31 | end 32 | 33 | it "should parse 'NdXrY' as 'roll N times X-sided dice, re-roll and replace a Y or less (once)'" do 34 | variations = { 35 | '1d6r1' => { bunches: [{ ndice: 1, sides: 6, multiplier: 1, rerolls: [[1, :>=, :reroll_replace]] }], 36 | offset: 0 }, 37 | '2d20r7' => { bunches: [{ ndice: 2, sides: 20, multiplier: 1, rerolls: [[7, :>=, :reroll_replace]] }], 38 | offset: 0 }, 39 | '1d8r2' => { bunches: [{ ndice: 1, sides: 8, multiplier: 1, rerolls: [[2, :>=, :reroll_replace]] }], 40 | offset: 0 } 41 | } 42 | 43 | variations.each do |input, expected_output| 44 | expect(parser.parse(input)).to eql expected_output 45 | end 46 | end 47 | 48 | it "should parse 'NdXmZ' as 'roll N times X-sided dice, a value of Z or more equals 1 (success)'" do 49 | variations = { 50 | '5d6m6' => { bunches: [{ ndice: 5, sides: 6, multiplier: 1, maps: [[6, :<=, 1]] }], 51 | offset: 0 }, 52 | '2d10m7' => { bunches: [{ ndice: 2, sides: 10, multiplier: 1, maps: [[7, :<=, 1]] }], 53 | offset: 0 } 54 | } 55 | 56 | variations.each do |input, expected_output| 57 | expect(parser.parse(input)).to eql expected_output 58 | end 59 | end 60 | 61 | it "should parse 'NdXkC' as 'roll N times X-sided dice, add together the best C'" do 62 | variations = { 63 | '5d10k3' => { bunches: [{ ndice: 5, sides: 10, multiplier: 1, keep_mode: :keep_best, keep_number: 3 }], 64 | offset: 0 } 65 | } 66 | 67 | variations.each do |input, expected_output| 68 | expect(parser.parse(input)).to eql expected_output 69 | end 70 | end 71 | 72 | it "should parse 'NdXx' as 'roll N times X-sided *exploding* dice'" do 73 | variations = { 74 | '5d10x' => { bunches: [{ ndice: 5, sides: 10, multiplier: 1, rerolls: [[10, :==, :reroll_add]] }], 75 | offset: 0 }, 76 | '3d6x' => { bunches: [{ ndice: 3, sides: 6, multiplier: 1, rerolls: [[6, :==, :reroll_add]] }], 77 | offset: 0 } 78 | } 79 | 80 | variations.each do |input, expected_output| 81 | expect(parser.parse(input)).to eql expected_output 82 | end 83 | end 84 | 85 | it 'should successfully parse combinations of modifiers in any valid order' do 86 | variations = { 87 | '5d10r1x' => { 88 | bunches: [{ ndice: 5, sides: 10, multiplier: 1, 89 | rerolls: [[1, :>=, :reroll_replace], [10, :==, :reroll_add]] }], offset: 0 90 | }, 91 | '3d6xk2' => { 92 | bunches: [{ ndice: 3, sides: 6, multiplier: 1, rerolls: [[6, :==, :reroll_add]], 93 | keep_mode: :keep_best, keep_number: 2 }], offset: 0 94 | }, 95 | '4d6m8x' => { 96 | bunches: [{ ndice: 4, sides: 6, multiplier: 1, maps: [[8, :<=, 1]], 97 | rerolls: [[6, :==, :reroll_add]] }], offset: 0 98 | } 99 | } 100 | 101 | variations.each do |input, expected_output| 102 | expect(parser.parse(input)).to eql expected_output 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/games_dice/dice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # This class models a combination of GamesDice::Bunch objects plus a fixed offset. 5 | # 6 | # An object of this class is a dice "recipe" that specifies the numbers and types of 7 | # dice that can be rolled to generate an integer value. 8 | # 9 | # @example '3d6+6' hitpoints, whatever that means in the game you are playing 10 | # d = GamesDice::Dice.new( [{:ndice => 3, :sides => 6}], 6, 'Hit points' ) 11 | # d.roll # => 20 12 | # d.result # => 20 13 | # d.explain_result # => "3d6: 3 + 5 + 6 = 14. 14 + 6 = 20" 14 | # d.probabilities.expected # => 16.5 15 | # 16 | # @example Roll d20 twice, take best result, and add 5. 17 | # d = GamesDice::Dice.new( [{:ndice => 2, :sides => 20 , :keep_mode => :keep_best, :keep_number => 1}], 5 ) 18 | # d.roll # => 21 19 | # d.result # => 21 20 | # d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21" 21 | # 22 | class Dice 23 | # The first parameter is an array of values that are passed to GamesDice::Bunch constructors. 24 | # @param [Array] bunches Array of options for creating bunches 25 | # @param [Integer] offset Total offset 26 | # @param [String] name Optional label for the dice 27 | # @option bunches [Integer] :ndice Number of dice in the bunch, *mandatory* 28 | # @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory* 29 | # @option bunches [String] :name Optional name for the bunch 30 | # @option bunches [Array] :rerolls Optional rules that cause the die to roll again 31 | # @option bunches [Array] :maps Optional rules to convert a value into a final result 32 | # for the die 33 | # @option bunches [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to 34 | # GamesDice::Die's constructor 35 | # @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst* 36 | # @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil 37 | # @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the 38 | # Bunch total is to be added or subtracted 39 | # @return [GamesDice::Dice] 40 | def initialize(bunches, offset = 0, name = '') 41 | @name = name 42 | @offset = offset 43 | @bunches = bunches.map { |b| GamesDice::Bunch.new(b) } 44 | @bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 } 45 | @result = nil 46 | end 47 | 48 | # Name to help identify dice 49 | # @return [String] 50 | attr_reader :name 51 | 52 | # Bunches of dice that are components of the object 53 | # @return [Array] 54 | attr_reader :bunches 55 | 56 | # Multipliers for each bunch of identical dice. Typically 1 or -1 to represent groups of dice that 57 | # are either added or subtracted from the total. 58 | # @return [Array] 59 | attr_reader :bunch_multipliers 60 | 61 | # Fixed offset added to sum of all bunches. 62 | # @return [Integer] 63 | attr_reader :offset 64 | 65 | # Result of most-recent roll, or nil if no roll made yet. 66 | # @return [Integer,nil] 67 | attr_reader :result 68 | 69 | # Simulates rolling dice 70 | # @return [Integer] Sum of all rolled dice 71 | def roll 72 | @result = @offset + bunches_weighted_sum(:roll) 73 | end 74 | 75 | # @!attribute [r] min 76 | # Minimum possible result from a call to #roll 77 | # @return [Integer] 78 | def min 79 | @min ||= @offset + bunches_weighted_sum(:min) 80 | end 81 | 82 | # @!attribute [r] max 83 | # Maximum possible result from a call to #roll 84 | # @return [Integer] 85 | def max 86 | @max ||= @offset + bunches_weighted_sum(:max) 87 | end 88 | 89 | # @!attribute [r] minmax 90 | # Convenience method, same as [dice.min, dice.max] 91 | # @return [Array] 92 | def minmax 93 | [min, max] 94 | end 95 | 96 | # Calculates the probability distribution for the dice. When the dice include components with 97 | # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of 98 | # recursion. 99 | # @return [GamesDice::Probabilities] Probability distribution of dice. 100 | def probabilities 101 | return @probabilities if @probabilities 102 | 103 | @bunch_multipliers.zip(@bunches).inject(GamesDice::Probabilities.new([1.0], @offset)) do |probs, mb| 104 | m, b = mb 105 | GamesDice::Probabilities.add_distributions_mult(1, probs, m, b.probabilities) 106 | end 107 | end 108 | 109 | # @!attribute [r] explain_result 110 | # @return [String,nil] Explanation of result, or nil if no call to #roll yet. 111 | def explain_result 112 | return nil unless @result 113 | 114 | explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" } 115 | 116 | return @offset.to_s if explanations.count.zero? 117 | 118 | return simple_explanation(explanations.first) if explanations.count == 1 119 | 120 | multi_explanations(explanations) 121 | end 122 | 123 | private 124 | 125 | def simple_explanation(explanation) 126 | return explanation if @offset.zero? 127 | 128 | "#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}" 129 | end 130 | 131 | def multi_explanations(explanations) 132 | bunch_values = @bunch_multipliers.zip(@bunches).map { |m, b| m * b.result } 133 | bunch_values << @offset if @offset != 0 134 | explanations << array_to_sum(bunch_values) 135 | explanations.join('. ') 136 | end 137 | 138 | def array_to_sum(array) 139 | (numbers_to_strings(array) + ['=', array.inject(:+)]).join(' ') 140 | end 141 | 142 | def numbers_to_strings(array) 143 | [array.first.to_s] + array.drop(1).map { |n| n.negative? ? "- #{n.abs}" : "+ #{n}" } 144 | end 145 | 146 | def bunches_weighted_sum(summed_method) 147 | @bunch_multipliers.zip(@bunches).inject(0) do |total, mb| 148 | m, b = mb 149 | total + (m * b.send(summed_method)) 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/games_dice/complex_die.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'games_dice/complex_die_helpers' 4 | 5 | module GamesDice 6 | # This class models a die that is built up from a simpler unit by adding rules to re-roll 7 | # and interpret the value shown. 8 | # 9 | # An object of this class represents a single complex die. It rolls 1..#sides, with equal weighting 10 | # for each value. The value from a roll may be used to trigger yet more rolls that combine together. 11 | # After any re-rolls, the value can be interpretted ("mapped") as another integer, which is used as 12 | # the final result. 13 | # 14 | # @example An open-ended percentile die from a popular RPG 15 | # d = GamesDice::ComplexDie.new( 100, :rerolls => [[96, :<=, :reroll_add],[5, :>=, :reroll_subtract]] ) 16 | # d.roll # => # 17 | # d.result.value # => -23 18 | # d.explain_result # => "[4-27] -23" 19 | # 20 | # @example An "exploding" six-sided die with a target number 21 | # d = GamesDice::ComplexDie.new( 6, :rerolls => [[6, :<=, :reroll_add]], :maps => [[8, :<=, 1, 'Success']] ) 22 | # d.roll # => # 23 | # d.result.value # => 1 24 | # d.explain_result # => "[6+5] 11 Success" 25 | # 26 | class ComplexDie 27 | include RollHelpers 28 | include ProbabilityHelpers 29 | include MinMaxHelpers 30 | 31 | # @!visibility private 32 | # arbitrary limit to speed up probability calculations. It should 33 | # be larger than anything seen in real-world tabletop games. 34 | MAX_REROLLS = 1000 35 | 36 | # Creates new instance of GamesDice::ComplexDie 37 | # @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor 38 | # @param [Hash] options 39 | # @option options [Array] :rerolls The rules that cause the die to roll again 40 | # @option options [Array] :maps The rules to convert a value into a final result 41 | # for the die 42 | # @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to 43 | # GamesDice::Die's constructor 44 | # @return [GamesDice::ComplexDie] 45 | def initialize(sides, options = {}) 46 | @basic_die = GamesDice::Die.new(sides, options[:prng]) 47 | 48 | @rerolls = construct_rerolls(options[:rerolls]) 49 | @maps = construct_maps(options[:maps]) 50 | 51 | @total = nil 52 | @result = nil 53 | 54 | @probabilities_complete = true 55 | end 56 | 57 | # The simple component used by this complex one 58 | # @return [GamesDice::Die] Object used to make individual dice rolls for the complex die 59 | attr_reader :basic_die 60 | 61 | # @return [Array, nil] Sequence of re-roll rules, or nil if re-rolls are not required. 62 | attr_reader :rerolls 63 | 64 | # @return [Array, nil] Sequence of map rules, or nil if mapping is not required. 65 | attr_reader :maps 66 | 67 | # @return [GamesDice::DieResult, nil] Result of last call to #roll, nil if no call made yet 68 | attr_reader :result 69 | 70 | # Whether or not #probabilities includes all possible outcomes. 71 | # True if all possible results are represented and assigned a probability. Dice with open-ended re-rolls 72 | # may have calculations cut short, and will result in a false value of this attribute. Even when this 73 | # attribute is false, probabilities should still be accurate to nearest 1e-9. 74 | # @return [Boolean, nil] Depending on completeness when generating #probabilities 75 | attr_reader :probabilities_complete 76 | 77 | # @!attribute [r] sides 78 | # @return [Integer] Number of sides. 79 | def sides 80 | @basic_die.sides 81 | end 82 | 83 | # @!attribute [r] explain_result 84 | # @return [String,nil] Explanation of result, or nil if no call to #roll yet. 85 | def explain_result 86 | @result.explain_value 87 | end 88 | 89 | # The minimum possible result from a call to #roll. This is not always the same as the theoretical 90 | # minimum, due to limits on the maximum number of rerolls. 91 | # @!attribute [r] min 92 | # @return [Integer] 93 | def min 94 | return @min_result if @min_result 95 | 96 | calc_minmax 97 | @min_result 98 | end 99 | 100 | # @!attribute [r] max 101 | # @return [Integer] Maximum possible result from a call to #roll 102 | def max 103 | return @max_result if @max_result 104 | 105 | calc_minmax 106 | @max_result 107 | end 108 | 109 | # Calculates the probability distribution for the die. For open-ended re-roll rules, there are some 110 | # arbitrary limits imposed to prevent large amounts of recursion. Probabilities should be to nearest 111 | # 1e-9 at worst. 112 | # @return [GamesDice::Probabilities] Probability distribution of die. 113 | def probabilities 114 | @probabilities ||= calculate_probabilities 115 | end 116 | 117 | # Simulates rolling the die 118 | # @param [Symbol] reason Assign a reason for rolling the first die. 119 | # @return [GamesDice::DieResult] Detailed results from rolling the die, including resolution of rules. 120 | def roll(reason = :basic) 121 | @result = GamesDice::DieResult.new(@basic_die.roll, reason) 122 | roll_apply_rerolls 123 | roll_apply_maps 124 | @result 125 | end 126 | 127 | private 128 | 129 | def construct_rerolls(rerolls_input) 130 | check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls' 131 | end 132 | 133 | def construct_maps(maps_input) 134 | check_and_construct maps_input, GamesDice::MapRule, 'maps' 135 | end 136 | 137 | def check_and_construct(input, klass, label) 138 | return nil unless input 139 | raise TypeError, "#{label} should be an Array, instead got #{input.inspect}" unless input.is_a?(Array) 140 | 141 | input.map do |i| 142 | case i 143 | when Array then klass.new(*i) 144 | when klass then i 145 | else 146 | raise TypeError, "items in #{label} should be #{klass.name} or Array, instead got #{i.inspect}" 147 | end 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/games_dice/die_result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | # Module supports using DieResults directly in calculations 5 | # @!visibility private 6 | module ExpressionHelpers 7 | # all coercions simply use #value (i.e. nil or a Integer) 8 | def coerce(thing) 9 | @value.coerce(thing) 10 | end 11 | 12 | # addition uses #value 13 | def +(other) 14 | @value + other 15 | end 16 | 17 | # subtraction uses #value 18 | def -(other) 19 | @value - other 20 | end 21 | 22 | # multiplication uses #value 23 | def *(other) 24 | @value * other 25 | end 26 | 27 | # comparison <=> uses #value 28 | def <=>(other) 29 | value <=> other 30 | end 31 | end 32 | 33 | # This class models the output of GamesDice::ComplexDie. 34 | # 35 | # An object of the class represents the results of a roll of a ComplexDie, including any re-rolls and 36 | # value mapping. 37 | # 38 | # @example Building up a result manually 39 | # dr = GamesDice::DieResult.new 40 | # dr.add_roll 5 41 | # dr.add_roll 4, :reroll_replace 42 | # dr.value # => 4 43 | # dr.rolls # => [5, 4] 44 | # dr.roll_reasons # => [:basic, :reroll_replace] 45 | # # dr can behave as dr.value due to coercion and support for some operators 46 | # dr + 6 # => 10 47 | # 48 | # @example Using a result from GamesDice::ComplexDie 49 | # # An "exploding" six-sided die that needs a result of 8 to score "1 Success" 50 | # d = GamesDice::ComplexDie.new( 6, :rerolls => [[6, :<=, :reroll_add]], :maps => [[8, :<=, 1, 'Success']] ) 51 | # # Generate result object by rolling the die 52 | # dr = d.roll 53 | # dr.rolls # => [6, 3] 54 | # dr.roll_reasons # => [:basic, :reroll_add] 55 | # dr.total # => 9 56 | # dr.value # => 1 57 | # dr.explain_value # => "[6+3] 9 Success" 58 | # 59 | class DieResult 60 | include Comparable 61 | include ExpressionHelpers 62 | 63 | # Creates new instance of GamesDice::DieResult. The object can be initialised "empty" or with a first result. 64 | # @param [Integer,nil] first_roll_result Value for first roll of the die. 65 | # @param [Symbol] first_roll_reason Reason for first roll of the die. 66 | # @return [GamesDice::DieResult] 67 | def initialize(first_roll_result = nil, first_roll_reason = :basic) 68 | unless GamesDice::REROLL_TYPES.key?(first_roll_reason) 69 | raise ArgumentError, "Unrecognised reason for roll #{first_roll_reason}" 70 | end 71 | 72 | if first_roll_result 73 | init_with_result(first_roll_result, first_roll_reason) 74 | else 75 | init_empty 76 | end 77 | @mapped = false 78 | @value = @total 79 | end 80 | 81 | # The individual die rolls that combined to generate this result. 82 | # @return [Array] Un-processed values of each die roll used for this result. 83 | attr_reader :rolls 84 | 85 | # The individual reasons for each roll of the die. See GamesDice::RerollRule for allowed values. 86 | # @return [Array] Reasons for each die roll, indexes match the #rolls Array. 87 | attr_reader :roll_reasons 88 | 89 | # Combined result of all rolls, *before* mapping. 90 | # @return [Integer,nil] 91 | attr_reader :total 92 | 93 | # Combined result of all rolls, *after* mapping. 94 | # @return [Integer,nil] 95 | attr_reader :value 96 | 97 | # Whether or not #value has been mapped from #total. 98 | # @return [Boolean] 99 | attr_reader :mapped 100 | 101 | # Adds value from a new roll to the object. GamesDice::DieResult tracks reasons for the roll 102 | # and makes the correct adjustment to the total so far. Any mapped value is cleared. 103 | # @param [Integer] roll_result Value result from rolling the die. 104 | # @param [Symbol] roll_reason Reason for rolling the die. 105 | # @return [Integer] Total so far 106 | def add_roll(roll_result, roll_reason = :basic) 107 | raise ArgumentError, "Unrecognised roll reason #{roll_reason}" unless GamesDice::REROLL_TYPES.key?(roll_reason) 108 | 109 | @rolls << Integer(roll_result) 110 | @roll_reasons << roll_reason 111 | @total = 0 if @rolls.length == 1 112 | 113 | apply_roll_to_total(roll_reason, roll_result) 114 | 115 | @mapped = false 116 | @value = @total 117 | end 118 | 119 | # Sets value arbitrarily, and notes that the value has been mapped. Used by GamesDice::ComplexDie 120 | # when there are one or more GamesDice::MapRule objects to process for a die. 121 | # @param [Integer] to_value Replacement value. 122 | # @param [String] description Description of what the mapped value represents e.g. "Success" 123 | # @return [nil] 124 | def apply_map(to_value, description = '') 125 | @mapped = true 126 | @value = to_value 127 | @map_description = description 128 | nil 129 | end 130 | 131 | # Generates a text description of how #value is determined. If #value has been mapped, includes the 132 | # map description, but does not include the mapped value. 133 | # @return [String] Explanation of #value. 134 | def explain_value 135 | text = if @rolls.length < 2 136 | @total.to_s 137 | else 138 | explain_value_multiple_rolls 139 | end 140 | text += " #{@map_description}" if @mapped && @map_description && @map_description.length.positive? 141 | text 142 | end 143 | 144 | # @!visibility private 145 | # This is mis-named, it doesn't explain the total at all! It is used to generate summaries of keeper dice. 146 | def explain_total 147 | text = @total.to_s 148 | text += " #{@map_description}" if @mapped && @map_description && @map_description.length.positive? 149 | text 150 | end 151 | 152 | # This is a deep clone, all attributes are also cloned. 153 | # @return [GamesDice::DieResult] 154 | def clone 155 | cloned = GamesDice::DieResult.new 156 | cloned.instance_variable_set('@rolls', @rolls.clone) 157 | cloned.instance_variable_set('@roll_reasons', @roll_reasons.clone) 158 | cloned.instance_variable_set('@total', @total) 159 | cloned.instance_variable_set('@value', @value) 160 | cloned.instance_variable_set('@mapped', @mapped) 161 | cloned.instance_variable_set('@map_description', @map_description) 162 | cloned 163 | end 164 | 165 | private 166 | 167 | # Splitting this method up further, or flattening it will not make it read better. If 168 | # we had a few more reroll reasons, they could maybe be grouped and method split up. 169 | # rubocop:disable Metrics/MethodLength 170 | def apply_roll_to_total(roll_reason, roll_result) 171 | case roll_reason 172 | when :basic, :reroll_new_die, :reroll_new_keeper, :reroll_replace 173 | @total = roll_result 174 | when :reroll_add 175 | @total += roll_result 176 | when :reroll_subtract 177 | @total -= roll_result 178 | when :reroll_use_best 179 | @total = [@value, roll_result].max 180 | when :reroll_use_worst 181 | @total = [@value, roll_result].min 182 | end 183 | end 184 | # rubocop:enable Metrics/MethodLength 185 | 186 | def init_with_result(first_roll_result, first_roll_reason) 187 | @rolls = [Integer(first_roll_result)] 188 | @roll_reasons = [first_roll_reason] 189 | @total = @rolls[0] 190 | end 191 | 192 | def init_empty 193 | @rolls = [] 194 | @roll_reasons = [] 195 | @total = nil 196 | end 197 | 198 | def explain_value_multiple_rolls 199 | text = "[#{@rolls[0]}" 200 | text = (1..@rolls.length - 1).inject(text) do |so_far, i| 201 | so_far + GamesDice::REROLL_TYPES[@roll_reasons[i]] + @rolls[i].to_s 202 | end 203 | text + "] #{@total}" 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/games_dice/bunch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'games_dice/bunch_helpers' 4 | 5 | module GamesDice 6 | # This class models a number of identical dice, which may be either GamesDice::Die or 7 | # GamesDice::ComplexDie objects. 8 | # 9 | # An object of this class represents a fixed number of indentical dice that may be rolled and their 10 | # values summed to make a total for the bunch. 11 | # 12 | # @example The ubiquitous '3d6' 13 | # d = GamesDice::Bunch.new( :ndice => 3, :sides => 6 ) 14 | # d.roll # => 14 15 | # d.result # => 14 16 | # d.explain_result # => "2 + 6 + 6 = 14" 17 | # d.max # => 18 18 | # 19 | # @example Roll 5d10, and keep the best 2 20 | # d = GamesDice::Bunch.new( :ndice => 5, :sides => 10 , :keep_mode => :keep_best, :keep_number => 2 ) 21 | # d.roll # => 18 22 | # d.result # => 18 23 | # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18" 24 | # 25 | class Bunch 26 | include KeepHelpers 27 | include ExplainHelpers 28 | 29 | # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie 30 | # and decides which of those classes to instantiate. 31 | # @param [Hash] options 32 | # @option options [Integer] :ndice Number of dice in the bunch, *mandatory* 33 | # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory* 34 | # @option options [String] :name Optional name for the bunch 35 | # @option options [Array] :rerolls Optional rules that cause the die to roll again 36 | # @option options [Array] :maps Optional rules to convert a value into a final result 37 | # for the die 38 | # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to 39 | # GamesDice::Die's constructor 40 | # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst* 41 | # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil 42 | # @return [GamesDice::Bunch] 43 | def initialize(options) 44 | name_number_sides_from_hash(options) 45 | keep_mode_from_hash(options) 46 | 47 | raise ':prng does not support the rand() method' if options[:prng] && !options[:prng].respond_to?(:rand) 48 | 49 | @single_die = if options[:rerolls] || options[:maps] 50 | GamesDice::ComplexDie.new(@sides, complex_die_params_from_hash(options)) 51 | else 52 | GamesDice::Die.new(@sides, options[:prng]) 53 | end 54 | end 55 | 56 | # Name to help identify bunch 57 | # @return [String] 58 | attr_reader :name 59 | 60 | # Number of dice to roll 61 | # @return [Integer] 62 | attr_reader :ndice 63 | 64 | # Individual die from the bunch 65 | # @return [GamesDice::Die,GamesDice::ComplexDie] 66 | attr_reader :single_die 67 | 68 | # Can be nil, :keep_best or :keep_worst 69 | # @return [Symbol,nil] 70 | attr_reader :keep_mode 71 | 72 | # Number of "best" or "worst" results to select when #keep_mode is not nil. 73 | # @return [Integer,nil] 74 | attr_reader :keep_number 75 | 76 | # Result of most-recent roll, or nil if no roll made yet. 77 | # @return [Integer,nil] 78 | attr_reader :result 79 | 80 | # @!attribute [r] label 81 | # Description that will be used in explanations with more than one bunch 82 | # @return [String] 83 | def label 84 | return @name if @name != '' 85 | 86 | "#{@ndice}d#{@sides}" 87 | end 88 | 89 | # @!attribute [r] rerolls 90 | # Sequence of re-roll rules, or nil if re-rolls are not required. 91 | # @return [Array, nil] 92 | def rerolls 93 | @single_die.rerolls 94 | end 95 | 96 | # @!attribute [r] maps 97 | # Sequence of map rules, or nil if mapping is not required. 98 | # @return [Array, nil] 99 | def maps 100 | @single_die.maps 101 | end 102 | 103 | # @!attribute [r] result_details 104 | # After calling #roll, this is an array of GamesDice::DieResult objects. There is one from each #single_die rolled, 105 | # allowing inspection of how the result was obtained. 106 | # @return [Array, nil] Sequence of GamesDice::DieResult objects. 107 | def result_details 108 | return nil unless @raw_result_details 109 | 110 | @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r } 111 | end 112 | 113 | # @!attribute [r] min 114 | # Minimum possible result from a call to #roll 115 | # @return [Integer] 116 | def min 117 | n = @keep_mode ? [@keep_number, @ndice].min : @ndice 118 | n * @single_die.min 119 | end 120 | 121 | # @!attribute [r] max 122 | # Maximum possible result from a call to #roll 123 | # @return [Integer] 124 | def max 125 | n = @keep_mode ? [@keep_number, @ndice].min : @ndice 126 | n * @single_die.max 127 | end 128 | 129 | # Calculates the probability distribution for the bunch. When the bunch is composed of dice with 130 | # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of 131 | # recursion. 132 | # @return [GamesDice::Probabilities] Probability distribution of bunch. 133 | def probabilities 134 | return @probabilities if @probabilities 135 | 136 | @probabilities = if @keep_mode && @ndice > @keep_number 137 | @single_die.probabilities.repeat_n_sum_k(@ndice, @keep_number, @keep_mode) 138 | else 139 | @single_die.probabilities.repeat_sum(@ndice) 140 | end 141 | 142 | @probabilities 143 | end 144 | 145 | # Simulates rolling the bunch of identical dice 146 | # @return [Integer] Sum of all rolled dice, or sum of all keepers 147 | def roll 148 | generate_raw_results 149 | return @result if !@keep_mode || @keep_number.to_i >= @ndice 150 | 151 | use_dice = case @keep_mode 152 | when :keep_best then @raw_result_details.sort[-@keep_number..] 153 | when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)] 154 | end 155 | 156 | @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result } 157 | end 158 | 159 | # @!attribute [r] explain_result 160 | # Explanation of result, or nil if no call to #roll yet. 161 | # @return [String,nil] 162 | def explain_result 163 | return nil unless @result 164 | 165 | # With #keep_mode, we may need to show unused and used dice separately 166 | used_dice = result_details 167 | used_dice, = find_used_dice_due_to_keep_mode(result_details) if @keep_mode && @keep_number < @ndice 168 | 169 | build_explanation(used_dice) 170 | end 171 | 172 | private 173 | 174 | def generate_raw_results 175 | @result = 0 176 | @raw_result_details = [] 177 | 178 | @ndice.times do 179 | @result += @single_die.roll 180 | @raw_result_details << @single_die.result 181 | end 182 | end 183 | 184 | def name_number_sides_from_hash(options) 185 | @name = options[:name].to_s 186 | @ndice = Integer(options[:ndice]) 187 | raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice.positive? 188 | 189 | @sides = Integer(options[:sides]) 190 | raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides.positive? 191 | end 192 | 193 | def complex_die_params_from_hash(options) 194 | cd_hash = {} 195 | %i[maps rerolls].each do |k| 196 | cd_hash[k] = options[k].clone if options[k] 197 | end 198 | # We deliberately do not clone this object, it will often be intended that it is shared 199 | cd_hash[:prng] = options[:prng] 200 | cd_hash 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/games_dice/complex_die_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GamesDice 4 | class ComplexDie 5 | # @!visibility private 6 | # Private extension methods for GamesDice::ComplexDie probability calculations 7 | module ProbabilityHelpers 8 | private 9 | 10 | def calculate_probabilities 11 | if @rerolls && @maps 12 | GamesDice::Probabilities.from_h(prob_hash_with_rerolls_and_maps) 13 | elsif @rerolls 14 | GamesDice::Probabilities.from_h(recursive_probabilities) 15 | elsif @maps 16 | GamesDice::Probabilities.from_h(prob_hash_with_just_maps) 17 | else 18 | @basic_die.probabilities 19 | end 20 | end 21 | 22 | def prob_hash_with_rerolls_and_maps 23 | prob_hash = {} 24 | reroll_probs = recursive_probabilities 25 | reroll_probs.each do |v, p| 26 | add_mapped_to_prob_hash(prob_hash, v, p) 27 | end 28 | prob_hash 29 | end 30 | 31 | def prob_hash_with_just_maps 32 | prob_hash = {} 33 | @basic_die.probabilities.each do |v, p| 34 | add_mapped_to_prob_hash(prob_hash, v, p) 35 | end 36 | prob_hash 37 | end 38 | 39 | def add_mapped_to_prob_hash(prob_hash, orig_val, prob) 40 | mapped_val, = calc_maps(orig_val) 41 | prob_hash[mapped_val] ||= 0.0 42 | prob_hash[mapped_val] += prob 43 | end 44 | 45 | RecurseStack = Struct.new(:depth, :roll_reason, :subtracting, :probabilities, :prior_probability, :prior_result, 46 | :rerolls_left) do 47 | def initialize 48 | self.depth = 0 49 | self.roll_reason = :basic 50 | self.subtracting = false 51 | self.probabilities = {} 52 | self.prior_probability = 1.0 53 | end 54 | end 55 | 56 | def recursive_probabilities(stack = RecurseStack.new) 57 | stack.prior_probability = stack.prior_probability / @basic_die.sides 58 | stack.depth += 1 59 | 60 | @basic_die.each_value do |die_val| 61 | recurse_probs_for_value(die_val, stack) 62 | end 63 | stack.probabilities 64 | end 65 | 66 | def recurse_probs_for_value(die_val, stack) 67 | result_so_far, rerolls_remaining = calc_result_so_far(die_val, stack) 68 | rule_idx = find_matching_reroll_rule(die_val, result_so_far.rolls.length, rerolls_remaining) 69 | 70 | if conintue_recursing?(stack, rule_idx) 71 | continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx) 72 | else 73 | end_recursion_store_probs(stack, result_so_far) 74 | end 75 | end 76 | 77 | def conintue_recursing?(stack, rule_idx) 78 | if stack.depth >= 20 || stack.prior_probability < 1.0e-16 79 | @probabilities_complete = false 80 | return false 81 | end 82 | 83 | !rule_idx.nil? 84 | end 85 | 86 | def continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx) 87 | rule = @rerolls[rule_idx] 88 | rerolls_remaining[rule_idx] -= 1 89 | recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule) 90 | end 91 | 92 | def end_recursion_store_probs(stack, result_so_far) 93 | t = result_so_far.total 94 | stack.probabilities[t] ||= 0.0 95 | stack.probabilities[t] += stack.prior_probability 96 | end 97 | 98 | def recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule) 99 | next_stack = stack.clone 100 | next_stack.prior_result = result_so_far 101 | next_stack.rerolls_left = rerolls_remaining 102 | next_stack.subtracting = true if stack.subtracting || rule.type == :reroll_subtract 103 | 104 | # Apply the rule (note reversal for additions, after a subtract) 105 | next_stack.roll_reason = if stack.subtracting && rule.type == :reroll_add 106 | :reroll_subtract 107 | else 108 | rule.type 109 | end 110 | 111 | recursive_probabilities next_stack 112 | end 113 | 114 | def calc_result_so_far(die_val, stack) 115 | if stack.prior_result 116 | result_so_far = stack.prior_result.clone 117 | rerolls_remaining = stack.rerolls_left.clone 118 | result_so_far.add_roll(die_val, stack.roll_reason) 119 | else 120 | rerolls_remaining = @rerolls.map(&:limit) 121 | result_so_far = GamesDice::DieResult.new(die_val, stack.roll_reason) 122 | end 123 | [result_so_far, rerolls_remaining] 124 | end 125 | end 126 | 127 | # @!visibility private 128 | # Private extension methods for GamesDice::ComplexDie simulating rolls 129 | module RollHelpers 130 | private 131 | 132 | def roll_apply_rerolls 133 | return unless @rerolls 134 | 135 | subtracting = false 136 | rerolls_remaining = @rerolls.map(&:limit) 137 | 138 | rerolls_loop(subtracting, rerolls_remaining) 139 | end 140 | 141 | def rerolls_loop(subtracting, rerolls_remaining) 142 | loop do 143 | rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining) 144 | break unless rule_idx 145 | 146 | rule = @rerolls[rule_idx] 147 | rerolls_remaining[rule_idx] -= 1 148 | subtracting = true if rule.type == :reroll_subtract 149 | roll_apply_reroll_rule rule, subtracting 150 | end 151 | end 152 | 153 | def roll_apply_reroll_rule(rule, is_subtracting) 154 | # Apply the rule (note reversal for additions, after a subtract) 155 | if is_subtracting && rule.type == :reroll_add 156 | @result.add_roll(@basic_die.roll, :reroll_subtract) 157 | else 158 | @result.add_roll(@basic_die.roll, rule.type) 159 | end 160 | end 161 | 162 | # Find which rule, if any, is being triggered 163 | def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining) 164 | @rerolls.zip(rerolls_remaining).find_index do |rule, remaining| 165 | next if rule.type == :reroll_subtract && num_rolls > 1 166 | 167 | remaining.positive? && rule.applies?(check_value) 168 | end 169 | end 170 | 171 | def roll_apply_maps 172 | return unless @maps 173 | 174 | m, n = calc_maps(@result.value) 175 | @result.apply_map(m, n) 176 | end 177 | 178 | def calc_maps(original_value) 179 | y = 0 180 | n = '' 181 | @maps.find do |rule| 182 | if (maybe_y = rule.map_from(original_value)) 183 | y = maybe_y 184 | n = rule.mapped_name 185 | end 186 | maybe_y 187 | end 188 | [y, n] 189 | end 190 | end 191 | 192 | # @!visibility private 193 | # Private extension methods for GamesDice::ComplexDie calculating min and max (which is surprisingly complex) 194 | module MinMaxHelpers 195 | private 196 | 197 | def calc_minmax 198 | @min_result = probabilities.min 199 | @max_result = probabilities.max 200 | return if @probabilities_complete 201 | 202 | logical_min, logical_max = logical_minmax 203 | @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax 204 | end 205 | 206 | def minmax_mappings(possible_values) 207 | possible_values.map do |x| 208 | map_val, = calc_maps(x) 209 | map_val 210 | end.minmax 211 | end 212 | 213 | # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we 214 | # have to. The inaccuracy is that min_result..max_result may contain 'holes' which have extreme map values that 215 | # cannot actually occur. In practice it is likely a non-issue unless someone went out of their way to invent a 216 | # dice schem that broke it. 217 | def logical_minmax 218 | return @basic_die.minmax unless @rerolls || @maps 219 | return minmax_mappings(@basic_die.all_values) unless @rerolls 220 | 221 | min_result, max_result = logical_rerolls_minmax 222 | return minmax_mappings((min_result..max_result)) if @maps 223 | 224 | [min_result, max_result] 225 | end 226 | 227 | def logical_rerolls_minmax 228 | min_result = @basic_die.min 229 | max_result = @basic_die.max 230 | min_subtract = find_minimum_possible_subtract 231 | max_add = find_maximum_possible_adds 232 | min_result = [min_subtract - max_add, min_subtract - max_result].min if min_subtract 233 | [min_result, max_add + max_result] 234 | end 235 | 236 | def find_minimum_possible_subtract 237 | min_subtract = nil 238 | @rerolls.select { |r| r.type == :reroll_subtract }.each do |rule| 239 | min_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.min 240 | next unless min_reroll 241 | 242 | min_subtract = [min_reroll, min_subtract].compact.min 243 | end 244 | min_subtract 245 | end 246 | 247 | def find_maximum_possible_adds 248 | total_add = 0 249 | @rerolls.select { |r| r.type == :reroll_add }.each do |rule| 250 | max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max 251 | next unless max_reroll 252 | 253 | total_add += max_reroll * rule.limit 254 | end 255 | total_add 256 | end 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /lib/games_dice/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parslet' 4 | 5 | module GamesDice 6 | # Based on the parslet gem, this class defines the dice mini-language used by GamesDice.create 7 | # 8 | # An instance of this class is a parser for the language. There are no user-definable instance 9 | # variables. 10 | # 11 | class Parser < Parslet::Parser 12 | # Parslet rules that define the dice string grammar. 13 | rule(:integer) { match('[0-9]').repeat(1) } 14 | rule(:plus_minus_integer) { (match('[+-]') >> integer) | integer } 15 | rule(:range) { integer.as(:range_start) >> str('..') >> integer.as(:range_end) } 16 | rule(:dlabel) { match('[d]') } 17 | rule(:space) { match('\s').repeat(1) } 18 | rule(:space?) { space.maybe } 19 | rule(:underscore) { str('_').repeat(1) } 20 | rule(:underscore?) { space.maybe } 21 | 22 | rule(:bunch_start) { integer.as(:ndice) >> dlabel >> integer.as(:sides) } 23 | 24 | rule(:reroll_label) { match(['r']).as(:reroll) } 25 | rule(:keep_label) { match(['k']).as(:keep) } 26 | rule(:map_label) { match(['m']).as(:map) } 27 | rule(:alias_label) { match(['x']).as(:alias) } 28 | 29 | rule(:single_modifier) { alias_label } 30 | rule(:modifier_label) { reroll_label | keep_label | map_label } 31 | rule(:simple_modifier) { modifier_label >> integer.as(:simple_value) } 32 | rule(:comparison_op) { str('>=') | str('<=') | str('==') | str('>') | str('<') } 33 | rule(:ctl_string) { match('[a-z_]').repeat(1) } 34 | rule(:output_string) { match('[A-Za-z0-9_]').repeat(1) } 35 | 36 | rule(:opint_or_int) { (comparison_op.as(:comparison) >> integer.as(:compare_num)) | integer.as(:compare_num) } 37 | rule(:comma) { str(',') } 38 | rule(:stop) { str('.') } 39 | 40 | rule(:condition_only) { opint_or_int.as(:condition) } 41 | rule(:num_only) { integer.as(:num) } 42 | 43 | rule(:condition_and_type) { opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) } 44 | rule(:condition_and_num) { opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) } 45 | 46 | rule(:condition_type_and_num) do 47 | opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) >> comma >> integer.as(:num) 48 | end 49 | 50 | rule(:condition_num_and_output) do 51 | opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) >> comma >> output_string.as(:output) 52 | end 53 | 54 | rule(:num_and_type) { integer.as(:num) >> comma >> ctl_string.as(:type) } 55 | 56 | rule(:reroll_params) { condition_type_and_num | condition_and_type | condition_only } 57 | rule(:map_params) { condition_num_and_output | condition_and_num | condition_only } 58 | rule(:keeper_params) { num_and_type | num_only } 59 | 60 | rule(:full_reroll) { reroll_label >> str(':') >> reroll_params >> stop } 61 | rule(:full_map) { map_label >> str(':') >> map_params >> stop } 62 | rule(:full_keepers) { keep_label >> str(':') >> keeper_params >> stop } 63 | 64 | rule(:complex_modifier) { full_reroll | full_map | full_keepers } 65 | 66 | rule(:bunch_modifier) { complex_modifier | (single_modifier >> stop.maybe) | (simple_modifier >> stop.maybe) } 67 | rule(:bunch) { bunch_start >> bunch_modifier.repeat.as(:mods) } 68 | 69 | rule(:operator) { match('[+-]').as(:op) >> space? } 70 | rule(:add_bunch) { operator >> bunch >> space? } 71 | rule(:add_constant) { operator >> integer.as(:constant) >> space? } 72 | rule(:dice_expression) { add_bunch | add_constant } 73 | rule(:expressions) { dice_expression.repeat.as(:bunches) } 74 | root :expressions 75 | 76 | # Parses a string description in the dice mini-language, and returns data for feeding into 77 | # GamesDice::Dice constructor. 78 | # @param [String] dice_description Text to parse e.g. '1d6' 79 | # @return [Hash] Analysis of dice_description 80 | def parse(dice_description) 81 | dice_description = dice_description.to_s.strip 82 | # Force first item to start '+' for simpler parse rules 83 | dice_description = "+#{dice_description}" unless dice_description =~ /\A[+-]/ 84 | dice_expressions = super(dice_description) 85 | { 86 | bunches: ParseTreeProcessor.collect_bunches(dice_expressions), 87 | offset: ParseTreeProcessor.collect_offset(dice_expressions) 88 | } 89 | end 90 | 91 | # Class converts parse tree to GamesDice hash model 92 | # @!visibility private 93 | class ParseTreeProcessor 94 | class << self 95 | def collect_bunches(dice_expressions) 96 | dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash| 97 | out_hash = {} 98 | collect_bunch_basics(in_hash, out_hash) 99 | collect_bunch_multiplier(in_hash, out_hash) if in_hash[:op] 100 | 101 | in_hash[:mods]&.each do |mod| 102 | collect_bunch_modifier(mod, out_hash) 103 | end 104 | 105 | out_hash 106 | end 107 | end 108 | 109 | def collect_bunch_basics(in_hash, out_hash) 110 | %i[ndice sides].each do |s| 111 | next unless in_hash[s] 112 | 113 | out_hash[s] = in_hash[s].to_i 114 | end 115 | end 116 | 117 | def collect_bunch_multiplier(in_hash, out_hash) 118 | optype = in_hash[:op].to_s 119 | out_hash[:multiplier] = case optype 120 | when '+' then 1 121 | when '-' then -1 122 | end 123 | end 124 | 125 | def collect_bunch_modifier(mod, out_hash) 126 | if mod[:alias] 127 | ParseTreeBunchModifier.collect_alias_modifier mod, out_hash 128 | elsif mod[:keep] 129 | ParseTreeBunchModifier.collect_keeper_rule mod, out_hash 130 | elsif mod[:map] 131 | ParseTreeBunchModifier.collect_map_rule mod, out_hash 132 | elsif mod[:reroll] 133 | ParseTreeBunchModifier.collect_reroll_rule mod, out_hash 134 | end 135 | end 136 | 137 | def collect_offset(dice_expressions) 138 | dice_expressions[:bunches].select { |h| h[:constant] }.inject(0) do |total, in_hash| 139 | c = in_hash[:constant].to_i 140 | optype = in_hash[:op].to_s 141 | if optype == '+' 142 | total + c 143 | else 144 | total - c 145 | end 146 | end 147 | end 148 | end 149 | end 150 | 151 | # Class for collating bunch data into bunch construction hash 152 | # @!visibility private 153 | class ParseTreeBunchModifier 154 | class << self 155 | # Called when we have a single letter convenient alias for common dice adjustments 156 | def collect_alias_modifier(alias_mod, out_hash) 157 | alias_name = alias_mod[:alias].to_s 158 | case alias_name 159 | when 'x' # Exploding re-roll 160 | out_hash[:rerolls] ||= [] 161 | out_hash[:rerolls] << [out_hash[:sides], :==, :reroll_add] 162 | end 163 | end 164 | 165 | # Called for any parsed reroll rule 166 | def collect_reroll_rule(reroll_mod, out_hash) 167 | out_hash[:rerolls] ||= [] 168 | if reroll_mod[:simple_value] 169 | out_hash[:rerolls] << [reroll_mod[:simple_value].to_i, :>=, :reroll_replace] 170 | return 171 | end 172 | 173 | collect_complex_reroll_rule(reroll_mod, out_hash) 174 | end 175 | 176 | def collect_complex_reroll_rule(reroll_mod, out_hash) 177 | # Typical reroll_mod: {:reroll=>"r"@5, :condition=>{:compare_num=>"10"@7}, :type=>"add"@10} 178 | op = get_op_symbol(reroll_mod[:condition][:comparison] || '==') 179 | v = reroll_mod[:condition][:compare_num].to_i 180 | type = "reroll_#{reroll_mod[:type] || 'replace'}".to_sym 181 | 182 | out_hash[:rerolls] << if reroll_mod[:num] 183 | [v, op, type, reroll_mod[:num].to_i] 184 | else 185 | [v, op, type] 186 | end 187 | end 188 | 189 | # Called for any parsed keeper mode 190 | def collect_keeper_rule(keeper_mod, out_hash) 191 | raise 'Cannot set keepers for a bunch twice' if out_hash[:keep_mode] 192 | 193 | if keeper_mod[:simple_value] 194 | out_hash[:keep_mode] = :keep_best 195 | out_hash[:keep_number] = keeper_mod[:simple_value].to_i 196 | return 197 | end 198 | 199 | # Typical keeper_mod: {:keep=>"k"@5, :num=>"1"@7, :type=>"worst"@9} 200 | out_hash[:keep_number] = keeper_mod[:num].to_i 201 | out_hash[:keep_mode] = "keep_#{keeper_mod[:type] || 'best'}".to_sym 202 | end 203 | 204 | # Called for any parsed map mode 205 | def collect_map_rule(map_mod, out_hash) 206 | out_hash[:maps] ||= [] 207 | if map_mod[:simple_value] 208 | out_hash[:maps] << [map_mod[:simple_value].to_i, :<=, 1] 209 | return 210 | end 211 | 212 | collect_complex_map_rule(map_mod, out_hash) 213 | end 214 | 215 | def collect_complex_map_rule(map_mod, out_hash) 216 | # Typical map_mod: {:map=>"m"@4, :condition=>{:compare_num=>"5"@6}, :num=>"2"@8, :output=>"Qwerty"@10} 217 | op = get_op_symbol(map_mod[:condition][:comparison] || '>=') 218 | v = map_mod[:condition][:compare_num].to_i 219 | out_val = 1 220 | out_val = map_mod[:num].to_i if map_mod[:num] 221 | 222 | out_hash[:maps] << if map_mod[:output] 223 | [v, op, out_val, map_mod[:output].to_s] 224 | else 225 | [v, op, out_val] 226 | end 227 | end 228 | 229 | # The dice description language uses (r).op.x, whilst GamesDice::RerollRule uses x.op.(r), so 230 | # as well as converting to a symbol, we must reverse sense of input to constructor 231 | OP_CONVERSION = { 232 | '==' => :==, 233 | '>=' => :<=, 234 | '>' => :<, 235 | '<' => :>, 236 | '<=' => :>= 237 | }.freeze 238 | 239 | def get_op_symbol(parsed_op_string) 240 | OP_CONVERSION[parsed_op_string.to_s] 241 | end 242 | end 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /spec/die_result_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::DieResult do 6 | describe '.new' do 7 | it "should work without parameters to represent 'no results yet'" do 8 | die_result = GamesDice::DieResult.new 9 | expect(die_result.value).to eql nil 10 | expect(die_result.rolls).to eql [] 11 | expect(die_result.roll_reasons).to eql [] 12 | end 13 | 14 | it 'should work with a single Integer param to represent an initial result' do 15 | die_result = GamesDice::DieResult.new(8) 16 | expect(die_result.value).to eql 8 17 | expect(die_result.rolls).to eql [8] 18 | expect(die_result.roll_reasons).to eql [:basic] 19 | end 20 | 21 | it 'should not accept a param that cannot be coerced to Integer' do 22 | expect(-> { GamesDice::DieResult.new([]) }).to raise_error(TypeError) 23 | expect(-> { GamesDice::DieResult.new('N') }).to raise_error(ArgumentError) 24 | end 25 | 26 | it 'should not accept unknown reasons for making a roll' do 27 | expect(-> { GamesDice::DieResult.new(8, 'wooo') }).to raise_error(ArgumentError) 28 | expect(-> { GamesDice::DieResult.new(8, :frabulous) }).to raise_error(ArgumentError) 29 | end 30 | end 31 | 32 | describe '#add_roll' do 33 | context "starting from 'no results yet'" do 34 | let(:die_result) { GamesDice::DieResult.new } 35 | 36 | it 'should create an initial result' do 37 | die_result.add_roll(5) 38 | expect(die_result.value).to eql 5 39 | expect(die_result.rolls).to eql [5] 40 | expect(die_result.roll_reasons).to eql [:basic] 41 | end 42 | 43 | it 'should accept non-basic reasons for the first roll' do 44 | die_result.add_roll(4, :reroll_subtract) 45 | expect(die_result.value).to eql(-4) 46 | expect(die_result.rolls).to eql [4] 47 | expect(die_result.roll_reasons).to eql [:reroll_subtract] 48 | end 49 | 50 | it 'should not accept a first param that cannot be coerced to Integer' do 51 | expect(-> { die_result.add_roll([]) }).to raise_error(TypeError) 52 | expect(-> { die_result.add_roll('N') }).to raise_error(ArgumentError) 53 | end 54 | 55 | it 'should not accept an unsupported second param' do 56 | expect(-> { die_result.add_roll(5, []) }).to raise_error(ArgumentError) 57 | expect(-> { die_result.add_roll(15, :bam) }).to raise_error(ArgumentError) 58 | end 59 | end 60 | 61 | context 'starting with an initial result' do 62 | let(:die_result) { GamesDice::DieResult.new(7) } 63 | 64 | it 'should not accept a first param that cannot be coerced to Integer' do 65 | expect(-> { die_result.add_roll([]) }).to raise_error(TypeError) 66 | expect(-> { die_result.add_roll('N') }).to raise_error(ArgumentError) 67 | end 68 | 69 | it 'should not accept an unsupported second param' do 70 | expect(-> { die_result.add_roll(5, []) }).to raise_error(ArgumentError) 71 | expect(-> { die_result.add_roll(15, :bam) }).to raise_error(ArgumentError) 72 | end 73 | 74 | context 'add another basic roll' do 75 | it 'should replace an initial result, as if the die were re-rolled' do 76 | die_result.add_roll(5) 77 | expect(die_result.value).to eql 5 78 | expect(die_result.rolls).to eql [7, 5] 79 | expect(die_result.roll_reasons).to eql %i[basic basic] 80 | end 81 | end 82 | 83 | context 'exploding dice' do 84 | it 'should add to value when exploding up' do 85 | die_result.add_roll(6, :reroll_add) 86 | expect(die_result.value).to eql 13 87 | expect(die_result.rolls).to eql [7, 6] 88 | expect(die_result.roll_reasons).to eql %i[basic reroll_add] 89 | end 90 | 91 | it 'should subtract from value when exploding down' do 92 | die_result.add_roll(4, :reroll_subtract) 93 | expect(die_result.value).to eql 3 94 | expect(die_result.rolls).to eql [7, 4] 95 | expect(die_result.roll_reasons).to eql %i[basic reroll_subtract] 96 | end 97 | end 98 | 99 | context 're-roll dice' do 100 | it 'should optionally replace roll unconditionally' do 101 | die_result.add_roll(2, :reroll_replace) 102 | expect(die_result.value).to eql 2 103 | expect(die_result.rolls).to eql [7, 2] 104 | expect(die_result.roll_reasons).to eql %i[basic reroll_replace] 105 | 106 | die_result.add_roll(5, :reroll_replace) 107 | expect(die_result.value).to eql 5 108 | expect(die_result.rolls).to eql [7, 2, 5] 109 | expect(die_result.roll_reasons).to eql %i[basic reroll_replace reroll_replace] 110 | end 111 | 112 | it 'should optionally use best roll' do 113 | die_result.add_roll(2, :reroll_use_best) 114 | expect(die_result.value).to eql 7 115 | expect(die_result.rolls).to eql [7, 2] 116 | expect(die_result.roll_reasons).to eql %i[basic reroll_use_best] 117 | 118 | die_result.add_roll(9, :reroll_use_best) 119 | expect(die_result.value).to eql 9 120 | expect(die_result.rolls).to eql [7, 2, 9] 121 | expect(die_result.roll_reasons).to eql %i[basic reroll_use_best reroll_use_best] 122 | end 123 | 124 | it 'should optionally use worst roll' do 125 | die_result.add_roll(4, :reroll_use_worst) 126 | expect(die_result.value).to eql 4 127 | expect(die_result.rolls).to eql [7, 4] 128 | expect(die_result.roll_reasons).to eql %i[basic reroll_use_worst] 129 | 130 | die_result.add_roll(5, :reroll_use_worst) 131 | expect(die_result.value).to eql 4 132 | expect(die_result.rolls).to eql [7, 4, 5] 133 | expect(die_result.roll_reasons).to eql %i[basic reroll_use_worst reroll_use_worst] 134 | end 135 | end 136 | 137 | context 'combinations of reroll reasons' do 138 | it 'should correctly handle valid reasons for extra rolls in combination' do 139 | die_result.add_roll(10, :reroll_add) 140 | die_result.add_roll(3, :reroll_subtract) 141 | expect(die_result.value).to eql 14 142 | expect(die_result.rolls).to eql [7, 10, 3] 143 | expect(die_result.roll_reasons).to eql %i[basic reroll_add reroll_subtract] 144 | 145 | die_result.add_roll(12, :reroll_replace) 146 | expect(die_result.value).to eql 12 147 | expect(die_result.rolls).to eql [7, 10, 3, 12] 148 | expect(die_result.roll_reasons).to eql %i[basic reroll_add reroll_subtract reroll_replace] 149 | 150 | die_result.add_roll(9, :reroll_use_best) 151 | expect(die_result.value).to eql 12 152 | expect(die_result.rolls).to eql [7, 10, 3, 12, 9] 153 | expect(die_result.roll_reasons).to eql %i[basic reroll_add reroll_subtract reroll_replace 154 | reroll_use_best] 155 | 156 | die_result.add_roll(15, :reroll_add) 157 | expect(die_result.value).to eql 27 158 | expect(die_result.rolls).to eql [7, 10, 3, 12, 9, 15] 159 | expect(die_result.roll_reasons).to eql %i[basic reroll_add reroll_subtract reroll_replace 160 | reroll_use_best reroll_add] 161 | end 162 | end 163 | end 164 | end 165 | 166 | describe '#explain_value' do 167 | let(:die_result) { GamesDice::DieResult.new } 168 | 169 | it "should be empty string for 'no results yet'" do 170 | expect(die_result.explain_value).to eql '' 171 | end 172 | 173 | it 'should be a simple stringified number when there is one die roll' do 174 | die_result.add_roll(3) 175 | expect(die_result.explain_value).to eql '3' 176 | end 177 | 178 | it 'should describe all single rolls made and how they combine' do 179 | die_result.add_roll(6) 180 | expect(die_result.explain_value).to eql '6' 181 | 182 | die_result.add_roll(5, :reroll_add) 183 | expect(die_result.explain_value).to eql '[6+5] 11' 184 | 185 | die_result.add_roll(2, :reroll_replace) 186 | expect(die_result.explain_value).to eql '[6+5|2] 2' 187 | 188 | die_result.add_roll(7, :reroll_subtract) 189 | expect(die_result.explain_value).to eql '[6+5|2-7] -5' 190 | 191 | die_result.add_roll(4, :reroll_use_worst) 192 | expect(die_result.explain_value).to eql '[6+5|2-7\\4] -5' 193 | 194 | die_result.add_roll(3, :reroll_use_best) 195 | expect(die_result.explain_value).to eql '[6+5|2-7\\4/3] 3' 196 | end 197 | end 198 | 199 | it 'should combine via +,- and * intuitively based on #value' do 200 | die_result = GamesDice::DieResult.new(7) 201 | expect((die_result + 3)).to eql 10 202 | expect((4 + die_result)).to eql 11 203 | expect((die_result - 2)).to eql 5 204 | expect((9 - die_result)).to eql 2 205 | 206 | expect((die_result + 7.7)).to eql 14.7 207 | expect((4.1 + die_result)).to eql 11.1 208 | 209 | expect((die_result * 2)).to eql 14 210 | expect((1 * die_result)).to eql 7 211 | 212 | other_die_result = GamesDice::DieResult.new(6) 213 | other_die_result.add_roll(6, :reroll_add) 214 | expect((die_result + other_die_result)).to eql 19 215 | expect((other_die_result - die_result)).to eql 5 216 | end 217 | 218 | it 'should support comparison with >,<,>=,<= as if it were an integer, based on #value' do 219 | die_result = GamesDice::DieResult.new(7) 220 | 221 | expect((die_result > 3)).to eql true 222 | expect((die_result < 14)).to eql true 223 | expect((die_result >= 7)).to eql true 224 | expect((die_result <= 9.5)).to eql true 225 | expect((die_result < 3)).to eql false 226 | expect((die_result > 14)).to eql false 227 | expect((die_result <= 8)).to eql true 228 | expect((die_result >= 14)).to eql false 229 | 230 | other_die_result = GamesDice::DieResult.new(6) 231 | other_die_result.add_roll(6, :reroll_add) 232 | expect((die_result > other_die_result)).to eql false 233 | expect((other_die_result > die_result)).to eql true 234 | expect((die_result >= other_die_result)).to eql false 235 | expect((other_die_result >= die_result)).to eql true 236 | expect((die_result < other_die_result)).to eql true 237 | expect((other_die_result < die_result)).to eql false 238 | expect((die_result <= other_die_result)).to eql true 239 | expect((other_die_result <= die_result)).to eql false 240 | end 241 | 242 | it 'should sort, based on #value' do 243 | die_results = [ 244 | GamesDice::DieResult.new(7), GamesDice::DieResult.new(5), GamesDice::DieResult.new(8), GamesDice::DieResult.new(3) 245 | ] 246 | 247 | die_results.sort! 248 | 249 | expect(die_results[0].value).to eql 3 250 | expect(die_results[1].value).to eql 5 251 | expect(die_results[2].value).to eql 7 252 | expect(die_results[3].value).to eql 8 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/complex_die_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::ComplexDie do 6 | before do 7 | # Set state of default PRNG 8 | srand(4567) 9 | end 10 | 11 | it 'should represent a basic die as an object' do 12 | die = GamesDice::ComplexDie.new(6) 13 | expect(die.min).to eql 1 14 | expect(die.max).to eql 6 15 | expect(die.sides).to eql 6 16 | end 17 | 18 | it "should return results based on Ruby's internal rand() by default" do 19 | die = GamesDice::ComplexDie.new(10) 20 | [5, 4, 10, 4, 7, 8, 1, 9].each do |expected| 21 | expect(die.roll.value).to eql expected 22 | expect(die.result.value).to eql expected 23 | end 24 | end 25 | 26 | it 'should use any object with a rand(Integer) method' do 27 | prng = TestPRNG.new 28 | die = GamesDice::ComplexDie.new(20, prng: prng) 29 | [16, 7, 3, 11, 16, 18, 20, 7].each do |expected| 30 | expect(die.roll.value).to eql expected 31 | expect(die.result.value).to eql expected 32 | end 33 | end 34 | 35 | it 'should optionally accept a rerolls param' do 36 | GamesDice::ComplexDie.new(10, rerolls: []) 37 | GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(6, :<=, :reroll_add)]) 38 | GamesDice::ComplexDie.new(10, 39 | rerolls: [GamesDice::RerollRule.new(6, :<=, :reroll_add), 40 | GamesDice::RerollRule.new(1, :>=, :reroll_subtract)]) 41 | GamesDice::ComplexDie.new(10, rerolls: [[6, :<=, :reroll_add]]) 42 | GamesDice::ComplexDie.new(10, rerolls: [[6, :<=, :reroll_add], [1, :>=, :reroll_subtract]]) 43 | 44 | expect(lambda do 45 | GamesDice::ComplexDie.new(10, rerolls: 7) 46 | end).to raise_error(TypeError) 47 | 48 | expect(lambda do 49 | GamesDice::ComplexDie.new(10, rerolls: ['hello']) 50 | end).to raise_error(TypeError) 51 | 52 | expect(lambda do 53 | GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(6, :<=, :reroll_add), :reroll_add]) 54 | end).to raise_error(TypeError) 55 | 56 | expect(lambda do 57 | GamesDice::ComplexDie.new(10, rerolls: [7]) 58 | end).to raise_error(TypeError) 59 | 60 | expect(lambda do 61 | GamesDice::ComplexDie.new(10, rerolls: [['hello']]) 62 | end).to raise_error(ArgumentError) 63 | 64 | expect(lambda do 65 | GamesDice::ComplexDie.new(10, rerolls: [[6, :<=, :reroll_add], :reroll_add]) 66 | end).to raise_error(TypeError) 67 | end 68 | 69 | it 'should optionally accept a maps param' do 70 | GamesDice::ComplexDie.new(10, maps: []) 71 | GamesDice::ComplexDie.new(10, maps: [GamesDice::MapRule.new(7, :<=, 1)]) 72 | GamesDice::ComplexDie.new(10, maps: [GamesDice::MapRule.new(7, :<=, 1), GamesDice::MapRule.new(1, :>, -1)]) 73 | GamesDice::ComplexDie.new(10, maps: [[7, :<=, 1]]) 74 | GamesDice::ComplexDie.new(10, maps: [[7, :<=, 1], [1, :>, -1]]) 75 | 76 | expect(lambda do 77 | GamesDice::ComplexDie.new(10, maps: 7) 78 | end).to raise_error(TypeError) 79 | 80 | expect(lambda do 81 | GamesDice::ComplexDie.new(10, maps: [7]) 82 | end).to raise_error(TypeError) 83 | 84 | expect(lambda do 85 | GamesDice::ComplexDie.new(10, maps: [[7]]) 86 | end).to raise_error(ArgumentError) 87 | 88 | expect(lambda do 89 | GamesDice::ComplexDie.new(10, maps: ['hello']) 90 | end).to raise_error(TypeError) 91 | 92 | expect(lambda do 93 | GamesDice::ComplexDie.new(10, 94 | maps: [GamesDice::MapRule.new(7, :<=, 1), 95 | GamesDice::RerollRule.new(6, :<=, :reroll_add)]) 96 | end).to raise_error(TypeError) 97 | end 98 | 99 | describe 'with rerolls' do 100 | it 'should calculate correct minimum and maximum results' do 101 | die = GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(10, :<=, :reroll_add, 3)]) 102 | expect(die.min).to eql 1 103 | expect(die.max).to eql 40 104 | 105 | die = GamesDice::ComplexDie.new(10, rerolls: [[1, :>=, :reroll_subtract]]) 106 | expect(die.min).to eql(-9) 107 | expect(die.max).to eql 10 108 | 109 | die = GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(10, :<=, :reroll_add)]) 110 | expect(die.min).to eql 1 111 | expect(die.max).to eql 10_010 112 | end 113 | 114 | it 'should simulate a d10 that rerolls and adds on a result of 10' do 115 | die = GamesDice::ComplexDie.new(10, rerolls: [[10, :<=, :reroll_add]]) 116 | [5, 4, 14, 7, 8, 1, 9].each do |expected| 117 | expect(die.roll.value).to eql expected 118 | expect(die.result.value).to eql expected 119 | end 120 | end 121 | 122 | it 'should explain how it got results outside range 1 to 10 on a d10' do 123 | die = GamesDice::ComplexDie.new(10, rerolls: [[10, :<=, :reroll_add], [1, :>=, :reroll_subtract]]) 124 | ['5', '4', '[10+4] 14', '7', '8', '[1-9] -8'].each do |expected| 125 | die.roll 126 | expect(die.explain_result).to eql expected 127 | end 128 | end 129 | 130 | it 'should calculate an expected result' do 131 | die = GamesDice::ComplexDie.new(10, rerolls: [[10, :<=, :reroll_add], [1, :>=, :reroll_subtract]]) 132 | expect(die.probabilities.expected).to be_within(1e-10).of 5.5 133 | 134 | die = GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(1, :<=, :reroll_use_best, 1)]) 135 | expect(die.probabilities.expected).to be_within(1e-10).of 7.15 136 | 137 | die = GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(1, :<=, :reroll_use_worst, 2)]) 138 | expect(die.probabilities.expected).to be_within(1e-10).of 3.025 139 | 140 | die = GamesDice::ComplexDie.new(6, rerolls: [GamesDice::RerollRule.new(6, :<=, :reroll_add)]) 141 | expect(die.probabilities.expected).to be_within(1e-10).of 4.2 142 | 143 | die = GamesDice::ComplexDie.new(8, rerolls: [GamesDice::RerollRule.new(1, :>=, :reroll_use_best)]) 144 | expect(die.probabilities.expected).to be_within(1e-10).of 5.0 145 | 146 | die = GamesDice::ComplexDie.new(4, rerolls: [GamesDice::RerollRule.new(1, :>=, :reroll_replace, 1)]) 147 | expect(die.probabilities.expected).to be_within(1e-10).of 2.875 148 | end 149 | 150 | it 'should calculate probabilities of each possible result' do 151 | die = GamesDice::ComplexDie.new(6, rerolls: [GamesDice::RerollRule.new(7, :>, :reroll_add, 1)]) 152 | probs = die.probabilities.to_h 153 | expect(probs[11]).to be_within(1e-10).of 2 / 36.0 154 | expect(probs[8]).to be_within(1e-10).of 5 / 36.0 155 | expect(probs.values.inject(:+)).to be_within(1e-9).of 1.0 156 | 157 | die = GamesDice::ComplexDie.new(10, rerolls: [GamesDice::RerollRule.new(10, :<=, :reroll_add)]) 158 | probs = die.probabilities.to_h 159 | expect(probs[8]).to be_within(1e-10).of 0.1 160 | expect(probs[10]).to be_nil 161 | expect(probs[13]).to be_within(1e-10).of 0.01 162 | expect(probs[27]).to be_within(1e-10).of 0.001 163 | expect(probs.values.inject(:+)).to be_within(1e-9).of 1.0 164 | 165 | die = GamesDice::ComplexDie.new(6, rerolls: [GamesDice::RerollRule.new(1, :>=, :reroll_replace, 1)]) 166 | probs = die.probabilities.to_h 167 | expect(probs[1]).to be_within(1e-10).of 1 / 36.0 168 | expect(probs[2]).to be_within(1e-10).of 7 / 36.0 169 | expect(probs.values.inject(:+)).to be_within(1e-9).of 1.0 170 | end 171 | 172 | it 'should calculate aggregate probabilities' do 173 | die = GamesDice::ComplexDie.new(6, rerolls: [GamesDice::RerollRule.new(7, :>, :reroll_add, 1)]) 174 | probs = die.probabilities 175 | expect(probs.p_gt(7)).to be_within(1e-10).of 15 / 36.0 176 | expect(probs.p_gt(-10)).to eql 1.0 177 | expect(probs.p_gt(12)).to eql 0.0 178 | 179 | expect(probs.p_ge(7)).to be_within(1e-10).of 21 / 36.0 180 | expect(probs.p_ge(2)).to eql 1.0 181 | expect(probs.p_ge(15)).to eql 0.0 182 | 183 | expect(probs.p_lt(7)).to be_within(1e-10).of 15 / 36.0 184 | expect(probs.p_lt(-10)).to eql 0.0 185 | expect(probs.p_lt(13)).to eql 1.0 186 | 187 | expect(probs.p_le(7)).to be_within(1e-10).of 21 / 36.0 188 | expect(probs.p_le(1)).to eql 0.0 189 | expect(probs.p_le(12)).to eql 1.0 190 | end 191 | end 192 | 193 | describe 'with maps' do 194 | it 'should calculate correct minimum and maximum results' do 195 | die = GamesDice::ComplexDie.new(10, maps: [GamesDice::MapRule.new(7, :<=, 1, 'S')]) 196 | expect(die.min).to eql 0 197 | expect(die.max).to eql 1 198 | end 199 | 200 | it 'should simulate a d10 that scores 1 for success on a value of 7 or more' do 201 | die = GamesDice::ComplexDie.new(10, maps: [[7, :<=, 1, 'S']]) 202 | [0, 0, 1, 0, 1, 1, 0, 1].each do |expected| 203 | expect(die.roll.value).to eql expected 204 | expect(die.result.value).to eql expected 205 | end 206 | end 207 | 208 | it 'should label the mappings applied with the provided names' do 209 | die = GamesDice::ComplexDie.new(10, maps: [[7, :<=, 1, 'S'], [1, :>=, -1, 'F']]) 210 | ['5', '4', '10 S', '4', '7 S', '8 S', '1 F', '9 S'].each do |expected| 211 | die.roll 212 | expect(die.explain_result).to eql expected 213 | end 214 | end 215 | 216 | it 'should calculate an expected result' do 217 | die = GamesDice::ComplexDie.new(10, 218 | maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'), 219 | GamesDice::MapRule.new(1, :>=, -1, 'F')]) 220 | expect(die.probabilities.expected).to be_within(1e-10).of 0.3 221 | end 222 | 223 | it 'should calculate probabilities of each possible result' do 224 | die = GamesDice::ComplexDie.new(10, 225 | maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'), 226 | GamesDice::MapRule.new(1, :>=, -1, 'F')]) 227 | probs_hash = die.probabilities.to_h 228 | expect(probs_hash[1]).to be_within(1e-10).of 0.4 229 | expect(probs_hash[0]).to be_within(1e-10).of 0.5 230 | expect(probs_hash[-1]).to be_within(1e-10).of 0.1 231 | expect(probs_hash.values.inject(:+)).to be_within(1e-9).of 1.0 232 | end 233 | 234 | it 'should calculate aggregate probabilities' do 235 | die = GamesDice::ComplexDie.new(10, 236 | maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'), 237 | GamesDice::MapRule.new(1, :>=, -1, 'F')]) 238 | probs = die.probabilities 239 | expect(probs.p_gt(-2)).to eql 1.0 240 | expect(probs.p_gt(-1)).to be_within(1e-10).of 0.9 241 | expect(probs.p_gt(1)).to eql 0.0 242 | 243 | expect(probs.p_ge(1)).to be_within(1e-10).of 0.4 244 | expect(probs.p_ge(-1)).to eql 1.0 245 | expect(probs.p_ge(2)).to eql 0.0 246 | 247 | expect(probs.p_lt(1)).to be_within(1e-10).of 0.6 248 | expect(probs.p_lt(-1)).to eql 0.0 249 | expect(probs.p_lt(2)).to eql 1.0 250 | 251 | expect(probs.p_le(-1)).to be_within(1e-10).of 0.1 252 | expect(probs.p_le(-2)).to eql 0.0 253 | expect(probs.p_le(1)).to eql 1.0 254 | end 255 | end 256 | 257 | describe 'with rerolls and maps together' do 258 | before do 259 | @die = GamesDice::ComplexDie.new(6, 260 | rerolls: [[6, :<=, :reroll_add]], 261 | maps: [GamesDice::MapRule.new(9, :<=, 1, 'Success')]) 262 | end 263 | 264 | it 'should calculate correct minimum and maximum results' do 265 | expect(@die.min).to eql 0 266 | expect(@die.max).to eql 1 267 | end 268 | 269 | it 'should calculate an expected result' do 270 | expect(@die.probabilities.expected).to be_within(1e-10).of 4 / 36.0 271 | end 272 | 273 | it 'should calculate probabilities of each possible result' do 274 | probs_hash = @die.probabilities.to_h 275 | expect(probs_hash[1]).to be_within(1e-10).of 4 / 36.0 276 | expect(probs_hash[0]).to be_within(1e-10).of 32 / 36.0 277 | expect(probs_hash.values.inject(:+)).to be_within(1e-9).of 1.0 278 | end 279 | 280 | it 'should calculate aggregate probabilities' do 281 | probs = @die.probabilities 282 | 283 | expect(probs.p_gt(0)).to be_within(1e-10).of 4 / 36.0 284 | expect(probs.p_gt(-2)).to eql 1.0 285 | expect(probs.p_gt(1)).to eql 0.0 286 | 287 | expect(probs.p_ge(1)).to be_within(1e-10).of 4 / 36.0 288 | expect(probs.p_ge(-1)).to eql 1.0 289 | expect(probs.p_ge(2)).to eql 0.0 290 | 291 | expect(probs.p_lt(1)).to be_within(1e-10).of 32 / 36.0 292 | expect(probs.p_lt(0)).to eql 0.0 293 | expect(probs.p_lt(2)).to eql 1.0 294 | 295 | expect(probs.p_le(0)).to be_within(1e-10).of 32 / 36.0 296 | expect(probs.p_le(-1)).to eql 0.0 297 | expect(probs.p_le(1)).to eql 1.0 298 | end 299 | 300 | it 'should apply mapping to final re-rolled result' do 301 | [0, 1, 0, 0].each do |expected| 302 | expect(@die.roll.value).to eql expected 303 | expect(@die.result.value).to eql expected 304 | end 305 | end 306 | 307 | it 'should explain how it got each result' do 308 | ['5', '[6+4] 10 Success', '[6+2] 8', '5'].each do |expected| 309 | @die.roll 310 | expect(@die.explain_result).to eql expected 311 | end 312 | end 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /spec/readme_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | # This spec demonstrates that documentation from the README.md works as intended 5 | 6 | describe GamesDice do 7 | describe '#create' do 8 | it "converts a string such as '3d6+6' into a GamesDice::Dice object" do 9 | d = GamesDice.create '3d6+6' 10 | expect(d).to be_a GamesDice::Dice 11 | end 12 | 13 | it "takes a parameter 'dice_description', which is a string such as '3d6' or '2d4-1'" do 14 | d = GamesDice.create '3d6' 15 | expect(d).to be_a GamesDice::Dice 16 | d = GamesDice.create '2d4-1' 17 | expect(d).to be_a GamesDice::Dice 18 | end 19 | 20 | it "takes an optional parameter 'prng', which should be an object that has a method 'rand( integer )'" do 21 | prng = TestPRNG.new 22 | 23 | d = GamesDice.create '3d6', prng 24 | expect(d).to be_a GamesDice::Dice 25 | 26 | (0..5).each do |dresult| 27 | allow(prng).to receive(:rand).and_return(dresult) 28 | expect(prng).to receive(:rand).with(6) 29 | expect(d.roll).to eql (dresult + 1) * 3 30 | end 31 | end 32 | end 33 | end 34 | 35 | describe GamesDice::Dice do 36 | before :each do 37 | srand(67_809) 38 | end 39 | 40 | let(:dice) { GamesDice.create '3d6' } 41 | 42 | describe '#roll' do 43 | it 'simulates rolling the dice as they were described in the constructor' do 44 | expected_results = [11, 15, 11, 12, 12, 9] 45 | expected_results.each do |expected| 46 | expect(dice.roll).to eql expected 47 | end 48 | end 49 | end 50 | 51 | describe '#result' do 52 | it 'returns the value from the last call to roll' do 53 | expected_results = [11, 15, 11, 12, 12, 9] 54 | expected_results.each do |expected| 55 | dice.roll 56 | expect(dice.result).to eql expected 57 | end 58 | end 59 | 60 | it 'will be nil if no roll has been made yet' do 61 | expect(dice.result).to be_nil 62 | end 63 | end 64 | 65 | describe '#explain_result' do 66 | it 'attempts to show how the result from the last call to roll was composed' do 67 | expected_results = [ 68 | '3d6: 3 + 6 + 2 = 11', 69 | '3d6: 4 + 5 + 6 = 15', 70 | '3d6: 3 + 6 + 2 = 11', 71 | '3d6: 5 + 6 + 1 = 12' 72 | ] 73 | expected_results.each do |expected| 74 | dice.roll 75 | expect(dice.explain_result).to eql expected 76 | end 77 | end 78 | 79 | it 'will be nil if no roll has been made yet' do 80 | expect(dice.explain_result).to be_nil 81 | end 82 | end 83 | 84 | describe '#max' do 85 | it 'returns the maximum possible value from a roll of the dice' do 86 | expect(dice.max).to eql 18 87 | end 88 | end 89 | 90 | describe '#min' do 91 | it 'returns the minimum possible value from a roll of the dice' do 92 | expect(dice.min).to eql 3 93 | end 94 | end 95 | 96 | describe '#minmax' do 97 | it 'returns an array [ dice.min, dice.max ]' do 98 | expect(dice.minmax).to eql [3, 18] 99 | end 100 | end 101 | 102 | describe '#probabilities' do 103 | it 'calculates probability distribution for the dice' do 104 | pd = dice.probabilities 105 | expect(pd).to be_a GamesDice::Probabilities 106 | expect(pd.p_eql(3)).to be_within(1e-10).of 1.0 / 216 107 | expect(pd.p_eql(11)).to be_within(1e-10).of 27.0 / 216 108 | end 109 | end 110 | end 111 | 112 | describe GamesDice::Probabilities do 113 | let(:probs) { GamesDice.create('3d6').probabilities } 114 | 115 | describe '#to_h' do 116 | it 'returns a hash representation of the probability distribution' do 117 | h = probs.to_h 118 | expect(h).to be_valid_distribution 119 | expect(h[3]).to be_within(1e-10).of 1.0 / 216 120 | expect(h[11]).to be_within(1e-10).of 27.0 / 216 121 | end 122 | end 123 | 124 | describe '#max' do 125 | it 'returns maximum result in the probability distribution' do 126 | expect(probs.max).to eql 18 127 | end 128 | end 129 | 130 | describe '#min' do 131 | it 'returns minimum result in the probability distribution' do 132 | expect(probs.min).to eql 3 133 | end 134 | end 135 | 136 | describe '#p_eql( n )' do 137 | it 'returns the probability of a result equal to the integer n' do 138 | expect(probs.p_eql(3)).to be_within(1e-10).of 1.0 / 216 139 | expect(probs.p_eql(2)).to eql 0.0 140 | end 141 | end 142 | 143 | describe '#p_gt( n )' do 144 | it 'returns the probability of a result greater than the integer n' do 145 | expect(probs.p_gt(17)).to be_within(1e-10).of 1.0 / 216 146 | expect(probs.p_gt(2)).to eql 1.0 147 | end 148 | end 149 | 150 | describe '#p_ge( n )' do 151 | it 'returns the probability of a result greater than the integer n' do 152 | expect(probs.p_ge(17)).to be_within(1e-10).of 4.0 / 216 153 | expect(probs.p_ge(3)).to eql 1.0 154 | end 155 | end 156 | 157 | describe '#p_le( n )' do 158 | it 'returns the probability of a result less than or equal to the integer n' do 159 | expect(probs.p_le(17)).to be_within(1e-10).of 215.0 / 216 160 | expect(probs.p_le(3)).to be_within(1e-10).of 1.0 / 216 161 | end 162 | end 163 | 164 | describe '#p_lt( n )' do 165 | it 'returns the probability of a result less than the integer n' do 166 | expect(probs.p_lt(17)).to be_within(1e-10).of 212.0 / 216 167 | expect(probs.p_lt(3)).to eql 0.0 168 | end 169 | end 170 | end 171 | 172 | describe 'String Dice Description' do 173 | before :each do 174 | srand(35_241) 175 | end 176 | 177 | describe "'1d6'" do 178 | it 'returns expected results from rolling' do 179 | d = GamesDice.create '1d6' 180 | expect((1..20).map { |_n| d.roll }).to eql [6, 3, 2, 3, 4, 6, 4, 2, 6, 3, 3, 5, 6, 6, 3, 6, 5, 2, 1, 4] 181 | end 182 | end 183 | 184 | describe "'2d6 + 1d4'" do 185 | it 'returns expected results from rolling' do 186 | d = GamesDice.create '2d6 + 1d4' 187 | expect((1..5).map { |_n| d.roll }).to eql [11, 10, 12, 12, 14] 188 | end 189 | end 190 | 191 | describe "'1d100 + 1d20 - 5'" do 192 | it 'returns expected results from rolling' do 193 | d = GamesDice.create '1d100 + 1d20 - 5' 194 | expect((1..5).map { |_n| d.roll }).to eql [75, 78, 24, 102, 32] 195 | end 196 | end 197 | 198 | describe "'1d10x'" do 199 | it 'returns expected results from rolling' do 200 | d = GamesDice.create '1d10x' 201 | expect((1..20).map { |_n| d.roll }).to eql [2, 3, 4, 7, 6, 7, 4, 2, 6, 3, 7, 5, 6, 7, 6, 6, 5, 19, 4, 19] 202 | end 203 | end 204 | 205 | describe "'1d6r1'" do 206 | it 'returns expected results from rolling' do 207 | d = GamesDice.create '1d6r1' 208 | expect((1..20).map { |_n| d.roll }).to eql [6, 3, 2, 3, 4, 6, 4, 2, 6, 3, 3, 5, 6, 6, 3, 6, 5, 2, 4, 2] 209 | end 210 | end 211 | 212 | describe "'5d10r:10,add.k2'" do 213 | it 'returns expected results from rolling' do 214 | d = GamesDice.create '5d10r:10,add.k2' 215 | expect((1..5).map { |_n| d.roll }).to eql [13, 13, 14, 38, 15] 216 | end 217 | end 218 | 219 | describe "'3d10m6'" do 220 | it 'returns expected results from rolling' do 221 | d = GamesDice.create '3d10m6' 222 | expect((1..6).map { |_n| d.roll }).to eql [0, 3, 1, 1, 3, 2] 223 | end 224 | end 225 | 226 | describe "'5d10k2'" do 227 | it 'returns expected results from rolling' do 228 | d = GamesDice.create '5d10k2' 229 | expect((1..5).map { |_n| d.roll }).to eql [13, 13, 14, 19, 19] 230 | end 231 | end 232 | 233 | describe "'5d10x'" do 234 | it "is the same as '5d10r:10,add.'" do 235 | srand(235_241) 236 | d = GamesDice.create '5d10x' 237 | results1 = (1..50).map { d.roll } 238 | 239 | srand(235_241) 240 | d = GamesDice.create '5d10r:10,add.' 241 | results2 = (1..50).map { d.roll } 242 | 243 | expect(results1).to eql results2 244 | end 245 | end 246 | 247 | describe "'1d6r:1.'" do 248 | it "should return same as '1d6r1'" do 249 | srand(235_241) 250 | d = GamesDice.create '1d6r:1.' 251 | results1 = (1..50).map { d.roll } 252 | 253 | srand(235_241) 254 | d = GamesDice.create '1d6r1' 255 | results2 = (1..50).map { d.roll } 256 | 257 | expect(results1).to eql results2 258 | end 259 | end 260 | 261 | describe "'1d10r:10,replace,1.'" do 262 | it 'should roll a 10-sided die, re-roll a result of 10 and take the value of the second roll' do 263 | d = GamesDice.create '1d10r:10,replace,1.' 264 | expect((1..27).map do 265 | d.roll 266 | end).to eql [2, 3, 4, 7, 6, 7, 4, 2, 6, 3, 7, 5, 6, 7, 6, 6, 5, 9, 4, 9, 8, 3, 1, 6, 7, 1, 1] 267 | end 268 | end 269 | 270 | describe "'1d20r:<=10,use_best,1.'" do 271 | it 'should roll a 20-sided die, re-roll a result if 10 or lower, and use best result' do 272 | d = GamesDice.create '1d20r:<=10,use_best,1.' 273 | expect((1..20).map do 274 | d.roll 275 | end).to eql [18, 19, 20, 20, 3, 11, 7, 20, 15, 19, 6, 16, 17, 16, 15, 11, 9, 15, 20, 16] 276 | end 277 | end 278 | 279 | describe "'5d10r:10,add.k2', '5d10xk2' and '5d10x.k2'" do 280 | it 'should all be equivalent' do 281 | srand(135_241) 282 | d = GamesDice.create '5d10r:10,add.k2' 283 | results1 = (1..50).map { d.roll } 284 | 285 | srand(135_241) 286 | d = GamesDice.create '5d10xk2' 287 | results2 = (1..50).map { d.roll } 288 | 289 | srand(135_241) 290 | d = GamesDice.create '5d10x.k2' 291 | results3 = (1..50).map { d.roll } 292 | 293 | expect(results1).to eql results2 294 | expect(results1).to eql results3 295 | end 296 | end 297 | 298 | describe "'5d10r:>8,add.'" do 299 | it 'returns expected results from rolling' do 300 | d = GamesDice.create '5d10r:>8,add.' 301 | expect((1..5).map { |_n| d.roll }).to eql [22, 22, 31, 64, 26] 302 | end 303 | end 304 | 305 | describe "'9d6x.m:10.'" do 306 | it 'returns expected results from rolling' do 307 | d = GamesDice.create '9d6x.m:10.' 308 | expect((1..5).map { |_n| d.roll }).to eql [1, 2, 1, 1, 1] 309 | end 310 | it 'can be explained as number of exploding dice scoring 10+' do 311 | d = GamesDice.create '9d6x.m:10.' 312 | expect((1..5).map do |_n| 313 | d.roll 314 | d.explain_result 315 | end).to eql [ 316 | '9d6: [6+3] 9, 2, 3, 4, [6+4] 10, 2, [6+3] 9, 3, 5. Successes: 1', 317 | '9d6: [6+6+3] 15, [6+5] 11, 2, 1, 4, 2, 1, 3, 5. Successes: 2', 318 | '9d6: 1, [6+6+1] 13, 2, 1, 1, 3, [6+1] 7, 5, 4. Successes: 1', 319 | '9d6: [6+4] 10, 3, 4, 5, 5, 1, [6+3] 9, 3, 5. Successes: 1', 320 | '9d6: [6+3] 9, 3, [6+5] 11, 4, 2, 2, 1, 4, 5. Successes: 1' 321 | ] 322 | end 323 | end 324 | 325 | describe "'9d6x.m:10,1,S.'" do 326 | it 'returns expected results from rolling' do 327 | d = GamesDice.create '9d6x.m:10,1,S.' 328 | expect((1..5).map { |_n| d.roll }).to eql [1, 2, 1, 1, 1] 329 | end 330 | it "includes the string 'S' next to each success" do 331 | d = GamesDice.create '9d6x.m:10,1,S.' 332 | expect((1..5).map do |_n| 333 | d.roll 334 | d.explain_result 335 | end).to eql [ 336 | '9d6: [6+3] 9, 2, 3, 4, [6+4] 10 S, 2, [6+3] 9, 3, 5. Successes: 1', 337 | '9d6: [6+6+3] 15 S, [6+5] 11 S, 2, 1, 4, 2, 1, 3, 5. Successes: 2', 338 | '9d6: 1, [6+6+1] 13 S, 2, 1, 1, 3, [6+1] 7, 5, 4. Successes: 1', 339 | '9d6: [6+4] 10 S, 3, 4, 5, 5, 1, [6+3] 9, 3, 5. Successes: 1', 340 | '9d6: [6+3] 9, 3, [6+5] 11 S, 4, 2, 2, 1, 4, 5. Successes: 1' 341 | ] 342 | end 343 | end 344 | 345 | describe "'5d10m:>=6,1,S.m:==1,-1,F.'" do 346 | it 'returns expected results from rolling' do 347 | d = GamesDice.create '5d10m:>=6,1,S.m:==1,-1,F.' 348 | expect((1..10).map { |_n| d.roll }).to eql [2, 2, 4, 3, 2, 1, 1, 3, 3, 0] 349 | end 350 | it "includes the string 'S' next to each success, and 'F' next to each 'fumble'" do 351 | d = GamesDice.create '5d10m:>=6,1,S.m:==1,-1,F.' 352 | expect((1..5).map do |_n| 353 | d.roll 354 | d.explain_result 355 | end).to eql [ 356 | '5d10: 2, 3, 4, 7 S, 6 S. Successes: 2', 357 | '5d10: 7 S, 4, 2, 6 S, 3. Successes: 2', 358 | '5d10: 7 S, 5, 6 S, 7 S, 6 S. Successes: 4', 359 | '5d10: 6 S, 5, 10 S, 9 S, 4. Successes: 3', 360 | '5d10: 10 S, 9 S, 8 S, 3, 1 F. Successes: 2' 361 | ] 362 | end 363 | end 364 | 365 | describe "'4d6k:3.r:1,replace,1.'" do 366 | it 'represents roll 4 six-sided dice, re-roll any 1s, and keep best 3.' do 367 | d = GamesDice.create '4d6k:3.r:1,replace,1.' 368 | expect((1..10).map { |_n| d.roll }).to eql [12, 14, 14, 18, 11, 17, 11, 15, 14, 14] 369 | end 370 | it 'includes re-rolls and keeper choice in explanations' do 371 | d = GamesDice.create '4d6k:3.r:1,replace,1.' 372 | expect((1..5).map do |_n| 373 | d.roll 374 | d.explain_result 375 | end).to eql [ 376 | '4d6: 6, 3, 2, 3. Keep: 3 + 3 + 6 = 12', 377 | '4d6: 4, 6, 4, 2. Keep: 4 + 4 + 6 = 14', 378 | '4d6: 6, 3, 3, 5. Keep: 3 + 5 + 6 = 14', 379 | '4d6: 6, 6, 3, 6. Keep: 6 + 6 + 6 = 18', 380 | '4d6: 5, 2, [1|4] 4, 2. Keep: 2 + 4 + 5 = 11' 381 | ] 382 | end 383 | end 384 | 385 | describe "'2d20k:1,worst.'" do 386 | it 'represents roll 2 twenty-sided dice, return lowest of the two results' do 387 | d = GamesDice.create '2d20k:1,worst.' 388 | expect((1..10).map { |_n| d.roll }).to eql [18, 6, 2, 3, 5, 10, 15, 1, 7, 10] 389 | end 390 | it 'includes keeper choice in explanations' do 391 | d = GamesDice.create '2d20k:1,worst.' 392 | expect((1..5).map do |_n| 393 | d.roll 394 | d.explain_result 395 | end).to eql [ 396 | '2d20: 18, 19. Keep: 18', 397 | '2d20: 20, 6. Keep: 6', 398 | '2d20: 20, 2. Keep: 2', 399 | '2d20: 3, 11. Keep: 3', 400 | '2d20: 5, 7. Keep: 5' 401 | ] 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GamesDice 2 | [![Gem Version](https://badge.fury.io/rb/games_dice.png)](http://badge.fury.io/rb/games_dice) 3 | [![Build Status](https://travis-ci.org/neilslater/games_dice.png?branch=master)](https://api.travis-ci.com/neilslater/games_dice.svg) 4 | [![Coverage Status](https://coveralls.io/repos/neilslater/games_dice/badge.png?branch=master)](https://coveralls.io/r/neilslater/games_dice?branch=master) 5 | [![Inline docs](http://inch-ci.org/github/neilslater/games_dice.png?branch=master)](http://inch-ci.org/github/neilslater/games_dice) 6 | [![Code Climate](https://codeclimate.com/github/neilslater/games_dice.png)](https://codeclimate.com/github/neilslater/games_dice) 7 | 8 | A library for simulating dice. Use it to construct dice-rolling systems used in role-playing and board games. 9 | 10 | ## Description 11 | 12 | GamesDice can emulate a variety of rules-driven dice systems that are used to generate integer results 13 | within a game. 14 | 15 | The main features of GamesDice are: 16 | 17 | * Uses string dice descriptions, the basics of which are familiar to many game players e.g. '2d6 + 3' 18 | * Supports some common features of dice systems: 19 | * Re-rolls that replace or modify the previous roll 20 | * Counting number of "successes" from a set of dice 21 | * Keeping the best, or worst, results from a set of dice 22 | * Can explain how a result was achieved in terms of the individual die rolls 23 | * Can calculate probabilities and expected values 24 | 25 | There are no game mechanics implemented in GamesDice, such as the chance to hit in a fantasy combat 26 | game. There is no support for player interaction within a roll, such as player choice on whether or 27 | not to re-roll a specific die within a combined set. These things are of course possible if you use the 28 | gem as-is, and add them as features within your project code. 29 | 30 | ## Supported Ruby Versions 31 | 32 | GamesDice is tested routinely on MRI Ruby versions 2.7 and higher. 33 | 34 | Older versions of GamesDice (before 0.4.0) support older Rubies, and also offer pure Ruby versions 35 | of probability calculations, so can be used with JRuby. 36 | 37 | ## Installation 38 | 39 | Add this line to your application's Gemfile: 40 | 41 | gem 'games_dice' 42 | 43 | And then execute: 44 | 45 | $ bundle 46 | 47 | Or install it yourself as: 48 | 49 | $ gem install games_dice 50 | 51 | ## Usage 52 | 53 | require 'games_dice' 54 | 55 | dice = GamesDice.create '4d6+3' 56 | dice.roll # => 17 (e.g.) 57 | 58 | ## Library API 59 | 60 | Although you can refer to the documentation for the contained classes, and use it if needed to 61 | build some exotic dice systems, all you need to know to access the core features is described 62 | here. 63 | 64 | ### GamesDice factory method 65 | 66 | #### GamesDice.create 67 | 68 | dice = GamesDice.create dice_description, prng 69 | 70 | Converts a string such as '3d6+6' into a GamesDice::Dice object 71 | 72 | Parameters: 73 | 74 | * dice_description is a string such as '3d6' or '2d4-1'. See String Dice Descriptions below for possibilities. 75 | * prng is optional, if provided it should be an object that has a method 'rand( integer )' that works like Ruby's built-in rand method 76 | 77 | Returns a GamesDice::Dice object. 78 | 79 | ### GamesDice::Dice instance methods 80 | 81 | Example results given for '3d6'. Unless noted, methods do not take any parameters. 82 | 83 | #### dice.roll 84 | 85 | Simulates rolling the dice as they were described in the constructor, and keeps a record of how the 86 | simulation result was achieved. 87 | 88 | dice.roll # => 12 89 | 90 | #### dice.result 91 | 92 | Returns the value from the last call to roll. This will be nil if no roll has been made yet. 93 | 94 | dice.result # => nil 95 | dice.roll 96 | dice.result # => 12 97 | 98 | #### dice.explain_result 99 | 100 | Returns a string that attempts to show how the result from the last call to roll was composed 101 | from individual results. This will be nil if no roll has been made yet. 102 | 103 | dice.explain_result # => nil 104 | dice.roll # => 12 105 | dice.explain_result # => "3d6: 4 + 2 + 6 = 12" 106 | 107 | The exact format is the subject of refinement in future versions of the gem. 108 | 109 | #### dice.max 110 | 111 | Returns the maximum possible value from a roll of the dice. Dice with the possibility of rolling 112 | progressively higher and higher values will return an arbitrary high value. 113 | 114 | dice.max # => 18 115 | 116 | #### dice.min 117 | 118 | Returns the minimum possible value from a roll of the dice. Dice with the possibility of rolling 119 | progressively lower and lower values will return an arbitrary low value. 120 | 121 | dice.min # => 3 122 | ß 123 | #### dice.minmax 124 | 125 | Convenience method, returns an array [ dice.min, dice.max ] 126 | 127 | dice.minmax # => [3,18] 128 | 129 | #### dice.probabilities 130 | 131 | Calculates probability distribution for the dice. 132 | 133 | Returns a GamesDice::Probabilities object that describes the probability distribution. 134 | 135 | probabilities = dice.probabilities 136 | 137 | Note that some distributions, involving keeping a number best or worst results, can take 138 | significant time to calculate. If the theoretical distribution would contain a large number 139 | of very low probabilities due to a possibility of large numbers re-rolls, then the 140 | calculations cut short, typically approximating to the nearest 1.0e-10. 141 | 142 | ### GamesDice::Probabilities instance methods 143 | 144 | #### probabilities.to_h 145 | 146 | Returns a hash representation of the probability distribution. Each key is a possible result 147 | from rolling the dice (an Integer), and the associated value is the probability of a roll 148 | returning that result (a Float, between 0.0 and 1.0 inclusive). 149 | 150 | distribution = probabilities.to_h 151 | distribution[3] # => 0.0046296296296 152 | 153 | #### probabilities.max 154 | 155 | Returns maximum result in the probability distribution. This may not be the theoretical maximum 156 | possible on the dice, if for example the dice can roll open-ended high results. 157 | 158 | probabilities.max # => 18 159 | 160 | #### probabilities.min 161 | 162 | Returns minimum result in the probability distribution. This may not be the theoretical minimum 163 | possible on the dice, if for example the dice can roll open-ended low results. 164 | 165 | probabilities.min # => 3 166 | 167 | #### probabilities.p_eql( n ) 168 | 169 | Returns the probability of a result equal to the integer n. 170 | 171 | probabilities.p_eql( 3 ) # => 0.004629629629 172 | probabilities.p_eql( 2 ) # => 0.0 173 | 174 | Probabilities below 1e-10 due to requiring long sequences of re-rolls will calculate as 0.0 175 | 176 | #### probabilities.p_gt( n ) 177 | 178 | Returns the probability of a result greater than the integer n. 179 | 180 | probabilities.p_gt( 17 ) # => 0.004629629629 181 | probabilities.p_gt( 2 ) # => 1.0 182 | 183 | #### probabilities.p_ge( n ) 184 | 185 | Returns the probability of a result greater than or equal to the integer n. 186 | 187 | probabilities.p_ge( 17 ) # => 0.0185185185185 188 | probabilities.p_ge( 3 ) # => 1.0 189 | 190 | #### probabilities.p_le( n ) 191 | 192 | Returns the probability of a result less than or equal to the integer n. 193 | 194 | probabilities.p_le( 17 ) # => 0.9953703703703 195 | probabilities.p_le( 3 ) # => 0.0046296296296 196 | 197 | #### probabilities.p_lt( n ) 198 | 199 | Returns the probability of a result less than the integer n. 200 | 201 | probabilities.p_lt( 17 ) # => 0.9953703703703 202 | probabilities.p_lt( 3 ) # => 0.0 203 | 204 | #### probabilities.expected 205 | 206 | Returns the mean result, weighted by probabality of each value. 207 | 208 | probabilities.expected # => 10.5 (rounded to nearest 1e-9) 209 | 210 | ## String Dice Descriptions 211 | 212 | The dice descriptions are a mini-language. A simple six-sided die is described like this: 213 | 214 | 1d6 215 | 216 | where the first integer is the number of dice to add together, and the second number is the number 217 | of sides on each die. Spaces are allowed before the first number, and after the dice description, but 218 | not between either number and the "d". 219 | 220 | The dice mini-language allows for adding and subtracting integers and groups of dice in a list, e.g. 221 | 222 | 2d6 + 1d4 223 | 1d100 + 1d20 - 5 224 | 225 | That is the limit of combining dice and constants though, no multiplications, or bracketed constructs 226 | like "(1d8)d8" - you can still use games_dice to help simulate these, but you will need to add your own 227 | code to do so. 228 | 229 | ### Die Modifiers 230 | 231 | After the number of sides, you may add one or more modifiers, that affect all of the dice in that 232 | "NdX" group. A die modifier can be a single character, e.g. 233 | 234 | 1d10x 235 | 236 | A die modifier can also be a single letter plus an integer value, e.g. 237 | 238 | 1d6r1 239 | 240 | You can add comma-seperated parameters to a modifier by using a ":" (colon) character after the 241 | modifier letter, and a "." (full stop) to signify the end of the parameters. What parameters are 242 | accepted, and what they mean, depends on the modifier: 243 | 244 | 5d10r:>8,add. 245 | 246 | You can use more than one modifier. Modifiers should be separated by a "." (full stop) character, although 247 | this is optional if you use modifiers without parameters: 248 | 249 | 5d10r:10,add.k2 250 | 5d10xk2 251 | 5d10x.k2 252 | 253 | are all equivalent. 254 | 255 | #### Rerolls 256 | 257 | You can specify that dice rolling certain values should be re-rolled, and how that re-roll should be 258 | interpretted. 259 | 260 | The simple form specifies a low value that will automatically trigger a re-roll and replace: 261 | 262 | 1d6r1 263 | 264 | When rolled, this die will score from 1 to 6. If it rolls a 1, it will roll again automatically 265 | and use that result instead. 266 | 267 | The full version of this modifier, allows you to specify from 1 to 3 parameters: 268 | 269 | 1d10r:[VALUE_COMPARISON],[REROLL_TYPE],[LIMIT]. 270 | 271 | Where: 272 | 273 | * VALUE_COMPARISON is one of >, >=, == (default), <= < plus an integer to set conditions on when the reroll should occur 274 | * REROLL_TYPE is one of 275 | * replace (default) - use the new value in place of existing value for the die 276 | * add - add result of reroll to running total, and ignore any subtract rules 277 | * subtract - subtract result of reroll from running total, and reverse sense of any further add results 278 | * use_best - use the new value if it is higher than the existing value 279 | * use_worst - use the new value if it is lower than the existing value 280 | * LIMIT is an integer that sets the maximum number of times that the rule can be triggered, the default is 1000 281 | 282 | Examples: 283 | 284 | 1d6r:1. # Same as "1d6r1" 285 | 1d10r:10,replace,1. # Roll a 10-sided die, re-roll a result of 10 and take the value of the second roll 286 | 1d20r:<=10,use_best,1. # Roll a 20-sided die, re-roll a result if 10 or lower, and use best result 287 | 288 | #### Maps 289 | 290 | You can specify that the value shown on each die is converted to some other set of values. If 291 | you add at least one map modifier, all unmapped values will map to 0 by default. 292 | 293 | The simple form specifies a value above which the result is considered to be 1, as in "one success": 294 | 295 | 3d10m6 296 | 297 | When rolled, this will score from 0 to 3 - the number of the ten-sided dice that scored 6 or higher. 298 | 299 | The full version of this modifier, allows you to specify from 1 to 3 parameters: 300 | 301 | 3d10m:[VALUE_COMPARISON],[MAP_VALUE],[DESCRIPTION]. 302 | 303 | Where: 304 | 305 | * VALUE_COMPARISON is one of >, >= (default), ==, <= < plus an integer to set conditions on when the map should occur 306 | * MAP_VALUE is an integer that will be used in place of a result from a die, default value is 1 307 | * maps are tested in order that they are declared, and first one that matches is applied 308 | * when at least one map has been defined, all unmapped values default to 0 309 | * DESCRIPTION is a word or character to use to denote the map in any explanation 310 | 311 | Examples: 312 | 313 | 9d6x.m:10. # Roll 9 six-sided "exploding" dice, and count 1 for any result of 10 or more 314 | 9d6x.m:10,1,S. # Same as above, but with each success marked with "S" in the explanation 315 | 5d10m:>=6,1,S.m:==1,-1,F. # Roll 5 ten-sided dice, count 1 for any result of 6 or more, or -1 for any result of 1 316 | 317 | #### Keepers 318 | 319 | You can specify that only a sub-set of highest or lowest dice values will contribute to the final 320 | total. 321 | 322 | The simple form indicates the number of highest value dice to keep. 323 | 324 | 5d10k2 325 | 326 | When rolled, this will score from 2 to 20 - the sum of the two highest scoring ten-sided dice, out of 327 | five. 328 | 329 | The full version of this modifier, allows you to specify from 1 to 2 parameters: 330 | 331 | 5d10k:[KEEP_NUM],[KEEP_TYPE]. 332 | 333 | Where: 334 | 335 | * KEEP_NUM is an integer specifying the number of dice to keep. 336 | * KEEP_TYPE is one of 337 | * best - keep highest values and add them together 338 | * worst - keep lowest values and add them together 339 | 340 | Examples: 341 | 342 | 4d6k:3.r:1,replace,1. # Roll 4 six-sided dice, re-roll any 1s, and keep best 3. 343 | 2d20k:1,worst. # Roll 2 twenty-sided dice, return lowest of the two results. 344 | 345 | #### Combinations 346 | 347 | * When there are many modifiers, they are applied in strict order: 348 | * First by type: re-rolls, maps, keepers 349 | * Then according to the order they were specified 350 | * A maximum of one re-roll modifier, and one map modifier are applied to each individual die rolled 351 | * Only one keepers modifier is applied per dice type. Specifying a second one will cause an error 352 | 353 | #### Aliases 354 | 355 | Some combinations of modifiers crop up in well-known games, and have been allocated single-character 356 | short codes. 357 | 358 | This is an alias for "exploding" dice: 359 | 360 | 5d10x # Same as '5d10r:10,add.' 361 | 362 | When rolled, this will score from 5 to theoretically any higher number, as results of 10 on any 363 | die mean that die rolls again and the result is added on. 364 | 365 | ## Contributing 366 | 367 | 1. Fork it 368 | 2. Create your feature branch (`git checkout -b my-new-feature`) 369 | 3. Commit your changes (`git commit -am 'Add some feature'`) 370 | 4. Push to the branch (`git push origin my-new-feature`) 371 | 5. Create new Pull Request 372 | 373 | I am always interested to receive information about dice rolling schemes that this library could or 374 | should include in its repertoire. 375 | -------------------------------------------------------------------------------- /spec/bunch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::Bunch do 6 | describe 'dice scheme' do 7 | before :each do 8 | srand(67_809) 9 | end 10 | 11 | describe '1d10' do 12 | let(:bunch) { GamesDice::Bunch.new(sides: 10, ndice: 1) } 13 | 14 | it 'should simulate rolling a ten-sided die' do 15 | [3, 2, 8, 8, 5, 3, 7].each do |expected_total| 16 | expect(bunch.roll).to eql expected_total 17 | expect(bunch.result).to eql expected_total 18 | end 19 | end 20 | 21 | it 'should concisely explain each result' do 22 | %w[3 2 8 8].each do |expected_explain| 23 | bunch.roll 24 | expect(bunch.explain_result).to eql expected_explain 25 | end 26 | end 27 | 28 | it 'should calculate correct min, max = 1,10' do 29 | expect(bunch.min).to eql 1 30 | expect(bunch.max).to eql 10 31 | end 32 | 33 | it 'should have a mean value of 5.5' do 34 | expect(bunch.probabilities.expected).to be_within(1e-10).of 5.5 35 | end 36 | 37 | it 'should calculate probabilities correctly' do 38 | prob_hash = bunch.probabilities.to_h 39 | expect(prob_hash[1]).to be_within(1e-10).of 0.1 40 | expect(prob_hash[2]).to be_within(1e-10).of 0.1 41 | expect(prob_hash[3]).to be_within(1e-10).of 0.1 42 | expect(prob_hash[4]).to be_within(1e-10).of 0.1 43 | expect(prob_hash[5]).to be_within(1e-10).of 0.1 44 | expect(prob_hash[6]).to be_within(1e-10).of 0.1 45 | expect(prob_hash[7]).to be_within(1e-10).of 0.1 46 | expect(prob_hash[8]).to be_within(1e-10).of 0.1 47 | expect(prob_hash[9]).to be_within(1e-10).of 0.1 48 | expect(prob_hash[10]).to be_within(1e-10).of 0.1 49 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 50 | end 51 | end 52 | 53 | describe '2d6' do 54 | let(:bunch) { GamesDice::Bunch.new(sides: 6, ndice: 2) } 55 | 56 | it 'should simulate rolling two six-sided dice and adding them' do 57 | [9, 6, 11, 9, 7, 7, 10].each do |expected_total| 58 | expect(bunch.roll).to eql expected_total 59 | expect(bunch.result).to eql expected_total 60 | end 61 | end 62 | 63 | it 'should concisely explain each result' do 64 | ['3 + 6 = 9', '2 + 4 = 6', '5 + 6 = 11', '3 + 6 = 9', '2 + 5 = 7', '6 + 1 = 7', 65 | '5 + 5 = 10'].each do |expected_explain| 66 | bunch.roll 67 | expect(bunch.explain_result).to eql expected_explain 68 | end 69 | end 70 | 71 | it 'should calculate correct min, max = 2,12' do 72 | expect(bunch.min).to eql 2 73 | expect(bunch.max).to eql 12 74 | end 75 | 76 | it 'should have a mean value of 7.0' do 77 | expect(bunch.probabilities.expected).to be_within(1e-10).of 7.0 78 | end 79 | 80 | it 'should calculate probabilities correctly' do 81 | prob_hash = bunch.probabilities.to_h 82 | expect(prob_hash[2]).to be_within(1e-10).of 1 / 36.0 83 | expect(prob_hash[3]).to be_within(1e-10).of 2 / 36.0 84 | expect(prob_hash[4]).to be_within(1e-10).of 3 / 36.0 85 | expect(prob_hash[5]).to be_within(1e-10).of 4 / 36.0 86 | expect(prob_hash[6]).to be_within(1e-10).of 5 / 36.0 87 | expect(prob_hash[7]).to be_within(1e-10).of 6 / 36.0 88 | expect(prob_hash[8]).to be_within(1e-10).of 5 / 36.0 89 | expect(prob_hash[9]).to be_within(1e-10).of 4 / 36.0 90 | expect(prob_hash[10]).to be_within(1e-10).of 3 / 36.0 91 | expect(prob_hash[11]).to be_within(1e-10).of 2 / 36.0 92 | expect(prob_hash[12]).to be_within(1e-10).of 1 / 36.0 93 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 94 | end 95 | end 96 | 97 | describe '20d10' do 98 | let(:bunch) { GamesDice::Bunch.new(sides: 10, ndice: 20) } 99 | 100 | it 'should simulate rolling twenty ten-sided dice and adding them' do 101 | [132, 103, 102, 124, 132, 96, 111].each do |expected_total| 102 | expect(bunch.roll).to eql expected_total 103 | expect(bunch.result).to eql expected_total 104 | end 105 | end 106 | 107 | it 'should concisely explain each result' do 108 | explains = ['3 + 2 + 8 + 8 + 5 + 3 + 7 + 7 + 6 + 10 + 7 + 6 + 9 + 5 + 5 + 8 + 10 + 9 + 5 + 9 = 132', 109 | '3 + 9 + 1 + 4 + 3 + 5 + 7 + 1 + 10 + 4 + 7 + 7 + 6 + 5 + 2 + 7 + 4 + 9 + 7 + 2 = 103', 110 | '6 + 1 + 1 + 3 + 1 + 4 + 9 + 6 + 3 + 10 + 9 + 10 + 8 + 4 + 1 + 4 + 2 + 1 + 10 + 9 = 102'] 111 | 112 | explains.each do |expected_explain| 113 | bunch.roll 114 | expect(bunch.explain_result).to eql expected_explain 115 | end 116 | end 117 | 118 | it 'should calculate correct min, max = 20,200' do 119 | expect(bunch.min).to eql 20 120 | expect(bunch.max).to eql 200 121 | end 122 | 123 | it 'should have a mean value of 110.0' do 124 | expect(bunch.probabilities.expected).to be_within(1e-8).of 110.0 125 | end 126 | 127 | it 'should calculate probabilities correctly' do 128 | prob_hash = bunch.probabilities.to_h 129 | expect(prob_hash[20]).to be_within(1e-26).of 1e-20 130 | expect(prob_hash[110]).to be_within(1e-10).of 0.0308191892 131 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 132 | end 133 | end 134 | 135 | describe '4d6 keep best 3' do 136 | let(:bunch) { GamesDice::Bunch.new(sides: 6, ndice: 4, keep_mode: :keep_best, keep_number: 3) } 137 | 138 | it 'should simulate rolling four six-sided dice and adding the best three values' do 139 | [13, 17, 13, 12, 13, 10, 14].each do |expected_total| 140 | expect(bunch.roll).to eql expected_total 141 | expect(bunch.result).to eql expected_total 142 | end 143 | end 144 | 145 | it 'should concisely explain each result' do 146 | ['3, 6, 2, 4. Keep: 3 + 4 + 6 = 13', 147 | '5, 6, 3, 6. Keep: 5 + 6 + 6 = 17', 148 | '2, 5, 6, 1. Keep: 2 + 5 + 6 = 13', 149 | '5, 5, 2, 1. Keep: 2 + 5 + 5 = 12'].each do |expected_explain| 150 | bunch.roll 151 | expect(bunch.explain_result).to eql expected_explain 152 | end 153 | end 154 | 155 | it 'should calculate correct min, max = 3,18' do 156 | expect(bunch.min).to eql 3 157 | expect(bunch.max).to eql 18 158 | end 159 | 160 | it 'should have a mean value of roughly 12.2446' do 161 | expect(bunch.probabilities.expected).to be_within(1e-9).of 12.244598765 162 | end 163 | 164 | it 'should calculate probabilities correctly' do 165 | prob_hash = bunch.probabilities.to_h 166 | expect(prob_hash[3]).to be_within(1e-10).of 1 / 1296.0 167 | expect(prob_hash[4]).to be_within(1e-10).of 4 / 1296.0 168 | expect(prob_hash[5]).to be_within(1e-10).of 10 / 1296.0 169 | expect(prob_hash[6]).to be_within(1e-10).of 21 / 1296.0 170 | expect(prob_hash[7]).to be_within(1e-10).of 38 / 1296.0 171 | expect(prob_hash[8]).to be_within(1e-10).of 62 / 1296.0 172 | expect(prob_hash[9]).to be_within(1e-10).of 91 / 1296.0 173 | expect(prob_hash[10]).to be_within(1e-10).of 122 / 1296.0 174 | expect(prob_hash[11]).to be_within(1e-10).of 148 / 1296.0 175 | expect(prob_hash[12]).to be_within(1e-10).of 167 / 1296.0 176 | expect(prob_hash[13]).to be_within(1e-10).of 172 / 1296.0 177 | expect(prob_hash[14]).to be_within(1e-10).of 160 / 1296.0 178 | expect(prob_hash[15]).to be_within(1e-10).of 131 / 1296.0 179 | expect(prob_hash[16]).to be_within(1e-10).of 94 / 1296.0 180 | expect(prob_hash[17]).to be_within(1e-10).of 54 / 1296.0 181 | expect(prob_hash[18]).to be_within(1e-10).of 21 / 1296.0 182 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 183 | end 184 | end 185 | 186 | describe '10d10 keep worst one' do 187 | let(:bunch) { GamesDice::Bunch.new(sides: 10, ndice: 10, keep_mode: :keep_worst, keep_number: 1) } 188 | 189 | it 'should simulate rolling ten ten-sided dice and keeping the worst value' do 190 | [2, 5, 1, 2, 1, 1, 2].each do |expected_total| 191 | expect(bunch.roll).to eql expected_total 192 | expect(bunch.result).to eql expected_total 193 | end 194 | end 195 | 196 | it 'should concisely explain each result' do 197 | ['3, 2, 8, 8, 5, 3, 7, 7, 6, 10. Keep: 2', 198 | '7, 6, 9, 5, 5, 8, 10, 9, 5, 9. Keep: 5', 199 | '3, 9, 1, 4, 3, 5, 7, 1, 10, 4. Keep: 1', 200 | '7, 7, 6, 5, 2, 7, 4, 9, 7, 2. Keep: 2'].each do |expected_explain| 201 | bunch.roll 202 | expect(bunch.explain_result).to eql expected_explain 203 | end 204 | end 205 | 206 | it 'should calculate correct min, max = 1,10' do 207 | expect(bunch.min).to eql 1 208 | expect(bunch.max).to eql 10 209 | end 210 | 211 | it 'should have a mean value of roughly 1.491' do 212 | expect(bunch.probabilities.expected).to be_within(1e-9).of 1.4914341925 213 | end 214 | 215 | it 'should calculate probabilities correctly' do 216 | prob_hash = bunch.probabilities.to_h 217 | expect(prob_hash[1]).to be_within(1e-10).of 0.6513215599 218 | expect(prob_hash[2]).to be_within(1e-10).of 0.2413042577 219 | expect(prob_hash[3]).to be_within(1e-10).of 0.0791266575 220 | expect(prob_hash[4]).to be_within(1e-10).of 0.0222009073 221 | expect(prob_hash[5]).to be_within(1e-10).of 0.0050700551 222 | expect(prob_hash[6]).to be_within(1e-10).of 0.0008717049 223 | expect(prob_hash[7]).to be_within(1e-10).of 0.0000989527 224 | expect(prob_hash[8]).to be_within(1e-10).of 0.0000058025 225 | expect(prob_hash[9]).to be_within(1e-10).of 0.0000001023 226 | expect(prob_hash[10]).to be_within(1e-18).of 1e-10 227 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 228 | end 229 | end 230 | 231 | describe '5d10, re-roll and add on 10s, keep best 2' do 232 | let(:bunch) do 233 | GamesDice::Bunch.new( 234 | sides: 10, ndice: 5, keep_mode: :keep_best, keep_number: 2, 235 | rerolls: [GamesDice::RerollRule.new(10, :==, :reroll_add)] 236 | ) 237 | end 238 | 239 | it "should simulate rolling five ten-sided 'exploding' dice and adding the best two values" do 240 | [16, 24, 17, 28, 12, 21, 16].each do |expected_total| 241 | expect(bunch.roll).to eql expected_total 242 | expect(bunch.result).to eql expected_total 243 | end 244 | end 245 | 246 | it 'should concisely explain each result' do 247 | ['3, 2, 8, 8, 5. Keep: 8 + 8 = 16', 248 | '3, 7, 7, 6, [10+7] 17. Keep: 7 + 17 = 24', 249 | '6, 9, 5, 5, 8. Keep: 8 + 9 = 17', 250 | '[10+9] 19, 5, 9, 3, 9. Keep: 9 + 19 = 28'].each do |expected_explain| 251 | bunch.roll 252 | expect(bunch.explain_result).to eql expected_explain 253 | end 254 | end 255 | 256 | it 'should calculate correct min, max = 2, > 100' do 257 | expect(bunch.min).to eql 2 258 | expect(bunch.max).to be > 100 259 | end 260 | 261 | it 'should have a mean value of roughly 18.986' do 262 | expect(bunch.probabilities.expected).to be_within(1e-9).of 18.9859925804 263 | end 264 | 265 | it 'should calculate probabilities correctly' do 266 | prob_hash = bunch.probabilities.to_h 267 | probs = prob_hash.values_at(*2..30) 268 | expected_probs = [0.00001, 0.00005, 0.00031, 0.00080, 0.00211, 0.00405, 0.00781, 0.01280, 0.02101, 0.0312, 269 | 0.045715, 0.060830, 0.077915, 0.090080, 0.097935, 0.091230, 0.070015, 0.020480, 0.032805, 270 | 0.0328, 0.0334626451, 0.0338904805, 0.0338098781, 0.0328226480, 0.0304393461, 0.0260456005, 271 | 0.0189361531, 0.0082804480, 0.0103524151] 272 | 273 | probs.zip(expected_probs) do |got_prob, expected_prob| 274 | expect(got_prob).to be_within(1e-10).of(expected_prob) 275 | end 276 | 277 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 278 | end 279 | end 280 | 281 | describe 'roll 2d20, keep best value' do 282 | let(:bunch) do 283 | GamesDice::Bunch.new( 284 | sides: 20, ndice: 2, keep_mode: :keep_best, keep_number: 1 285 | ) 286 | end 287 | 288 | it 'should simulate rolling two twenty-sided dice and keeping the best value' do 289 | [19, 18, 14, 6, 13, 10, 16].each do |expected_total| 290 | expect(bunch.roll).to eql expected_total 291 | expect(bunch.result).to eql expected_total 292 | end 293 | end 294 | 295 | it 'should concisely explain each result' do 296 | ['19, 14. Keep: 19', 297 | '18, 16. Keep: 18', 298 | '5, 14. Keep: 14', 299 | '3, 6. Keep: 6'].each do |expected_explain| 300 | bunch.roll 301 | expect(bunch.explain_result).to eql expected_explain 302 | end 303 | end 304 | 305 | it 'should calculate correct min, max = 1,20' do 306 | expect(bunch.min).to eql 1 307 | expect(bunch.max).to eql 20 308 | end 309 | 310 | it 'should have a mean value of 13.825' do 311 | expect(bunch.probabilities.expected).to be_within(1e-9).of 13.825 312 | end 313 | 314 | it 'should calculate probabilities correctly' do 315 | prob_hash = bunch.probabilities.to_h 316 | expect(prob_hash[1]).to be_within(1e-10).of 1 / 400.0 317 | expect(prob_hash[2]).to be_within(1e-10).of 3 / 400.0 318 | expect(prob_hash[3]).to be_within(1e-10).of 5 / 400.0 319 | expect(prob_hash[4]).to be_within(1e-10).of 7 / 400.0 320 | expect(prob_hash[5]).to be_within(1e-10).of 9 / 400.0 321 | expect(prob_hash[6]).to be_within(1e-10).of 11 / 400.0 322 | expect(prob_hash[7]).to be_within(1e-10).of 13 / 400.0 323 | expect(prob_hash[8]).to be_within(1e-10).of 15 / 400.0 324 | expect(prob_hash[9]).to be_within(1e-10).of 17 / 400.0 325 | expect(prob_hash[10]).to be_within(1e-10).of 19 / 400.0 326 | expect(prob_hash[11]).to be_within(1e-10).of 21 / 400.0 327 | expect(prob_hash[12]).to be_within(1e-10).of 23 / 400.0 328 | expect(prob_hash[13]).to be_within(1e-10).of 25 / 400.0 329 | expect(prob_hash[14]).to be_within(1e-10).of 27 / 400.0 330 | expect(prob_hash[15]).to be_within(1e-10).of 29 / 400.0 331 | expect(prob_hash[16]).to be_within(1e-10).of 31 / 400.0 332 | expect(prob_hash[17]).to be_within(1e-10).of 33 / 400.0 333 | expect(prob_hash[18]).to be_within(1e-10).of 35 / 400.0 334 | expect(prob_hash[19]).to be_within(1e-10).of 37 / 400.0 335 | expect(prob_hash[20]).to be_within(1e-10).of 39 / 400.0 336 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 337 | end 338 | end 339 | 340 | describe 'roll 2d20, keep worst value' do 341 | let(:bunch) do 342 | GamesDice::Bunch.new( 343 | sides: 20, ndice: 2, keep_mode: :keep_worst, keep_number: 1 344 | ) 345 | end 346 | 347 | it 'should simulate rolling two twenty-sided dice and keeping the best value' do 348 | [14, 16, 5, 3, 7, 5, 9].each do |expected_total| 349 | expect(bunch.roll).to eql expected_total 350 | expect(bunch.result).to eql expected_total 351 | end 352 | end 353 | 354 | it 'should concisely explain each result' do 355 | ['19, 14. Keep: 14', 356 | '18, 16. Keep: 16', 357 | '5, 14. Keep: 5', 358 | '3, 6. Keep: 3'].each do |expected_explain| 359 | bunch.roll 360 | expect(bunch.explain_result).to eql expected_explain 361 | end 362 | end 363 | 364 | it 'should calculate correct min, max = 1,20' do 365 | expect(bunch.min).to eql 1 366 | expect(bunch.max).to eql 20 367 | end 368 | 369 | it 'should have a mean value of 7.175' do 370 | expect(bunch.probabilities.expected).to be_within(1e-9).of 7.175 371 | end 372 | 373 | it 'should calculate probabilities correctly' do 374 | prob_hash = bunch.probabilities.to_h 375 | expect(prob_hash[1]).to be_within(1e-10).of 39 / 400.0 376 | expect(prob_hash[2]).to be_within(1e-10).of 37 / 400.0 377 | expect(prob_hash[3]).to be_within(1e-10).of 35 / 400.0 378 | expect(prob_hash[4]).to be_within(1e-10).of 33 / 400.0 379 | expect(prob_hash[5]).to be_within(1e-10).of 31 / 400.0 380 | expect(prob_hash[6]).to be_within(1e-10).of 29 / 400.0 381 | expect(prob_hash[7]).to be_within(1e-10).of 27 / 400.0 382 | expect(prob_hash[8]).to be_within(1e-10).of 25 / 400.0 383 | expect(prob_hash[9]).to be_within(1e-10).of 23 / 400.0 384 | expect(prob_hash[10]).to be_within(1e-10).of 21 / 400.0 385 | expect(prob_hash[11]).to be_within(1e-10).of 19 / 400.0 386 | expect(prob_hash[12]).to be_within(1e-10).of 17 / 400.0 387 | expect(prob_hash[13]).to be_within(1e-10).of 15 / 400.0 388 | expect(prob_hash[14]).to be_within(1e-10).of 13 / 400.0 389 | expect(prob_hash[15]).to be_within(1e-10).of 11 / 400.0 390 | expect(prob_hash[16]).to be_within(1e-10).of 9 / 400.0 391 | expect(prob_hash[17]).to be_within(1e-10).of 7 / 400.0 392 | expect(prob_hash[18]).to be_within(1e-10).of 5 / 400.0 393 | expect(prob_hash[19]).to be_within(1e-10).of 3 / 400.0 394 | expect(prob_hash[20]).to be_within(1e-10).of 1 / 400.0 395 | expect(prob_hash.values.inject(:+)).to be_within(1e-9).of 1.0 396 | end 397 | end 398 | end 399 | end 400 | -------------------------------------------------------------------------------- /spec/probability_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'helpers' 4 | 5 | describe GamesDice::Probabilities do 6 | describe 'class methods' do 7 | describe '#new' do 8 | it 'should create a new distribution from an array and offset' do 9 | pr = GamesDice::Probabilities.new([1.0], 1) 10 | expect(pr).to be_a GamesDice::Probabilities 11 | expect(pr.to_h).to be_valid_distribution 12 | end 13 | 14 | it 'should raise an error if passed incorrect parameter types' do 15 | expect(-> { GamesDice::Probabilities.new([nil], 20) }).to raise_error TypeError 16 | expect(-> { GamesDice::Probabilities.new([0.3, nil, 0.5], 7) }).to raise_error TypeError 17 | expect(-> { GamesDice::Probabilities.new([0.3, 0.2, 0.5], {}) }).to raise_error TypeError 18 | expect(-> { GamesDice::Probabilities.new({ x: :y }, 17) }).to raise_error TypeError 19 | end 20 | 21 | it 'should raise an error if distribution is incomplete or inaccurate' do 22 | expect(-> { GamesDice::Probabilities.new([0.3, 0.2, 0.6], 3) }).to raise_error ArgumentError 23 | expect(-> { GamesDice::Probabilities.new([], 1) }).to raise_error ArgumentError 24 | expect(-> { GamesDice::Probabilities.new([0.9], 1) }).to raise_error ArgumentError 25 | expect(-> { GamesDice::Probabilities.new([-0.9, 0.2, 0.9], 1) }).to raise_error ArgumentError 26 | end 27 | end 28 | 29 | describe '#for_fair_die' do 30 | it 'should create a new distribution based on number of sides' do 31 | pr2 = GamesDice::Probabilities.for_fair_die(2) 32 | expect(pr2).to be_a GamesDice::Probabilities 33 | expect(pr2.to_h).to eql({ 1 => 0.5, 2 => 0.5 }) 34 | (1..20).each do |sides| 35 | pr = GamesDice::Probabilities.for_fair_die(sides) 36 | expect(pr).to be_a GamesDice::Probabilities 37 | h = pr.to_h 38 | expect(h).to be_valid_distribution 39 | expect(h.keys.count).to eql sides 40 | h.each_value { |v| expect(v).to be_within(1e-10).of 1.0 / sides } 41 | end 42 | end 43 | 44 | it 'should raise an error if number of sides is not an integer' do 45 | expect(-> { GamesDice::Probabilities.for_fair_die({}) }).to raise_error TypeError 46 | end 47 | 48 | it 'should raise an error if number of sides is too low or too high' do 49 | expect(-> { GamesDice::Probabilities.for_fair_die(0) }).to raise_error ArgumentError 50 | expect(-> { GamesDice::Probabilities.for_fair_die(1_000_001) }).to raise_error ArgumentError 51 | end 52 | end 53 | 54 | describe '#add_distributions' do 55 | it 'should combine two distributions to create a third one' do 56 | d4a = GamesDice::Probabilities.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1) 57 | d4b = GamesDice::Probabilities.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1) 58 | pr = GamesDice::Probabilities.add_distributions(d4a, d4b) 59 | expect(pr.to_h).to be_valid_distribution 60 | end 61 | 62 | it 'should calculate a classic 2d6 distribution accurately' do 63 | d6 = GamesDice::Probabilities.for_fair_die(6) 64 | pr = GamesDice::Probabilities.add_distributions(d6, d6) 65 | h = pr.to_h 66 | expect(h).to be_valid_distribution 67 | expect(h[2]).to be_within(1e-9).of 1.0 / 36 68 | expect(h[3]).to be_within(1e-9).of 2.0 / 36 69 | expect(h[4]).to be_within(1e-9).of 3.0 / 36 70 | expect(h[5]).to be_within(1e-9).of 4.0 / 36 71 | expect(h[6]).to be_within(1e-9).of 5.0 / 36 72 | expect(h[7]).to be_within(1e-9).of 6.0 / 36 73 | expect(h[8]).to be_within(1e-9).of 5.0 / 36 74 | expect(h[9]).to be_within(1e-9).of 4.0 / 36 75 | expect(h[10]).to be_within(1e-9).of 3.0 / 36 76 | expect(h[11]).to be_within(1e-9).of 2.0 / 36 77 | expect(h[12]).to be_within(1e-9).of 1.0 / 36 78 | end 79 | 80 | it 'should raise an error if either parameter is not a GamesDice::Probabilities object' do 81 | d10 = GamesDice::Probabilities.for_fair_die(10) 82 | expect(-> { GamesDice::Probabilities.add_distributions('', 6) }).to raise_error TypeError 83 | expect(-> { GamesDice::Probabilities.add_distributions(d10, 6) }).to raise_error TypeError 84 | expect(-> { GamesDice::Probabilities.add_distributions('', d10) }).to raise_error TypeError 85 | end 86 | end 87 | 88 | describe '#add_distributions_mult' do 89 | it 'should combine two multiplied distributions to create a third one' do 90 | d4a = GamesDice::Probabilities.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1) 91 | d4b = GamesDice::Probabilities.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1) 92 | pr = GamesDice::Probabilities.add_distributions_mult(2, d4a, -1, d4b) 93 | expect(pr.to_h).to be_valid_distribution 94 | end 95 | 96 | it "should calculate a distribution for '1d6 - 1d4' accurately" do 97 | d6 = GamesDice::Probabilities.for_fair_die(6) 98 | d4 = GamesDice::Probabilities.for_fair_die(4) 99 | pr = GamesDice::Probabilities.add_distributions_mult(1, d6, -1, d4) 100 | h = pr.to_h 101 | expect(h).to be_valid_distribution 102 | expect(h[-3]).to be_within(1e-9).of 1.0 / 24 103 | expect(h[-2]).to be_within(1e-9).of 2.0 / 24 104 | expect(h[-1]).to be_within(1e-9).of 3.0 / 24 105 | expect(h[0]).to be_within(1e-9).of 4.0 / 24 106 | expect(h[1]).to be_within(1e-9).of 4.0 / 24 107 | expect(h[2]).to be_within(1e-9).of 4.0 / 24 108 | expect(h[3]).to be_within(1e-9).of 3.0 / 24 109 | expect(h[4]).to be_within(1e-9).of 2.0 / 24 110 | expect(h[5]).to be_within(1e-9).of 1.0 / 24 111 | end 112 | 113 | it 'should add asymmetric distributions accurately' do 114 | da = GamesDice::Probabilities.new([0.7, 0.0, 0.3], 2) 115 | db = GamesDice::Probabilities.new([0.5, 0.3, 0.2], 2) 116 | pr = GamesDice::Probabilities.add_distributions_mult(1, da, 2, db) 117 | h = pr.to_h 118 | expect(h).to be_valid_distribution 119 | expect(h[6]).to be_within(1e-9).of 0.7 * 0.5 120 | expect(h[8]).to be_within(1e-9).of (0.7 * 0.3) + (0.3 * 0.5) 121 | expect(h[10]).to be_within(1e-9).of (0.7 * 0.2) + (0.3 * 0.3) 122 | expect(h[12]).to be_within(1e-9).of 0.3 * 0.2 123 | end 124 | 125 | it 'should raise an error if passed incorrect objects for distributions' do 126 | d10 = GamesDice::Probabilities.for_fair_die(10) 127 | expect(-> { GamesDice::Probabilities.add_distributions_mult(1, '', -1, 6) }).to raise_error TypeError 128 | expect(-> { GamesDice::Probabilities.add_distributions_mult(2, d10, 3, 6) }).to raise_error TypeError 129 | expect(-> { GamesDice::Probabilities.add_distributions_mult(1, '', -1, d10) }).to raise_error TypeError 130 | end 131 | 132 | it 'should raise an error if passed incorrect objects for multipliers' do 133 | d10 = GamesDice::Probabilities.for_fair_die(10) 134 | expect(-> { GamesDice::Probabilities.add_distributions_mult({}, d10, [], d10) }).to raise_error TypeError 135 | expect(-> { GamesDice::Probabilities.add_distributions_mult([7], d10, 3, d10) }).to raise_error TypeError 136 | expect(-> { GamesDice::Probabilities.add_distributions_mult(1, d10, {}, d10) }).to raise_error TypeError 137 | end 138 | end 139 | 140 | describe '#from_h' do 141 | it 'should create a Probabilities object from a valid hash' do 142 | pr = GamesDice::Probabilities.from_h({ 7 => 0.5, 9 => 0.5 }) 143 | expect(pr).to be_a GamesDice::Probabilities 144 | end 145 | 146 | it 'should raise an ArgumentError when called with a non-valid hash' do 147 | expect(-> { GamesDice::Probabilities.from_h({ 7 => 0.5, 9 => 0.6 }) }).to raise_error ArgumentError 148 | end 149 | 150 | it 'should raise an TypeError when called with data that is not a hash' do 151 | expect(-> { GamesDice::Probabilities.from_h(:foo) }).to raise_error TypeError 152 | end 153 | 154 | it 'should raise a TypeError when called when keys and values are not all integers and floats' do 155 | expect(-> { GamesDice::Probabilities.from_h({ 'x' => 0.5, 9 => 0.5 }) }).to raise_error TypeError 156 | expect(-> { GamesDice::Probabilities.from_h({ 7 => [], 9 => 0.5 }) }).to raise_error TypeError 157 | end 158 | 159 | it 'should raise an ArgumentError when results are spread very far apart' do 160 | expect(-> { GamesDice::Probabilities.from_h({ 0 => 0.5, 2_000_000 => 0.5 }) }).to raise_error ArgumentError 161 | end 162 | end 163 | end 164 | 165 | describe 'instance methods' do 166 | let(:pr2) { GamesDice::Probabilities.for_fair_die(2) } 167 | let(:pr4) { GamesDice::Probabilities.for_fair_die(4) } 168 | let(:pr6) { GamesDice::Probabilities.for_fair_die(6) } 169 | let(:pr10) { GamesDice::Probabilities.for_fair_die(10) } 170 | let(:pra) { GamesDice::Probabilities.new([0.4, 0.2, 0.4], -1) } 171 | 172 | describe '#each' do 173 | it 'should iterate through all result/probability pairs' do 174 | yielded = [] 175 | pr4.each { |r, p| yielded << [r, p] } 176 | expect(yielded).to eql [[1, 0.25], [2, 0.25], [3, 0.25], [4, 0.25]] 177 | end 178 | 179 | it 'should skip zero probabilities' do 180 | pr_plus_minus = GamesDice::Probabilities.new([0.5, 0.0, 0.5], -1) 181 | yielded = [] 182 | pr_plus_minus.each { |r, p| yielded << [r, p] } 183 | expect(yielded).to eql [[-1, 0.5], [1, 0.5]] 184 | end 185 | end 186 | 187 | describe '#p_eql' do 188 | it 'should return probability of getting a number inside the range' do 189 | expect(pr2.p_eql(2)).to be_within(1.0e-9).of 0.5 190 | expect(pr4.p_eql(1)).to be_within(1.0e-9).of 0.25 191 | expect(pr6.p_eql(6)).to be_within(1.0e-9).of 1.0 / 6 192 | expect(pr10.p_eql(3)).to be_within(1.0e-9).of 0.1 193 | expect(pra.p_eql(-1)).to be_within(1.0e-9).of 0.4 194 | end 195 | 196 | it 'should return 0.0 for values not covered by distribution' do 197 | expect(pr2.p_eql(3)).to eql 0.0 198 | expect(pr4.p_eql(-1)).to eql 0.0 199 | expect(pr6.p_eql(8)).to eql 0.0 200 | expect(pr10.p_eql(11)).to eql 0.0 201 | expect(pra.p_eql(2)).to eql 0.0 202 | end 203 | 204 | it 'should raise a TypeError if asked for probability of non-Integer' do 205 | expect(-> { pr2.p_eql([]) }).to raise_error TypeError 206 | end 207 | end 208 | 209 | describe '#p_gt' do 210 | it 'should return probability of getting a number greater than target' do 211 | expect(pr2.p_gt(1)).to be_within(1.0e-9).of 0.5 212 | expect(pr4.p_gt(3)).to be_within(1.0e-9).of 0.25 213 | expect(pr6.p_gt(2)).to be_within(1.0e-9).of 4.0 / 6 214 | expect(pr10.p_gt(6)).to be_within(1.0e-9).of 0.4 215 | 216 | # Trying more than one, due to possibilities of caching error (in pure Ruby implementation) 217 | expect(pra.p_gt(-2)).to be_within(1.0e-9).of 1.0 218 | expect(pra.p_gt(-1)).to be_within(1.0e-9).of 0.6 219 | expect(pra.p_gt(0)).to be_within(1.0e-9).of 0.4 220 | expect(pra.p_gt(1)).to be_within(1.0e-9).of 0.0 221 | end 222 | 223 | it 'should return 0.0 when the target number is equal or higher than maximum possible' do 224 | expect(pr2.p_gt(2)).to eql 0.0 225 | expect(pr4.p_gt(5)).to eql 0.0 226 | expect(pr6.p_gt(6)).to eql 0.0 227 | expect(pr10.p_gt(20)).to eql 0.0 228 | expect(pra.p_gt(3)).to eql 0.0 229 | end 230 | 231 | it 'should return 1.0 when the target number is lower than minimum' do 232 | expect(pr2.p_gt(0)).to eql 1.0 233 | expect(pr4.p_gt(-5)).to eql 1.0 234 | expect(pr6.p_gt(0)).to eql 1.0 235 | expect(pr10.p_gt(-200)).to eql 1.0 236 | expect(pra.p_gt(-2)).to eql 1.0 237 | end 238 | 239 | it 'should raise a TypeError if asked for probability of non-Integer' do 240 | expect(-> { pr2.p_gt({}) }).to raise_error TypeError 241 | end 242 | end 243 | 244 | describe '#p_ge' do 245 | it 'should return probability of getting a number greater than or equal to target' do 246 | expect(pr2.p_ge(2)).to be_within(1.0e-9).of 0.5 247 | expect(pr4.p_ge(3)).to be_within(1.0e-9).of 0.5 248 | expect(pr6.p_ge(2)).to be_within(1.0e-9).of 5.0 / 6 249 | expect(pr10.p_ge(6)).to be_within(1.0e-9).of 0.5 250 | end 251 | 252 | it 'should return 0.0 when the target number is higher than maximum possible' do 253 | expect(pr2.p_ge(6)).to eql 0.0 254 | expect(pr4.p_ge(5)).to eql 0.0 255 | expect(pr6.p_ge(7)).to eql 0.0 256 | expect(pr10.p_ge(20)).to eql 0.0 257 | end 258 | 259 | it 'should return 1.0 when the target number is lower than or equal to minimum possible' do 260 | expect(pr2.p_ge(1)).to eql 1.0 261 | expect(pr4.p_ge(-5)).to eql 1.0 262 | expect(pr6.p_ge(1)).to eql 1.0 263 | expect(pr10.p_ge(-200)).to eql 1.0 264 | end 265 | 266 | it 'should raise a TypeError if asked for probability of non-Integer' do 267 | expect(-> { pr4.p_ge({}) }).to raise_error TypeError 268 | end 269 | end 270 | 271 | describe '#p_le' do 272 | it 'should return probability of getting a number less than or equal to target' do 273 | expect(pr2.p_le(1)).to be_within(1.0e-9).of 0.5 274 | expect(pr4.p_le(2)).to be_within(1.0e-9).of 0.5 275 | expect(pr6.p_le(2)).to be_within(1.0e-9).of 2.0 / 6 276 | expect(pr10.p_le(6)).to be_within(1.0e-9).of 0.6 277 | end 278 | 279 | it 'should return 1.0 when the target number is higher than or equal to maximum possible' do 280 | expect(pr2.p_le(6)).to eql 1.0 281 | expect(pr4.p_le(4)).to eql 1.0 282 | expect(pr6.p_le(7)).to eql 1.0 283 | expect(pr10.p_le(10)).to eql 1.0 284 | end 285 | 286 | it 'should return 0.0 when the target number is lower than minimum possible' do 287 | expect(pr2.p_le(0)).to eql 0.0 288 | expect(pr4.p_le(-5)).to eql 0.0 289 | expect(pr6.p_le(0)).to eql 0.0 290 | expect(pr10.p_le(-200)).to eql 0.0 291 | end 292 | 293 | it 'should raise a TypeError if asked for probability of non-Integer' do 294 | expect(-> { pr4.p_le([]) }).to raise_error TypeError 295 | end 296 | end 297 | 298 | describe '#p_lt' do 299 | it 'should return probability of getting a number less than target' do 300 | expect(pr2.p_lt(2)).to be_within(1.0e-9).of 0.5 301 | expect(pr4.p_lt(3)).to be_within(1.0e-9).of 0.5 302 | expect(pr6.p_lt(2)).to be_within(1.0e-9).of 1 / 6.0 303 | expect(pr10.p_lt(6)).to be_within(1.0e-9).of 0.5 304 | end 305 | 306 | it 'should return 1.0 when the target number is higher than maximum possible' do 307 | expect(pr2.p_lt(6)).to eql 1.0 308 | expect(pr4.p_lt(5)).to eql 1.0 309 | expect(pr6.p_lt(7)).to eql 1.0 310 | expect(pr10.p_lt(20)).to eql 1.0 311 | end 312 | 313 | it 'should return 0.0 when the target number is lower than or equal to minimum possible' do 314 | expect(pr2.p_lt(1)).to eql 0.0 315 | expect(pr4.p_lt(-5)).to eql 0.0 316 | expect(pr6.p_lt(1)).to eql 0.0 317 | expect(pr10.p_lt(-200)).to eql 0.0 318 | end 319 | 320 | it 'should raise a TypeError if asked for probability of non-Integer' do 321 | expect(-> { pr6.p_lt({}) }).to raise_error TypeError 322 | end 323 | end 324 | 325 | describe '#to_h' do 326 | # This is used loads in other tests 327 | it 'should represent a valid distribution with each integer result associated with its probability' do 328 | expect(pr2.to_h).to be_valid_distribution 329 | expect(pr4.to_h).to be_valid_distribution 330 | expect(pr6.to_h).to be_valid_distribution 331 | expect(pr10.to_h).to be_valid_distribution 332 | end 333 | end 334 | 335 | describe '#min' do 336 | it 'should return lowest possible result allowed by distribution' do 337 | expect(pr2.min).to eql 1 338 | expect(pr4.min).to eql 1 339 | expect(pr6.min).to eql 1 340 | expect(pr10.min).to eql 1 341 | expect(GamesDice::Probabilities.add_distributions(pr6, pr10).min).to eql 2 342 | end 343 | end 344 | 345 | describe '#max' do 346 | it 'should return highest possible result allowed by distribution' do 347 | expect(pr2.max).to eql 2 348 | expect(pr4.max).to eql 4 349 | expect(pr6.max).to eql 6 350 | expect(pr10.max).to eql 10 351 | expect(GamesDice::Probabilities.add_distributions(pr6, pr10).max).to eql 16 352 | end 353 | end 354 | 355 | describe '#expected' do 356 | it 'should return the weighted mean value' do 357 | expect(pr2.expected).to be_within(1.0e-9).of 1.5 358 | expect(pr4.expected).to be_within(1.0e-9).of 2.5 359 | expect(pr6.expected).to be_within(1.0e-9).of 3.5 360 | expect(pr10.expected).to be_within(1.0e-9).of 5.5 361 | expect(GamesDice::Probabilities.add_distributions(pr6, pr10).expected).to be_within(1.0e-9).of 9.0 362 | end 363 | end 364 | 365 | describe '#given_ge' do 366 | it 'should return a new distribution with probabilities calculated assuming value is >= target' do 367 | pd = pr2.given_ge(2) 368 | expect(pd.to_h).to eql({ 2 => 1.0 }) 369 | pd = pr10.given_ge(4) 370 | expect(pd.to_h).to be_valid_distribution 371 | expect(pd.p_eql(3)).to eql 0.0 372 | expect(pd.p_eql(10)).to be_within(1.0e-9).of 0.1 / 0.7 373 | end 374 | 375 | it 'should raise a TypeError if asked for probability of non-Integer' do 376 | expect(-> { pr10.given_ge([]) }).to raise_error TypeError 377 | end 378 | end 379 | 380 | describe '#given_le' do 381 | it 'should return a new distribution with probabilities calculated assuming value is <= target' do 382 | pd = pr2.given_le(2) 383 | expect(pd.to_h).to eql({ 1 => 0.5, 2 => 0.5 }) 384 | pd = pr10.given_le(4) 385 | expect(pd.to_h).to be_valid_distribution 386 | expect(pd.p_eql(3)).to be_within(1.0e-9).of 0.1 / 0.4 387 | expect(pd.p_eql(10)).to eql 0.0 388 | end 389 | 390 | it 'should raise a TypeError if asked for probability of non-Integer' do 391 | expect(-> { pr10.given_le({}) }).to raise_error TypeError 392 | end 393 | end 394 | 395 | describe '#repeat_sum' do 396 | it 'should output a valid distribution if params are valid' do 397 | d4a = GamesDice::Probabilities.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1) 398 | d4b = GamesDice::Probabilities.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1) 399 | pr = d4a.repeat_sum(7) 400 | expect(pr.to_h).to be_valid_distribution 401 | pr = d4b.repeat_sum(12) 402 | expect(pr.to_h).to be_valid_distribution 403 | end 404 | 405 | it 'should raise an error if any param is unexpected type' do 406 | d6 = GamesDice::Probabilities.for_fair_die(6) 407 | expect(-> { d6.repeat_sum({}) }).to raise_error TypeError 408 | end 409 | 410 | it 'should raise an error if distribution would have more than a million results' do 411 | d1000 = GamesDice::Probabilities.for_fair_die(1000) 412 | expect(-> { d1000.repeat_sum(11_000) }).to raise_error(RuntimeError, /Too many probability slots/) 413 | end 414 | 415 | it "should calculate a '3d6' distribution accurately" do 416 | d6 = GamesDice::Probabilities.for_fair_die(6) 417 | pr = d6.repeat_sum(3) 418 | h = pr.to_h 419 | expect(h).to be_valid_distribution 420 | expect(h[3]).to be_within(1e-9).of 1.0 / 216 421 | expect(h[4]).to be_within(1e-9).of 3.0 / 216 422 | expect(h[5]).to be_within(1e-9).of 6.0 / 216 423 | expect(h[6]).to be_within(1e-9).of 10.0 / 216 424 | expect(h[7]).to be_within(1e-9).of 15.0 / 216 425 | expect(h[8]).to be_within(1e-9).of 21.0 / 216 426 | expect(h[9]).to be_within(1e-9).of 25.0 / 216 427 | expect(h[10]).to be_within(1e-9).of 27.0 / 216 428 | expect(h[11]).to be_within(1e-9).of 27.0 / 216 429 | expect(h[12]).to be_within(1e-9).of 25.0 / 216 430 | expect(h[13]).to be_within(1e-9).of 21.0 / 216 431 | expect(h[14]).to be_within(1e-9).of 15.0 / 216 432 | expect(h[15]).to be_within(1e-9).of 10.0 / 216 433 | expect(h[16]).to be_within(1e-9).of 6.0 / 216 434 | expect(h[17]).to be_within(1e-9).of 3.0 / 216 435 | expect(h[18]).to be_within(1e-9).of 1.0 / 216 436 | end 437 | end 438 | 439 | describe '#repeat_n_sum_k' do 440 | it 'should output a valid distribution if params are valid' do 441 | d4a = GamesDice::Probabilities.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1) 442 | d4b = GamesDice::Probabilities.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1) 443 | pr = d4a.repeat_n_sum_k(3, 2) 444 | expect(pr.to_h).to be_valid_distribution 445 | pr = d4b.repeat_n_sum_k(12, 4) 446 | expect(pr.to_h).to be_valid_distribution 447 | end 448 | 449 | it 'should raise an error if any param is unexpected type' do 450 | d6 = GamesDice::Probabilities.for_fair_die(6) 451 | expect(-> { d6.repeat_n_sum_k({}, 10) }).to raise_error TypeError 452 | expect(-> { d6.repeat_n_sum_k(10, {}) }).to raise_error TypeError 453 | end 454 | 455 | it 'should raise an error if n is greater than 170' do 456 | d6 = GamesDice::Probabilities.for_fair_die(6) 457 | expect(-> { d6.repeat_n_sum_k(171, 10) }).to raise_error(RuntimeError, /Too many dice/) 458 | end 459 | 460 | it "should calculate a '4d6 keep best 3' distribution accurately" do 461 | d6 = GamesDice::Probabilities.for_fair_die(6) 462 | pr = d6.repeat_n_sum_k(4, 3) 463 | h = pr.to_h 464 | expect(h).to be_valid_distribution 465 | expect(h[3]).to be_within(1e-10).of 1 / 1296.0 466 | expect(h[4]).to be_within(1e-10).of 4 / 1296.0 467 | expect(h[5]).to be_within(1e-10).of 10 / 1296.0 468 | expect(h[6]).to be_within(1e-10).of 21 / 1296.0 469 | expect(h[7]).to be_within(1e-10).of 38 / 1296.0 470 | expect(h[8]).to be_within(1e-10).of 62 / 1296.0 471 | expect(h[9]).to be_within(1e-10).of 91 / 1296.0 472 | expect(h[10]).to be_within(1e-10).of 122 / 1296.0 473 | expect(h[11]).to be_within(1e-10).of 148 / 1296.0 474 | expect(h[12]).to be_within(1e-10).of 167 / 1296.0 475 | expect(h[13]).to be_within(1e-10).of 172 / 1296.0 476 | expect(h[14]).to be_within(1e-10).of 160 / 1296.0 477 | expect(h[15]).to be_within(1e-10).of 131 / 1296.0 478 | expect(h[16]).to be_within(1e-10).of 94 / 1296.0 479 | expect(h[17]).to be_within(1e-10).of 54 / 1296.0 480 | expect(h[18]).to be_within(1e-10).of 21 / 1296.0 481 | end 482 | 483 | it "should calculate a '2d20 keep worst result' distribution accurately" do 484 | d20 = GamesDice::Probabilities.for_fair_die(20) 485 | pr = d20.repeat_n_sum_k(2, 1, :keep_worst) 486 | h = pr.to_h 487 | expect(h).to be_valid_distribution 488 | expect(h[1]).to be_within(1e-10).of 39 / 400.0 489 | expect(h[2]).to be_within(1e-10).of 37 / 400.0 490 | expect(h[3]).to be_within(1e-10).of 35 / 400.0 491 | expect(h[4]).to be_within(1e-10).of 33 / 400.0 492 | expect(h[5]).to be_within(1e-10).of 31 / 400.0 493 | expect(h[6]).to be_within(1e-10).of 29 / 400.0 494 | expect(h[7]).to be_within(1e-10).of 27 / 400.0 495 | expect(h[8]).to be_within(1e-10).of 25 / 400.0 496 | expect(h[9]).to be_within(1e-10).of 23 / 400.0 497 | expect(h[10]).to be_within(1e-10).of 21 / 400.0 498 | expect(h[11]).to be_within(1e-10).of 19 / 400.0 499 | expect(h[12]).to be_within(1e-10).of 17 / 400.0 500 | expect(h[13]).to be_within(1e-10).of 15 / 400.0 501 | expect(h[14]).to be_within(1e-10).of 13 / 400.0 502 | expect(h[15]).to be_within(1e-10).of 11 / 400.0 503 | expect(h[16]).to be_within(1e-10).of 9 / 400.0 504 | expect(h[17]).to be_within(1e-10).of 7 / 400.0 505 | expect(h[18]).to be_within(1e-10).of 5 / 400.0 506 | expect(h[19]).to be_within(1e-10).of 3 / 400.0 507 | expect(h[20]).to be_within(1e-10).of 1 / 400.0 508 | end 509 | end 510 | end 511 | 512 | describe 'serialisation via Marshall' do 513 | it 'can load a saved GamesDice::Probabilities' do 514 | # rubocop:disable Security/MarshalLoad 515 | # This is a test of using Marshal on a fixed test file 516 | pd6 = File.open(fixture('probs_fair_die_6.dat')) { |file| Marshal.load(file) } 517 | # rubocop:enable Security/MarshalLoad 518 | expect(pd6.to_h).to be_valid_distribution 519 | expect(pd6.p_gt(4)).to be_within(1e-10).of 1.0 / 3 520 | end 521 | end 522 | end 523 | -------------------------------------------------------------------------------- /ext/games_dice/probabilities.c: -------------------------------------------------------------------------------- 1 | // ext/games_dice/probabilities.c 2 | 3 | #include "probabilities.h" 4 | 5 | // Ruby 1.8.7 compatibility patch 6 | #ifndef DBL2NUM 7 | #define DBL2NUM( dbl_val ) rb_float_new( dbl_val ) 8 | #endif 9 | 10 | // Force inclusion of hash declarations (only MRI includes by default) 11 | #ifdef HAVE_RUBY_ST_H 12 | #include "ruby/st.h" 13 | #else 14 | #include "st.h" 15 | #endif 16 | 17 | VALUE Probabilities = Qnil; 18 | 19 | /////////////////////////////////////////////////////////////////////////////////////////////////// 20 | // 21 | // General utils 22 | // 23 | 24 | inline int max( int *a, int n ) { 25 | int m = -1000000000; 26 | int i; 27 | for ( i=0; i < n; i++ ) { 28 | m = a[i] > m ? a[i] : m; 29 | } 30 | return m; 31 | } 32 | 33 | inline int min( int *a, int n ) { 34 | int m = 1000000000; 35 | int i; 36 | for ( i=0; i < n; i++ ) { 37 | m = a[i] < m ? a[i] : m; 38 | } 39 | return m; 40 | } 41 | 42 | /////////////////////////////////////////////////////////////////////////////////////////////////// 43 | // 44 | // Quick factorials, that fit into doubles. . . the size of this structure sets the 45 | // maximum possible n in repeat_n_sum_k calculations 46 | // 47 | 48 | // There is no point calculating these, a cache of them is just fine. 49 | double nfact[171] = { 50 | 1.0, 1.0, 2.0, 6.0, 51 | 24.0, 120.0, 720.0, 5040.0, 52 | 40320.0, 362880.0, 3628800.0, 39916800.0, 53 | 479001600.0, 6227020800.0, 87178291200.0, 1307674368000.0, 54 | 20922789888000.0, 355687428096000.0, 6402373705728000.0, 121645100408832000.0, 55 | 2432902008176640000.0, 51090942171709440000.0, 1124000727777607700000.0, 25852016738884980000000.0, 56 | 620448401733239400000000.0, 15511210043330986000000000.0, 403291461126605650000000000.0, 10888869450418352000000000000.0, 57 | 304888344611713870000000000000.0, 8841761993739702000000000000000.0, 2.6525285981219107e+32, 8.222838654177922e+33, 58 | 2.631308369336935e+35, 8.683317618811886e+36, 2.9523279903960416e+38, 1.0333147966386145e+40, 59 | 3.7199332678990125e+41, 1.3763753091226346e+43, 5.230226174666011e+44, 2.0397882081197444e+46, 60 | 8.159152832478977e+47, 3.345252661316381e+49, 1.40500611775288e+51, 6.041526306337383e+52, 61 | 2.658271574788449e+54, 1.1962222086548019e+56, 5.502622159812089e+57, 2.5862324151116818e+59, 62 | 1.2413915592536073e+61, 6.082818640342675e+62, 3.0414093201713376e+64, 1.5511187532873822e+66, 63 | 8.065817517094388e+67, 4.2748832840600255e+69, 2.308436973392414e+71, 1.2696403353658276e+73, 64 | 7.109985878048635e+74, 4.0526919504877214e+76, 2.3505613312828785e+78, 1.3868311854568984e+80, 65 | 8.32098711274139e+81, 5.075802138772248e+83, 3.146997326038794e+85, 1.98260831540444e+87, 66 | 1.2688693218588417e+89, 8.247650592082472e+90, 5.443449390774431e+92, 3.647111091818868e+94, 67 | 2.4800355424368305e+96, 1.711224524281413e+98, 1.1978571669969892e+100, 8.504785885678623e+101, 68 | 6.1234458376886085e+103, 4.4701154615126844e+105, 3.307885441519386e+107, 2.48091408113954e+109, 69 | 1.8854947016660504e+111, 1.4518309202828587e+113, 1.1324281178206297e+115, 8.946182130782976e+116, 70 | 7.156945704626381e+118, 5.797126020747368e+120, 4.753643337012842e+122, 3.945523969720659e+124, 71 | 3.314240134565353e+126, 2.81710411438055e+128, 2.4227095383672734e+130, 2.107757298379528e+132, 72 | 1.8548264225739844e+134, 1.650795516090846e+136, 1.4857159644817615e+138, 1.352001527678403e+140, 73 | 1.2438414054641308e+142, 1.1567725070816416e+144, 1.087366156656743e+146, 1.032997848823906e+148, 74 | 9.916779348709496e+149, 9.619275968248212e+151, 9.426890448883248e+153, 9.332621544394415e+155, 75 | 9.332621544394415e+157, 9.42594775983836e+159, 9.614466715035127e+161, 9.90290071648618e+163, 76 | 1.0299016745145628e+166, 1.081396758240291e+168, 1.1462805637347084e+170, 1.226520203196138e+172, 77 | 1.324641819451829e+174, 1.4438595832024937e+176, 1.588245541522743e+178, 1.7629525510902446e+180, 78 | 1.974506857221074e+182, 2.2311927486598138e+184, 2.5435597334721877e+186, 2.925093693493016e+188, 79 | 3.393108684451898e+190, 3.969937160808721e+192, 4.684525849754291e+194, 5.574585761207606e+196, 80 | 6.689502913449127e+198, 8.094298525273444e+200, 9.875044200833601e+202, 1.214630436702533e+205, 81 | 1.506141741511141e+207, 1.882677176888926e+209, 2.372173242880047e+211, 3.0126600184576594e+213, 82 | 3.856204823625804e+215, 4.974504222477287e+217, 6.466855489220474e+219, 8.47158069087882e+221, 83 | 1.1182486511960043e+224, 1.4872707060906857e+226, 1.9929427461615188e+228, 2.6904727073180504e+230, 84 | 3.659042881952549e+232, 5.012888748274992e+234, 6.917786472619489e+236, 9.615723196941089e+238, 85 | 1.3462012475717526e+241, 1.898143759076171e+243, 2.695364137888163e+245, 3.854370717180073e+247, 86 | 5.5502938327393044e+249, 8.047926057471992e+251, 1.1749972043909107e+254, 1.727245890454639e+256, 87 | 2.5563239178728654e+258, 3.80892263763057e+260, 5.713383956445855e+262, 8.62720977423324e+264, 88 | 1.3113358856834524e+267, 2.0063439050956823e+269, 3.0897696138473508e+271, 4.789142901463394e+273, 89 | 7.471062926282894e+275, 1.1729568794264145e+278, 1.853271869493735e+280, 2.9467022724950384e+282, 90 | 4.7147236359920616e+284, 7.590705053947219e+286, 1.2296942187394494e+289, 2.0044015765453026e+291, 91 | 3.287218585534296e+293, 5.423910666131589e+295, 9.003691705778438e+297, 1.503616514864999e+300, 92 | 2.5260757449731984e+302, 4.269068009004705e+304, 7.257415615307999e+306 }; 93 | 94 | double num_arrangements( int *args, int nargs ) { 95 | int sum = 0; 96 | double div_by = 1.0; 97 | int i; 98 | for ( i = 0; i < nargs; i++ ) { 99 | sum += args[i]; 100 | if ( sum > 170 ) { 101 | rb_raise( rb_eRuntimeError, "Too many dice to calculate numbers of arrangements" ); 102 | } 103 | div_by *= nfact[ args[i] ]; 104 | } 105 | return nfact[ sum ] / div_by; 106 | } 107 | 108 | /////////////////////////////////////////////////////////////////////////////////////////////////// 109 | // 110 | // Probability List basics - create, delete, copy 111 | // 112 | 113 | ProbabilityList *create_probability_list() { 114 | ProbabilityList *pl; 115 | pl = malloc (sizeof(ProbabilityList)); 116 | if ( pl == NULL ) { 117 | rb_raise(rb_eRuntimeError, "Could not allocate memory for Probabilities"); 118 | } 119 | pl->probs = NULL; 120 | pl->cumulative = NULL; 121 | pl->slots = 0; 122 | pl->offset = 0; 123 | return pl; 124 | } 125 | 126 | void destroy_probability_list( ProbabilityList *pl ) { 127 | xfree( pl->cumulative ); 128 | xfree( pl->probs ); 129 | xfree( pl ); 130 | return; 131 | } 132 | 133 | double *alloc_probs( ProbabilityList *pl, int slots ) { 134 | double *pr; 135 | 136 | if ( slots < 1 || slots > 1000000 ) { 137 | rb_raise(rb_eArgError, "Bad number of probability slots"); 138 | } 139 | pl->slots = slots; 140 | 141 | pr = ALLOC_N( double, slots ); 142 | pl->probs = pr; 143 | 144 | pl->cumulative = ALLOC_N( double, slots ); 145 | 146 | return pr; 147 | } 148 | 149 | double calc_cumulative( ProbabilityList *pl ) { 150 | double *c = pl->cumulative; 151 | double *pr = pl->probs; 152 | int i; 153 | double t = 0.0; 154 | for(i=0; i < pl->slots; i++) { 155 | t += pr[i]; 156 | c[i] = t; 157 | } 158 | return t; 159 | } 160 | 161 | double *alloc_probs_iv( ProbabilityList *pl, int slots, double iv ) { 162 | double *pr; 163 | int i; 164 | 165 | if ( iv < 0.0 || iv > 1.0 ) { 166 | rb_raise(rb_eArgError, "Bad single probability value"); 167 | } 168 | pr= alloc_probs( pl, slots ); 169 | for(i=0; islots ); 180 | pl->offset = orig->offset; 181 | memcpy( pr, orig->probs, orig->slots * sizeof(double) ); 182 | memcpy( pl->cumulative, orig->cumulative, orig->slots * sizeof(double) ); 183 | return pl; 184 | } 185 | 186 | inline ProbabilityList *new_basic_pl( int nslots, double iv, int o ) { 187 | ProbabilityList *pl = create_probability_list(); 188 | alloc_probs_iv( pl, nslots, iv ); 189 | pl->offset = o; 190 | return pl; 191 | } 192 | 193 | /////////////////////////////////////////////////////////////////////////////////////////////////// 194 | // 195 | // Probability List core "native" methods 196 | // 197 | 198 | inline int pl_min( ProbabilityList *pl ) { 199 | return pl->offset; 200 | } 201 | 202 | inline int pl_max( ProbabilityList *pl ) { 203 | return pl->offset + pl->slots - 1; 204 | } 205 | 206 | ProbabilityList *pl_add_distributions( ProbabilityList *pl_a, ProbabilityList *pl_b ) { 207 | double *pr; 208 | int s = pl_a->slots + pl_b->slots - 1; 209 | int o = pl_a->offset + pl_b->offset; 210 | int i,j; 211 | 212 | ProbabilityList *pl = create_probability_list(); 213 | pl->offset = o; 214 | pr = alloc_probs_iv( pl, s, 0.0 ); 215 | for ( i=0; i < pl_a->slots; i++ ) { for ( j=0; j < pl_b->slots; j++ ) { 216 | pr[ i + j ] += (pl_a->probs)[i] * (pl_b->probs)[j]; 217 | } } 218 | calc_cumulative( pl ); 219 | return pl; 220 | } 221 | 222 | ProbabilityList *pl_add_distributions_mult( int mul_a, ProbabilityList *pl_a, int mul_b, ProbabilityList *pl_b ) { 223 | int pts[4] = { 224 | mul_a * pl_min( pl_a ) + mul_b * pl_min( pl_b ), 225 | mul_a * pl_max( pl_a ) + mul_b * pl_min( pl_b ), 226 | mul_a * pl_min( pl_a ) + mul_b * pl_max( pl_b ), 227 | mul_a * pl_max( pl_a ) + mul_b * pl_max( pl_b ) }; 228 | 229 | double *pr; 230 | int combined_min = min( pts, 4 ); 231 | int combined_max = max( pts, 4 ); 232 | int s = 1 + combined_max - combined_min; 233 | int i,j; 234 | 235 | ProbabilityList *pl = create_probability_list(); 236 | pl->offset = combined_min; 237 | pr = alloc_probs_iv( pl, s, 0.0 ); 238 | for ( i=0; i < pl_a->slots; i++ ) { for ( j=0; j < pl_b->slots; j++ ) { 239 | int k = mul_a * (i + pl_a->offset) + mul_b * (j + pl_b->offset) - combined_min; 240 | pr[ k ] += (pl_a->probs)[i] * (pl_b->probs)[j]; 241 | } } 242 | calc_cumulative( pl ); 243 | return pl; 244 | } 245 | 246 | inline double pl_p_eql( ProbabilityList *pl, int target ) { 247 | int idx = target - pl->offset; 248 | if ( idx < 0 || idx >= pl->slots ) { 249 | return 0.0; 250 | } 251 | return (pl->probs)[idx]; 252 | } 253 | 254 | inline double pl_p_gt( ProbabilityList *pl, int target ) { 255 | return 1.0 - pl_p_le( pl, target ); 256 | } 257 | 258 | inline double pl_p_lt( ProbabilityList *pl, int target ) { 259 | return pl_p_le( pl, target - 1 ); 260 | } 261 | 262 | inline double pl_p_le( ProbabilityList *pl, int target ) { 263 | int idx = target - pl->offset; 264 | if ( idx < 0 ) { 265 | return 0.0; 266 | } 267 | if ( idx >= pl->slots - 1 ) { 268 | return 1.0; 269 | } 270 | return (pl->cumulative)[idx]; 271 | } 272 | 273 | inline double pl_p_ge( ProbabilityList *pl, int target ) { 274 | return 1.0 - pl_p_le( pl, target - 1 ); 275 | } 276 | 277 | inline double pl_expected( ProbabilityList *pl ) { 278 | double t = 0.0; 279 | int o = pl->offset; 280 | int s = pl->slots; 281 | double *pr = pl->probs; 282 | int i; 283 | for ( i = 0; i < s ; i++ ) { 284 | t += ( i + o ) * pr[i]; 285 | } 286 | return t; 287 | } 288 | 289 | ProbabilityList *pl_given_ge( ProbabilityList *pl, int target ) { 290 | int m = pl_min( pl ); 291 | double p, mult; 292 | double *pr; 293 | double *new_pr; 294 | int o,i,s; 295 | ProbabilityList *new_pl; 296 | 297 | if ( m > target ) { 298 | target = m; 299 | } 300 | p = pl_p_ge( pl, target ); 301 | if ( p <= 0.0 ) { 302 | rb_raise( rb_eRuntimeError, "Cannot calculate given probabilities, divide by zero" ); 303 | } 304 | mult = 1.0/p; 305 | s = pl->slots + pl->offset - target; 306 | pr = pl->probs; 307 | 308 | new_pl = create_probability_list(); 309 | new_pl->offset = target; 310 | new_pr = alloc_probs( new_pl, s ); 311 | o = target - pl->offset; 312 | 313 | for ( i = 0; i < s; i++ ) { 314 | new_pr[i] = pr[o + i] * mult; 315 | } 316 | calc_cumulative( new_pl ); 317 | return new_pl; 318 | } 319 | 320 | ProbabilityList *pl_given_le( ProbabilityList *pl, int target ) { 321 | int m = pl_max( pl ); 322 | double p, mult; 323 | double *pr; 324 | double *new_pr; 325 | int i,s; 326 | ProbabilityList *new_pl; 327 | 328 | if ( m < target ) { 329 | target = m; 330 | } 331 | p = pl_p_le( pl, target ); 332 | if ( p <= 0.0 ) { 333 | rb_raise( rb_eRuntimeError, "Cannot calculate given probabilities, divide by zero" ); 334 | } 335 | mult = 1.0/p; 336 | s = target - pl->offset + 1; 337 | pr = pl->probs; 338 | 339 | new_pl = create_probability_list(); 340 | new_pl->offset = pl->offset; 341 | new_pr = alloc_probs( new_pl, s ); 342 | 343 | for ( i = 0; i < s; i++ ) { 344 | new_pr[i] = pr[i] * mult; 345 | } 346 | calc_cumulative( new_pl ); 347 | return new_pl; 348 | } 349 | 350 | ProbabilityList *pl_repeat_sum( ProbabilityList *pl, int n ) { 351 | ProbabilityList *pd_power = NULL; 352 | ProbabilityList *pd_result = NULL; 353 | ProbabilityList *pd_next = NULL; 354 | int power = 1; 355 | 356 | if ( n < 1 ) { 357 | rb_raise( rb_eRuntimeError, "Cannot calculate repeat_sum when n < 1" ); 358 | } 359 | if ( n * pl->slots - n > 1000000 ) { 360 | rb_raise( rb_eRuntimeError, "Too many probability slots" ); 361 | } 362 | 363 | pd_power = copy_probability_list( pl ); 364 | 365 | while ( 1 ) { 366 | if ( power & n ) { 367 | if ( pd_result ) { 368 | pd_next = pl_add_distributions( pd_result, pd_power ); 369 | destroy_probability_list( pd_result ); 370 | pd_result = pd_next; 371 | } else { 372 | pd_result = copy_probability_list( pd_power ); 373 | } 374 | } 375 | power = power << 1; 376 | if ( power > n ) break; 377 | pd_next = pl_add_distributions( pd_power, pd_power ); 378 | destroy_probability_list( pd_power ); 379 | pd_power = pd_next; 380 | } 381 | destroy_probability_list( pd_power ); 382 | 383 | return pd_result; 384 | } 385 | 386 | // Assigns { p_rejected, p_maybe, p_kept } to buffer 387 | void calc_p_table( ProbabilityList *pl, int q, int kbest, double *buffer ) { 388 | if ( kbest ) { 389 | buffer[2] = pl_p_gt( pl, q ); 390 | buffer[1] = pl_p_eql( pl, q ); 391 | buffer[0] = pl_p_lt( pl, q ); 392 | } else { 393 | buffer[2] = pl_p_lt( pl, q ); 394 | buffer[1] = pl_p_eql( pl, q ); 395 | buffer[0] = pl_p_gt( pl, q ); 396 | } 397 | return; 398 | } 399 | 400 | // Assigns a list of pl variants to a buffer 401 | void calc_keep_distributions( ProbabilityList *pl, int k, int q, int kbest, ProbabilityList **pl_array ) { 402 | ProbabilityList *pl_kd; 403 | 404 | int n; 405 | for ( n=0; n 0.0 && k > 1 ) { 410 | pl_kd = pl_given_ge( pl, q + 1 ); 411 | for ( n = 1; n < k; n++ ) { 412 | pl_array[n] = pl_repeat_sum( pl_kd, n ); 413 | (pl_array[n])->offset += q * ( k - n ); 414 | } 415 | } 416 | } else { 417 | if ( pl_p_lt( pl, q ) > 0.0 && k > 1 ) { 418 | pl_kd = pl_given_le( pl, q - 1 ); 419 | for ( n = 1; n < k; n++ ) { 420 | pl_array[n] = pl_repeat_sum( pl_kd, n ); 421 | (pl_array[n])->offset += q * ( k - n ); 422 | } 423 | } 424 | } 425 | 426 | return; 427 | } 428 | 429 | inline void clear_pl_array( int k, ProbabilityList **pl_array ) { 430 | int n; 431 | for ( n=0; n pivot point (vs == pivot point) 444 | ProbabilityList *keep_distributions[171]; 445 | ProbabilityList *kd; 446 | ProbabilityList *pl_result = NULL; 447 | 448 | double *pr; 449 | int d = n - k; 450 | int i, j, q, dn, kn, mn, kdq; 451 | double p_sequence; 452 | 453 | if ( n < 1 ) { 454 | rb_raise( rb_eRuntimeError, "Cannot calculate repeat_n_sum_k when n < 1" ); 455 | } 456 | if ( k < 1 ) { 457 | rb_raise( rb_eRuntimeError, "Cannot calculate repeat_sum_k when k < 1" ); 458 | } 459 | if ( k >= n ) { 460 | return pl_repeat_sum( pl, n ); 461 | } 462 | if ( k * pl->slots - k >= 1000000 ) { 463 | rb_raise( rb_eRuntimeError, "Too many probability slots" ); 464 | } 465 | if ( n > 170 ) { 466 | rb_raise( rb_eRuntimeError, "Too many dice to calculate combinations" ); 467 | } 468 | 469 | // Init target 470 | pl_result = create_probability_list(); 471 | pr = alloc_probs_iv( pl_result, 1 + k * (pl->slots - 1), 0.0 ); 472 | pl_result->offset = pl->offset * k; 473 | 474 | for ( i = 0; i < pl->slots; i++ ) { 475 | if ( pl->probs[i] <= 0.0 ) continue; 476 | 477 | q = i + pl->offset; 478 | calc_keep_distributions( pl, k, q, kbest, keep_distributions ); 479 | calc_p_table( pl, q, kbest, p_table ); 480 | 481 | for ( kn = 0; kn < k; kn++ ) { 482 | // Construct keepers. maybes, discards (just counts of these) . . . 483 | if ( kn > 0 && ! ( p_table[2] > 0.0 ) ) continue; 484 | 485 | for ( dn = 0; dn <= d; dn++ ) { 486 | mn = (k - kn) + ( d - dn ); 487 | if ( dn > 0 && ! ( p_table[0] > 0.0 ) ) continue; 488 | p_sequence = 1.0; 489 | for ( j = 0; j < dn; j++ ) { p_sequence *= p_table[0]; } 490 | for ( j = 0; j < mn; j++ ) { p_sequence *= p_table[1]; } 491 | for ( j = 0; j < kn; j++ ) { p_sequence *= p_table[2]; } 492 | keep_combos[0] = dn; 493 | keep_combos[1] = mn; 494 | keep_combos[2] = kn; 495 | p_sequence *= num_arrangements( keep_combos, 3 ); 496 | kd = keep_distributions[ kn ]; 497 | 498 | for ( j = 0; j < kd->slots; j++ ) { 499 | kdq = j + kd->offset; 500 | pr[ kdq - pl_result->offset ] += p_sequence * kd->probs[ j ]; 501 | } 502 | } 503 | } 504 | clear_pl_array( k, keep_distributions ); 505 | } 506 | 507 | calc_cumulative( pl_result ); 508 | return pl_result; 509 | } 510 | 511 | 512 | 513 | /////////////////////////////////////////////////////////////////////////////////////////////////// 514 | // 515 | // Ruby integration 516 | // 517 | 518 | inline VALUE pl_as_ruby_class( ProbabilityList *pl, VALUE klass ) { 519 | return Data_Wrap_Struct( klass, 0, destroy_probability_list, pl ); 520 | } 521 | 522 | VALUE pl_alloc(VALUE klass) { 523 | return pl_as_ruby_class( create_probability_list(), klass ); 524 | } 525 | 526 | inline ProbabilityList *get_probability_list( VALUE obj ) { 527 | ProbabilityList *pl; 528 | Data_Get_Struct( obj, ProbabilityList, pl ); 529 | return pl; 530 | } 531 | 532 | void assert_value_wraps_pl( VALUE obj ) { 533 | if ( TYPE(obj) != T_DATA || 534 | RDATA(obj)->dfree != (RUBY_DATA_FUNC)destroy_probability_list) { 535 | rb_raise( rb_eTypeError, "Expected a Probabilities object, but got something else" ); 536 | } 537 | } 538 | 539 | // Validate key/value from hash, and adjust object properties as required 540 | int validate_key_value( VALUE key, VALUE val, VALUE obj ) { 541 | int k = NUM2INT( key ); 542 | ProbabilityList *pl = get_probability_list( obj ); 543 | // Not assigned (to avoid "unused" warning), but this throws execption if val cannot be coerced to double 544 | NUM2DBL( val ); 545 | 546 | if ( k > 0x7fffffff ) { 547 | rb_raise( rb_eArgError, "Result too large" ); 548 | } 549 | 550 | if ( k < pl->offset ) { 551 | if ( pl->slots < 1 ) { 552 | pl->slots = 1; 553 | } else { 554 | pl->slots = pl->slots - k + pl->offset; 555 | } 556 | pl->offset = k; 557 | } else if ( k - pl->offset >= pl->slots ) { 558 | pl->slots = 1 + k - pl->offset; 559 | } 560 | return ST_CONTINUE; 561 | } 562 | 563 | // Copy key/value from hash 564 | int copy_key_value( VALUE key, VALUE val, VALUE obj ) { 565 | int k = NUM2INT( key ); 566 | double v = NUM2DBL( val ); 567 | ProbabilityList *pl = get_probability_list( obj ); 568 | pl->probs[ k - pl->offset ] = v; 569 | return ST_CONTINUE; 570 | } 571 | 572 | /////////////////////////////////////////////////////////////////////////////////////////////////// 573 | // 574 | // Ruby class and instance methods for Probabilities 575 | // 576 | 577 | /* 578 | * @overload initialize(probs, offset) 579 | * Creates new instance of GamesDice::Probabilities. 580 | * @param [Array] probs Each entry in the array is the probability of getting a result 581 | * @param [Integer] offset The result associated with index of 0 in the array 582 | * @return [GamesDice::Probabilities] 583 | */ 584 | VALUE probabilities_initialize( VALUE self, VALUE arr, VALUE offset ) { 585 | int i, o, s; 586 | double error, p_item; 587 | ProbabilityList *pl; 588 | double *pr; 589 | 590 | o = NUM2INT(offset); 591 | Check_Type( arr, T_ARRAY ); 592 | s = FIX2INT( rb_funcall( arr, rb_intern("count"), 0 ) ); 593 | pl = get_probability_list( self ); 594 | pl->offset = o; 595 | pr = alloc_probs( pl, s ); 596 | for(i=0; i 1.0 ) { 601 | rb_raise( rb_eArgError, "Probability must be in range 0.0..1.0" ); 602 | } 603 | pr[i] = p_item; 604 | } 605 | error = calc_cumulative( pl ) - 1.0; 606 | if ( error < -1.0e-8 ) { 607 | rb_raise( rb_eArgError, "Total probabilities are less than 1.0" ); 608 | } else if ( error > 1.0e-8 ) { 609 | rb_raise( rb_eArgError, "Total probabilities are greater than 1.0" ); 610 | } 611 | return self; 612 | } 613 | 614 | /* 615 | * @overload clone 616 | * Cloning an object of this class creates a deep copy of the probabilities hash. 617 | * @return [GamesDice::Probabilities] 618 | */ 619 | VALUE probabilities_initialize_copy( VALUE copy, VALUE orig ) { 620 | ProbabilityList *pl_copy; 621 | ProbabilityList *pl_orig; 622 | double *pr; 623 | 624 | if (copy == orig) return copy; 625 | pl_copy = get_probability_list( copy ); 626 | pl_orig = get_probability_list( orig ); 627 | 628 | pr = alloc_probs( pl_copy, pl_orig->slots ); 629 | pl_copy->offset = pl_orig->offset; 630 | memcpy( pr, pl_orig->probs, pl_orig->slots * sizeof(double) ); 631 | memcpy( pl_copy->cumulative, pl_orig->cumulative, pl_orig->slots * sizeof(double) );; 632 | 633 | return copy; 634 | } 635 | 636 | /* 637 | * A hash representation of the distribution. Each key is an integer result, 638 | * and the matching value is probability of getting that result. A new hash is generated on each 639 | * call to this method. 640 | * @return [Hash] 641 | */ 642 | VALUE probabilities_to_h( VALUE self ) { 643 | ProbabilityList *pl = get_probability_list( self ); 644 | VALUE h = rb_hash_new(); 645 | double *pr = pl->probs; 646 | int s = pl->slots; 647 | int o = pl->offset; 648 | int i; 649 | for(i=0; i 0.0 ) { 651 | rb_hash_aset( h, INT2FIX( o + i ), DBL2NUM( pr[i] ) ); 652 | } 653 | } 654 | return h; 655 | } 656 | 657 | /* 658 | * @overload min 659 | * @!attribute [r] min 660 | * Minimum result in the distribution 661 | * @return [Integer] 662 | */ 663 | VALUE probabilities_min( VALUE self ) { 664 | return INT2NUM( pl_min( get_probability_list( self ) ) ); 665 | } 666 | 667 | /* 668 | * @overload max 669 | * @!attribute [r] max 670 | * Maximum result in the distribution 671 | * @return [Integer] 672 | */ 673 | VALUE probabilities_max( VALUE self ) { 674 | return INT2NUM( pl_max( get_probability_list( self ) ) ); 675 | } 676 | 677 | /* 678 | * Probability of result equalling specific target 679 | * @param [Integer] target 680 | * @return [Float] in range (0.0..1.0) 681 | */ 682 | VALUE probabilites_p_eql( VALUE self, VALUE target ) { 683 | return DBL2NUM( pl_p_eql( get_probability_list( self ), NUM2INT(target) ) ); 684 | } 685 | 686 | /* 687 | * Probability of result being greater than specific target 688 | * @param [Integer] target 689 | * @return [Float] in range (0.0..1.0) 690 | */ 691 | VALUE probabilites_p_gt( VALUE self, VALUE target ) { 692 | return DBL2NUM( pl_p_gt( get_probability_list( self ), NUM2INT(target) ) ); 693 | } 694 | 695 | /* 696 | * Probability of result being equal to or greater than specific target 697 | * @param [Integer] target 698 | * @return [Float] in range (0.0..1.0) 699 | */ 700 | VALUE probabilites_p_ge( VALUE self, VALUE target ) { 701 | return DBL2NUM( pl_p_ge( get_probability_list( self ), NUM2INT(target) ) ); 702 | } 703 | 704 | /* 705 | * Probability of result being equal to or less than specific target 706 | * @param [Integer] target 707 | * @return [Float] in range (0.0..1.0) 708 | */ 709 | VALUE probabilites_p_le( VALUE self, VALUE target ) { 710 | return DBL2NUM( pl_p_le( get_probability_list( self ), NUM2INT(target) ) ); 711 | } 712 | 713 | /* 714 | * Probability of result being less than specific target 715 | * @param [Integer] target 716 | * @return [Float] in range (0.0..1.0) 717 | */ 718 | VALUE probabilites_p_lt( VALUE self, VALUE target ) { 719 | return DBL2NUM( pl_p_lt( get_probability_list( self ), NUM2INT(target) ) ); 720 | } 721 | 722 | /* 723 | * @overload expected 724 | * @!attribute [r] expected 725 | * Expected value of distribution. 726 | * @return [Float] 727 | */ 728 | VALUE probabilites_expected( VALUE self ) { 729 | return DBL2NUM( pl_expected( get_probability_list( self ) ) ); 730 | } 731 | 732 | /* 733 | * Probability distribution derived from this one, where we know (or are only interested in 734 | * situations where) the result is greater than or equal to target. 735 | * @param [Integer] target 736 | * @return [GamesDice::Probabilities] new distribution. 737 | */ 738 | VALUE probabilities_given_ge( VALUE self, VALUE target ) { 739 | int t = NUM2INT(target); 740 | ProbabilityList *pl = get_probability_list( self ); 741 | return pl_as_ruby_class( pl_given_ge( pl, t ), Probabilities ); 742 | } 743 | 744 | /* 745 | * Probability distribution derived from this one, where we know (or are only interested in 746 | * situations where) the result is less than or equal to target. 747 | * @param [Integer] target 748 | * @return [GamesDice::Probabilities] new distribution. 749 | */ 750 | VALUE probabilities_given_le( VALUE self, VALUE target ) { 751 | int t = NUM2INT(target); 752 | ProbabilityList *pl = get_probability_list( self ); 753 | return pl_as_ruby_class( pl_given_le( pl, t ), Probabilities ); 754 | } 755 | 756 | /* 757 | * @overload repeat_sum(n) 758 | * Adds a distribution to itself repeatedly, to simulate a number of dice 759 | * results being summed. 760 | * @param [Integer] n Number of repetitions, must be at least 1 761 | * @return [GamesDice::Probabilities] new distribution 762 | */ 763 | VALUE probabilities_repeat_sum( VALUE self, VALUE nsum ) { 764 | int n = NUM2INT(nsum); 765 | ProbabilityList *pl = get_probability_list( self ); 766 | return pl_as_ruby_class( pl_repeat_sum( pl, n ), Probabilities ); 767 | } 768 | 769 | /* 770 | * @overload repeat_n_sum_k(n, k, kmode = :keep_best) 771 | * Calculates distribution generated by summing best k results of n iterations 772 | * of the distribution. 773 | * @param [Integer] n Number of repetitions, must be at least 1 774 | * @param [Integer] k Number of best results to keep and sum 775 | * @return [GamesDice::Probabilities] new distribution 776 | */ 777 | VALUE probabilities_repeat_n_sum_k( int argc, VALUE* argv, VALUE self ) { 778 | VALUE nsum, nkeepers, kmode; 779 | int keep_best, n, k; 780 | ProbabilityList *pl; 781 | 782 | rb_scan_args( argc, argv, "21", &nsum, &nkeepers, &kmode ); 783 | 784 | keep_best = 1; 785 | if (NIL_P(kmode)) { 786 | keep_best = 1; 787 | } else if ( rb_intern("keep_worst") == SYM2ID(kmode) ) { 788 | keep_best = 0; 789 | } else if ( rb_intern("keep_best") != SYM2ID(kmode) ) { 790 | rb_raise( rb_eArgError, "Keep mode not recognised" ); 791 | } 792 | 793 | n = NUM2INT(nsum); 794 | k = NUM2INT(nkeepers); 795 | pl = get_probability_list( self ); 796 | return pl_as_ruby_class( pl_repeat_n_sum_k( pl, n, k, keep_best ), Probabilities ); 797 | } 798 | 799 | /* 800 | * Iterates through value, probability pairs 801 | * @yieldparam [Integer] result A result that may be possible in the dice scheme 802 | * @yieldparam [Float] probability Probability of result, in range 0.0..1.0 803 | * @return [GamesDice::Probabilities] this object 804 | */ 805 | VALUE probabilities_each( VALUE self ) { 806 | ProbabilityList *pl = get_probability_list( self ); 807 | int i; 808 | double *pr = pl->probs; 809 | int o = pl->offset; 810 | for ( i = 0; i < pl->slots; i++ ) { 811 | if ( pr[i] > 0.0 ) { 812 | VALUE a = rb_ary_new2( 2 ); 813 | rb_ary_store( a, 0, INT2NUM( i + o )); 814 | rb_ary_store( a, 1, DBL2NUM( pr[i] )); 815 | rb_yield( a ); 816 | } 817 | } 818 | return self; 819 | } 820 | 821 | /* 822 | * Distribution for a die with equal chance of rolling 1..N 823 | * @param [Integer] sides Number of sides on die 824 | * @return [GamesDice::Probabilities] 825 | */ 826 | VALUE probabilities_for_fair_die( VALUE self, VALUE sides ) { 827 | int s = NUM2INT( sides ); 828 | VALUE obj; 829 | ProbabilityList *pl; 830 | 831 | if ( s < 1 ) { 832 | rb_raise( rb_eArgError, "Number of sides should be 1 or more" ); 833 | } 834 | if ( s > 100000 ) { 835 | rb_raise( rb_eArgError, "Number of sides should be less than 100001" ); 836 | } 837 | obj = pl_alloc( Probabilities ); 838 | pl = get_probability_list( obj ); 839 | pl->offset = 1; 840 | alloc_probs_iv( pl, s, 1.0/s ); 841 | return obj; 842 | } 843 | 844 | /* 845 | * @overload from_h(prob_hash) 846 | * Creates new instance of GamesDice::Probabilities. 847 | * @param [Hash] prob_hash A hash representation of the distribution, each key is an integer result, 848 | * and the matching value is probability of getting that result 849 | * @return [GamesDice::Probabilities] 850 | */ 851 | VALUE probabilities_from_h( VALUE self, VALUE hash ) { 852 | VALUE obj; 853 | ProbabilityList *pl; 854 | double error; 855 | 856 | Check_Type( hash, T_HASH ); 857 | 858 | obj = pl_alloc( Probabilities ); 859 | pl = get_probability_list( obj ); 860 | 861 | 862 | // Set these up so that they get adjusted during hash iteration 863 | pl->offset = 0x7fffffff; 864 | pl->slots = 0; 865 | // First iteration establish min/max and validate all key/values 866 | rb_hash_foreach( hash, validate_key_value, obj ); 867 | 868 | alloc_probs_iv( pl, pl->slots, 0.0 ); 869 | // Second iteration copy key/value pairs into structure 870 | rb_hash_foreach( hash, copy_key_value, obj ); 871 | 872 | error = calc_cumulative( pl ) - 1.0; 873 | if ( error < -1.0e-8 ) { 874 | rb_raise( rb_eArgError, "Total probabilities are less than 1.0" ); 875 | } else if ( error > 1.0e-8 ) { 876 | rb_raise( rb_eArgError, "Total probabilities are greater than 1.0" ); 877 | } 878 | return obj; 879 | } 880 | 881 | /* 882 | * @overload add_distributions(pd_a, pd_b) 883 | * Combines two distributions to create a third, that represents the distribution created when adding 884 | * results together. 885 | * @param [GamesDice::Probabilities] pd_a First distribution 886 | * @param [GamesDice::Probabilities] pd_b Second distribution 887 | * @return [GamesDice::Probabilities] 888 | */ 889 | VALUE probabilities_add_distributions( VALUE self, VALUE gdpa, VALUE gdpb ) { 890 | ProbabilityList *pl_a = get_probability_list( gdpa ); 891 | ProbabilityList *pl_b = get_probability_list( gdpb ); 892 | assert_value_wraps_pl( gdpa ); 893 | assert_value_wraps_pl( gdpb ); 894 | pl_a = get_probability_list( gdpa ); 895 | pl_b = get_probability_list( gdpb ); 896 | return pl_as_ruby_class( pl_add_distributions( pl_a, pl_b ), Probabilities ); 897 | } 898 | 899 | /* 900 | * @overload add_distributions_mult(m_a, pd_a, m_b, pd_b) 901 | * Combines two distributions with multipliers to create a third, that represents the distribution 902 | * created when adding weighted results together. 903 | * @param [Integer] m_a Weighting for first distribution 904 | * @param [GamesDice::Probabilities] pd_a First distribution 905 | * @param [Integer] m_b Weighting for second distribution 906 | * @param [GamesDice::Probabilities] pd_b Second distribution 907 | * @return [GamesDice::Probabilities] 908 | */ 909 | VALUE probabilities_add_distributions_mult( VALUE self, VALUE m_a, VALUE gdpa, VALUE m_b, VALUE gdpb ) { 910 | int mul_a, mul_b; 911 | ProbabilityList *pl_a; 912 | ProbabilityList *pl_b; 913 | 914 | assert_value_wraps_pl( gdpa ); 915 | assert_value_wraps_pl( gdpb ); 916 | mul_a = NUM2INT( m_a ); 917 | pl_a = get_probability_list( gdpa ); 918 | mul_b = NUM2INT( m_b ); 919 | pl_b = get_probability_list( gdpb ); 920 | return pl_as_ruby_class( pl_add_distributions_mult( mul_a, pl_a, mul_b, pl_b ), Probabilities ); 921 | } 922 | 923 | /////////////////////////////////////////////////////////////////////////////////////////////////// 924 | // 925 | // Setup Probabilities class for Ruby interpretter 926 | // 927 | 928 | void init_probabilities_class() { 929 | VALUE GamesDice = rb_define_module("GamesDice"); 930 | Probabilities = rb_define_class_under( GamesDice, "Probabilities", rb_cObject ); 931 | rb_define_alloc_func( Probabilities, pl_alloc ); 932 | rb_define_method( Probabilities, "initialize", probabilities_initialize, 2 ); 933 | rb_define_method( Probabilities, "initialize_copy", probabilities_initialize_copy, 1 ); 934 | rb_define_method( Probabilities, "to_h", probabilities_to_h, 0 ); 935 | rb_define_method( Probabilities, "min", probabilities_min, 0 ); 936 | rb_define_method( Probabilities, "max", probabilities_max, 0 ); 937 | rb_define_method( Probabilities, "p_eql", probabilites_p_eql, 1 ); 938 | rb_define_method( Probabilities, "p_gt", probabilites_p_gt, 1 ); 939 | rb_define_method( Probabilities, "p_ge", probabilites_p_ge, 1 ); 940 | rb_define_method( Probabilities, "p_le", probabilites_p_le, 1 ); 941 | rb_define_method( Probabilities, "p_lt", probabilites_p_lt, 1 ); 942 | rb_define_method( Probabilities, "expected", probabilites_expected, 0 ); 943 | rb_define_method( Probabilities, "each", probabilities_each, 0 ); 944 | rb_define_method( Probabilities, "given_ge", probabilities_given_ge, 1 ); 945 | rb_define_method( Probabilities, "given_le", probabilities_given_le, 1 ); 946 | rb_define_method( Probabilities, "repeat_sum", probabilities_repeat_sum, 1 ); 947 | rb_define_method( Probabilities, "repeat_n_sum_k", probabilities_repeat_n_sum_k, -1 ); 948 | rb_define_singleton_method( Probabilities, "for_fair_die", probabilities_for_fair_die, 1 ); 949 | rb_define_singleton_method( Probabilities, "add_distributions", probabilities_add_distributions, 2 ); 950 | rb_define_singleton_method( Probabilities, "add_distributions_mult", probabilities_add_distributions_mult, 4 ); 951 | rb_define_singleton_method( Probabilities, "from_h", probabilities_from_h, 1 ); 952 | return; 953 | } 954 | --------------------------------------------------------------------------------