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.
- data/games_dice.gemspec +4 -2
- data/lib/games_dice.rb +3 -0
- data/lib/games_dice/bunch.rb +275 -320
- data/lib/games_dice/complex_die.rb +226 -269
- data/lib/games_dice/constants.rb +16 -0
- data/lib/games_dice/dice.rb +43 -0
- data/lib/games_dice/die.rb +58 -92
- data/lib/games_dice/die_result.rb +3 -15
- data/lib/games_dice/map_rule.rb +41 -43
- data/lib/games_dice/probabilities.rb +97 -0
- data/lib/games_dice/reroll_rule.rb +41 -58
- data/lib/games_dice/version.rb +1 -1
- data/spec/bunch_spec.rb +196 -188
- data/spec/complex_die_spec.rb +77 -68
- data/spec/dice_spec.rb +34 -0
- data/spec/die_spec.rb +25 -29
- data/spec/probability_spec.rb +265 -0
- metadata +31 -8
@@ -1,295 +1,252 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
#
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
-
|
71
|
-
|
72
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
#
|
115
|
-
|
116
|
-
|
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
|
-
|
120
|
-
|
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
|
-
|
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
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
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
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
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
|
-
|
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
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
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
|
-
|
224
|
-
|
225
|
-
|
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
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
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
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
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
|
-
#
|
267
|
-
|
268
|
-
|
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
|
-
|
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
|
-
|
247
|
+
|
292
248
|
end
|
249
|
+
probabilities.clone
|
250
|
+
end
|
293
251
|
|
294
|
-
|
295
|
-
end # module GamesDice
|
252
|
+
end # class ComplexDie
|