dicey 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -34
  3. data/exe/dicey +2 -2
  4. data/lib/dicey/abstract_die.rb +24 -5
  5. data/lib/dicey/cli/blender.rb +19 -14
  6. data/lib/dicey/{sum_frequency_calculators/runner.rb → cli/calculator_runner.rb} +9 -15
  7. data/lib/dicey/{sum_frequency_calculators/test_runner.rb → cli/calculator_test_runner.rb} +37 -13
  8. data/lib/dicey/cli/formatters/base_list_formatter.rb +36 -0
  9. data/lib/dicey/cli/formatters/base_map_formatter.rb +38 -0
  10. data/lib/dicey/cli/formatters/gnuplot_formatter.rb +28 -0
  11. data/lib/dicey/cli/formatters/json_formatter.rb +14 -0
  12. data/lib/dicey/cli/formatters/list_formatter.rb +14 -0
  13. data/lib/dicey/cli/formatters/null_formatter.rb +17 -0
  14. data/lib/dicey/cli/formatters/yaml_formatter.rb +14 -0
  15. data/lib/dicey/cli/options.rb +15 -11
  16. data/lib/dicey/cli/roller.rb +47 -0
  17. data/lib/dicey/cli.rb +23 -0
  18. data/lib/dicey/die_foundry.rb +3 -2
  19. data/lib/dicey/distribution_calculators/auto_selector.rb +73 -0
  20. data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/base_calculator.rb +44 -37
  21. data/lib/dicey/distribution_calculators/binomial.rb +62 -0
  22. data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/empirical.rb +9 -10
  23. data/lib/dicey/distribution_calculators/iterative.rb +51 -0
  24. data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/multinomial_coefficients.rb +21 -12
  25. data/lib/dicey/{sum_frequency_calculators/kronecker_substitution.rb → distribution_calculators/polynomial_convolution.rb} +15 -8
  26. data/lib/dicey/distribution_calculators/trivial.rb +56 -0
  27. data/lib/dicey/distribution_properties_calculator.rb +5 -2
  28. data/lib/dicey/mixins/missing_math.rb +44 -0
  29. data/lib/dicey/mixins/rational_to_integer.rb +1 -0
  30. data/lib/dicey/mixins/vectorize_dice.rb +17 -12
  31. data/lib/dicey/mixins/warn_about_vector_number.rb +19 -0
  32. data/lib/dicey/numeric_die.rb +1 -1
  33. data/lib/dicey/version.rb +1 -1
  34. data/lib/dicey.rb +26 -5
  35. metadata +30 -26
  36. data/lib/dicey/output_formatters/gnuplot_formatter.rb +0 -24
  37. data/lib/dicey/output_formatters/hash_formatter.rb +0 -36
  38. data/lib/dicey/output_formatters/json_formatter.rb +0 -12
  39. data/lib/dicey/output_formatters/key_value_formatter.rb +0 -34
  40. data/lib/dicey/output_formatters/list_formatter.rb +0 -12
  41. data/lib/dicey/output_formatters/null_formatter.rb +0 -15
  42. data/lib/dicey/output_formatters/yaml_formatter.rb +0 -12
  43. data/lib/dicey/roller.rb +0 -46
  44. data/lib/dicey/sum_frequency_calculators/auto_selector.rb +0 -41
  45. data/lib/dicey/sum_frequency_calculators/brute_force.rb +0 -41
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_map_formatter"
4
+
5
+ module Dicey
6
+ module CLI
7
+ module Formatters
8
+ # Formats a hash as a YAML document under +results+ key, with optional +description+ key.
9
+ class YAMLFormatter < BaseMapFormatter
10
+ METHOD = :to_yaml
11
+ end
12
+ end
13
+ end
14
+ end
@@ -7,19 +7,19 @@ module Dicey
7
7
  # Helper class for parsing command-line options and generating help.
8
8
  class Options
9
9
  # Allowed modes (--mode) (only directly selectable).
10
- MODES = %w[frequencies roll].freeze
10
+ MODES = %w[distribution roll].freeze
11
11
  # Allowed result types (--result).
12
- RESULT_TYPES = %w[frequencies probabilities].freeze
12
+ RESULT_TYPES = %w[weights probabilities].freeze
13
13
  # Allowed output formats (--format).
14
14
  FORMATS = %w[list gnuplot json yaml null].freeze
15
15
 
16
16
  # Default values for initial values of the options.
17
- DEFAULT_OPTIONS = { mode: "frequencies", format: "list", result: "frequencies" }.freeze
17
+ DEFAULT_OPTIONS = { mode: "distribution", format: "list", result: "weights" }.freeze
18
18
 
19
19
  def initialize(initial_options = DEFAULT_OPTIONS.dup)
20
20
  @options = initial_options
21
21
  @parser = ::OptionParser.new
22
- @parser.program_name = "dicey"
22
+ @parser.program_name = "Dicey"
23
23
  @parser.version = Dicey::VERSION
24
24
 
25
25
  add_banner_and_version
@@ -53,9 +53,9 @@ module Dicey
53
53
 
54
54
  def add_banner_and_version
55
55
  @parser.banner = <<~TEXT
56
- Usage: #{@parser.program_name} [options] <die> [<die> ...]
57
- #{@parser.program_name} [options] -- <die> [<die> ...]
58
- #{@parser.program_name} --test [full|quiet]
56
+ Usage: dicey [options] <die> [<die> ...]
57
+ dicey [options] -- <die> [<die> ...]
58
+ dicey --test [full|short|quiet]
59
59
  All option names and arguments can be abbreviated if abbreviation is unambiguous.
60
60
  A lone "--" separates options and die definitions, allowing definitions to start with "-".
61
61
  TEXT
@@ -64,15 +64,15 @@ module Dicey
64
64
  def add_common_options
65
65
  easy_option("-m", "--mode MODE", MODES, "What kind of action or calculation to perform.")
66
66
  easy_option("-r", "--result RESULT_TYPE", RESULT_TYPES,
67
- "Select type of result to calculate (only for frequencies).")
67
+ "Select type of result to calculate (only for distribution).")
68
68
  easy_option("-f", "--format FORMAT", FORMATS, "Select output format for results.")
69
69
  end
70
70
 
71
71
  def add_test_options
72
72
  @parser.on_tail(
73
- "--test [REPORT_STYLE]", %w[full quiet],
73
+ "--test [REPORT_STYLE]", %w[full short quiet],
74
74
  "Check predefined calculation cases and exit.",
75
- "REPORT_STYLE can be: `full`, `quiet`.", "`full` is default."
75
+ "REPORT_STYLE can be: `full`, `short`, `quiet`.", "`full` is default."
76
76
  ) do |report_style|
77
77
  @options[:mode] = :test
78
78
  @options[:report_style] = report_style&.to_sym || :full
@@ -84,7 +84,11 @@ module Dicey
84
84
  puts @parser.help
85
85
  exit
86
86
  end
87
- @parser.on_tail("-v", "--version", "Show program version and exit.") do
87
+ @parser.on_tail("-V", "--version", "Show program version and exit.") do
88
+ puts @parser.ver
89
+ exit
90
+ end
91
+ @parser.on_tail("-v", "(Deprecated) Show program version and exit.") do
88
92
  puts @parser.ver
89
93
  exit
90
94
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../die_foundry"
4
+
5
+ require_relative "../mixins/rational_to_integer"
6
+ require_relative "../mixins/vectorize_dice"
7
+ require_relative "../mixins/warn_about_vector_number"
8
+
9
+ module Dicey
10
+ module CLI
11
+ # Let the dice roll!
12
+ #
13
+ # This is the implementation of roll mode for the CLI.
14
+ class Roller
15
+ include Mixins::RationalToInteger
16
+ include Mixins::VectorizeDice
17
+ include Mixins::WarnAboutVectorNumber
18
+
19
+ # @param arguments [Array<String>] die definitions
20
+ # @param format [#call] formatter for output
21
+ # @return [String]
22
+ # @raise [DiceyError]
23
+ def call(arguments, format:, **)
24
+ raise DiceyError, "no dice!" if arguments.empty?
25
+
26
+ dice = arguments.flat_map { |definition| die_foundry.cast(definition) }
27
+ result = roll_dice(dice)
28
+
29
+ format.call({ "roll" => rational_to_integer(result) }, AbstractDie.describe(dice))
30
+ rescue TypeError
31
+ warn_about_vector_number
32
+ raise DiceyError, "can not roll dice with non-numeric sides!"
33
+ end
34
+
35
+ private
36
+
37
+ def die_foundry
38
+ @die_foundry ||= DieFoundry.new
39
+ end
40
+
41
+ def roll_dice(dice)
42
+ dice = vectorize_dice(dice)
43
+ dice.sum(&:roll)
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/dicey/cli.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ # @api private
5
+ # Classes pertaining to CLI. These are not intended to be used by API consumers.
6
+ #
7
+ # If you *really* need to simulate CLI from inside your code, use {.call}.
8
+ #
9
+ # @note Not loaded by default, use +require "dicey/cli"+ as needed.
10
+ module CLI
11
+ require_relative "cli/blender"
12
+
13
+ # @api public
14
+ # Parse options and arguments and run calculations, printing results.
15
+ #
16
+ # @param argv [Array<String>] arguments for the program
17
+ # @return [Boolean]
18
+ # @raise [DiceyError] anything can happen
19
+ def self.call(argv = ARGV)
20
+ Blender.new.call(argv)
21
+ end
22
+ end
23
+ end
@@ -6,7 +6,8 @@ require_relative "regular_die"
6
6
  require_relative "mixins/rational_to_integer"
7
7
 
8
8
  module Dicey
9
- # Helper class to define die definitions and automatically select the best one.
9
+ # Helper to create dice from string definitions.
10
+ # See {#call} and constants for available formats.
10
11
  class DieFoundry
11
12
  include Mixins::RationalToInteger
12
13
 
@@ -42,7 +43,7 @@ module Dicey
42
43
  # Following definitions are recognized:
43
44
  # - positive integer (like "6" or "20"), which produces a {RegularDie};
44
45
  # - integer range (like "3-6" or "(-5..5)"), which produces a {NumericDie};
45
- # - list of integers (like "3,4,5", "(-1,0,1)", or "2,"), which produces a {NumericDie};
46
+ # - list of integers (like "(3,4,5)", "-1,0,1", or "2,"), which produces a {NumericDie};
46
47
  # - list of decimal numbers (like "0.5,0.2,0.8" or "(2.0,)"), which produces a {NumericDie},
47
48
  # but uses +Rational+ for values to maintain precise results;
48
49
  # - list of strings, possibly mixed with numbers (like "0.5,asdf" or "(👑,♠️,♥️,♣️,♦️,⚓️)"),
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "binomial"
4
+ require_relative "iterative"
5
+ require_relative "multinomial_coefficients"
6
+ require_relative "polynomial_convolution"
7
+ require_relative "trivial"
8
+
9
+ module Dicey
10
+ # Calculators for probability distributions of dice.
11
+ #
12
+ # All calculators are subclasses of {BaseCalculator} which implements
13
+ # the core logic and public methods.
14
+ #
15
+ # Following calculators are available:
16
+ # - {Iterative}
17
+ # - {PolynomialConvolution}
18
+ # - {MultinomialCoefficients}
19
+ # - {Empirical} (manual selection only)
20
+ #
21
+ # You will probably want to use {AutoSelector} and not bother
22
+ # with selecting a calculator manually.
23
+ #
24
+ # @example
25
+ # dice = Dicey::NumericDie.from_list([1, 4, 6], [2, 3, 5])
26
+ # calculator = Dicey::DistributionCalculators::AutoSelector.call(dice)
27
+ # calculator&.call(dice) or raise
28
+ module DistributionCalculators
29
+ # Tool to automatically select a calculator for a given set of dice.
30
+ #
31
+ # Calculator is guaranteed to be compatible, with a strong chance of being the most performant.
32
+ #
33
+ # @see BaseCalculator#heuristic_complexity
34
+ class AutoSelector
35
+ # Calculators to consider when selecting a match.
36
+ AVAILABLE_CALCULATORS = [
37
+ Trivial.new,
38
+ Binomial.new,
39
+ PolynomialConvolution.new,
40
+ MultinomialCoefficients.new,
41
+ Iterative.new,
42
+ ].freeze
43
+
44
+ # (see #call)
45
+ # Uses shared {INSTANCE} for calls.
46
+ def self.call(dice)
47
+ INSTANCE.call(dice)
48
+ end
49
+
50
+ # @param calculators [Array<BaseCalculator>]
51
+ # calculators which this instance will consider
52
+ def initialize(calculators = AVAILABLE_CALCULATORS)
53
+ @calculators = calculators
54
+ end
55
+
56
+ # Instance to be used through {.call}.
57
+ INSTANCE = new.freeze # rubocop:disable Layout/ClassStructure
58
+ # Have to call .new after defining #initialize.
59
+
60
+ # Determine best (or adequate) calculator for a given set of dice
61
+ # based on heuristics from the list of available calculators.
62
+ #
63
+ # @param dice [Enumerable<NumericDie>]
64
+ # @return [BaseCalculator, nil] +nil+ if no calculator is compatible
65
+ def call(dice)
66
+ compatible = @calculators.select { _1.valid_for?(dice) }
67
+ return if compatible.empty?
68
+
69
+ compatible.min_by { _1.heuristic_complexity(dice) }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
- # Calculators for probability distributions of dice.
5
- module SumFrequencyCalculators
6
- # Base frequencies calculator.
4
+ module DistributionCalculators
5
+ # Base class for implementing distribution calculators.
7
6
  #
8
- # *Result types:*
9
- # - +:frequencies+ (default)
10
- # - +:probabilities+
7
+ # Calculators have the following methods, each taking an array of dice:
8
+ # - {#call} to actually calculate the distribution;
9
+ # - {#valid_for?} to check if the calculator can handle the dice;
10
+ # - {#heuristic_complexity} to determine the complexity of calculation,
11
+ # mostly useful for {AutoSelector}.
11
12
  #
12
- # By default, returns frequencies as they are easier to calculate and
13
- # can be represented with integers.
14
- # Probabilities are calculated using +Rational+ numbers to return exact results.
13
+ # By default, {#call} returns weights as they are easier to calculate and
14
+ # can be represented with integers (except for {Empirical} calculator).
15
+ # If probabilities are requested, they are calculated using +Rational+ numbers
16
+ # to produce exact results.
17
+ #
18
+ # An empty list of dice is considered a degenerate case, always valid for any calculator.
15
19
  #
16
20
  # *Options:*
17
21
  #
@@ -23,7 +27,7 @@ module Dicey
23
27
  # @abstract
24
28
  class BaseCalculator
25
29
  # Possible values for +result_type+ argument in {#call}.
26
- RESULT_TYPES = %i[frequencies probabilities].freeze
30
+ RESULT_TYPES = %i[weights probabilities].freeze
27
31
 
28
32
  # Calculate distribution (probability mass function) for the list of dice.
29
33
  #
@@ -33,22 +37,25 @@ module Dicey
33
37
  # @param result_type [Symbol] one of {RESULT_TYPES}
34
38
  # @param options [Hash{Symbol => Any}] calculator-specific options,
35
39
  # refer to the calculator's documentation to see what it accepts
36
- # @return [Hash{Numeric => Numeric}] frequencies or probabilities for each outcome
40
+ # @return [Hash{Any => Numeric}] weight or probability for each outcome,
41
+ # sorted by outcome if possible
37
42
  # @raise [DiceyError] if +result_type+ is invalid
38
- # @raise [DiceyError] if dice list is invalid for the calculator
43
+ # @raise [DiceyError] if +dice+ list is invalid for the calculator
39
44
  # @raise [DiceyError] if calculator returned obviously wrong results
40
- def call(dice, result_type: :frequencies, **options)
45
+ # (should not happen in released versions)
46
+ def call(dice, result_type: :weights, **options)
41
47
  unless RESULT_TYPES.include?(result_type)
42
48
  raise DiceyError, "#{result_type} is not a valid result type!"
43
49
  end
50
+ raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
51
+
44
52
  # Short-circuit for a degenerate case.
45
53
  return {} if dice.empty?
46
- raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
47
54
 
48
- frequencies = calculate(dice, **options)
49
- verify_result(frequencies, dice)
50
- frequencies = sort_result(frequencies)
51
- transform_result(frequencies, result_type)
55
+ distribution = calculate(dice, **options)
56
+ verify_result(distribution, dice)
57
+ distribution = sort_result(distribution)
58
+ transform_result(distribution, result_type)
52
59
  end
53
60
 
54
61
  # Whether this calculator can be used for the list of dice.
@@ -56,17 +63,17 @@ module Dicey
56
63
  # @param dice [Enumerable<AbstractDie>]
57
64
  # @return [Boolean]
58
65
  def valid_for?(dice)
59
- dice.is_a?(Enumerable) && dice.all?(AbstractDie) && validate(dice)
66
+ dice.is_a?(Enumerable) && (dice.empty? || (dice.all?(AbstractDie) && validate(dice)))
60
67
  end
61
68
 
62
69
  # Heuristic complexity of the calculator, used to determine best calculator.
63
70
  #
64
- # Returns 0 for an empty list of dice.
71
+ # Will always return a value, even if the calculator is not valid for the dice.
65
72
  #
66
73
  # @see AutoSelector
67
74
  #
68
75
  # @param dice [Enumerable<AbstractDie>]
69
- # @return [Integer]
76
+ # @return [Integer] 0 if +dice+ is empty, otherwise can be any value
70
77
  def heuristic_complexity(dice)
71
78
  return 0 if dice.empty?
72
79
 
@@ -90,43 +97,43 @@ module Dicey
90
97
  raise NotImplementedError
91
98
  end
92
99
 
93
- # Peform frequencies calculation.
100
+ # Calculate weights of outcomes for the dice.
94
101
  # (see #call)
95
102
  def calculate(dice, **nil)
96
103
  raise NotImplementedError
97
104
  end
98
105
 
99
- # Check that resulting frequencies actually add up to what they are supposed to be.
106
+ # Check that resulting weights actually add up to what they are supposed to be.
100
107
  #
101
- # @param frequencies [Hash{Numeric => Integer}]
108
+ # @param distribution [Hash{Numeric => Integer}]
102
109
  # @param dice [Enumerable<AbstractDie>]
103
110
  # @return [void]
104
111
  # @raise [DiceyError] if result is wrong
105
- def verify_result(frequencies, dice)
106
- valid = frequencies.values.sum == (dice.map(&:sides_num).reduce(:*) || 0)
112
+ def verify_result(distribution, dice)
113
+ valid = distribution.values.sum == (dice.map(&:sides_num).reduce(:*) || 0)
107
114
  raise DiceyError, "calculator #{self.class} returned invalid results!" unless valid
108
115
  end
109
116
 
110
117
  # Depending on the order of sides, result may not be in an ascending order,
111
118
  # so it's best to fix that for presentation (if possible).
112
- def sort_result(frequencies)
113
- frequencies.sort.to_h
119
+ def sort_result(distribution)
120
+ distribution.sort.to_h
114
121
  rescue
115
- # Probably Complex numbers got into the mix, leave as is.
116
- frequencies
122
+ # Sort failed, leave as is.
123
+ distribution
117
124
  end
118
125
 
119
- # Transform calculated frequencies to requested result_type, if needed.
126
+ # Transform calculated weights to requested result type, if needed.
120
127
  #
121
- # @param frequencies [Hash{Numeric => Integer}]
128
+ # @param distribution [Hash{Numeric => Integer}]
122
129
  # @param result_type [Symbol] one of {RESULT_TYPES}
123
130
  # @return [Hash{Numeric => Numeric}]
124
- def transform_result(frequencies, result_type)
125
- if result_type == :frequencies
126
- frequencies
131
+ def transform_result(distribution, result_type)
132
+ if result_type == :weights
133
+ distribution
127
134
  else
128
- total = frequencies.values.sum
129
- frequencies.transform_values { Rational(_1, total) }
135
+ total = distribution.values.sum
136
+ distribution.transform_values { Rational(_1, total) }
130
137
  end
131
138
  end
132
139
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ require_relative "../mixins/missing_math"
6
+ require_relative "../mixins/vectorize_dice"
7
+
8
+ module Dicey
9
+ module DistributionCalculators
10
+ # Calculator for a collection of equal {AbstractDie} with two sides, like coins,
11
+ # using binomial distribution (very fast).
12
+ #
13
+ # If dice include non-numeric sides, gem +vector_number+ has to be installed.
14
+ class Binomial < BaseCalculator
15
+ include Mixins::MissingMath
16
+ include Mixins::VectorizeDice
17
+
18
+ private
19
+
20
+ def validate(dice)
21
+ dice.first.sides_num == 2 && dice.all? { _1 == dice.first }
22
+ end
23
+
24
+ def calculate_heuristic(dice_count, _sides_count)
25
+ 384 * dice_count**2 + 6_760_000
26
+ end
27
+
28
+ def calculate(dice, **nil)
29
+ die = vectorize_dice(dice.first)
30
+
31
+ coefficients = recurrent_combinations(dice.size)
32
+ first, second = die.sides_list
33
+ sliding_sums(first, second, coefficients)
34
+ end
35
+
36
+ def recurrent_combinations(dice_count)
37
+ # Calculating three factorials for each combination is pretty expensive.
38
+ # As the actual formulas just go through factorials in order,
39
+ # we can drastically reduce the complexity by reusing previous values.
40
+ count_factorial = factorial(dice_count)
41
+ index_factorial = 1
42
+ reverse_factorial = count_factorial
43
+ combinations = Array.new(dice_count + 1, 1)
44
+ (1..dice_count).each do |i|
45
+ index_factorial *= i
46
+ reverse_factorial /= (dice_count + 1 - i)
47
+ combinations[i] = count_factorial / (index_factorial * reverse_factorial)
48
+ end
49
+ combinations
50
+ end
51
+
52
+ def sliding_sums(side_a, side_b, coefficients)
53
+ length = coefficients.size - 1
54
+ coefficients.each_with_index.with_object({}) do |(coefficient, i), hash|
55
+ outcome = (side_a * (length - i)) + (side_b * i)
56
+ hash[outcome] ||= 0
57
+ hash[outcome] += coefficient
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -3,16 +3,17 @@
3
3
  require_relative "base_calculator"
4
4
 
5
5
  require_relative "../mixins/vectorize_dice"
6
+ require_relative "../mixins/warn_about_vector_number"
6
7
 
7
8
  module Dicey
8
- module SumFrequencyCalculators
9
+ module DistributionCalculators
9
10
  # "Calculator" for a collection of {AbstractDie} using empirically-obtained statistics.
10
11
  #
11
12
  # @note This calculator is mostly a joke. It can be useful for educational purposes,
12
- # or to verify results of {BruteForce} when in doubt. It is not used by default.
13
+ # or to verify results of {Iterative} when in doubt. It is not used automatically.
13
14
  #
14
15
  # Does a number of rolls and calculates approximate probabilities from that.
15
- # Even if frequencies are requested, results are non-integer.
16
+ # Even if weights are requested, results are non-integer.
16
17
  #
17
18
  # If dice include non-numeric sides, gem +vector_number+ has to be installed.
18
19
  #
@@ -20,6 +21,7 @@ module Dicey
20
21
  # - *rolls* (Integer) (_defaults_ _to:_ _N_) — number of rolls to perform
21
22
  class Empirical < BaseCalculator
22
23
  include Mixins::VectorizeDice
24
+ include Mixins::WarnAboutVectorNumber
23
25
 
24
26
  # Default number of rolls to perform.
25
27
  N = 10_000
@@ -30,20 +32,17 @@ module Dicey
30
32
  if defined?(VectorNumber) || dice.all?(NumericDie)
31
33
  true
32
34
  else
33
- warn <<~TEXT
34
- Dice with non-numeric sides need gem "vector_number" to be present and available.
35
- If this is intended, please install the gem.
36
- TEXT
37
- false
35
+ warn_about_vector_number
38
36
  end
39
37
  end
40
38
 
41
39
  def calculate_heuristic(dice_count, sides_count)
42
- N * dice_count * Math.log2(sides_count)
40
+ (39100 * dice_count + 196_000_000) + (412_000 * sides_count - 9_360_000)
43
41
  end
44
42
 
45
43
  def calculate(dice, rolls: N)
46
- dice = vectorize_dice(dice) if defined?(VectorNumber)
44
+ dice = vectorize_dice(dice)
45
+
47
46
  statistics = rolls.times.with_object(Hash.new(0)) { |_, hash| hash[dice.sum(&:roll)] += 1 }
48
47
  total_results = dice.map(&:sides_num).reduce(:*)
49
48
  statistics.transform_values { Rational(_1 * total_results, rolls) }
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ require_relative "../mixins/vectorize_dice"
6
+ require_relative "../mixins/warn_about_vector_number"
7
+
8
+ module Dicey
9
+ module DistributionCalculators
10
+ # Calculator for a collection of {AbstractDie} which goes through
11
+ # every possible combination of dice (somewhat slow).
12
+ #
13
+ # If dice include non-numeric sides, gem +vector_number+ has to be installed.
14
+ class Iterative < BaseCalculator
15
+ include Mixins::VectorizeDice
16
+ include Mixins::WarnAboutVectorNumber
17
+
18
+ private
19
+
20
+ def validate(dice)
21
+ if defined?(VectorNumber) || dice.all?(NumericDie)
22
+ true
23
+ else
24
+ warn_about_vector_number
25
+ end
26
+ end
27
+
28
+ def calculate_heuristic(dice_count, sides_count)
29
+ (157_000 * dice_count**2 + 12_500_000) + (195_000 * sides_count**2 + 257_000_000)
30
+ end
31
+
32
+ def calculate(dice, **nil)
33
+ dice = vectorize_dice(dice)
34
+
35
+ dice[1..].reduce(dice.first.sides_list.tally) do |previous_distribution, die|
36
+ convolve_with_die(previous_distribution, die.sides_list.tally)
37
+ end
38
+ end
39
+
40
+ def convolve_with_die(previous_distribution, die_sides)
41
+ previous_distribution.each_with_object({}) do |(outcome, weight), next_distribution|
42
+ die_sides.each do |side, side_weight|
43
+ next_outcome = outcome + side
44
+ next_distribution[next_outcome] ||= 0
45
+ next_distribution[next_outcome] += weight * side_weight
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,15 +3,26 @@
3
3
  require_relative "base_calculator"
4
4
 
5
5
  module Dicey
6
- module SumFrequencyCalculators
7
- # Calculator for multiple equal dice with sides forming an arithmetic sequence (fast).
6
+ module DistributionCalculators
7
+ # Calculator for multiple equal dice with sides forming an arithmetic sequence,
8
+ # including all regular dice (fast).
8
9
  #
9
10
  # Example dice: (1,2,3,4), (-2,-1,0,1,2), (0,0.2,0.4,0.6), (-1,-2,-3).
10
11
  #
11
- # Based on extension of Pascal's triangle for a higher number of coefficients.
12
+ # Rolling multiple of the same dice is the same thing as rolling a single die
13
+ # multiple times and summing the results.
14
+ # This arrangement corresponds to a multinomial distribution.
15
+ #
16
+ # The usual way to calculate probabilities for such distribution involves
17
+ # way too many factorials for large numbers for comfort.
18
+ # (`Math.gamma` doesn't even handle large enough numbers, and produces Floats anyway).
19
+ # Instead, we use a Pascal's triangle extension for a higher number of coefficients.
20
+ # Currently, algorithm is limited to arithmetic sequences as I'm not sure
21
+ # how to calculate values for other cases.
22
+ #
23
+ # @see https://en.wikipedia.org/wiki/Multinomial_distribution
12
24
  # @see https://en.wikipedia.org/wiki/Pascal's_triangle
13
25
  # @see https://en.wikipedia.org/wiki/Trinomial_triangle
14
- # @see https://en.wikipedia.org/wiki/Multinomial_distribution
15
26
  class MultinomialCoefficients < BaseCalculator
16
27
  private
17
28
 
@@ -35,9 +46,7 @@ module Dicey
35
46
  end
36
47
 
37
48
  def calculate_heuristic(dice_count, sides_count)
38
- # Fitting shows both coefficients to be around 500,
39
- # but empirical runtime doesn't agree, so 150 it is.
40
- 150 * (dice_count**2.2) * 500 * (sides_count**1.9)
49
+ (784 * dice_count**2 - 809_000) + (100 * sides_count**2 - 38200)
41
50
  end
42
51
 
43
52
  def calculate(dice, **nil)
@@ -45,8 +54,8 @@ module Dicey
45
54
  number_of_sides = first_die.sides_num
46
55
  number_of_dice = dice.size
47
56
 
48
- frequencies = multinomial_coefficients(number_of_dice, number_of_sides)
49
- result_sums_list(first_die.sides_list, number_of_dice).zip(frequencies).to_h
57
+ weights = multinomial_coefficients(number_of_dice, number_of_sides)
58
+ outcomes_list(first_die.sides_list, number_of_dice).zip(weights).to_h
50
59
  end
51
60
 
52
61
  # Calculate coefficients for a multinomial of the form
@@ -80,18 +89,18 @@ module Dicey
80
89
  length = (row_index * window_size) + 1
81
90
  (0...length).map do |col_index|
82
91
  # Have to clamp to 0 to prevent accessing array from the end.
83
- # BUG: TruffleRuby can't handle endless range in #clamp (see https://github.com/oracle/truffleruby/issues/3945)
92
+ # TruffleRuby can't handle endless range in #clamp (see https://github.com/oracle/truffleruby/issues/3945).
84
93
  window_range = ((col_index - window_size).clamp(0..col_index)..col_index)
85
94
  window_range.sum { |i| previous_row.fetch(i, 0) }
86
95
  end
87
96
  end
88
97
 
89
- # Get sequence of sums which correspond to calculated frequencies.
98
+ # Get sequence of outcomes which correspond to calculated weights.
90
99
  #
91
100
  # @param sides_list [Enumerable<Numeric>]
92
101
  # @param number_of_dice [Integer]
93
102
  # @return [Array<Numeric>]
94
- def result_sums_list(sides_list, number_of_dice)
103
+ def outcomes_list(sides_list, number_of_dice)
95
104
  first = number_of_dice * sides_list.first
96
105
  last = number_of_dice * sides_list.last
97
106
  return [first] if first == last