games_dice 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +69 -5
  3. data/.gitignore +0 -1
  4. data/.rubocop.yml +13 -48
  5. data/CHANGELOG.md +26 -0
  6. data/Gemfile +6 -3
  7. data/README.md +46 -5
  8. data/Rakefile +90 -2
  9. data/ext/games_dice/extconf.rb +26 -0
  10. data/ext/games_dice/games_dice.c +1 -1
  11. data/ext/games_dice/probabilities.c +341 -211
  12. data/ext/games_dice/probabilities.h +3 -29
  13. data/games_dice.gemspec +1 -1
  14. data/lib/games_dice/bunch.rb +50 -36
  15. data/lib/games_dice/bunch_helpers.rb +35 -14
  16. data/lib/games_dice/complex_die.rb +64 -22
  17. data/lib/games_dice/complex_die_helpers.rb +122 -144
  18. data/lib/games_dice/dice.rb +61 -17
  19. data/lib/games_dice/dice_helpers.rb +37 -0
  20. data/lib/games_dice/die_result.rb +6 -8
  21. data/lib/games_dice/marshal.rb +2 -7
  22. data/lib/games_dice/parser.rb +30 -0
  23. data/lib/games_dice/probability_budget.rb +90 -0
  24. data/lib/games_dice/probability_calculation.rb +95 -0
  25. data/lib/games_dice/probability_shortcuts.rb +65 -0
  26. data/lib/games_dice/recipe_cache.rb +155 -0
  27. data/lib/games_dice/version.rb +1 -1
  28. data/lib/games_dice.rb +4 -0
  29. data/script/check_documentation.rb +10 -0
  30. data/spec/games_dice/bunch_regressions_spec.rb +46 -0
  31. data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
  32. data/spec/games_dice/complex_die_budget_spec.rb +60 -0
  33. data/spec/games_dice/complex_die_limits_spec.rb +119 -0
  34. data/spec/games_dice/complex_die_spec.rb +228 -0
  35. data/spec/games_dice/dice_mutation_spec.rb +165 -0
  36. data/spec/games_dice/dice_regressions_spec.rb +50 -0
  37. data/spec/games_dice/dice_spec.rb +61 -0
  38. data/spec/games_dice/die_result_spec.rb +221 -0
  39. data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
  40. data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
  41. data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
  42. data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
  43. data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
  44. data/spec/games_dice/probabilities_spec.rb +484 -0
  45. data/spec/games_dice/probability_budget_spec.rb +17 -0
  46. data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
  47. data/spec/games_dice_spec.rb +382 -0
  48. data/spec/helpers.rb +64 -2
  49. data/spec/simple_cov_spec.rb +42 -0
  50. data/spec/support/coverage.rb +12 -0
  51. data/spec/support/validation.rb +14 -0
  52. data/spec/yard/cli/stats_spec.rb +43 -0
  53. metadata +30 -13
  54. data/spec/complex_die_spec.rb +0 -314
  55. data/spec/dice_spec.rb +0 -33
  56. data/spec/die_result_spec.rb +0 -254
  57. data/spec/fixtures/probs_fair_die_6.dat +0 -2
  58. data/spec/probability_spec.rb +0 -522
  59. data/spec/readme_spec.rb +0 -410
@@ -3,125 +3,32 @@
3
3
  module GamesDice
4
4
  class ComplexDie
5
5
  # @!visibility private
6
- # Private extension methods for GamesDice::ComplexDie probability calculations
6
+ # Private cache handling for bounded probability calculations.
7
7
  module ProbabilityHelpers
8
8
  private
9
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
10
+ def probabilities_for_budget(limit)
11
+ refresh_recipe
12
+ return cached_probabilities if valid_probability_cache?(limit)
77
13
 
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
14
+ with_recipe do
15
+ cache_calculation(ProbabilityCalculation.new(self, limit))
82
16
  end
83
-
84
- !rule_idx.nil?
17
+ rescue StandardError
18
+ @probabilities_complete = nil
19
+ raise
85
20
  end
86
21
 
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)
22
+ def cached_probabilities
23
+ @probabilities_complete = @probability_error.zero?
24
+ @probabilities
91
25
  end
92
26
 
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]
27
+ def cache_calculation(calculation)
28
+ distribution = calculation.calculate
29
+ @probability_error = calculation.error
30
+ @probabilities_complete = @probability_error.zero?
31
+ @probabilities = distribution
125
32
  end
126
33
  end
127
34
 
@@ -130,21 +37,21 @@ module GamesDice
130
37
  module RollHelpers
131
38
  private
132
39
 
133
- def roll_apply_rerolls
134
- return unless @rerolls
40
+ def roll_apply_rerolls(rules)
41
+ return unless rules
135
42
 
136
43
  subtracting = false
137
- rerolls_remaining = @rerolls.map(&:limit)
44
+ rerolls_remaining = rules.map(&:limit)
138
45
 
139
- rerolls_loop(subtracting, rerolls_remaining)
46
+ rerolls_loop(subtracting, rerolls_remaining, rules)
140
47
  end
141
48
 
142
- def rerolls_loop(subtracting, rerolls_remaining)
49
+ def rerolls_loop(subtracting, rerolls_remaining, rules)
143
50
  loop do
144
- rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
51
+ rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining, nil, rules)
145
52
  break unless rule_idx
146
53
 
147
- rule = @rerolls[rule_idx]
54
+ rule = rules[rule_idx]
148
55
  rerolls_remaining[rule_idx] -= 1
149
56
  subtracting = true if rule.type == :reroll_subtract
150
57
  roll_apply_reroll_rule rule, subtracting
@@ -161,32 +68,36 @@ module GamesDice
161
68
  end
162
69
 
163
70
  # 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
71
+ def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining, budget = nil, rules = @rerolls)
72
+ return unless rules
73
+
74
+ rules.each_with_index do |rule, index|
75
+ next unless available_reroll?(rule, num_rolls, rerolls_remaining[index])
167
76
 
168
- remaining.positive? && rule.applies?(check_value)
77
+ budget&.predicate
78
+ return index if rule.applies?(check_value)
169
79
  end
80
+ nil
81
+ end
82
+
83
+ def available_reroll?(rule, num_rolls, remaining)
84
+ remaining.positive? && !(rule.type == :reroll_subtract && num_rolls > 1)
170
85
  end
171
86
 
172
- def roll_apply_maps
173
- return unless @maps
87
+ def roll_apply_maps(maps)
88
+ return unless maps
174
89
 
175
- m, n = calc_maps(@result.value)
176
- @result.apply_map(m, n)
90
+ m, n = calc_maps(@result.value, nil, maps)
91
+ @result.apply_map(m, n.dup)
177
92
  end
178
93
 
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
94
+ def calc_maps(original_value, budget = nil, maps = @maps)
95
+ maps.each do |rule|
96
+ budget&.predicate
97
+ mapped = rule.map_from(original_value)
98
+ return [mapped, rule.mapped_name] if mapped
188
99
  end
189
- [y, n]
100
+ [0, '']
190
101
  end
191
102
  end
192
103
 
@@ -196,12 +107,66 @@ module GamesDice
196
107
  private
197
108
 
198
109
  def calc_minmax
199
- @min_result = probabilities.min
200
- @max_result = probabilities.max
201
- return if @probabilities_complete
110
+ cheap = cheap_minmax
111
+ if cheap
112
+ @min_result, @max_result = cheap
113
+ else
114
+ calculate_minmax
115
+ end
116
+ end
117
+
118
+ def calculate_minmax
119
+ distribution = probabilities
120
+ if @probabilities_complete
121
+ @min_result = distribution.min
122
+ @max_result = distribution.max
123
+ else
124
+ @min_result, @max_result = logical_minmax
125
+ end
126
+ rescue ProbabilityLimitError
127
+ @min_result, @max_result = safe_minmax
128
+ end
129
+
130
+ def cheap_minmax
131
+ return if @maps
132
+ return [1, sides] if !@rerolls || @rerolls.empty?
133
+ return unless @rerolls.one?
134
+
135
+ rule = @rerolls.first
136
+ return unless rule.instance_of?(RerollRule) && ProbabilityShortcuts.boolean_rule?(rule)
137
+
138
+ cheap_rule_minmax(rule)
139
+ end
140
+
141
+ def cheap_rule_minmax(rule)
142
+ return [1, sides] if rule.type == :reroll_replace
143
+ return unless sides == 1 && rule.type == :reroll_add
202
144
 
203
- logical_min, logical_max = logical_minmax
204
- @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
145
+ value = 1 + (rule.applies?(1) ? [rule.limit, 0].max : 0)
146
+ [value, value]
147
+ end
148
+
149
+ def safe_minmax
150
+ return safe_mapped_minmax if @maps
151
+
152
+ adds = positive_rule_limits(:reroll_add)
153
+ subtracts = positive_rule_limits(:reroll_subtract)
154
+ return [1, sides * (1 + adds)] if subtracts.zero?
155
+
156
+ maximum = sides * (1 + adds + subtracts)
157
+ [-maximum, maximum]
158
+ end
159
+
160
+ def safe_mapped_minmax
161
+ unless @maps.all? { |rule| rule.instance_of?(MapRule) && ProbabilityShortcuts.boolean_rule?(rule) }
162
+ raise ProbabilityLimitError, 'Cannot establish finite dice bounds within calculation limits'
163
+ end
164
+
165
+ [0, *@maps.map(&:mapped_value)].minmax
166
+ end
167
+
168
+ def positive_rule_limits(type)
169
+ (@rerolls || []).sum { |rule| rule.type == type ? [rule.limit, 0].max : 0 }
205
170
  end
206
171
 
207
172
  def minmax_mappings(possible_values)
@@ -216,15 +181,28 @@ module GamesDice
216
181
  # cannot actually occur. In practice it is likely a non-issue unless someone went out of their way to invent a
217
182
  # dice schem that broke it.
218
183
  def logical_minmax
219
- return @basic_die.minmax unless @rerolls || @maps
220
- return minmax_mappings(@basic_die.all_values) unless @rerolls
184
+ return safe_minmax unless inexpensive_bounds?
221
185
 
222
186
  min_result, max_result = logical_rerolls_minmax
223
- return minmax_mappings(min_result..max_result) if @maps
187
+ if @maps
188
+ return safe_minmax if (max_result - min_result + 1) * @maps.length > ProbabilityBudget::MAX_PREDICATES
189
+
190
+ return minmax_mappings(min_result..max_result)
191
+ end
224
192
 
225
193
  [min_result, max_result]
226
194
  end
227
195
 
196
+ def inexpensive_bounds?
197
+ rules = [*(@rerolls || []), *(@maps || [])]
198
+ rules.all? { |rule| ordinary_boolean_rule?(rule) } &&
199
+ sides * rules.length <= ProbabilityBudget::MAX_FACES
200
+ end
201
+
202
+ def ordinary_boolean_rule?(rule)
203
+ (rule.instance_of?(RerollRule) || rule.instance_of?(MapRule)) && ProbabilityShortcuts.boolean_rule?(rule)
204
+ end
205
+
228
206
  def logical_rerolls_minmax
229
207
  min_result = @basic_die.min
230
208
  max_result = @basic_die.max
@@ -251,7 +229,7 @@ module GamesDice
251
229
  max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
252
230
  next unless max_reroll
253
231
 
254
- total_add += max_reroll * rule.limit
232
+ total_add += max_reroll * [rule.limit, 0].max
255
233
  end
256
234
  total_add
257
235
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'games_dice/dice_helpers'
4
+
3
5
  module GamesDice
4
6
  # This class models a combination of GamesDice::Bunch objects plus a fixed offset.
5
7
  #
@@ -20,6 +22,9 @@ module GamesDice
20
22
  # d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
21
23
  #
22
24
  class Dice
25
+ include RecipeQueries
26
+ include ProbabilityHelpers
27
+
23
28
  # The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
24
29
  # @param [Array<Hash>] bunches Array of options for creating bunches
25
30
  # @param [Integer] offset Total offset
@@ -43,6 +48,7 @@ module GamesDice
43
48
  @bunches = bunches.map { |b| GamesDice::Bunch.new(b) }
44
49
  @bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
45
50
  @result = nil
51
+ initialize_recipe
46
52
  end
47
53
 
48
54
  # Name to help identify dice
@@ -64,26 +70,35 @@ module GamesDice
64
70
 
65
71
  # Result of most-recent roll, or nil if no roll made yet.
66
72
  # @return [Integer,nil]
67
- attr_reader :result
73
+ def result
74
+ refresh_recipe
75
+ @result
76
+ end
68
77
 
69
78
  # Simulates rolling dice
70
79
  # @return [Integer] Sum of all rolled dice
71
80
  def roll
72
- @result = @offset + bunches_weighted_sum(:roll)
81
+ with_recipe { @result = @offset + bunches_weighted_sum(:roll) }
73
82
  end
74
83
 
75
84
  # @!attribute [r] min
76
- # Minimum possible result from a call to #roll
85
+ # Lower bound on the value returned by #roll. No roll will be below this value.
86
+ # For some complex dice, this may be lower than any value that can actually
87
+ # be rolled, because calculating the exact minimum would take too much work.
77
88
  # @return [Integer]
78
89
  def min
79
- @min ||= @offset + bunches_weighted_sum(:min)
90
+ refresh_recipe
91
+ @min ||= with_recipe { @offset + weighted_bound(:min, :max) }
80
92
  end
81
93
 
82
94
  # @!attribute [r] max
83
- # Maximum possible result from a call to #roll
95
+ # Upper bound on the value returned by #roll. No roll will exceed this value.
96
+ # For some complex dice, this may be higher than any value that can actually
97
+ # be rolled, because calculating the exact maximum would take too much work.
84
98
  # @return [Integer]
85
99
  def max
86
- @max ||= @offset + bunches_weighted_sum(:max)
100
+ refresh_recipe
101
+ @max ||= with_recipe { @offset + weighted_bound(:max, :min) }
87
102
  end
88
103
 
89
104
  # @!attribute [r] minmax
@@ -93,22 +108,27 @@ module GamesDice
93
108
  [min, max]
94
109
  end
95
110
 
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.
111
+ # Calculates the probability distribution for the combined dice result.
112
+ #
113
+ # Very unlikely sequences of rerolls may be left out to keep calculation
114
+ # time reasonable. This changes the probability of any combined result by
115
+ # at most 1e-10 (one in ten billion), in addition to normal floating-point
116
+ # rounding. This limit covers all the bunches together.
117
+ #
118
+ # Raises ProbabilityLimitError if this accuracy cannot be achieved within
119
+ # the calculation's work limits.
120
+ # @return [GamesDice::Probabilities] Probability distribution of the dice.
100
121
  def probabilities
101
- return @probabilities if @probabilities
122
+ refresh_recipe
123
+ return @probabilities if valid_probability_cache?(ProbabilityBudget::LIMIT)
102
124
 
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
125
+ @probabilities = with_recipe { combined_probabilities }
107
126
  end
108
127
 
109
128
  # @!attribute [r] explain_result
110
129
  # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
111
130
  def explain_result
131
+ refresh_recipe
112
132
  return nil unless @result
113
133
 
114
134
  explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
@@ -122,10 +142,34 @@ module GamesDice
122
142
 
123
143
  private
124
144
 
145
+ def recipe_sources
146
+ [[@bunches, Bunch], [@bunch_multipliers, Numeric]]
147
+ end
148
+
149
+ def recipe_labels
150
+ [@name]
151
+ end
152
+
153
+ def validate_recipe
154
+ return if @bunches.length == @bunch_multipliers.length
155
+
156
+ raise ArgumentError, 'bunches and bunch_multipliers must have the same length'
157
+ end
158
+
159
+ def weighted_bound(positive, negative)
160
+ @bunches.zip(@bunch_multipliers).sum do |bunch, multiplier|
161
+ next 0 if multiplier.zero?
162
+
163
+ multiplier * bunch.public_send(multiplier.negative? ? negative : positive)
164
+ end
165
+ end
166
+
125
167
  def simple_explanation(explanation)
126
- return explanation if @offset.zero?
168
+ return explanation if @offset.zero? && @bunch_multipliers[0] == 1
127
169
 
128
- "#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}"
170
+ values = [@bunch_multipliers[0] * @bunches[0].result]
171
+ values << @offset unless @offset.zero?
172
+ "#{explanation}. #{array_to_sum(values)}"
129
173
  end
130
174
 
131
175
  def multi_explanations(explanations)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GamesDice
4
+ class Dice
5
+ # @!visibility private
6
+ # Private combination helpers sharing one truncation budget across bunches.
7
+ module ProbabilityHelpers
8
+ Combination = Struct.new(:distribution, :error)
9
+ private_constant :Combination
10
+
11
+ private
12
+
13
+ def combined_probabilities
14
+ per_die = ProbabilityBudget.share(ProbabilityBudget::LIMIT, [probability_dice_count, 1].max)
15
+ result = Combination.new(Probabilities.new([1.0], @offset), 0.0)
16
+ @bunches.zip(@bunch_multipliers).each do |bunch, multiplier|
17
+ next if multiplier.zero?
18
+
19
+ component = component_probabilities(bunch, per_die, result)
20
+ result.distribution = Probabilities.add_distributions_mult(1, result.distribution, multiplier, component)
21
+ end
22
+ @probability_error = ProbabilityBudget.check(result.error, ProbabilityBudget::LIMIT)
23
+ result.distribution
24
+ end
25
+
26
+ def probability_dice_count
27
+ @bunches.each_with_index.sum { |bunch, index| @bunch_multipliers[index].zero? ? 0 : bunch.ndice }
28
+ end
29
+
30
+ def component_probabilities(bunch, per_die, result)
31
+ component = bunch.__send__(:probabilities_for_budget, (per_die * bunch.ndice).prev_float)
32
+ result.error = ProbabilityBudget.add(result.error, bunch.instance_variable_get(:@probability_error))
33
+ component
34
+ end
35
+ end
36
+ end
37
+ end
@@ -164,9 +164,6 @@ module GamesDice
164
164
 
165
165
  private
166
166
 
167
- # Splitting this method up further, or flattening it will not make it read better. If
168
- # we had a few more reroll reasons, they could maybe be grouped and method split up.
169
- # rubocop:disable Metrics/MethodLength
170
167
  def apply_roll_to_total(roll_reason, roll_result)
171
168
  case roll_reason
172
169
  when :basic, :reroll_new_die, :reroll_new_keeper, :reroll_replace
@@ -175,13 +172,14 @@ module GamesDice
175
172
  @total += roll_result
176
173
  when :reroll_subtract
177
174
  @total -= roll_result
178
- when :reroll_use_best
179
- @total = [@value, roll_result].max
180
- when :reroll_use_worst
181
- @total = [@value, roll_result].min
175
+ when :reroll_use_best, :reroll_use_worst
176
+ @total = select_roll(roll_reason, roll_result)
182
177
  end
183
178
  end
184
- # rubocop:enable Metrics/MethodLength
179
+
180
+ def select_roll(roll_reason, roll_result)
181
+ roll_reason == :reroll_use_best ? [@value, roll_result].max : [@value, roll_result].min
182
+ end
185
183
 
186
184
  def init_with_result(first_roll_result, first_roll_reason)
187
185
  @rolls = [Integer(first_roll_result)]
@@ -29,13 +29,8 @@ module GamesDice
29
29
  end
30
30
 
31
31
  # @!visibility private
32
- def self._load(buf)
33
- # Use of Marshal for general-purpose object serialisation is discouraged. However, this class does support
34
- # it for backwards-compatibility.
35
- # rubocop:disable Security/MarshalLoad
36
- h = Marshal.load buf
37
- # rubocop:enable Security/MarshalLoad
38
- from_h h
32
+ def marshal_load(distribution)
33
+ initialize_copy(self.class.from_h(distribution))
39
34
  end
40
35
  end
41
36
  end
@@ -92,6 +92,9 @@ module GamesDice
92
92
  # @!visibility private
93
93
  class ParseTreeProcessor
94
94
  class << self
95
+ # Collects dice terms into constructor options, leaving constant terms for {collect_offset}.
96
+ # @param [Hash] dice_expressions Parsed expression tree.
97
+ # @return [Array<Hash>] Options for each GamesDice::Bunch.
95
98
  def collect_bunches(dice_expressions)
96
99
  dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash|
97
100
  out_hash = {}
@@ -106,6 +109,10 @@ module GamesDice
106
109
  end
107
110
  end
108
111
 
112
+ # Copies the parsed number of dice and sides into constructor options as integers.
113
+ # @param [Hash] in_hash Parsed dice term.
114
+ # @param [Hash] out_hash Constructor options to update.
115
+ # @return [Array<Symbol>] The processed field names.
109
116
  def collect_bunch_basics(in_hash, out_hash)
110
117
  %i[ndice sides].each do |s|
111
118
  next unless in_hash[s]
@@ -114,6 +121,10 @@ module GamesDice
114
121
  end
115
122
  end
116
123
 
124
+ # Converts a term's sign to its numeric multiplier.
125
+ # @param [Hash] in_hash Parsed dice term with an :op entry.
126
+ # @param [Hash] out_hash Constructor options to update.
127
+ # @return [Integer,nil] Multiplier, or nil for an unknown operator.
117
128
  def collect_bunch_multiplier(in_hash, out_hash)
118
129
  optype = in_hash[:op].to_s
119
130
  out_hash[:multiplier] = case optype
@@ -122,6 +133,10 @@ module GamesDice
122
133
  end
123
134
  end
124
135
 
136
+ # Dispatches a parsed modifier to its alias, keeper, map, or reroll collector.
137
+ # @param [Hash] mod Parsed modifier.
138
+ # @param [Hash] out_hash Constructor options to update.
139
+ # @return [Object] Result of the selected collector, or nil for an unknown modifier.
125
140
  def collect_bunch_modifier(mod, out_hash)
126
141
  if mod[:alias]
127
142
  ParseTreeBunchModifier.collect_alias_modifier mod, out_hash
@@ -134,6 +149,9 @@ module GamesDice
134
149
  end
135
150
  end
136
151
 
152
+ # Sums signed constant terms separately from dice terms.
153
+ # @param [Hash] dice_expressions Parsed expression tree.
154
+ # @return [Integer] Fixed offset for GamesDice::Dice.
137
155
  def collect_offset(dice_expressions)
138
156
  dice_expressions[:bunches].select { |h| h[:constant] }.inject(0) do |total, in_hash|
139
157
  c = in_hash[:constant].to_i
@@ -173,6 +191,11 @@ module GamesDice
173
191
  collect_complex_reroll_rule(reroll_mod, out_hash)
174
192
  end
175
193
 
194
+ # Appends a reroll rule, reversing the comparison and preserving an explicit limit.
195
+ # Omitted types default to replacement; an omitted limit uses RerollRule's default.
196
+ # @param [Hash] reroll_mod Parsed long-form reroll modifier.
197
+ # @param [Hash] out_hash Constructor options with an initialized :rerolls array.
198
+ # @return [Array<Array>] Updated reroll rules.
176
199
  def collect_complex_reroll_rule(reroll_mod, out_hash)
177
200
  # Typical reroll_mod: {:reroll=>"r"@5, :condition=>{:compare_num=>"10"@7}, :type=>"add"@10}
178
201
  op = get_op_symbol(reroll_mod[:condition][:comparison] || '==')
@@ -212,6 +235,10 @@ module GamesDice
212
235
  collect_complex_map_rule(map_mod, out_hash)
213
236
  end
214
237
 
238
+ # Appends a map rule, defaulting to a threshold comparison and a mapped value of one.
239
+ # @param [Hash] map_mod Parsed long-form map modifier, optionally including a label.
240
+ # @param [Hash] out_hash Constructor options with an initialized :maps array.
241
+ # @return [Array<Array>] Updated map rules.
215
242
  def collect_complex_map_rule(map_mod, out_hash)
216
243
  # Typical map_mod: {:map=>"m"@4, :condition=>{:compare_num=>"5"@6}, :num=>"2"@8, :output=>"Qwerty"@10}
217
244
  op = get_op_symbol(map_mod[:condition][:comparison] || '>=')
@@ -236,6 +263,9 @@ module GamesDice
236
263
  '<=' => :>=
237
264
  }.freeze
238
265
 
266
+ # Reverses a notation comparison for rules that send the operator to the trigger value.
267
+ # @param [#to_s] parsed_op_string Comparison from the parse tree.
268
+ # @return [Symbol,nil] Reversed operator, or nil for an unknown comparison.
239
269
  def get_op_symbol(parsed_op_string)
240
270
  OP_CONVERSION[parsed_op_string.to_s]
241
271
  end