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.
- checksums.yaml +4 -4
- data/README.md +50 -34
- data/exe/dicey +2 -2
- data/lib/dicey/abstract_die.rb +24 -5
- data/lib/dicey/cli/blender.rb +19 -14
- data/lib/dicey/{sum_frequency_calculators/runner.rb → cli/calculator_runner.rb} +9 -15
- data/lib/dicey/{sum_frequency_calculators/test_runner.rb → cli/calculator_test_runner.rb} +37 -13
- data/lib/dicey/cli/formatters/base_list_formatter.rb +36 -0
- data/lib/dicey/cli/formatters/base_map_formatter.rb +38 -0
- data/lib/dicey/cli/formatters/gnuplot_formatter.rb +28 -0
- data/lib/dicey/cli/formatters/json_formatter.rb +14 -0
- data/lib/dicey/cli/formatters/list_formatter.rb +14 -0
- data/lib/dicey/cli/formatters/null_formatter.rb +17 -0
- data/lib/dicey/cli/formatters/yaml_formatter.rb +14 -0
- data/lib/dicey/cli/options.rb +15 -11
- data/lib/dicey/cli/roller.rb +47 -0
- data/lib/dicey/cli.rb +23 -0
- data/lib/dicey/die_foundry.rb +3 -2
- data/lib/dicey/distribution_calculators/auto_selector.rb +73 -0
- data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/base_calculator.rb +44 -37
- data/lib/dicey/distribution_calculators/binomial.rb +62 -0
- data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/empirical.rb +9 -10
- data/lib/dicey/distribution_calculators/iterative.rb +51 -0
- data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/multinomial_coefficients.rb +21 -12
- data/lib/dicey/{sum_frequency_calculators/kronecker_substitution.rb → distribution_calculators/polynomial_convolution.rb} +15 -8
- data/lib/dicey/distribution_calculators/trivial.rb +56 -0
- data/lib/dicey/distribution_properties_calculator.rb +5 -2
- data/lib/dicey/mixins/missing_math.rb +44 -0
- data/lib/dicey/mixins/rational_to_integer.rb +1 -0
- data/lib/dicey/mixins/vectorize_dice.rb +17 -12
- data/lib/dicey/mixins/warn_about_vector_number.rb +19 -0
- data/lib/dicey/numeric_die.rb +1 -1
- data/lib/dicey/version.rb +1 -1
- data/lib/dicey.rb +26 -5
- metadata +30 -26
- data/lib/dicey/output_formatters/gnuplot_formatter.rb +0 -24
- data/lib/dicey/output_formatters/hash_formatter.rb +0 -36
- data/lib/dicey/output_formatters/json_formatter.rb +0 -12
- data/lib/dicey/output_formatters/key_value_formatter.rb +0 -34
- data/lib/dicey/output_formatters/list_formatter.rb +0 -12
- data/lib/dicey/output_formatters/null_formatter.rb +0 -15
- data/lib/dicey/output_formatters/yaml_formatter.rb +0 -12
- data/lib/dicey/roller.rb +0 -46
- data/lib/dicey/sum_frequency_calculators/auto_selector.rb +0 -41
- 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
|
data/lib/dicey/cli/options.rb
CHANGED
|
@@ -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[
|
|
10
|
+
MODES = %w[distribution roll].freeze
|
|
11
11
|
# Allowed result types (--result).
|
|
12
|
-
RESULT_TYPES = %w[
|
|
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: "
|
|
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 = "
|
|
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:
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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("-
|
|
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
|
data/lib/dicey/die_foundry.rb
CHANGED
|
@@ -6,7 +6,8 @@ require_relative "regular_die"
|
|
|
6
6
|
require_relative "mixins/rational_to_integer"
|
|
7
7
|
|
|
8
8
|
module Dicey
|
|
9
|
-
# Helper
|
|
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", "
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
# Base frequencies calculator.
|
|
4
|
+
module DistributionCalculators
|
|
5
|
+
# Base class for implementing distribution calculators.
|
|
7
6
|
#
|
|
8
|
-
#
|
|
9
|
-
# -
|
|
10
|
-
# -
|
|
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
|
|
13
|
-
# can be represented with integers.
|
|
14
|
-
#
|
|
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[
|
|
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{
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
verify_result(
|
|
50
|
-
|
|
51
|
-
transform_result(
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
106
|
+
# Check that resulting weights actually add up to what they are supposed to be.
|
|
100
107
|
#
|
|
101
|
-
# @param
|
|
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(
|
|
106
|
-
valid =
|
|
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(
|
|
113
|
-
|
|
119
|
+
def sort_result(distribution)
|
|
120
|
+
distribution.sort.to_h
|
|
114
121
|
rescue
|
|
115
|
-
#
|
|
116
|
-
|
|
122
|
+
# Sort failed, leave as is.
|
|
123
|
+
distribution
|
|
117
124
|
end
|
|
118
125
|
|
|
119
|
-
# Transform calculated
|
|
126
|
+
# Transform calculated weights to requested result type, if needed.
|
|
120
127
|
#
|
|
121
|
-
# @param
|
|
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(
|
|
125
|
-
if result_type == :
|
|
126
|
-
|
|
131
|
+
def transform_result(distribution, result_type)
|
|
132
|
+
if result_type == :weights
|
|
133
|
+
distribution
|
|
127
134
|
else
|
|
128
|
-
total =
|
|
129
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
data/lib/dicey/{sum_frequency_calculators → distribution_calculators}/multinomial_coefficients.rb
RENAMED
|
@@ -3,15 +3,26 @@
|
|
|
3
3
|
require_relative "base_calculator"
|
|
4
4
|
|
|
5
5
|
module Dicey
|
|
6
|
-
module
|
|
7
|
-
# Calculator for multiple equal dice with sides forming an arithmetic sequence
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|