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.
- checksums.yaml +7 -0
- data/README.md +236 -0
- data/exe/dicey +6 -0
- data/exe/dicey-to-gnuplot +27 -0
- data/lib/dicey/abstract_die.rb +91 -0
- data/lib/dicey/cli/blender.rb +81 -0
- data/lib/dicey/cli/options.rb +99 -0
- data/lib/dicey/die_foundry.rb +57 -0
- data/lib/dicey/numeric_die.rb +17 -0
- data/lib/dicey/output_formatters/gnuplot_formatter.rb +12 -0
- data/lib/dicey/output_formatters/hash_formatter.rb +32 -0
- data/lib/dicey/output_formatters/json_formatter.rb +12 -0
- data/lib/dicey/output_formatters/key_value_formatter.rb +20 -0
- data/lib/dicey/output_formatters/list_formatter.rb +12 -0
- data/lib/dicey/output_formatters/yaml_formatter.rb +12 -0
- data/lib/dicey/regular_die.rb +32 -0
- data/lib/dicey/roller.rb +28 -0
- data/lib/dicey/sum_frequency_calculators/base_calculator.rb +89 -0
- data/lib/dicey/sum_frequency_calculators/brute_force.rb +58 -0
- data/lib/dicey/sum_frequency_calculators/kronecker_substitution.rb +81 -0
- data/lib/dicey/sum_frequency_calculators/multinomial_coefficients.rb +104 -0
- data/lib/dicey/sum_frequency_calculators/runner.rb +38 -0
- data/lib/dicey/sum_frequency_calculators/test_runner.rb +111 -0
- data/lib/dicey/version.rb +5 -0
- data/lib/dicey.rb +10 -2
- data/sig/dicey.rbs +3 -0
- metadata +62 -60
- data/Rakefile +0 -7
- data/Readme.md +0 -21
- data/lib/dicey/dice.rb +0 -14
- data/test/dice_test.rb +0 -17
- data/test/die_test.rb +0 -13
- data/test/test_helper.rb +0 -2
|
@@ -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
|
data/lib/dicey/roller.rb
ADDED
|
@@ -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
|
data/lib/dicey.rb
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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