games_dice 0.0.3 → 0.0.5

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