games_dice 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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