games_dice 0.0.2 → 0.0.3

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.
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # GamesDice
2
2
 
3
+ [![Build Status](https://travis-ci.org/neilslater/games_dice.png?branch=master)](http://travis-ci.org/neilslater/games_dice)
4
+
3
5
  A library for simulating dice, intended for constructing a variety of dice systems as used in
4
6
  role-playing and board games.
5
7
 
data/Rakefile CHANGED
@@ -1 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ task :default => [:test]
5
+
6
+ desc "GamesDice unit tests"
7
+ RSpec::Core::RakeTask.new(:test) do |t|
8
+ t.pattern = "spec/*_spec.rb"
9
+ t.verbose = false
10
+ end
data/games_dice.gemspec CHANGED
@@ -11,9 +11,10 @@ Gem::Specification.new do |gem|
11
11
  gem.description = %q{A simulated-dice library, with flexible rules that allow dice systems from
12
12
  many board and roleplay games to be built, run and reported.}
13
13
  gem.summary = %q{Simulates and explains dice rolls from a variety of game systems.}
14
- gem.homepage = ""
14
+ gem.homepage = "https://github.com/neilslater/games_dice"
15
15
 
16
16
  gem.add_development_dependency "rspec"
17
+ gem.add_development_dependency "rake"
17
18
 
18
19
  gem.files = `git ls-files`.split($/)
19
20
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
data/lib/games_dice.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  require "games_dice/version"
2
2
  require "games_dice/die"
3
3
  require "games_dice/die_result"
4
+ require "games_dice/reroll_rule"
5
+ require "games_dice/map_rule"
6
+ require "games_dice/complex_die"
7
+ require "games_dice/bunch"
4
8
 
5
9
  module GamesDice
6
10
  # TODO: Factory methods for various dice schemes
@@ -0,0 +1,367 @@
1
+ module GamesDice
2
+
3
+ # models a set of identical dice, that can be "rolled" and combined into a simple integer result. The
4
+ # dice are identical in number of sides, and any rolling rules that apply to them
5
+ class Bunch
6
+ # attributes is a hash of symbols used to set attributes of the new Bunch object. Each
7
+ # attribute is explained in more detail in its own section. The following hash keys and values
8
+ # are mandatory:
9
+ # :ndice
10
+ # :sides
11
+ # The following are optional, and modify the behaviour of the Bunch object
12
+ # :name
13
+ # :prng
14
+ # :rerolls
15
+ # :maps
16
+ # :keep_mode
17
+ # :keep_number
18
+ # Any other keys provided to the constructor are ignored
19
+ def initialize( attributes )
20
+ @name = attributes[:name].to_s
21
+ @ndice = Integer(attributes[:ndice])
22
+ raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
23
+ @sides = Integer(attributes[:sides])
24
+ raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
25
+
26
+ options = Hash.new
27
+
28
+ if attributes[:prng]
29
+ # We deliberately do not clone this object, it will often be intended that it is shared
30
+ prng = attributes[:prng]
31
+ raise ":prng does not support the rand() method" if ! prng.respond_to?(:rand)
32
+ end
33
+
34
+ needs_complex_die = false
35
+
36
+ if attributes[:rerolls]
37
+ needs_complex_die = true
38
+ options[:rerolls] = attributes[:rerolls].clone
39
+ end
40
+
41
+ if attributes[:maps]
42
+ needs_complex_die = true
43
+ options[:maps] = attributes[:maps].clone
44
+ end
45
+
46
+ if needs_complex_die
47
+ options[:prng] = prng
48
+ @single_die = GamesDice::ComplexDie.new( @sides, options )
49
+ else
50
+ @single_die = GamesDice::Die.new( @sides, prng )
51
+ end
52
+
53
+ case attributes[:keep_mode]
54
+ when nil then
55
+ @keep_mode = nil
56
+ when :keep_best then
57
+ @keep_mode = :keep_best
58
+ @keep_number = Integer(attributes[:keep_number] || 1)
59
+ when :keep_worst then
60
+ @keep_mode = :keep_worst
61
+ @keep_number = Integer(attributes[:keep_number] || 1)
62
+ else
63
+ raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{attributes[:keep_mode].inspect}"
64
+ end
65
+ end
66
+
67
+ # the string name as provided to the constructor, it will appear in explain_result
68
+ attr_reader :name
69
+
70
+ # integer number of dice to roll (initially, before re-rolls etc)
71
+ attr_reader :ndice
72
+
73
+ # individual die that will be rolled, #ndice times, an GamesDice::Die or GamesDice::ComplexDie object.
74
+ attr_reader :single_die
75
+
76
+ # may be nil, :keep_best or :keep_worst
77
+ attr_reader :keep_mode
78
+
79
+ # number of "best" or "worst" results to select when #keep_mode is not nil. This attribute is
80
+ # 1 by default if :keep_mode is supplied, or nil by default otherwise.
81
+ attr_reader :keep_number
82
+
83
+ # after calling #roll, this is set to the final integer value from using the dice as specified
84
+ attr_reader :result
85
+
86
+ # either nil, or an array of GamesDice::RerollRule objects that are assessed on each roll of #single_die
87
+ # Reroll types :reroll_new_die and :reroll_new_keeper do not affect the #single_die, but are instead
88
+ # assessed in this container object
89
+ def rerolls
90
+ @single_die.rerolls
91
+ end
92
+
93
+ # either nil, or an array of GamesDice::MapRule objects that are assessed on each result of #single_die (after rerolls are completed)
94
+ def maps
95
+ @single_die.rerolls
96
+ end
97
+
98
+ # after calling #roll, this is an array of GamesDice::DieResult objects, one from each #single_die rolled,
99
+ # allowing inspection of how the result was obtained.
100
+ def result_details
101
+ return nil unless @raw_result_details
102
+ @raw_result_details.map { |r| r.is_a?(Fixnum) ? GamesDice::DieResult.new(r) : r }
103
+ end
104
+
105
+ # minimum possible integer value
106
+ def min
107
+ n = @keep_mode ? [@keep_number,@ndice].min : @ndice
108
+ return n * @single_die.min
109
+ end
110
+
111
+ # maximum possible integer value
112
+ def max
113
+ n = @keep_mode ? [@keep_number,@ndice].min : @ndice
114
+ return n * @single_die.max
115
+ end
116
+
117
+ # returns a hash of value (Integer) => probability (Float) pairs. Warning: Some dice schemes
118
+ # cause this method to take a long time, and use a lot of memory. The worst-case offenders are
119
+ # dice schemes with a #keep_mode of :keep_best or :keep_worst.
120
+ def probabilities
121
+ return @probabilities if @probabilities
122
+ @probabilities_complete = true
123
+
124
+ # TODO: It is possible to optimise this slightly by combining already-calculated values
125
+ # Adding dice is same as multiplying probability sets for that number of dice
126
+ # Combine(probabililities_3_dice, probabililities_single_die) == Combine(probabililities_2_dice, probabililities_2_dice)
127
+ # It is possible to minimise the total number of multiplications, gaining about 30% efficiency, with careful choices
128
+ single_roll_probs = @single_die.probabilities
129
+ if @keep_mode && @ndice > @keep_number
130
+ preadd_probs = {}
131
+ single_roll_probs.each { |k,v| preadd_probs[k.to_s] = v }
132
+
133
+ (@keep_number-1).times do
134
+ preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs
135
+ end
136
+ extra_dice = @ndice - @keep_number
137
+ extra_dice.times do
138
+ preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs, @keep_mode
139
+ end
140
+ combined_probs = {}
141
+ preadd_probs.each do |k,v|
142
+ total = k.split(';').map { |s| s.to_i }.inject(:+)
143
+ combined_probs[total] ||= 0.0
144
+ combined_probs[total] += v
145
+ end
146
+ else
147
+ combined_probs = single_roll_probs.clone
148
+ (@ndice-1).times do
149
+ combined_probs = prob_accumulate combined_probs, single_roll_probs
150
+ end
151
+ end
152
+
153
+ @probabilities = combined_probs
154
+ @probabilities_min, @probabilities_max = @probabilities.keys.minmax
155
+ @prob_ge = {}
156
+ @prob_le = {}
157
+ @probabilities
158
+ end
159
+
160
+ # returns probability than a roll will produce a number greater than target integer
161
+ def probability_gt target
162
+ probability_ge( Integer(target) + 1 )
163
+ end
164
+
165
+ # returns probability than a roll will produce a number greater than or equal to target integer
166
+ def probability_ge target
167
+ target = Integer(target)
168
+ return @prob_ge[target] if @prob_ge && @prob_ge[target]
169
+
170
+ # Force caching if not already done
171
+ probabilities
172
+ return 1.0 if target <= @probabilities_min
173
+ return 0.0 if target > @probabilities_max
174
+ @prob_ge[target] = probabilities.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
175
+ end
176
+
177
+ # returns probability than a roll will produce a number less than or equal to target integer
178
+ def probability_le target
179
+ target = Integer(target)
180
+ return @prob_le[target] if @prob_le && @prob_le[target]
181
+
182
+ # Force caching of probability table if not already done
183
+ probabilities
184
+ return 1.0 if target >= @probabilities_max
185
+ return 0.0 if target < @probabilities_min
186
+ @prob_le[target] = probabilities.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
187
+ end
188
+
189
+ # returns probability than a roll will produce a number less than target integer
190
+ def probability_lt target
191
+ probability_le( Integer(target) - 1 )
192
+ end
193
+
194
+ # returns mean expected value as a Float
195
+ def expected_result
196
+ @expected_result ||= probabilities.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
197
+ end
198
+
199
+ # simulate dice roll according to spec. Returns integer final total, and also stores it in #result
200
+ def roll
201
+ @result = 0
202
+ @raw_result_details = []
203
+
204
+ @ndice.times do
205
+ @result += @single_die.roll
206
+ @raw_result_details << @single_die.result
207
+ end
208
+
209
+ if ! @keep_mode
210
+ return @result
211
+ end
212
+
213
+ use_dice = if @keep_mode && @keep_number < @ndice
214
+ case @keep_mode
215
+ when :keep_best then @raw_result_details.sort[-@keep_number..-1]
216
+ when :keep_worst then @raw_result_details.sort[0..(@keep_number-1)]
217
+ end
218
+ else
219
+ @raw_result_details
220
+ end
221
+
222
+ @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
223
+ end
224
+
225
+ def explain_result
226
+ return nil unless @result
227
+
228
+ explanation = ''
229
+
230
+ # With #keep_mode, we may need to show unused and used dice separately
231
+ used_dice = result_details
232
+ unused_dice = []
233
+
234
+ # Pick highest numbers and their associated details
235
+ if @keep_mode && @keep_number < @ndice
236
+ full_dice = result_details.sort_by { |die_result| die_result.total }
237
+ case @keep_mode
238
+ when :keep_best then
239
+ used_dice = full_dice[-@keep_number..-1]
240
+ unused_dice = full_dice[0..full_dice.length-1-@keep_number]
241
+ when :keep_worst then
242
+ used_dice = full_dice[0..(@keep_number-1)]
243
+ unused_dice = full_dice[@keep_number..(full_dice.length-1)]
244
+ end
245
+ end
246
+
247
+ # Show unused dice (if any)
248
+ if @keep_mode || @single_die.maps
249
+ explanation += result_details.map do |die_result|
250
+ die_result.explain_value
251
+ end.join(', ')
252
+ if @keep_mode
253
+ separator = @single_die.maps ? ', ' : ' + '
254
+ explanation += ". Keep: " + used_dice.map do |die_result|
255
+ die_result.explain_total
256
+ end.join( separator )
257
+ end
258
+ if @single_die.maps
259
+ explanation += ". Successes: #{@result}"
260
+ end
261
+ explanation += " = #{@result}" if @keep_mode && ! @single_die.maps && @keep_number > 1
262
+ else
263
+ explanation += used_dice.map do |die_result|
264
+ die_result.explain_value
265
+ end.join(' + ')
266
+ explanation += " = #{@result}" if @ndice > 1
267
+ end
268
+
269
+ explanation
270
+ end
271
+
272
+ private
273
+
274
+ # combines two sets of probabilities where the end result is the first set of keys plus
275
+ # the second set of keys, at the associated probailities of the values
276
+ def prob_accumulate first_probs, second_probs
277
+ accumulator = Hash.new
278
+
279
+ first_probs.each do |v1,p1|
280
+ second_probs.each do |v2,p2|
281
+ v3 = v1 + v2
282
+ p3 = p1 * p2
283
+ accumulator[v3] ||= 0.0
284
+ accumulator[v3] += p3
285
+ end
286
+ end
287
+
288
+ accumulator
289
+ end
290
+
291
+ # combines two sets of probabilities, as above, except tracking unique permutations
292
+ def prob_accumulate_combinations so_far, die_probs, keep_rule = nil
293
+ accumulator = Hash.new
294
+
295
+ so_far.each do |sig,p1|
296
+ combo = sig.split(';').map { |s| s.to_i }
297
+
298
+ case keep_rule
299
+ when nil then
300
+ die_probs.each do |v2,p2|
301
+ new_sig = (combo + [v2]).sort.join(';')
302
+ p3 = p1 * p2
303
+ accumulator[new_sig] ||= 0.0
304
+ accumulator[new_sig] += p3
305
+ end
306
+ when :keep_best then
307
+ need_more_than = combo.min
308
+ die_probs.each do |v2,p2|
309
+ if v2 > need_more_than
310
+ new_sig = (combo + [v2]).sort[1..combo.size].join(';')
311
+ else
312
+ new_sig = sig
313
+ end
314
+ p3 = p1 * p2
315
+ accumulator[new_sig] ||= 0.0
316
+ accumulator[new_sig] += p3
317
+ end
318
+ when :keep_worst then
319
+ need_less_than = combo.max
320
+ die_probs.each do |v2,p2|
321
+ if v2 < need_less_than
322
+ new_sig = (combo + [v2]).sort[0..(combo.size-1)].join(';')
323
+ else
324
+ new_sig = sig
325
+ end
326
+ p3 = p1 * p2
327
+ accumulator[new_sig] ||= 0.0
328
+ accumulator[new_sig] += p3
329
+ end
330
+ end
331
+ end
332
+
333
+ accumulator
334
+ end
335
+
336
+ # Generates all sets of [throw_away,may_keep_exactly,keep_preferentially,combinations] that meet
337
+ # criteria for correct total number of dice and keep dice. These then need to be assessed for every
338
+ # die value by the caller to get a full set of probabilities
339
+ def generate_item_counts total_dice, keep_dice
340
+ # Constraints are:
341
+ # may_keep_exactly must be at least 1, and at most is all the dice
342
+ # keep_preferentially plus may_keep_exactly must be >= keep_dice, but keep_preferentially < keep dice
343
+ # sum of all three always == total_dice
344
+ item_counts = []
345
+ (1..total_dice).each do |may_keep_exactly|
346
+ min_kp = [keep_dice - may_keep_exactly, 0].max
347
+ max_kp = [keep_dice - 1, total_dice - may_keep_exactly].min
348
+ (min_kp..max_kp).each do |keep_preferentially|
349
+ counts = [ total_dice - may_keep_exactly - keep_preferentially, may_keep_exactly, keep_preferentially ]
350
+ counts << combinations(counts)
351
+ item_counts << counts
352
+ end
353
+ end
354
+ item_counts
355
+ end
356
+
357
+ # How many unique ways can a set of items, some of which are identical, be arranged?
358
+ def combinations item_counts
359
+ item_counts = item_counts.map { |i| Integer(i) }.select { |i| i > 0 }
360
+ total_items = item_counts.inject(:+)
361
+ numerator = 1.upto(total_items).inject(:*)
362
+ denominator = item_counts.map { |i| 1.upto(i).inject(:*) }.inject(:*)
363
+ numerator / denominator
364
+ end
365
+
366
+ end # class Bunch
367
+ end # module GamesDice
@@ -0,0 +1,295 @@
1
+ module GamesDice
2
+
3
+ # complex die that rolls 1..N, and may re-roll and adjust final value based on
4
+ # parameters it is instantiated with
5
+ # d = GamesDice::DieExploding.new( 6, :explode_up => true ) # 'exploding' die
6
+ # d.roll # => GamesDice::DieResult of rolling die
7
+ # d.result # => same GamesDice::DieResult as returned by d.roll
8
+ class ComplexDie
9
+
10
+ # arbitrary limit to simplify calculations and stay in Integer range for convenience. It should
11
+ # be much larger than anything seen in real-world tabletop games.
12
+ MAX_REROLLS = 1000
13
+
14
+ # sides is e.g. 6 for traditional cubic die, or 20 for icosahedron.
15
+ # It can take non-traditional values, such as 7, but must be at least 1.
16
+ #
17
+ # options_hash may contain keys setting following attributes
18
+ # :rerolls => an array of rules that cause the die to roll again, see #rerolls
19
+ # :maps => an array of rules to convert a value into a final result for the die, see #maps
20
+ # :prng => any object that has a rand(x) method, which will be used instead of internal rand()
21
+ def initialize(sides, options_hash = {})
22
+ @basic_die = GamesDice::Die.new(sides, options_hash[:prng])
23
+
24
+ @rerolls = options_hash[:rerolls]
25
+ validate_rerolls
26
+ @maps = options_hash[:maps]
27
+ validate_maps
28
+
29
+ @total = nil
30
+ @result = nil
31
+ end
32
+
33
+ # underlying GamesDice::Die object, used to generate all individual rolls
34
+ attr_reader :basic_die
35
+
36
+ # may be nil, in which case no re-rolls are triggered, or an array of GamesDice::RerollRule objects
37
+ attr_reader :rerolls
38
+
39
+ # may be nil, in which case no mappings apply, or an array of GamesDice::MapRule objects
40
+ attr_reader :maps
41
+
42
+ # result of last call to #roll, nil if no call made yet
43
+ attr_reader :result
44
+
45
+ # true if probability calculation did not hit any limitations, so has covered all possible scenarios
46
+ # false if calculation was cut short and probabilities are an approximation
47
+ # nil if probabilities have not been calculated yet
48
+ attr_reader :probabilities_complete
49
+
50
+ # number of sides, same as #basic_die.sides
51
+ def sides
52
+ @basic_die.sides
53
+ end
54
+
55
+ # string explanation of roll, including any re-rolls etc, same as #result.explain_value
56
+ def explain_result
57
+ @result.explain_value
58
+ end
59
+
60
+ # minimum possible value
61
+ def min
62
+ return @min_result if @min_result
63
+ @min_result, @max_result = probabilities.keys.minmax
64
+ return @min_result if @probabilities_complete
65
+ logical_min, logical_max = logical_minmax
66
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
67
+ @min_result
68
+ end
69
+
70
+ # maximum possible value. A ComplexDie with open-ended additive re-rolls will calculate roughly 1001 times the
71
+ # maximum of #basic_die.max (although the true value is infinite)
72
+ def max
73
+ return @max_result if @max_result
74
+ @min_result, @max_result = probabilities.keys.minmax
75
+ return @max_result if @probabilities_complete
76
+ logical_min, logical_max = logical_minmax
77
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
78
+ @max_result
79
+ end
80
+
81
+ # returns a hash of value (Integer) => probability (Float) pairs. For efficiency with re-rolls, the calculation may cut
82
+ # short based on depth of recursion or closeness to total 1.0 probability. Therefore low probabilities
83
+ # (less than one in a billion) in open-ended re-rolls are not always represented in the hash.
84
+ def probabilities
85
+ return @probabilities if @probabilities
86
+ @probabilities_complete = true
87
+ if @rerolls && @maps
88
+ reroll_probs = recursive_probabilities
89
+ @probabilities = {}
90
+ reroll_probs.each do |v,p|
91
+ m, n = calc_maps(v)
92
+ @probabilities[m] ||= 0.0
93
+ @probabilities[m] += p
94
+ end
95
+ elsif @rerolls
96
+ @probabilities = recursive_probabilities
97
+ elsif @maps
98
+ probs = @basic_die.probabilities
99
+ @probabilities = {}
100
+ probs.each do |v,p|
101
+ m, n = calc_maps(v)
102
+ @probabilities[m] ||= 0.0
103
+ @probabilities[m] += p
104
+ end
105
+ else
106
+ @probabilities = @basic_die.probabilities
107
+ end
108
+ @probabilities_min, @probabilities_max = @probabilities.keys.minmax
109
+ @prob_ge = {}
110
+ @prob_le = {}
111
+ @probabilities
112
+ end
113
+
114
+ # returns mean expected value as a Float
115
+ def expected_result
116
+ @expected_result ||= probabilities.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
117
+ end
118
+
119
+ # returns probability than a roll will produce a number greater than target integer
120
+ def probability_gt target
121
+ probability_ge( Integer(target) + 1 )
122
+ end
123
+
124
+ # returns probability than a roll will produce a number greater than or equal to target integer
125
+ def probability_ge target
126
+ target = Integer(target)
127
+ return @prob_ge[target] if @prob_ge && @prob_ge[target]
128
+
129
+ # Force caching if not already done
130
+ probabilities
131
+ return 1.0 if target <= @probabilities_min
132
+ return 0.0 if target > @probabilities_max
133
+ @prob_ge[target] = probabilities.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
134
+ end
135
+
136
+ # returns probability than a roll will produce a number less than or equal to target integer
137
+ def probability_le target
138
+ target = Integer(target)
139
+ return @prob_le[target] if @prob_le && @prob_le[target]
140
+
141
+ # Force caching of probability table if not already done
142
+ probabilities
143
+ return 1.0 if target >= @probabilities_max
144
+ return 0.0 if target < @probabilities_min
145
+ @prob_le[target] = probabilities.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
146
+ end
147
+
148
+ # returns probability than a roll will produce a number less than target integer
149
+ def probability_lt target
150
+ probability_le( Integer(target) - 1 )
151
+ end
152
+
153
+ # generates Integer between #min and #max, using rand()
154
+ # first roll reason can be over-ridden, required for re-roll types that spawn new dice
155
+ def roll( reason = :basic )
156
+ # Important bit - actually roll the die
157
+ @result = GamesDice::DieResult.new( @basic_die.roll, reason )
158
+
159
+ if @rerolls
160
+ subtracting = false
161
+ rerolls_remaining = @rerolls.map { |rule| rule.limit }
162
+ loop do
163
+ # Find which rule, if any, is being triggered
164
+ rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
165
+ next if rule.type == :reroll_subtract && @result.rolls.length > 1
166
+ remaining > 0 && rule.applies?( @basic_die.result )
167
+ end
168
+ break unless rule_idx
169
+
170
+ rule = @rerolls[ rule_idx ]
171
+ rerolls_remaining[ rule_idx ] -= 1
172
+ subtracting = true if rule.type == :reroll_subtract
173
+
174
+ # Apply the rule (note reversal for additions, after a subtract)
175
+ if subtracting && rule.type == :reroll_add
176
+ @result.add_roll( @basic_die.roll, :reroll_subtract )
177
+ else
178
+ @result.add_roll( @basic_die.roll, rule.type )
179
+ end
180
+ end
181
+ end
182
+
183
+ # apply any mapping
184
+ if @maps
185
+ m, n = calc_maps(@result.value)
186
+ @result.apply_map( m, n )
187
+ end
188
+
189
+ @result
190
+ end
191
+
192
+ private
193
+
194
+ def calc_maps x
195
+ y, n = 0, ''
196
+ @maps.find do |rule|
197
+ maybe_y = rule.map_from( x )
198
+ if maybe_y
199
+ y = maybe_y
200
+ n = rule.mapped_name
201
+ end
202
+ maybe_y
203
+ end
204
+ [y, n]
205
+ end
206
+
207
+ def validate_rerolls
208
+ return unless @rerolls
209
+ raise TypeError, "rerolls should be an Array, instead got #{@rerolls.inspect}" unless @rerolls.is_a?(Array)
210
+ @rerolls.each do |rule|
211
+ raise TypeError, "items in rerolls should be GamesDice::RerollRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::RerollRule)
212
+ end
213
+ end
214
+
215
+ def validate_maps
216
+ return unless @maps
217
+ raise TypeError, "maps should be an Array, instead got #{@maps.inspect}" unless @maps.is_a?(Array)
218
+ @maps.each do |rule|
219
+ raise TypeError, "items in maps should be GamesDice::MapRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::MapRule)
220
+ end
221
+ end
222
+
223
+ def minmax_mappings possible_values
224
+ possible_values.map { |x| m, n = calc_maps( x ); m }.minmax
225
+ end
226
+
227
+ # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
228
+ def logical_minmax
229
+ min_result = 1
230
+ max_result = @basic_die.sides
231
+ return [min_result,max_result] unless @rerolls || @maps
232
+ return minmax_mappings( (min_result..max_result) ) unless @rerolls
233
+ can_subtract = false
234
+ @rerolls.each do |rule|
235
+ next unless rule.type == :reroll_add || rule.type == :reroll_subtract
236
+ min_reroll,max_reroll = (1..@basic_die.sides).select { |v| rule.applies?( v ) }.minmax
237
+ next unless min_reroll && max_reroll
238
+ if rule.type == :reroll_subtract
239
+ can_subtract=true
240
+ min_result = min_reroll - @basic_die.sides
241
+ else
242
+ max_result += max_reroll * rule.limit
243
+ end
244
+ end
245
+ if can_subtract
246
+ min_result -= max_result + @basic_die.sides
247
+ end
248
+ return minmax_mappings( (min_result..max_result) ) if @maps
249
+ return [min_result,max_result]
250
+ end
251
+
252
+ def recursive_probabilities probabilities={},prior_probability=1.0,depth=0,prior_result=nil,rerolls_left=nil,roll_reason=:basic,subtracting=false
253
+ each_probability = prior_probability / @basic_die.sides
254
+ depth += 1
255
+ if depth >= 20 || each_probability < 1.0e-12
256
+ @probabilities_complete = false
257
+ stop_recursing = true
258
+ end
259
+
260
+ (1..@basic_die.sides).each do |v|
261
+ # calculate value, recurse if there is a reroll
262
+ result_so_far = prior_result ? prior_result.clone : GamesDice::DieResult.new(v,roll_reason)
263
+ result_so_far.add_roll(v,roll_reason) if prior_result
264
+ rerolls_remaining = rerolls_left ? rerolls_left.clone : @rerolls.map { |rule| rule.limit }
265
+
266
+ # Find which rule, if any, is being triggered
267
+ rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
268
+ next if rule.type == :reroll_subtract && result_so_far.rolls.length > 1
269
+ remaining > 0 && rule.applies?( v )
270
+ end
271
+
272
+ if rule_idx && ! stop_recursing
273
+ rule = @rerolls[ rule_idx ]
274
+ rerolls_remaining[ rule_idx ] -= 1
275
+ is_subtracting = true if subtracting || rule.type == :reroll_subtract
276
+
277
+ # Apply the rule (note reversal for additions, after a subtract)
278
+ if subtracting && rule.type == :reroll_add
279
+ recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,:reroll_subtract,is_subtracting
280
+ else
281
+ recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,rule.type,is_subtracting
282
+ end
283
+ # just accumulate value on a regular roll
284
+ else
285
+ t = result_so_far.total
286
+ probabilities[ t ] ||= 0.0
287
+ probabilities[ t ] += each_probability
288
+ end
289
+
290
+ end
291
+ probabilities.clone
292
+ end
293
+
294
+ end # class ComplexDie
295
+ end # module GamesDice