dicey 0.0.1 → 0.13.1

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.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ module OutputFormatters
5
+ # Base formatter for outputting lists of key-value pairs separated by newlines.
6
+ # Can add an optional description into the result.
7
+ # @abstract
8
+ class KeyValueFormatter
9
+ # @param hash [Hash{Object => Object}]
10
+ # @param description [String] text to add as a comment.
11
+ # @return [String]
12
+ def call(hash, description = nil)
13
+ initial_string = description ? "# #{description}\n" : +""
14
+ hash.each_with_object(initial_string) do |(key, value), output|
15
+ output << "#{key}#{self.class::SEPARATOR}#{value}\n"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "key_value_formatter"
4
+
5
+ module Dicey
6
+ module OutputFormatters
7
+ # Formats a hash as list of key => value pairs, similar to a Ruby Hash.
8
+ class ListFormatter < KeyValueFormatter
9
+ SEPARATOR = " => "
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hash_formatter"
4
+
5
+ module Dicey
6
+ module OutputFormatters
7
+ # Formats a hash as a YAML document under +results+ key, with optional +description+ key.
8
+ class YAMLFormatter < HashFormatter
9
+ METHOD = :to_yaml
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "numeric_die"
4
+
5
+ module Dicey
6
+ # Regular die, which has N sides with numbers from 1 to N.
7
+ class RegularDie < NumericDie
8
+ # Characters to use for small dice.
9
+ D6 = "⚀⚁⚂⚃⚄⚅"
10
+
11
+ # Create a list of regular dice with the same number of sides.
12
+ #
13
+ # @param dice [Integer]
14
+ # @param sides [Integer]
15
+ # @return [Array<RegularDie>]
16
+ def self.create_dice(dice, sides)
17
+ (1..dice).map { new(sides) }
18
+ end
19
+
20
+ # @param sides [Integer]
21
+ def initialize(sides)
22
+ super((1..sides))
23
+ end
24
+
25
+ # Dice with 1–6 sides are displayed with a single character.
26
+ # More than that, and we get into the square bracket territory.
27
+ # @return [String]
28
+ def to_s
29
+ (sides_num <= D6.size) ? D6[sides_num - 1] : "[#{sides_num}]"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "die_foundry"
4
+
5
+ module Dicey
6
+ # Let the dice roll!
7
+ class Roller
8
+ # @param arguments [Array<String>] die definitions
9
+ # @param format [#call] formatter for output
10
+ # @return [nil]
11
+ # @raise [DiceyError]
12
+ def call(arguments, format:, **)
13
+ raise DiceyError, "no dice!" if arguments.empty?
14
+
15
+ dice = arguments.map { |definition| die_foundry.cast(definition) }
16
+ dice.each(&:roll)
17
+
18
+ result = dice.sum(&:current)
19
+ format.call({ "roll" => result }, AbstractDie.describe(dice))
20
+ end
21
+
22
+ private
23
+
24
+ def die_foundry
25
+ @die_foundry ||= DieFoundry.new
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ module SumFrequencyCalculators
5
+ # Base frequencies calculator.
6
+ # @abstract
7
+ class BaseCalculator
8
+ # Possible values for +result_type+ argument in {#call}.
9
+ RESULT_TYPES = %i[frequencies probabilities].freeze
10
+
11
+ # @param dice [Enumerable<AbstractDie>]
12
+ # @param result_type [Symbol] one of {RESULT_TYPES}
13
+ # @return [Hash{Numeric => Numeric}] frequencies of each sum
14
+ # @raise [DiceyError] if +result_type+ is invalid
15
+ # @raise [DiceyError] if dice list is invalid for the calculator
16
+ # @raise [DiceyError] if calculator returned obviously wrong results
17
+ def call(dice, result_type: :frequencies)
18
+ unless RESULT_TYPES.include?(result_type)
19
+ raise DiceyError, "#{result_type} is not a valid result type!"
20
+ end
21
+ raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
22
+
23
+ frequencies = calculate(dice)
24
+ verify_result(frequencies, dice)
25
+ frequencies = sort_result(frequencies)
26
+ transform_result(frequencies, result_type)
27
+ end
28
+
29
+ # Whether this calculator can be used for the list of dice.
30
+ #
31
+ # @param dice [Enumerable<AbstractDie>]
32
+ # @return [Boolean]
33
+ def valid_for?(dice)
34
+ dice.is_a?(Enumerable) && dice.all? { _1.is_a?(AbstractDie) } && validate(dice)
35
+ end
36
+
37
+ private
38
+
39
+ # Do additional validation on the dice list.
40
+ # (see #valid_for?)
41
+ def validate(_dice)
42
+ true
43
+ end
44
+
45
+ # Peform frequencies calculation.
46
+ # (see #call)
47
+ def calculate(dice)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ # Check that resulting frequencies actually add up to what they are supposed to be.
52
+ #
53
+ # @param frequencies [Hash{Numeric => Integer}]
54
+ # @param dice [Enumerable<AbstractDie>]
55
+ # @return [void]
56
+ # @raise [DiceyError] if result is wrong
57
+ def verify_result(frequencies, dice)
58
+ valid = frequencies.values.sum == dice.map(&:sides_num).reduce(:*)
59
+ raise DiceyError, "calculator #{self.class} returned invalid results!" unless valid
60
+ end
61
+
62
+ # Depending on the order of sides, result may not be in an ascending order,
63
+ # so it's best to fix that for presentation (if possible).
64
+ def sort_result(frequencies)
65
+ frequencies.sort.to_h
66
+ rescue
67
+ # Probably Complex numbers got into the mix, leave as is.
68
+ frequencies
69
+ end
70
+
71
+ # Transform calculated frequencies to requested result_type, if needed.
72
+ #
73
+ # @param frequencies [Hash{Numeric => Integer}]
74
+ # @param result_type [Symbol] one of {RESULT_TYPES}
75
+ # @return [Hash{Numeric => Numeric}]
76
+ def transform_result(frequencies, result_type)
77
+ case result_type
78
+ when :frequencies
79
+ frequencies
80
+ when :probabilities
81
+ total = frequencies.values.sum
82
+ frequencies.transform_values { _1.fdiv(total) }
83
+ else
84
+ # Invalid, but was already checked in #call.
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ module Dicey
6
+ module SumFrequencyCalculators
7
+ # Calculator for a collection of {NumericDie} using exhaustive search (very slow).
8
+ class BruteForce < BaseCalculator
9
+ private
10
+
11
+ # def validate(dice)
12
+ # dice.all? { |die| die.is_a?(NumericDie) }
13
+ # end
14
+
15
+ def calculate(dice)
16
+ # TODO: Replace `combine_dice_enumerators` with `Enumerator.product`.
17
+ combine_dice_enumerators(dice).map(&:sum).tally
18
+ end
19
+
20
+ # Get an enumerator which goes through all possible permutations of dice sides.
21
+ #
22
+ # @param dice [Enumerable<NumericDie>]
23
+ # @return [Enumerator<Array>]
24
+ def combine_dice_enumerators(dice)
25
+ sides_num_list = dice.map(&:sides_num)
26
+ total = sides_num_list.reduce(:*)
27
+ Enumerator.new(total) do |yielder|
28
+ current_values = dice.map(&:next)
29
+ remaining_iterations = sides_num_list
30
+ total.times do
31
+ yielder << current_values
32
+ iterate_dice(dice, remaining_iterations, current_values)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Iterate through dice, getting next side for first die,
38
+ # then getting next side for second die, resetting first die, and so on.
39
+ # This is analogous to incrementing by 1 in a positional system
40
+ # where each position is a die.
41
+ #
42
+ # @param dice [Enumerable<NumericDie>]
43
+ # @param remaining_iterations [Array<Integer>]
44
+ # @param current_values [Array<Numeric>]
45
+ # @return [void]
46
+ def iterate_dice(dice, remaining_iterations, current_values)
47
+ dice.each_with_index do |die, i|
48
+ value = die.next
49
+ current_values[i] = value
50
+ remaining_iterations[i] -= 1
51
+ break if remaining_iterations[i].nonzero?
52
+
53
+ remaining_iterations[i] = die.sides_num
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ module Dicey
6
+ module SumFrequencyCalculators
7
+ # Calculator for lists of dice with non-negative integer sides (fast).
8
+ #
9
+ # Example dice: (1,2,3,4), (0,1,5,6), (5,4,5,4,5).
10
+ #
11
+ # Based on Kronecker substitution method for polynomial multiplication.
12
+ # @see https://en.wikipedia.org/wiki/Kronecker_substitution
13
+ # @see https://arxiv.org/pdf/0712.4046v1.pdf in particular section 3
14
+ class KroneckerSubstitution < BaseCalculator
15
+ private
16
+
17
+ def validate(dice)
18
+ dice.all? { |die| die.sides_list.all? { _1.is_a?(Integer) && _1 >= 0 } }
19
+ end
20
+
21
+ def calculate(dice)
22
+ polynomials = build_polynomials(dice)
23
+ evaluation_point = find_evaluation_point(polynomials)
24
+ values = evaluate_polynomials(polynomials, evaluation_point)
25
+ product = values.reduce(:*)
26
+ extract_coefficients(product, evaluation_point)
27
+ end
28
+
29
+ # Turn dice into hashes where keys are side values and values are numbers of those sides,
30
+ # representing corresponding polynomials where
31
+ # side values are powers and numbers are coefficients.
32
+ #
33
+ # @param dice [Enumerable<NumericDie>]
34
+ # @return [Array<Hash{Integer => Integer}>]
35
+ def build_polynomials(dice)
36
+ dice.map { _1.sides_list.tally }
37
+ end
38
+
39
+ # Find a power of 2 which is larger in magnitude than any resulting polynomial coefficients,
40
+ # and so able to hold each coefficient without overlap.
41
+ #
42
+ # @param polynomials [Array<Hash{Integer => Integer}>]
43
+ # @return [Integer]
44
+ def find_evaluation_point(polynomials)
45
+ polynomial_length = polynomials.flat_map(&:keys).max + 1
46
+ e = Math.log2(polynomial_length).ceil
47
+ b = polynomials.flat_map(&:values).max.bit_length
48
+ coefficient_magnitude = (polynomials.size * b) + ((polynomials.size - 1) * e)
49
+ 1 << coefficient_magnitude
50
+ end
51
+
52
+ # Get values of polynomials if +evaluation_point+ is substituted for the variable.
53
+ #
54
+ # @param polynomials [Array<Hash{Integer => Integer}>]
55
+ # @param evaluation_point [Integer]
56
+ # @return [Array<Integer>]
57
+ def evaluate_polynomials(polynomials, evaluation_point)
58
+ polynomials.map do |polynomial|
59
+ polynomial.sum { |power, coefficient| (evaluation_point**power) * coefficient }
60
+ end
61
+ end
62
+
63
+ # Unpack coefficients from the product of polynomial values,
64
+ # building resulting polynomial.
65
+ #
66
+ # @param product [Integer]
67
+ # @param evaluation_point [Integer]
68
+ # @return [Hash{Integer => Integer}]
69
+ def extract_coefficients(product, evaluation_point)
70
+ window = evaluation_point - 1
71
+ window_shift = window.bit_length
72
+ (0..).each_with_object({}) do |power, result|
73
+ coefficient = product & window
74
+ result[power] = coefficient unless coefficient.zero?
75
+ product >>= window_shift
76
+ break result if product.zero?
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ module Dicey
6
+ module SumFrequencyCalculators
7
+ # Calculator for multiple equal dice with sides forming an arithmetic sequence (fast).
8
+ #
9
+ # Example dice: (1,2,3,4), (-2,-1,0,1,2), (0,0.2,0.4,0.6), (-1,-2,-3).
10
+ #
11
+ # Based on extension of Pascal's triangle for a higher number of coefficients.
12
+ # @see https://en.wikipedia.org/wiki/Pascal%27s_triangle
13
+ # @see https://en.wikipedia.org/wiki/Trinomial_triangle
14
+ class MultinomialCoefficients < BaseCalculator
15
+ private
16
+
17
+ def validate(dice)
18
+ first_die = dice.first
19
+ return false unless first_die.is_a?(NumericDie)
20
+ return false unless dice.all? { _1 == first_die }
21
+ return true if first_die.sides_num == 1
22
+
23
+ arithmetic_sequence?(first_die.sides_list)
24
+ end
25
+
26
+ # @param sides_list [Array<Numeric>]
27
+ # @return [false, Array<Numeric>]
28
+ def arithmetic_sequence?(sides_list)
29
+ increment = sides_list[1] - sides_list[0]
30
+ return false if increment.zero?
31
+
32
+ sides_list.each_cons(2) { return false if _1 + increment != _2 }
33
+ end
34
+
35
+ # @param dice [Array<NumericDie>]
36
+ # @return [Hash{Numeric => Integer}]
37
+ def calculate(dice)
38
+ first_die = dice.first
39
+ number_of_sides = first_die.sides_num
40
+ number_of_dice = dice.size
41
+
42
+ frequencies = multinomial_coefficients(number_of_dice, number_of_sides)
43
+ result_sums_list(first_die.sides_list, number_of_dice).zip(frequencies).to_h
44
+ end
45
+
46
+ # Calculate coefficients for a multinomial of the form
47
+ # <tt>(x^1 +...+ x^m)^n</tt>, where +m+ is the number of sides and +n+ is the number of dice.
48
+ #
49
+ # @param dice [Integer] number of dice, must be positive
50
+ # @param sides [Integer] number of sides, must be positive
51
+ # @param throw_away_garbage [Boolean] whether to discard unused coefficients (debug option)
52
+ # @return [Array<Integer>]
53
+ def multinomial_coefficients(dice, sides, throw_away_garbage: true)
54
+ # This builds a triangular matrix where each first element is a 1.
55
+ # Each element is a sum of +m+ elements in the previous row
56
+ # with indices less or equal to its, with out-of-bounds indices corresponding to 0s.
57
+ # Example for m=3:
58
+ # 1
59
+ # 1 1 1
60
+ # 1 2 3 2 1
61
+ # 1 3 6 7 6 3 1, etc.
62
+ coefficients = [[1]]
63
+ (1..dice).each do |row_index|
64
+ row = next_row_of_coefficients(row_index, sides - 1, coefficients.last)
65
+ if throw_away_garbage
66
+ coefficients[0] = row
67
+ else
68
+ coefficients << row
69
+ end
70
+ end
71
+ coefficients.last
72
+ end
73
+
74
+ # @param row_index [Integer]
75
+ # @param window_size [Integer]
76
+ # @param previous_row [Array<Integer>]
77
+ # @return [Array<Integer>]
78
+ def next_row_of_coefficients(row_index, window_size, previous_row)
79
+ length = (row_index * window_size) + 1
80
+ (0..length).map do |col_index|
81
+ # Have to clamp to 0 to prevent accessing array from the end.
82
+ window_range = ((col_index - window_size).clamp(0..)..col_index)
83
+ window_range.sum { |i| previous_row.fetch(i, 0) }
84
+ end
85
+ end
86
+
87
+ # Get sequence of sums which correspond to calculated frequencies.
88
+ #
89
+ # @param sides_list [Enumerable<Numeric>]
90
+ # @param number_of_dice [Integer]
91
+ # @return [Array<Numeric>]
92
+ def result_sums_list(sides_list, number_of_dice)
93
+ first = number_of_dice * sides_list.first
94
+ last = number_of_dice * sides_list.last
95
+ return [first] if first == last
96
+
97
+ increment = sides_list[1] - sides_list[0]
98
+ Enumerator
99
+ .produce(first) { _1 + increment }
100
+ .take_while { (_1 < last) == (first < last) || _1 == last }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../die_foundry"
4
+
5
+ require_relative "brute_force"
6
+ require_relative "kronecker_substitution"
7
+ require_relative "multinomial_coefficients"
8
+
9
+ module Dicey
10
+ module SumFrequencyCalculators
11
+ # The defaultest runner which calculates roll frequencies from command-line dice.
12
+ class Runner
13
+ # Transform die definitions to roll frequencies.
14
+ #
15
+ # @param arguments [Array<String>] die definitions
16
+ # @param roll_calculators [Array<BaseCalculator>] list of calculators to use
17
+ # @param format [#call] formatter for output
18
+ # @param result [Symbol] result type selector
19
+ # @return [nil]
20
+ # @raise [DiceyError]
21
+ def call(arguments, roll_calculators:, format:, result:, **)
22
+ raise DiceyError, "no dice!" if arguments.empty?
23
+
24
+ dice = arguments.map { |definition| die_foundry.cast(definition) }
25
+ frequencies = roll_calculators.find { _1.valid_for?(dice) }&.call(dice, result_type: result)
26
+ raise DiceyError, "no calculator could handle these dice!" unless frequencies
27
+
28
+ format.call(frequencies, AbstractDie.describe(dice))
29
+ end
30
+
31
+ private
32
+
33
+ def die_foundry
34
+ @die_foundry ||= DieFoundry.new
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brute_force"
4
+ require_relative "kronecker_substitution"
5
+ require_relative "multinomial_coefficients"
6
+
7
+ module Dicey
8
+ module SumFrequencyCalculators
9
+ # A simple testing facility for roll frequency calculators.
10
+ class TestRunner
11
+ # These are manually calculated frequencies,
12
+ # with test cases for pretty much all variations of what this program can handle.
13
+ TEST_DATA = [
14
+ [[1], { 1 => 1 }],
15
+ [[10], { 1 => 1, 2 => 1, 3 => 1, 4 => 1, 5 => 1, 6 => 1, 7 => 1, 8 => 1, 9 => 1, 10 => 1 }],
16
+ [[2, 2], { 2 => 1, 3 => 2, 4 => 1 }],
17
+ [[3, 3], { 2 => 1, 3 => 2, 4 => 3, 5 => 2, 6 => 1 }],
18
+ [[4, 4], { 2 => 1, 3 => 2, 4 => 3, 5 => 4, 6 => 3, 7 => 2, 8 => 1 }],
19
+ [[9, 9],
20
+ { 2 => 1, 3 => 2, 4 => 3, 5 => 4, 6 => 5, 7 => 6, 8 => 7, 9 => 8, 10 => 9,
21
+ 11 => 8, 12 => 7, 13 => 6, 14 => 5, 15 => 4, 16 => 3, 17 => 2, 18 => 1 }],
22
+ [[2, 2, 2], { 3 => 1, 4 => 3, 5 => 3, 6 => 1 }],
23
+ [[3, 3, 3], { 3 => 1, 4 => 3, 5 => 6, 6 => 7, 7 => 6, 8 => 3, 9 => 1 }],
24
+ [[2, 2, 2, 2], { 4 => 1, 5 => 4, 6 => 6, 7 => 4, 8 => 1 }],
25
+ [[1, 2, 3], { 3 => 1, 4 => 2, 5 => 2, 6 => 1 }],
26
+ [[3, 2, 1], { 3 => 1, 4 => 2, 5 => 2, 6 => 1 }],
27
+ [[[0], 1], { 1 => 1 }],
28
+ [[4, 6], { 2 => 1, 3 => 2, 4 => 3, 5 => 4, 6 => 4, 7 => 4, 8 => 3, 9 => 2, 10 => 1 }],
29
+ [[[3, 17, 21]], { 3 => 1, 17 => 1, 21 => 1 }],
30
+ [[[3, 3, 3, 3, 3, 5, 5, 5]], { 3 => 5, 5 => 3 }],
31
+ [[[1, 4, 6], [1, 4, 6]], { 2 => 1, 5 => 2, 7 => 2, 8 => 1, 10 => 2, 12 => 1 }],
32
+ [[[3, 4, 3], [1, 3, 2]], { 4 => 2, 5 => 3, 6 => 3, 7 => 1 }],
33
+ [[[0, 0], [0, 0, 0], [0], [0, 0, 0, 0]], { 0 => 24 }],
34
+ [[[0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], { 0 => 12 }],
35
+ [[[-0.5, 0.5, 1], 6],
36
+ { 0.5 => 1, 1.5 => 2, 2 => 1, 2.5 => 2, 3 => 1, 3.5 => 2, 4 => 1,
37
+ 4.5 => 2, 5 => 1, 5.5 => 2, 6 => 1, 6.5 => 1, 7 => 1 }],
38
+ [Array.new(3) { [-0.25, 0.0, 0.25, 0.5, 0.75] },
39
+ { -0.75 => 1, -0.5 => 3, -0.25 => 6, 0.0 => 10, 0.25 => 15, 0.5 => 18, 0.75 => 19,
40
+ 1.0 => 18, 1.25 => 15, 1.5 => 10, 1.75 => 6, 2.0 => 3, 2.25 => 1 }],
41
+ [[[1.i, 2.i, 3.i], [1, 2, 3]],
42
+ { Complex(1, 1) => 1, Complex(2, 1) => 1, Complex(3, 1) => 1,
43
+ Complex(1, 2) => 1, Complex(2, 2) => 1, Complex(3, 2) => 1,
44
+ Complex(1, 3) => 1, Complex(2, 3) => 1, Complex(3, 3) => 1 }],
45
+ ].freeze
46
+
47
+ # Strings for displaying test results.
48
+ RESULT_TEXT = { pass: "✔", fail: "✘ 🠐 failure!", skip: "☂", crash: "⛐ 🠐 crash!" }.freeze
49
+ # Which test results are considered failures.
50
+ FAILURE_RESULTS = %i[fail crash].freeze
51
+
52
+ # Check all tests defined in {TEST_DATA} with every passed calculator.
53
+ #
54
+ # @param roll_calculators [Array<BaseCalculator>]
55
+ # @param report_style [Symbol] one of: +:full+, +:quiet+;
56
+ # +:quiet+ style does not output any text
57
+ # @return [Boolean] whether there are no failing tests
58
+ def call(*, roll_calculators:, report_style:, **)
59
+ results = TEST_DATA.to_h { |test| run_test(test, roll_calculators) }
60
+ full_report(results) if report_style == :full
61
+ results.values.none? do |test_result|
62
+ test_result.values.any? { FAILURE_RESULTS.include?(_1) }
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # @param test [Array(Array<Integer, Array<Numeric>>, Hash{Numeric => Integer})]
69
+ # pair of a dice list definition and expected results
70
+ # @param calculators [Array<BaseCalculator>]
71
+ # @return [Array(Array<NumericDie>, Hash{BaseCalculator => Symbol})]
72
+ # result of running the test in a format suitable for +#to_h+
73
+ def run_test(test, calculators)
74
+ dice = build_dice(test.first)
75
+ test_result =
76
+ calculators.each_with_object({}) do |calculator, hash|
77
+ hash[calculator] = run_test_on_calculator(calculator, dice, test.last)
78
+ end
79
+ [dice, test_result]
80
+ end
81
+
82
+ # Build a list of {NumericDie} objects from a plain definition.
83
+ #
84
+ # @param definition [Array<Integer, Array<Integer>>]
85
+ # @return [Array<NumericDie>]
86
+ def build_dice(definition)
87
+ definition.map { _1.is_a?(Integer) ? RegularDie.new(_1) : NumericDie.new(_1) }
88
+ end
89
+
90
+ # Determine test result for the selected calculator.
91
+ def run_test_on_calculator(calculator, dice, expectation)
92
+ return :skip unless calculator.valid_for?(dice)
93
+
94
+ (calculator.call(dice) == expectation) ? :pass : :fail
95
+ rescue
96
+ :crash
97
+ end
98
+
99
+ # Print results of running all tests.
100
+ def full_report(results)
101
+ results.each do |dice, test_result|
102
+ print "#{AbstractDie.describe(dice)}:\n"
103
+ test_result.each do |calculator, result|
104
+ print " #{calculator.class}: "
105
+ puts RESULT_TEXT[result]
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ VERSION = "0.13.1"
5
+ end
data/lib/dicey.rb CHANGED
@@ -1,3 +1,11 @@
1
- $:.unshift File.dirname(__FILE__)
1
+ # frozen_string_literal: true
2
2
 
3
- require 'dicey/dice'
3
+ # A library for rolling dice and calculating roll frequencies.
4
+ module Dicey
5
+ # General error for Dicey.
6
+ class DiceyError < StandardError; end
7
+
8
+ Dir["#{__dir__}/dicey/*.rb"].each { require _1 }
9
+ Dir["#{__dir__}/dicey/output_formatters/*.rb"].each { require _1 }
10
+ Dir["#{__dir__}/dicey/sum_frequency_calculators/*.rb"].each { require _1 }
11
+ end
data/sig/dicey.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module Dicey
2
+ VERSION: String
3
+ end