games_dice 0.4.0 → 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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'games_dice/bunch_helpers'
4
+
3
5
  module GamesDice
4
6
  # This class models a number of identical dice, which may be either GamesDice::Die or
5
7
  # GamesDice::ComplexDie objects.
@@ -21,6 +23,9 @@ module GamesDice
21
23
  # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
22
24
  #
23
25
  class Bunch
26
+ include KeepHelpers
27
+ include ExplainHelpers
28
+
24
29
  # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
25
30
  # and decides which of those classes to instantiate.
26
31
  # @param [Hash] options
@@ -28,8 +33,10 @@ module GamesDice
28
33
  # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
29
34
  # @option options [String] :name Optional name for the bunch
30
35
  # @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
31
- # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
32
- # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
36
+ # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result
37
+ # for the die
38
+ # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to
39
+ # GamesDice::Die's constructor
33
40
  # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
34
41
  # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
35
42
  # @return [GamesDice::Bunch]
@@ -138,26 +145,15 @@ module GamesDice
138
145
  # Simulates rolling the bunch of identical dice
139
146
  # @return [Integer] Sum of all rolled dice, or sum of all keepers
140
147
  def roll
141
- @result = 0
142
- @raw_result_details = []
148
+ generate_raw_results
149
+ return @result if !@keep_mode || @keep_number.to_i >= @ndice
143
150
 
144
- @ndice.times do
145
- @result += @single_die.roll
146
- @raw_result_details << @single_die.result
147
- end
148
-
149
- return @result unless @keep_mode
150
-
151
- use_dice = if @keep_mode && @keep_number < @ndice
152
- case @keep_mode
153
- when :keep_best then @raw_result_details.sort[-@keep_number..]
154
- when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
155
- end
156
- else
157
- @raw_result_details
151
+ use_dice = case @keep_mode
152
+ when :keep_best then @raw_result_details.sort[-@keep_number..]
153
+ when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
158
154
  end
159
155
 
160
- @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
156
+ @result = use_dice.sum
161
157
  end
162
158
 
163
159
  # @!attribute [r] explain_result
@@ -166,44 +162,25 @@ module GamesDice
166
162
  def explain_result
167
163
  return nil unless @result
168
164
 
169
- explanation = ''
170
-
171
165
  # With #keep_mode, we may need to show unused and used dice separately
172
166
  used_dice = result_details
173
- unused_dice = []
174
-
175
- # Pick highest numbers and their associated details
176
- if @keep_mode && @keep_number < @ndice
177
- full_dice = result_details.sort_by(&:total)
178
- case @keep_mode
179
- when :keep_best
180
- used_dice = full_dice[-@keep_number..]
181
- unused_dice = full_dice[0..full_dice.length - 1 - @keep_number]
182
- when :keep_worst
183
- used_dice = full_dice[0..(@keep_number - 1)]
184
- unused_dice = full_dice[@keep_number..(full_dice.length - 1)]
185
- end
186
- end
187
-
188
- # Show unused dice (if any)
189
- if @keep_mode || @single_die.maps
190
- explanation += result_details.map(&:explain_value).join(', ')
191
- if @keep_mode
192
- separator = @single_die.maps ? ', ' : ' + '
193
- explanation += ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
194
- end
195
- explanation += ". Successes: #{@result}" if @single_die.maps
196
- explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
197
- else
198
- explanation += used_dice.map(&:explain_value).join(' + ')
199
- explanation += " = #{@result}" if @ndice > 1
200
- end
167
+ used_dice, = find_used_dice_due_to_keep_mode(result_details) if @keep_mode && @keep_number < @ndice
201
168
 
202
- explanation
169
+ build_explanation(used_dice)
203
170
  end
204
171
 
205
172
  private
206
173
 
174
+ def generate_raw_results
175
+ @result = 0
176
+ @raw_result_details = []
177
+
178
+ @ndice.times do
179
+ @result += @single_die.roll
180
+ @raw_result_details << @single_die.result
181
+ end
182
+ end
183
+
207
184
  def name_number_sides_from_hash(options)
208
185
  @name = options[:name].to_s
209
186
  @ndice = Integer(options[:ndice])
@@ -213,21 +190,6 @@ module GamesDice
213
190
  raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides.positive?
214
191
  end
215
192
 
216
- def keep_mode_from_hash(options)
217
- case options[:keep_mode]
218
- when nil
219
- @keep_mode = nil
220
- when :keep_best
221
- @keep_mode = :keep_best
222
- @keep_number = Integer(options[:keep_number] || 1)
223
- when :keep_worst
224
- @keep_mode = :keep_worst
225
- @keep_number = Integer(options[:keep_number] || 1)
226
- else
227
- raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
228
- end
229
- end
230
-
231
193
  def complex_die_params_from_hash(options)
232
194
  cd_hash = {}
233
195
  %i[maps rerolls].each do |k|
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GamesDice
4
+ class Bunch
5
+ # @!visibility private
6
+ # Private extension methods for GamesDice::Bunch keep rules
7
+ module KeepHelpers
8
+ private
9
+
10
+ def keep_mode_from_hash(options)
11
+ @keep_mode = options[:keep_mode]
12
+ case @keep_mode
13
+ when nil
14
+ @keep_mode = nil
15
+ when :keep_best, :keep_worst
16
+ @keep_number = Integer(options[:keep_number] || 1)
17
+ else
18
+ raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
19
+ end
20
+ end
21
+
22
+ def find_used_dice_due_to_keep_mode(used_dice, unused_dice = [])
23
+ full_dice = result_details.sort_by(&:total)
24
+ case @keep_mode
25
+ when :keep_best
26
+ used_dice = full_dice[-@keep_number..]
27
+ unused_dice = full_dice[0..(full_dice.length - 1 - @keep_number)]
28
+ when :keep_worst
29
+ used_dice = full_dice[0..(@keep_number - 1)]
30
+ unused_dice = full_dice[@keep_number..]
31
+ end
32
+
33
+ [used_dice, unused_dice]
34
+ end
35
+
36
+ def explain_kept_dice(used_dice)
37
+ separator = @single_die.maps ? ', ' : ' + '
38
+ ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
39
+ end
40
+ end
41
+
42
+ # @!visibility private
43
+ # Private extension methods for GamesDice::Bunch explaining
44
+ module ExplainHelpers
45
+ private
46
+
47
+ def build_explanation(used_dice)
48
+ if @keep_mode || @single_die.maps
49
+ explanation = explain_with_keep_or_map(used_dice)
50
+ else
51
+ explanation = used_dice.map(&:explain_value).join(' + ')
52
+ explanation += " = #{@result}" if @ndice > 1
53
+ end
54
+
55
+ explanation
56
+ end
57
+
58
+ def explain_with_keep_or_map(used_dice)
59
+ explanation = result_details.map(&:explain_value).join(', ')
60
+ explanation += explain_kept_dice(used_dice) if @keep_mode
61
+ explanation += ". Successes: #{@result}" if @single_die.maps
62
+ explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
63
+
64
+ explanation
65
+ end
66
+ end
67
+ end
68
+ end
@@ -24,7 +24,9 @@ module GamesDice
24
24
  # d.explain_result # => "[6+5] 11 Success"
25
25
  #
26
26
  class ComplexDie
27
- include GamesDice::ComplexDieHelpers
27
+ include RollHelpers
28
+ include ProbabilityHelpers
29
+ include MinMaxHelpers
28
30
 
29
31
  # @!visibility private
30
32
  # arbitrary limit to speed up probability calculations. It should
@@ -35,8 +37,10 @@ module GamesDice
35
37
  # @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor
36
38
  # @param [Hash] options
37
39
  # @option options [Array<GamesDice::RerollRule,Array>] :rerolls The rules that cause the die to roll again
38
- # @option options [Array<GamesDice::MapRule,Array>] :maps The rules to convert a value into a final result for the die
39
- # @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
40
+ # @option options [Array<GamesDice::MapRule,Array>] :maps The rules to convert a value into a final result
41
+ # for the die
42
+ # @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to
43
+ # GamesDice::Die's constructor
40
44
  # @return [GamesDice::ComplexDie]
41
45
  def initialize(sides, options = {})
42
46
  @basic_die = GamesDice::Die.new(sides, options[:prng])
@@ -46,6 +50,8 @@ module GamesDice
46
50
 
47
51
  @total = nil
48
52
  @result = nil
53
+
54
+ @probabilities_complete = true
49
55
  end
50
56
 
51
57
  # The simple component used by this complex one
@@ -105,27 +111,7 @@ module GamesDice
105
111
  # 1e-9 at worst.
106
112
  # @return [GamesDice::Probabilities] Probability distribution of die.
107
113
  def probabilities
108
- return @probabilities if @probabilities
109
-
110
- @probabilities_complete = true
111
- if @rerolls && @maps
112
- reroll_probs = recursive_probabilities
113
- prob_hash = {}
114
- reroll_probs.each do |v, p|
115
- add_mapped_to_prob_hash(prob_hash, v, p)
116
- end
117
- elsif @rerolls
118
- prob_hash = recursive_probabilities
119
- elsif @maps
120
- prob_hash = {}
121
- @basic_die.probabilities.each do |v, p|
122
- add_mapped_to_prob_hash(prob_hash, v, p)
123
- end
124
- else
125
- @probabilities = @basic_die.probabilities
126
- return @probabilities
127
- end
128
- @probabilities = GamesDice::Probabilities.from_h(prob_hash)
114
+ @probabilities ||= calculate_probabilities
129
115
  end
130
116
 
131
117
  # Simulates rolling the die
@@ -140,63 +126,6 @@ module GamesDice
140
126
 
141
127
  private
142
128
 
143
- def add_mapped_to_prob_hash(prob_hash, v, p)
144
- m, n = calc_maps(v)
145
- prob_hash[m] ||= 0.0
146
- prob_hash[m] += p
147
- end
148
-
149
- def roll_apply_rerolls
150
- return unless @rerolls
151
-
152
- subtracting = false
153
- rerolls_remaining = @rerolls.map(&:limit)
154
-
155
- loop do
156
- rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
157
- break unless rule_idx
158
-
159
- rule = @rerolls[rule_idx]
160
- rerolls_remaining[rule_idx] -= 1
161
- subtracting = true if rule.type == :reroll_subtract
162
- roll_apply_reroll_rule rule, subtracting
163
- end
164
- end
165
-
166
- def roll_apply_reroll_rule(rule, is_subtracting)
167
- # Apply the rule (note reversal for additions, after a subtract)
168
- if is_subtracting && rule.type == :reroll_add
169
- @result.add_roll(@basic_die.roll, :reroll_subtract)
170
- else
171
- @result.add_roll(@basic_die.roll, rule.type)
172
- end
173
- end
174
-
175
- # Find which rule, if any, is being triggered
176
- def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining)
177
- @rerolls.zip(rerolls_remaining).find_index do |rule, remaining|
178
- next if rule.type == :reroll_subtract && num_rolls > 1
179
-
180
- remaining.positive? && rule.applies?(check_value)
181
- end
182
- end
183
-
184
- def roll_apply_maps
185
- return unless @maps
186
-
187
- m, n = calc_maps(@result.value)
188
- @result.apply_map(m, n)
189
- end
190
-
191
- def calc_minmax
192
- @min_result = probabilities.min
193
- @max_result = probabilities.max
194
- return if @probabilities_complete
195
-
196
- logical_min, logical_max = logical_minmax
197
- @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
198
- end
199
-
200
129
  def construct_rerolls(rerolls_input)
201
130
  check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
202
131
  end
@@ -218,70 +147,5 @@ module GamesDice
218
147
  end
219
148
  end
220
149
  end
221
-
222
- def calc_maps(x)
223
- y = 0
224
- n = ''
225
- @maps.find do |rule|
226
- maybe_y = rule.map_from(x)
227
- if maybe_y
228
- y = maybe_y
229
- n = rule.mapped_name
230
- end
231
- maybe_y
232
- end
233
- [y, n]
234
- end
235
-
236
- def minmax_mappings(possible_values)
237
- possible_values.map do |x|
238
- m, n = calc_maps(x)
239
- m
240
- end.minmax
241
- end
242
-
243
- # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
244
- # The inaccuracy is that min_result..max_result may contain 'holes' which have extreme map values that cannot actually
245
- # occur. In practice it is likely a non-issue unless someone went out of their way to invent a dice scheme that broke it.
246
- def logical_minmax
247
- return @basic_die.minmax unless @rerolls || @maps
248
- return minmax_mappings(@basic_die.all_values) unless @rerolls
249
-
250
- min_result, max_result = logical_rerolls_minmax
251
- return minmax_mappings((min_result..max_result)) if @maps
252
-
253
- [min_result, max_result]
254
- end
255
-
256
- def logical_rerolls_minmax
257
- min_result = @basic_die.min
258
- max_result = @basic_die.max
259
- min_subtract = find_minimum_possible_subtract
260
- max_add = find_maximum_possible_adds
261
- min_result = [min_subtract - max_add, min_subtract - max_result].min if min_subtract
262
- [min_result, max_add + max_result]
263
- end
264
-
265
- def find_minimum_possible_subtract
266
- min_subtract = nil
267
- @rerolls.select { |r| r.type == :reroll_subtract }.each do |rule|
268
- min_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.min
269
- next unless min_reroll
270
-
271
- min_subtract = [min_reroll, min_subtract].compact.min
272
- end
273
- min_subtract
274
- end
275
-
276
- def find_maximum_possible_adds
277
- total_add = 0
278
- @rerolls.select { |r| r.type == :reroll_add }.each do |rule|
279
- max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
280
- next unless max_reroll
281
-
282
- total_add += max_reroll * rule.limit
283
- end
284
- total_add
285
- end
286
150
  end
287
151
  end