games_dice 0.3.12 → 0.4.2

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 (43) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +38 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +53 -0
  5. data/.yardopts +1 -1
  6. data/CHANGELOG.md +18 -0
  7. data/Gemfile +12 -0
  8. data/README.md +11 -33
  9. data/Rakefile +10 -23
  10. data/ext/games_dice/extconf.rb +4 -22
  11. data/ext/games_dice/games_dice.c +1 -1
  12. data/ext/games_dice/probabilities.c +128 -9
  13. data/ext/games_dice/probabilities.h +1 -1
  14. data/games_dice.gemspec +22 -36
  15. data/lib/games_dice/bunch.rb +203 -247
  16. data/lib/games_dice/bunch_helpers.rb +68 -0
  17. data/lib/games_dice/complex_die.rb +151 -270
  18. data/lib/games_dice/complex_die_helpers.rb +260 -60
  19. data/lib/games_dice/constants.rb +10 -10
  20. data/lib/games_dice/dice.rb +153 -143
  21. data/lib/games_dice/die.rb +101 -97
  22. data/lib/games_dice/die_result.rb +206 -189
  23. data/lib/games_dice/map_rule.rb +72 -70
  24. data/lib/games_dice/marshal.rb +41 -13
  25. data/lib/games_dice/parser.rb +245 -218
  26. data/lib/games_dice/reroll_rule.rb +76 -77
  27. data/lib/games_dice/version.rb +4 -1
  28. data/lib/games_dice.rb +23 -25
  29. data/spec/bunch_spec.rb +399 -420
  30. data/spec/complex_die_spec.rb +314 -305
  31. data/spec/dice_spec.rb +33 -34
  32. data/spec/die_result_spec.rb +174 -181
  33. data/spec/die_spec.rb +81 -81
  34. data/spec/helpers.rb +25 -25
  35. data/spec/map_rule_spec.rb +40 -44
  36. data/spec/parser_spec.rb +106 -82
  37. data/spec/probability_spec.rb +522 -526
  38. data/spec/readme_spec.rb +410 -390
  39. data/spec/reroll_rule_spec.rb +40 -44
  40. metadata +17 -129
  41. data/.travis.yml +0 -14
  42. data/lib/games_dice/prob_helpers.rb +0 -259
  43. data/lib/games_dice/probabilities.rb +0 -244
@@ -1,60 +1,260 @@
1
- # @!visibility private
2
- module GamesDice::ComplexDieHelpers
3
-
4
- private
5
-
6
- def recursive_probabilities probabilities={},prior_probability=1.0,depth=0,prior_result=nil,rerolls_left=nil,roll_reason=:basic,subtracting=false
7
- each_probability = prior_probability / @basic_die.sides
8
- depth += 1
9
- if depth >= 20 || each_probability < 1.0e-16
10
- @probabilities_complete = false
11
- stop_recursing = true
12
- end
13
-
14
- @basic_die.each_value do |v|
15
- recurse_probs_for_value( v, roll_reason, probabilities, each_probability, depth, prior_result, rerolls_left, subtracting, stop_recursing )
16
- end
17
- probabilities
18
- end
19
-
20
- def recurse_probs_for_value v, roll_reason, probabilities, each_probability, depth, prior_result, rerolls_left, subtracting, stop_recursing
21
- # calculate value, recurse if there is a reroll
22
- result_so_far, rerolls_remaining = calc_result_so_far(prior_result, rerolls_left, v, roll_reason )
23
-
24
- # Find which rule, if any, is being triggered
25
- rule_idx = find_matching_reroll_rule( v, result_so_far.rolls.length, rerolls_remaining )
26
-
27
- if rule_idx && ! stop_recursing
28
- recurse_probs_with_rule( probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule_idx, subtracting )
29
- else
30
- t = result_so_far.total
31
- probabilities[ t ] ||= 0.0
32
- probabilities[ t ] += each_probability
33
- end
34
- end
35
-
36
- def recurse_probs_with_rule probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule_idx, subtracting
37
- rule = @rerolls[ rule_idx ]
38
- rerolls_remaining[ rule_idx ] -= 1
39
- is_subtracting = true if subtracting || rule.type == :reroll_subtract
40
-
41
- # Apply the rule (note reversal for additions, after a subtract)
42
- if subtracting && rule.type == :reroll_add
43
- recursive_probabilities probabilities, each_probability, depth, result_so_far, rerolls_remaining, :reroll_subtract, is_subtracting
44
- else
45
- recursive_probabilities probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule.type, is_subtracting
46
- end
47
- end
48
-
49
- def calc_result_so_far prior_result, rerolls_left, v, roll_reason
50
- if prior_result
51
- result_so_far = prior_result.clone
52
- result_so_far.add_roll(v,roll_reason)
53
- rerolls_remaining = rerolls_left.clone
54
- else
55
- result_so_far = GamesDice::DieResult.new(v,roll_reason)
56
- rerolls_remaining = @rerolls.map { |rule| rule.limit }
57
- end
58
- [result_so_far, rerolls_remaining]
59
- end
60
- end
1
+ # frozen_string_literal: true
2
+
3
+ module GamesDice
4
+ class ComplexDie
5
+ # @!visibility private
6
+ # Private extension methods for GamesDice::ComplexDie probability calculations
7
+ module ProbabilityHelpers
8
+ private
9
+
10
+ def calculate_probabilities
11
+ if @rerolls && @maps
12
+ GamesDice::Probabilities.from_h(prob_hash_with_rerolls_and_maps)
13
+ elsif @rerolls
14
+ GamesDice::Probabilities.from_h(recursive_probabilities)
15
+ elsif @maps
16
+ GamesDice::Probabilities.from_h(prob_hash_with_just_maps)
17
+ else
18
+ @basic_die.probabilities
19
+ end
20
+ end
21
+
22
+ def prob_hash_with_rerolls_and_maps
23
+ prob_hash = {}
24
+ reroll_probs = recursive_probabilities
25
+ reroll_probs.each do |v, p|
26
+ add_mapped_to_prob_hash(prob_hash, v, p)
27
+ end
28
+ prob_hash
29
+ end
30
+
31
+ def prob_hash_with_just_maps
32
+ prob_hash = {}
33
+ @basic_die.probabilities.each do |v, p|
34
+ add_mapped_to_prob_hash(prob_hash, v, p)
35
+ end
36
+ prob_hash
37
+ end
38
+
39
+ def add_mapped_to_prob_hash(prob_hash, orig_val, prob)
40
+ mapped_val, = calc_maps(orig_val)
41
+ prob_hash[mapped_val] ||= 0.0
42
+ prob_hash[mapped_val] += prob
43
+ end
44
+
45
+ RecurseStack = Struct.new(:depth, :roll_reason, :subtracting, :probabilities, :prior_probability, :prior_result,
46
+ :rerolls_left) do
47
+ def initialize
48
+ self.depth = 0
49
+ self.roll_reason = :basic
50
+ self.subtracting = false
51
+ self.probabilities = {}
52
+ self.prior_probability = 1.0
53
+ end
54
+ end
55
+ private_constant :RecurseStack
56
+
57
+ def recursive_probabilities(stack = RecurseStack.new)
58
+ stack.prior_probability = stack.prior_probability / @basic_die.sides
59
+ stack.depth += 1
60
+
61
+ @basic_die.each_value do |die_val|
62
+ recurse_probs_for_value(die_val, stack)
63
+ end
64
+ stack.probabilities
65
+ end
66
+
67
+ def recurse_probs_for_value(die_val, stack)
68
+ result_so_far, rerolls_remaining = calc_result_so_far(die_val, stack)
69
+ rule_idx = find_matching_reroll_rule(die_val, result_so_far.rolls.length, rerolls_remaining)
70
+
71
+ if conintue_recursing?(stack, rule_idx)
72
+ continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx)
73
+ else
74
+ end_recursion_store_probs(stack, result_so_far)
75
+ end
76
+ end
77
+
78
+ def conintue_recursing?(stack, rule_idx)
79
+ if stack.depth >= 20 || stack.prior_probability < 1.0e-16
80
+ @probabilities_complete = false
81
+ return false
82
+ end
83
+
84
+ !rule_idx.nil?
85
+ end
86
+
87
+ def continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx)
88
+ rule = @rerolls[rule_idx]
89
+ rerolls_remaining[rule_idx] -= 1
90
+ recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule)
91
+ end
92
+
93
+ def end_recursion_store_probs(stack, result_so_far)
94
+ t = result_so_far.total
95
+ stack.probabilities[t] ||= 0.0
96
+ stack.probabilities[t] += stack.prior_probability
97
+ end
98
+
99
+ def recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule)
100
+ next_stack = stack.clone
101
+ next_stack.prior_result = result_so_far
102
+ next_stack.rerolls_left = rerolls_remaining
103
+ next_stack.subtracting = true if stack.subtracting || rule.type == :reroll_subtract
104
+
105
+ # Apply the rule (note reversal for additions, after a subtract)
106
+ next_stack.roll_reason = if stack.subtracting && rule.type == :reroll_add
107
+ :reroll_subtract
108
+ else
109
+ rule.type
110
+ end
111
+
112
+ recursive_probabilities next_stack
113
+ end
114
+
115
+ def calc_result_so_far(die_val, stack)
116
+ if stack.prior_result
117
+ result_so_far = stack.prior_result.clone
118
+ rerolls_remaining = stack.rerolls_left.clone
119
+ result_so_far.add_roll(die_val, stack.roll_reason)
120
+ else
121
+ rerolls_remaining = @rerolls.map(&:limit)
122
+ result_so_far = GamesDice::DieResult.new(die_val, stack.roll_reason)
123
+ end
124
+ [result_so_far, rerolls_remaining]
125
+ end
126
+ end
127
+
128
+ # @!visibility private
129
+ # Private extension methods for GamesDice::ComplexDie simulating rolls
130
+ module RollHelpers
131
+ private
132
+
133
+ def roll_apply_rerolls
134
+ return unless @rerolls
135
+
136
+ subtracting = false
137
+ rerolls_remaining = @rerolls.map(&:limit)
138
+
139
+ rerolls_loop(subtracting, rerolls_remaining)
140
+ end
141
+
142
+ def rerolls_loop(subtracting, rerolls_remaining)
143
+ loop do
144
+ rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
145
+ break unless rule_idx
146
+
147
+ rule = @rerolls[rule_idx]
148
+ rerolls_remaining[rule_idx] -= 1
149
+ subtracting = true if rule.type == :reroll_subtract
150
+ roll_apply_reroll_rule rule, subtracting
151
+ end
152
+ end
153
+
154
+ def roll_apply_reroll_rule(rule, is_subtracting)
155
+ # Apply the rule (note reversal for additions, after a subtract)
156
+ if is_subtracting && rule.type == :reroll_add
157
+ @result.add_roll(@basic_die.roll, :reroll_subtract)
158
+ else
159
+ @result.add_roll(@basic_die.roll, rule.type)
160
+ end
161
+ end
162
+
163
+ # Find which rule, if any, is being triggered
164
+ def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining)
165
+ @rerolls.zip(rerolls_remaining).find_index do |rule, remaining|
166
+ next if rule.type == :reroll_subtract && num_rolls > 1
167
+
168
+ remaining.positive? && rule.applies?(check_value)
169
+ end
170
+ end
171
+
172
+ def roll_apply_maps
173
+ return unless @maps
174
+
175
+ m, n = calc_maps(@result.value)
176
+ @result.apply_map(m, n)
177
+ end
178
+
179
+ def calc_maps(original_value)
180
+ y = 0
181
+ n = ''
182
+ @maps.find do |rule|
183
+ if (maybe_y = rule.map_from(original_value))
184
+ y = maybe_y
185
+ n = rule.mapped_name
186
+ end
187
+ maybe_y
188
+ end
189
+ [y, n]
190
+ end
191
+ end
192
+
193
+ # @!visibility private
194
+ # Private extension methods for GamesDice::ComplexDie calculating min and max (which is surprisingly complex)
195
+ module MinMaxHelpers
196
+ private
197
+
198
+ def calc_minmax
199
+ @min_result = probabilities.min
200
+ @max_result = probabilities.max
201
+ return if @probabilities_complete
202
+
203
+ logical_min, logical_max = logical_minmax
204
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
205
+ end
206
+
207
+ def minmax_mappings(possible_values)
208
+ possible_values.map do |x|
209
+ map_val, = calc_maps(x)
210
+ map_val
211
+ end.minmax
212
+ end
213
+
214
+ # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we
215
+ # have to. The inaccuracy is that min_result..max_result may contain 'holes' which have extreme map values that
216
+ # cannot actually occur. In practice it is likely a non-issue unless someone went out of their way to invent a
217
+ # dice schem that broke it.
218
+ def logical_minmax
219
+ return @basic_die.minmax unless @rerolls || @maps
220
+ return minmax_mappings(@basic_die.all_values) unless @rerolls
221
+
222
+ min_result, max_result = logical_rerolls_minmax
223
+ return minmax_mappings(min_result..max_result) if @maps
224
+
225
+ [min_result, max_result]
226
+ end
227
+
228
+ def logical_rerolls_minmax
229
+ min_result = @basic_die.min
230
+ max_result = @basic_die.max
231
+ min_subtract = find_minimum_possible_subtract
232
+ max_add = find_maximum_possible_adds
233
+ min_result = [min_subtract - max_add, min_subtract - max_result].min if min_subtract
234
+ [min_result, max_add + max_result]
235
+ end
236
+
237
+ def find_minimum_possible_subtract
238
+ min_subtract = nil
239
+ @rerolls.select { |r| r.type == :reroll_subtract }.each do |rule|
240
+ min_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.min
241
+ next unless min_reroll
242
+
243
+ min_subtract = [min_reroll, min_subtract].compact.min
244
+ end
245
+ min_subtract
246
+ end
247
+
248
+ def find_maximum_possible_adds
249
+ total_add = 0
250
+ @rerolls.select { |r| r.type == :reroll_add }.each do |rule|
251
+ max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
252
+ next unless max_reroll
253
+
254
+ total_add += max_reroll * rule.limit
255
+ end
256
+ total_add
257
+ end
258
+ end
259
+ end
260
+ end
@@ -1,16 +1,16 @@
1
- module GamesDice
1
+ # frozen_string_literal: true
2
2
 
3
+ module GamesDice
3
4
  # Reasons for making a reroll, and text explanation symbols for them
4
5
  REROLL_TYPES = {
5
- :basic => ',',
6
- :reroll_add => '+',
7
- :reroll_subtract => '-',
8
- :reroll_replace => '|',
9
- :reroll_use_best => '/',
10
- :reroll_use_worst => '\\',
6
+ basic: ',',
7
+ reroll_add: '+',
8
+ reroll_subtract: '-',
9
+ reroll_replace: '|',
10
+ reroll_use_best: '/',
11
+ reroll_use_worst: '\\'
11
12
  # These are not yet implemented:
12
13
  # :reroll_new_die => '*',
13
14
  # :reroll_new_keeper => '*',
14
- }
15
-
16
- end
15
+ }.freeze
16
+ end
@@ -1,143 +1,153 @@
1
- # This class models a combination of GamesDice::Bunch objects plus a fixed offset.
2
- #
3
- # An object of this class is a dice "recipe" that specifies the numbers and types of
4
- # dice that can be rolled to generate an integer value.
5
- #
6
- # @example '3d6+6' hitpoints, whatever that means in the game you are playing
7
- # d = GamesDice::Dice.new( [{:ndice => 3, :sides => 6}], 6, 'Hit points' )
8
- # d.roll # => 20
9
- # d.result # => 20
10
- # d.explain_result # => "3d6: 3 + 5 + 6 = 14. 14 + 6 = 20"
11
- # d.probabilities.expected # => 16.5
12
- #
13
- # @example Roll d20 twice, take best result, and add 5.
14
- # d = GamesDice::Dice.new( [{:ndice => 2, :sides => 20 , :keep_mode => :keep_best, :keep_number => 1}], 5 )
15
- # d.roll # => 21
16
- # d.result # => 21
17
- # d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
18
- #
19
- class GamesDice::Dice
20
- # The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
21
- # @param [Array<Hash>] bunches Array of options for creating bunches
22
- # @param [Integer] offset Total offset
23
- # @param [String] name Optional label for the dice
24
- # @option bunches [Integer] :ndice Number of dice in the bunch, *mandatory*
25
- # @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
26
- # @option bunches [String] :name Optional name for the bunch
27
- # @option bunches [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
28
- # @option bunches [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
29
- # @option bunches [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
30
- # @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
31
- # @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
32
- # @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the Bunch total is to be added or subtracted
33
- # @return [GamesDice::Dice]
34
- def initialize( bunches, offset = 0, name = '' )
35
- @name = name
36
- @offset = offset
37
- @bunches = bunches.map { |b| GamesDice::Bunch.new( b ) }
38
- @bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
39
- @result = nil
40
- end
41
-
42
- # Name to help identify dice
43
- # @return [String]
44
- attr_reader :name
45
-
46
- # Bunches of dice that are components of the object
47
- # @return [Array<GamesDice::Bunch>]
48
- attr_reader :bunches
49
-
50
- # Multipliers for each bunch of identical dice. Typically 1 or -1 to represent groups of dice that
51
- # are either added or subtracted from the total.
52
- # @return [Array<Integer>]
53
- attr_reader :bunch_multipliers
54
-
55
- # Fixed offset added to sum of all bunches.
56
- # @return [Integer]
57
- attr_reader :offset
58
-
59
- # Result of most-recent roll, or nil if no roll made yet.
60
- # @return [Integer,nil]
61
- attr_reader :result
62
-
63
- # Simulates rolling dice
64
- # @return [Integer] Sum of all rolled dice
65
- def roll
66
- @result = @offset + bunches_weighted_sum( :roll )
67
- end
68
-
69
- # @!attribute [r] min
70
- # Minimum possible result from a call to #roll
71
- # @return [Integer]
72
- def min
73
- @min ||= @offset + bunches_weighted_sum( :min )
74
- end
75
-
76
- # @!attribute [r] max
77
- # Maximum possible result from a call to #roll
78
- # @return [Integer]
79
- def max
80
- @max ||= @offset + bunches_weighted_sum( :max )
81
- end
82
-
83
- # @!attribute [r] minmax
84
- # Convenience method, same as [dice.min, dice.max]
85
- # @return [Array<Integer>]
86
- def minmax
87
- [min,max]
88
- end
89
-
90
- # Calculates the probability distribution for the dice. When the dice include components with
91
- # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
92
- # recursion.
93
- # @return [GamesDice::Probabilities] Probability distribution of dice.
94
- def probabilities
95
- return @probabilities if @probabilities
96
- probs = @bunch_multipliers.zip(@bunches).inject( GamesDice::Probabilities.new( [1.0], @offset ) ) do |probs, mb|
97
- m,b = mb
98
- GamesDice::Probabilities.add_distributions_mult( 1, probs, m, b.probabilities )
99
- end
100
- end
101
-
102
- # @!attribute [r] explain_result
103
- # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
104
- def explain_result
105
- return nil unless @result
106
- explanations = @bunches.map { |bunch| bunch.label + ": " + bunch.explain_result }
107
-
108
- if explanations.count == 0
109
- return @offset.to_s
110
- end
111
-
112
- if explanations.count == 1
113
- if @offset !=0
114
- return explanations[0] + '. ' + array_to_sum( [ @bunches[0].result, @offset ] )
115
- else
116
- return explanations[0]
117
- end
118
- end
119
-
120
- bunch_values = @bunch_multipliers.zip(@bunches).map { |m,b| m * b.result }
121
- bunch_values << @offset if @offset != 0
122
- explanations << array_to_sum( bunch_values )
123
- return explanations.join('. ')
124
- end
125
-
126
- private
127
-
128
- def array_to_sum array
129
- ( numbers_to_strings(array) + [ '=', array.inject(:+) ] ).join(' ')
130
- end
131
-
132
- def numbers_to_strings array
133
- [ array.first.to_s ] + array.drop(1).map { |n| n < 0 ? '- ' + n.abs.to_s : '+ ' + n.to_s }
134
- end
135
-
136
- def bunches_weighted_sum summed_method
137
- @bunch_multipliers.zip(@bunches).inject(0) do |total,mb|
138
- m,b = mb
139
- total += m * b.send( summed_method )
140
- end
141
- end
142
-
143
- end # class Dice
1
+ # frozen_string_literal: true
2
+
3
+ module GamesDice
4
+ # This class models a combination of GamesDice::Bunch objects plus a fixed offset.
5
+ #
6
+ # An object of this class is a dice "recipe" that specifies the numbers and types of
7
+ # dice that can be rolled to generate an integer value.
8
+ #
9
+ # @example '3d6+6' hitpoints, whatever that means in the game you are playing
10
+ # d = GamesDice::Dice.new( [{:ndice => 3, :sides => 6}], 6, 'Hit points' )
11
+ # d.roll # => 20
12
+ # d.result # => 20
13
+ # d.explain_result # => "3d6: 3 + 5 + 6 = 14. 14 + 6 = 20"
14
+ # d.probabilities.expected # => 16.5
15
+ #
16
+ # @example Roll d20 twice, take best result, and add 5.
17
+ # d = GamesDice::Dice.new( [{:ndice => 2, :sides => 20 , :keep_mode => :keep_best, :keep_number => 1}], 5 )
18
+ # d.roll # => 21
19
+ # d.result # => 21
20
+ # d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
21
+ #
22
+ class Dice
23
+ # The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
24
+ # @param [Array<Hash>] bunches Array of options for creating bunches
25
+ # @param [Integer] offset Total offset
26
+ # @param [String] name Optional label for the dice
27
+ # @option bunches [Integer] :ndice Number of dice in the bunch, *mandatory*
28
+ # @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
29
+ # @option bunches [String] :name Optional name for the bunch
30
+ # @option bunches [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
31
+ # @option bunches [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result
32
+ # for the die
33
+ # @option bunches [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to
34
+ # GamesDice::Die's constructor
35
+ # @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
36
+ # @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
37
+ # @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the
38
+ # Bunch total is to be added or subtracted
39
+ # @return [GamesDice::Dice]
40
+ def initialize(bunches, offset = 0, name = '')
41
+ @name = name
42
+ @offset = offset
43
+ @bunches = bunches.map { |b| GamesDice::Bunch.new(b) }
44
+ @bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
45
+ @result = nil
46
+ end
47
+
48
+ # Name to help identify dice
49
+ # @return [String]
50
+ attr_reader :name
51
+
52
+ # Bunches of dice that are components of the object
53
+ # @return [Array<GamesDice::Bunch>]
54
+ attr_reader :bunches
55
+
56
+ # Multipliers for each bunch of identical dice. Typically 1 or -1 to represent groups of dice that
57
+ # are either added or subtracted from the total.
58
+ # @return [Array<Integer>]
59
+ attr_reader :bunch_multipliers
60
+
61
+ # Fixed offset added to sum of all bunches.
62
+ # @return [Integer]
63
+ attr_reader :offset
64
+
65
+ # Result of most-recent roll, or nil if no roll made yet.
66
+ # @return [Integer,nil]
67
+ attr_reader :result
68
+
69
+ # Simulates rolling dice
70
+ # @return [Integer] Sum of all rolled dice
71
+ def roll
72
+ @result = @offset + bunches_weighted_sum(:roll)
73
+ end
74
+
75
+ # @!attribute [r] min
76
+ # Minimum possible result from a call to #roll
77
+ # @return [Integer]
78
+ def min
79
+ @min ||= @offset + bunches_weighted_sum(:min)
80
+ end
81
+
82
+ # @!attribute [r] max
83
+ # Maximum possible result from a call to #roll
84
+ # @return [Integer]
85
+ def max
86
+ @max ||= @offset + bunches_weighted_sum(:max)
87
+ end
88
+
89
+ # @!attribute [r] minmax
90
+ # Convenience method, same as [dice.min, dice.max]
91
+ # @return [Array<Integer>]
92
+ def minmax
93
+ [min, max]
94
+ end
95
+
96
+ # Calculates the probability distribution for the dice. When the dice include components with
97
+ # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
98
+ # recursion.
99
+ # @return [GamesDice::Probabilities] Probability distribution of dice.
100
+ def probabilities
101
+ return @probabilities if @probabilities
102
+
103
+ @bunch_multipliers.zip(@bunches).inject(GamesDice::Probabilities.new([1.0], @offset)) do |probs, mb|
104
+ m, b = mb
105
+ GamesDice::Probabilities.add_distributions_mult(1, probs, m, b.probabilities)
106
+ end
107
+ end
108
+
109
+ # @!attribute [r] explain_result
110
+ # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
111
+ def explain_result
112
+ return nil unless @result
113
+
114
+ explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
115
+
116
+ return @offset.to_s if explanations.none?
117
+
118
+ return simple_explanation(explanations.first) if explanations.one?
119
+
120
+ multi_explanations(explanations)
121
+ end
122
+
123
+ private
124
+
125
+ def simple_explanation(explanation)
126
+ return explanation if @offset.zero?
127
+
128
+ "#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}"
129
+ end
130
+
131
+ def multi_explanations(explanations)
132
+ bunch_values = @bunch_multipliers.zip(@bunches).map { |m, b| m * b.result }
133
+ bunch_values << @offset if @offset != 0
134
+ explanations << array_to_sum(bunch_values)
135
+ explanations.join('. ')
136
+ end
137
+
138
+ def array_to_sum(array)
139
+ (numbers_to_strings(array) + ['=', array.sum]).join(' ')
140
+ end
141
+
142
+ def numbers_to_strings(array)
143
+ [array.first.to_s] + array.drop(1).map { |n| n.negative? ? "- #{n.abs}" : "+ #{n}" }
144
+ end
145
+
146
+ def bunches_weighted_sum(summed_method)
147
+ @bunch_multipliers.zip(@bunches).inject(0) do |total, mb|
148
+ m, b = mb
149
+ total + (m * b.send(summed_method))
150
+ end
151
+ end
152
+ end
153
+ end