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.
- 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
|