games_dice 0.0.3 → 0.0.5

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,295 +1,252 @@
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
1
+ # complex die that rolls 1..N, and may re-roll and adjust final value based on
2
+ # parameters it is instantiated with
3
+ # d = GamesDice::DieExploding.new( 6, :explode_up => true ) # 'exploding' die
4
+ # d.roll # => GamesDice::DieResult of rolling die
5
+ # d.result # => same GamesDice::DieResult as returned by d.roll
6
+ class GamesDice::ComplexDie
7
+
8
+ # arbitrary limit to simplify calculations and stay in Integer range for convenience. It should
9
+ # be much larger than anything seen in real-world tabletop games.
10
+ MAX_REROLLS = 1000
11
+
12
+ # sides is e.g. 6 for traditional cubic die, or 20 for icosahedron.
13
+ # It can take non-traditional values, such as 7, but must be at least 1.
14
+ #
15
+ # options_hash may contain keys setting following attributes
16
+ # :rerolls => an array of rules that cause the die to roll again, see #rerolls
17
+ # :maps => an array of rules to convert a value into a final result for the die, see #maps
18
+ # :prng => any object that has a rand(x) method, which will be used instead of internal rand()
19
+ def initialize(sides, options_hash = {})
20
+ @basic_die = GamesDice::Die.new(sides, options_hash[:prng])
21
+
22
+ @rerolls = options_hash[:rerolls]
23
+ validate_rerolls
24
+ @maps = options_hash[:maps]
25
+ validate_maps
26
+
27
+ @total = nil
28
+ @result = nil
29
+ end
30
+
31
+ # underlying GamesDice::Die object, used to generate all individual rolls
32
+ attr_reader :basic_die
33
+
34
+ # may be nil, in which case no re-rolls are triggered, or an array of GamesDice::RerollRule objects
35
+ attr_reader :rerolls
36
+
37
+ # may be nil, in which case no mappings apply, or an array of GamesDice::MapRule objects
38
+ attr_reader :maps
39
+
40
+ # result of last call to #roll, nil if no call made yet
41
+ attr_reader :result
42
+
43
+ # true if probability calculation did not hit any limitations, so has covered all possible scenarios
44
+ # false if calculation was cut short and probabilities are an approximation
45
+ # nil if probabilities have not been calculated yet
46
+ attr_reader :probabilities_complete
47
+
48
+ # number of sides, same as #basic_die.sides
49
+ def sides
50
+ @basic_die.sides
51
+ end
52
+
53
+ # string explanation of roll, including any re-rolls etc, same as #result.explain_value
54
+ def explain_result
55
+ @result.explain_value
56
+ end
57
+
58
+ # minimum possible value
59
+ def min
60
+ return @min_result if @min_result
61
+ @min_result, @max_result = [probabilities.min, probabilities.max]
62
+ return @min_result if @probabilities_complete
63
+ logical_min, logical_max = logical_minmax
64
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
65
+ @min_result
66
+ end
67
+
68
+ # maximum possible value. A ComplexDie with open-ended additive re-rolls will calculate roughly 1001 times the
69
+ # maximum of #basic_die.max (although the true value is infinite)
70
+ def max
71
+ return @max_result if @max_result
72
+ @min_result, @max_result = [probabilities.min, probabilities.max]
73
+ return @max_result if @probabilities_complete
74
+ logical_min, logical_max = logical_minmax
75
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
76
+ @max_result
77
+ end
78
+
79
+ # returns a hash of value (Integer) => probability (Float) pairs. For efficiency with re-rolls, the calculation may cut
80
+ # short based on depth of recursion or closeness to total 1.0 probability. Therefore low probabilities
81
+ # (less than one in a billion) in open-ended re-rolls are not always represented in the hash.
82
+ def probabilities
83
+ return @probabilities if @probabilities
84
+ @probabilities_complete = true
85
+ if @rerolls && @maps
86
+ reroll_probs = recursive_probabilities
87
+ prob_hash = {}
88
+ reroll_probs.each do |v,p|
89
+ m, n = calc_maps(v)
90
+ prob_hash[m] ||= 0.0
91
+ prob_hash[m] += p
92
+ end
93
+ elsif @rerolls
94
+ prob_hash = recursive_probabilities
95
+ elsif @maps
96
+ probs = @basic_die.probabilities.to_h
97
+ prob_hash = {}
98
+ probs.each do |v,p|
99
+ m, n = calc_maps(v)
100
+ prob_hash[m] ||= 0.0
101
+ prob_hash[m] += p
102
+ end
103
+ else
104
+ prob_hash = @basic_die.probabilities.to_h
105
+ end
106
+ @prob_ge = {}
107
+ @prob_le = {}
108
+ @probabilities = GamesDice::Probabilities.new( prob_hash )
109
+ end
110
+
111
+ # generates Integer between #min and #max, using rand()
112
+ # first roll reason can be over-ridden, required for re-roll types that spawn new dice
113
+ def roll( reason = :basic )
114
+ # Important bit - actually roll the die
115
+ @result = GamesDice::DieResult.new( @basic_die.roll, reason )
116
+
117
+ if @rerolls
118
+ subtracting = false
119
+ rerolls_remaining = @rerolls.map { |rule| rule.limit }
120
+ loop do
121
+ # Find which rule, if any, is being triggered
122
+ rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
123
+ next if rule.type == :reroll_subtract && @result.rolls.length > 1
124
+ remaining > 0 && rule.applies?( @basic_die.result )
125
+ end
126
+ break unless rule_idx
69
127
 
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
128
+ rule = @rerolls[ rule_idx ]
129
+ rerolls_remaining[ rule_idx ] -= 1
130
+ subtracting = true if rule.type == :reroll_subtract
80
131
 
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
132
+ # Apply the rule (note reversal for additions, after a subtract)
133
+ if subtracting && rule.type == :reroll_add
134
+ @result.add_roll( @basic_die.roll, :reroll_subtract )
135
+ else
136
+ @result.add_roll( @basic_die.roll, rule.type )
104
137
  end
105
- else
106
- @probabilities = @basic_die.probabilities
107
138
  end
108
- @probabilities_min, @probabilities_max = @probabilities.keys.minmax
109
- @prob_ge = {}
110
- @prob_le = {}
111
- @probabilities
112
139
  end
113
140
 
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] }
141
+ # apply any mapping
142
+ if @maps
143
+ m, n = calc_maps(@result.value)
144
+ @result.apply_map( m, n )
117
145
  end
118
146
 
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
147
+ @result
148
+ end
135
149
 
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]
150
+ private
140
151
 
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 )
152
+ def calc_maps x
153
+ y, n = 0, ''
154
+ @maps.find do |rule|
155
+ maybe_y = rule.map_from( x )
156
+ if maybe_y
157
+ y = maybe_y
158
+ n = rule.mapped_name
187
159
  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
160
+ maybe_y
161
+ end
162
+ [y, n]
163
+ end
164
+
165
+ def validate_rerolls
166
+ return unless @rerolls
167
+ raise TypeError, "rerolls should be an Array, instead got #{@rerolls.inspect}" unless @rerolls.is_a?(Array)
168
+ @rerolls.each do |rule|
169
+ raise TypeError, "items in rerolls should be GamesDice::RerollRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::RerollRule)
170
+ end
171
+ end
172
+
173
+ def validate_maps
174
+ return unless @maps
175
+ raise TypeError, "maps should be an Array, instead got #{@maps.inspect}" unless @maps.is_a?(Array)
176
+ @maps.each do |rule|
177
+ raise TypeError, "items in maps should be GamesDice::MapRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::MapRule)
178
+ end
179
+ end
180
+
181
+ def minmax_mappings possible_values
182
+ possible_values.map { |x| m, n = calc_maps( x ); m }.minmax
183
+ end
184
+
185
+ # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
186
+ def logical_minmax
187
+ min_result = 1
188
+ max_result = @basic_die.sides
189
+ return [min_result,max_result] unless @rerolls || @maps
190
+ return minmax_mappings( (min_result..max_result) ) unless @rerolls
191
+ can_subtract = false
192
+ @rerolls.each do |rule|
193
+ next unless rule.type == :reroll_add || rule.type == :reroll_subtract
194
+ min_reroll,max_reroll = (1..@basic_die.sides).select { |v| rule.applies?( v ) }.minmax
195
+ next unless min_reroll && max_reroll
196
+ if rule.type == :reroll_subtract
197
+ can_subtract=true
198
+ min_result = min_reroll - @basic_die.sides
199
+ else
200
+ max_result += max_reroll * rule.limit
203
201
  end
204
- [y, n]
205
202
  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
203
+ if can_subtract
204
+ min_result -= max_result + @basic_die.sides
213
205
  end
206
+ return minmax_mappings( (min_result..max_result) ) if @maps
207
+ return [min_result,max_result]
208
+ end
214
209
 
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
210
+ def recursive_probabilities probabilities={},prior_probability=1.0,depth=0,prior_result=nil,rerolls_left=nil,roll_reason=:basic,subtracting=false
211
+ each_probability = prior_probability / @basic_die.sides
212
+ depth += 1
213
+ if depth >= 20 || each_probability < 1.0e-12
214
+ @probabilities_complete = false
215
+ stop_recursing = true
221
216
  end
222
217
 
223
- def minmax_mappings possible_values
224
- possible_values.map { |x| m, n = calc_maps( x ); m }.minmax
225
- end
218
+ (1..@basic_die.sides).each do |v|
219
+ # calculate value, recurse if there is a reroll
220
+ result_so_far = prior_result ? prior_result.clone : GamesDice::DieResult.new(v,roll_reason)
221
+ result_so_far.add_roll(v,roll_reason) if prior_result
222
+ rerolls_remaining = rerolls_left ? rerolls_left.clone : @rerolls.map { |rule| rule.limit }
226
223
 
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
224
+ # Find which rule, if any, is being triggered
225
+ rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
226
+ next if rule.type == :reroll_subtract && result_so_far.rolls.length > 1
227
+ remaining > 0 && rule.applies?( v )
244
228
  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
229
 
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 }
230
+ if rule_idx && ! stop_recursing
231
+ rule = @rerolls[ rule_idx ]
232
+ rerolls_remaining[ rule_idx ] -= 1
233
+ is_subtracting = true if subtracting || rule.type == :reroll_subtract
265
234
 
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
235
+ # Apply the rule (note reversal for additions, after a subtract)
236
+ if subtracting && rule.type == :reroll_add
237
+ recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,:reroll_subtract,is_subtracting
284
238
  else
285
- t = result_so_far.total
286
- probabilities[ t ] ||= 0.0
287
- probabilities[ t ] += each_probability
239
+ recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,rule.type,is_subtracting
288
240
  end
289
-
241
+ # just accumulate value on a regular roll
242
+ else
243
+ t = result_so_far.total
244
+ probabilities[ t ] ||= 0.0
245
+ probabilities[ t ] += each_probability
290
246
  end
291
- probabilities.clone
247
+
292
248
  end
249
+ probabilities.clone
250
+ end
293
251
 
294
- end # class ComplexDie
295
- end # module GamesDice
252
+ end # class ComplexDie