games_dice 0.3.9 → 0.4.0

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,303 +1,287 @@
1
- # This class models a die that is built up from a simpler unit by adding rules to re-roll
2
- # and interpret the value shown.
3
- #
4
- # An object of this class represents a single complex die. It rolls 1..#sides, with equal weighting
5
- # for each value. The value from a roll may be used to trigger yet more rolls that combine together.
6
- # After any re-rolls, the value can be interpretted ("mapped") as another integer, which is used as
7
- # the final result.
8
- #
9
- # @example An open-ended percentile die from a popular RPG
10
- # d = GamesDice::ComplexDie.new( 100, :rerolls => [[96, :<=, :reroll_add],[5, :>=, :reroll_subtract]] )
11
- # d.roll # => #<GamesDice::DieResult:0x007ff03a2415f8 @rolls=[4, 27], ...>
12
- # d.result.value # => -23
13
- # d.explain_result # => "[4-27] -23"
14
- #
15
- # @example An "exploding" six-sided die with a target number
16
- # d = GamesDice::ComplexDie.new( 6, :rerolls => [[6, :<=, :reroll_add]], :maps => [[8, :<=, 1, 'Success']] )
17
- # d.roll # => #<GamesDice::DieResult:0x007ff03a1e8e08 @rolls=[6, 5], ...>
18
- # d.result.value # => 1
19
- # d.explain_result # => "[6+5] 11 Success"
20
- #
21
-
22
- class GamesDice::ComplexDie
23
-
24
- # @!visibility private
25
- # arbitrary limit to speed up probability calculations. It should
26
- # be larger than anything seen in real-world tabletop games.
27
- MAX_REROLLS = 1000
28
-
29
- # Creates new instance of GamesDice::ComplexDie
30
- # @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor
31
- # @param [Hash] options
32
- # @option options [Array<GamesDice::RerollRule,Array>] :rerolls The rules that cause the die to roll again
33
- # @option options [Array<GamesDice::MapRule,Array>] :maps The rules to convert a value into a final result for the die
34
- # @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
35
- # @return [GamesDice::ComplexDie]
36
- def initialize( sides, options = {} )
37
- @basic_die = GamesDice::Die.new(sides, options[:prng])
38
-
39
- @rerolls = construct_rerolls( options[:rerolls] )
40
- @maps = construct_maps( options[:maps] )
41
-
42
- @total = nil
43
- @result = nil
44
- end
45
-
46
- # The simple component used by this complex one
47
- # @return [GamesDice::Die] Object used to make individual dice rolls for the complex die
48
- attr_reader :basic_die
49
-
50
- # @return [Array<GamesDice::RerollRule>, nil] Sequence of re-roll rules, or nil if re-rolls are not required.
51
- attr_reader :rerolls
52
-
53
- # @return [Array<GamesDice::MapRule>, nil] Sequence of map rules, or nil if mapping is not required.
54
- attr_reader :maps
55
-
56
- # @return [GamesDice::DieResult, nil] Result of last call to #roll, nil if no call made yet
57
- attr_reader :result
58
-
59
- # Whether or not #probabilities includes all possible outcomes.
60
- # True if all possible results are represented and assigned a probability. Dice with open-ended re-rolls
61
- # may have calculations cut short, and will result in a false value of this attribute. Even when this
62
- # attribute is false, probabilities should still be accurate to nearest 1e-9.
63
- # @return [Boolean, nil] Depending on completeness when generating #probabilities
64
- attr_reader :probabilities_complete
65
-
66
- # @!attribute [r] sides
67
- # @return [Integer] Number of sides.
68
- def sides
69
- @basic_die.sides
70
- end
71
-
72
- # @!attribute [r] explain_result
73
- # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
74
- def explain_result
75
- @result.explain_value
76
- end
77
-
78
- # The minimum possible result from a call to #roll. This is not always the same as the theoretical
79
- # minimum, due to limits on the maximum number of rerolls.
80
- # @!attribute [r] min
81
- # @return [Integer]
82
- def min
83
- return @min_result if @min_result
84
- calc_minmax
85
- @min_result
86
- end
87
-
88
- # @!attribute [r] max
89
- # @return [Integer] Maximum possible result from a call to #roll
90
- def max
91
- return @max_result if @max_result
92
- calc_minmax
93
- @max_result
94
- end
95
-
96
- # Calculates the probability distribution for the die. For open-ended re-roll rules, there are some
97
- # arbitrary limits imposed to prevent large amounts of recursion. Probabilities should be to nearest
98
- # 1e-9 at worst.
99
- # @return [GamesDice::Probabilities] Probability distribution of die.
100
- def probabilities
101
- return @probabilities if @probabilities
102
- @probabilities_complete = true
103
- if @rerolls && @maps
104
- reroll_probs = recursive_probabilities
105
- prob_hash = {}
106
- reroll_probs.each do |v,p|
107
- m, n = calc_maps(v)
108
- prob_hash[m] ||= 0.0
109
- prob_hash[m] += p
110
- end
111
- elsif @rerolls
112
- prob_hash = recursive_probabilities
113
- elsif @maps
114
- prob_hash = {}
115
- @basic_die.probabilities.each do |v,p|
116
- m, n = calc_maps(v)
117
- prob_hash[m] ||= 0.0
118
- prob_hash[m] += p
119
- end
120
- else
121
- @probabilities = @basic_die.probabilities
122
- return @probabilities
123
- end
124
- @probabilities = GamesDice::Probabilities.from_h( prob_hash )
125
- end
126
-
127
- # Simulates rolling the die
128
- # @param [Symbol] reason Assign a reason for rolling the first die.
129
- # @return [GamesDice::DieResult] Detailed results from rolling the die, including resolution of rules.
130
- def roll( reason = :basic )
131
- @result = GamesDice::DieResult.new( @basic_die.roll, reason )
132
- roll_apply_rerolls
133
- roll_apply_maps
134
- @result
135
- end
136
-
137
- private
138
-
139
- def roll_apply_rerolls
140
- return unless @rerolls
141
- subtracting = false
142
- rerolls_remaining = @rerolls.map { |rule| rule.limit }
143
-
144
- loop do
145
- # Find which rule, if any, is being triggered
146
- rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
147
- next if rule.type == :reroll_subtract && @result.rolls.length > 1
148
- remaining > 0 && rule.applies?( @basic_die.result )
149
- end
150
- break unless rule_idx
151
-
152
- rule = @rerolls[ rule_idx ]
153
- rerolls_remaining[ rule_idx ] -= 1
154
- subtracting = true if rule.type == :reroll_subtract
155
-
156
- # Apply the rule (note reversal for additions, after a subtract)
157
- if subtracting && rule.type == :reroll_add
158
- @result.add_roll( @basic_die.roll, :reroll_subtract )
159
- else
160
- @result.add_roll( @basic_die.roll, rule.type )
161
- end
162
- end
163
- end
164
-
165
- def roll_apply_maps
166
- return unless @maps
167
- m, n = calc_maps(@result.value)
168
- @result.apply_map( m, n )
169
- end
170
-
171
- def calc_minmax
172
- @min_result, @max_result = [probabilities.min, probabilities.max]
173
- return if @probabilities_complete
174
- logical_min, logical_max = logical_minmax
175
- @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
176
- end
177
-
178
- def construct_rerolls rerolls_input
179
- check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
180
- end
181
-
182
- def construct_maps maps_input
183
- check_and_construct maps_input, GamesDice::MapRule, 'maps'
184
- end
185
-
186
- def check_and_construct input, klass, label
187
- return nil unless input
188
- raise TypeError, "#{label} should be an Array, instead got #{input.inspect}" unless input.is_a?(Array)
189
- input.map do |i|
190
- case i
191
- when Array then klass.new( *i )
192
- when klass then i
193
- else
194
- raise TypeError, "items in #{label} should be #{klass.name} or Array, instead got #{i.inspect}"
195
- end
196
- end
197
- end
198
-
199
- def calc_maps x
200
- y, n = 0, ''
201
- @maps.find do |rule|
202
- maybe_y = rule.map_from( x )
203
- if maybe_y
204
- y = maybe_y
205
- n = rule.mapped_name
206
- end
207
- maybe_y
208
- end
209
- [y, n]
210
- end
211
-
212
- def minmax_mappings possible_values
213
- possible_values.map { |x| m, n = calc_maps( x ); m }.minmax
214
- end
215
-
216
- # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
217
- def logical_minmax
218
- return [@basic_die.min,@basic_die.max] unless @rerolls || @maps
219
- return minmax_mappings( @basic_die.all_values ) unless @rerolls
220
- min_result, max_result = logical_rerolls_minmax
221
- return minmax_mappings( (min_result..max_result) ) if @maps
222
- return [min_result,max_result]
223
- end
224
-
225
- def logical_rerolls_minmax
226
- min_result = @basic_die.min
227
- max_result = @basic_die.max
228
- min_subtract, max_add = find_add_subtract_extremes
229
- if min_subtract
230
- min_result = [ min_subtract - max_add, min_subtract - max_result ].min
231
- end
232
- [ min_result, max_add + max_result ]
233
- end
234
-
235
- def find_add_subtract_extremes
236
- min_subtract = nil
237
- total_add = 0
238
- @rerolls.select {|r| [:reroll_add, :reroll_subtract].member?( r.type ) }.each do |rule|
239
- min_reroll,max_reroll = @basic_die.all_values.select { |v| rule.applies?( v ) }.minmax
240
- next unless min_reroll
241
- if rule.type == :reroll_subtract
242
- min_subtract = min_reroll if min_subtract.nil?
243
- min_subtract = min_reroll if min_subtract > min_reroll
244
- else
245
- total_add += max_reroll * rule.limit
246
- end
247
- end
248
- [ min_subtract, total_add ]
249
- end
250
-
251
- def recursive_probabilities probabilities={},prior_probability=1.0,depth=0,prior_result=nil,rerolls_left=nil,roll_reason=:basic,subtracting=false
252
- each_probability = prior_probability / @basic_die.sides
253
- depth += 1
254
- if depth >= 20 || each_probability < 1.0e-12
255
- @probabilities_complete = false
256
- stop_recursing = true
257
- end
258
-
259
- @basic_die.each_value do |v|
260
- # calculate value, recurse if there is a reroll
261
- result_so_far, rerolls_remaining = calc_result_so_far(prior_result, rerolls_left, v, roll_reason )
262
-
263
- # Find which rule, if any, is being triggered
264
- rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
265
- next if rule.type == :reroll_subtract && result_so_far.rolls.length > 1
266
- remaining > 0 && rule.applies?( v )
267
- end
268
-
269
- if rule_idx && ! stop_recursing
270
- rule = @rerolls[ rule_idx ]
271
- rerolls_remaining[ rule_idx ] -= 1
272
- is_subtracting = true if subtracting || rule.type == :reroll_subtract
273
-
274
- # Apply the rule (note reversal for additions, after a subtract)
275
- if subtracting && rule.type == :reroll_add
276
- recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,:reroll_subtract,is_subtracting
277
- else
278
- recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,rule.type,is_subtracting
279
- end
280
- # just accumulate value on a regular roll
281
- else
282
- t = result_so_far.total
283
- probabilities[ t ] ||= 0.0
284
- probabilities[ t ] += each_probability
285
- end
286
-
287
- end
288
- probabilities
289
- end
290
-
291
- def calc_result_so_far prior_result, rerolls_left, v, roll_reason
292
- if prior_result
293
- result_so_far = prior_result.clone
294
- result_so_far.add_roll(v,roll_reason)
295
- rerolls_remaining = rerolls_left.clone
296
- else
297
- result_so_far = GamesDice::DieResult.new(v,roll_reason)
298
- rerolls_remaining = @rerolls.map { |rule| rule.limit }
299
- end
300
- [result_so_far, rerolls_remaining]
301
- end
302
-
303
- end # class ComplexDie
1
+ # frozen_string_literal: true
2
+
3
+ require 'games_dice/complex_die_helpers'
4
+
5
+ module GamesDice
6
+ # This class models a die that is built up from a simpler unit by adding rules to re-roll
7
+ # and interpret the value shown.
8
+ #
9
+ # An object of this class represents a single complex die. It rolls 1..#sides, with equal weighting
10
+ # for each value. The value from a roll may be used to trigger yet more rolls that combine together.
11
+ # After any re-rolls, the value can be interpretted ("mapped") as another integer, which is used as
12
+ # the final result.
13
+ #
14
+ # @example An open-ended percentile die from a popular RPG
15
+ # d = GamesDice::ComplexDie.new( 100, :rerolls => [[96, :<=, :reroll_add],[5, :>=, :reroll_subtract]] )
16
+ # d.roll # => #<GamesDice::DieResult:0x007ff03a2415f8 @rolls=[4, 27], ...>
17
+ # d.result.value # => -23
18
+ # d.explain_result # => "[4-27] -23"
19
+ #
20
+ # @example An "exploding" six-sided die with a target number
21
+ # d = GamesDice::ComplexDie.new( 6, :rerolls => [[6, :<=, :reroll_add]], :maps => [[8, :<=, 1, 'Success']] )
22
+ # d.roll # => #<GamesDice::DieResult:0x007ff03a1e8e08 @rolls=[6, 5], ...>
23
+ # d.result.value # => 1
24
+ # d.explain_result # => "[6+5] 11 Success"
25
+ #
26
+ class ComplexDie
27
+ include GamesDice::ComplexDieHelpers
28
+
29
+ # @!visibility private
30
+ # arbitrary limit to speed up probability calculations. It should
31
+ # be larger than anything seen in real-world tabletop games.
32
+ MAX_REROLLS = 1000
33
+
34
+ # Creates new instance of GamesDice::ComplexDie
35
+ # @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor
36
+ # @param [Hash] options
37
+ # @option options [Array<GamesDice::RerollRule,Array>] :rerolls The rules that cause the die to roll again
38
+ # @option options [Array<GamesDice::MapRule,Array>] :maps The rules to convert a value into a final result for the die
39
+ # @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
40
+ # @return [GamesDice::ComplexDie]
41
+ def initialize(sides, options = {})
42
+ @basic_die = GamesDice::Die.new(sides, options[:prng])
43
+
44
+ @rerolls = construct_rerolls(options[:rerolls])
45
+ @maps = construct_maps(options[:maps])
46
+
47
+ @total = nil
48
+ @result = nil
49
+ end
50
+
51
+ # The simple component used by this complex one
52
+ # @return [GamesDice::Die] Object used to make individual dice rolls for the complex die
53
+ attr_reader :basic_die
54
+
55
+ # @return [Array<GamesDice::RerollRule>, nil] Sequence of re-roll rules, or nil if re-rolls are not required.
56
+ attr_reader :rerolls
57
+
58
+ # @return [Array<GamesDice::MapRule>, nil] Sequence of map rules, or nil if mapping is not required.
59
+ attr_reader :maps
60
+
61
+ # @return [GamesDice::DieResult, nil] Result of last call to #roll, nil if no call made yet
62
+ attr_reader :result
63
+
64
+ # Whether or not #probabilities includes all possible outcomes.
65
+ # True if all possible results are represented and assigned a probability. Dice with open-ended re-rolls
66
+ # may have calculations cut short, and will result in a false value of this attribute. Even when this
67
+ # attribute is false, probabilities should still be accurate to nearest 1e-9.
68
+ # @return [Boolean, nil] Depending on completeness when generating #probabilities
69
+ attr_reader :probabilities_complete
70
+
71
+ # @!attribute [r] sides
72
+ # @return [Integer] Number of sides.
73
+ def sides
74
+ @basic_die.sides
75
+ end
76
+
77
+ # @!attribute [r] explain_result
78
+ # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
79
+ def explain_result
80
+ @result.explain_value
81
+ end
82
+
83
+ # The minimum possible result from a call to #roll. This is not always the same as the theoretical
84
+ # minimum, due to limits on the maximum number of rerolls.
85
+ # @!attribute [r] min
86
+ # @return [Integer]
87
+ def min
88
+ return @min_result if @min_result
89
+
90
+ calc_minmax
91
+ @min_result
92
+ end
93
+
94
+ # @!attribute [r] max
95
+ # @return [Integer] Maximum possible result from a call to #roll
96
+ def max
97
+ return @max_result if @max_result
98
+
99
+ calc_minmax
100
+ @max_result
101
+ end
102
+
103
+ # Calculates the probability distribution for the die. For open-ended re-roll rules, there are some
104
+ # arbitrary limits imposed to prevent large amounts of recursion. Probabilities should be to nearest
105
+ # 1e-9 at worst.
106
+ # @return [GamesDice::Probabilities] Probability distribution of die.
107
+ def probabilities
108
+ return @probabilities if @probabilities
109
+
110
+ @probabilities_complete = true
111
+ if @rerolls && @maps
112
+ reroll_probs = recursive_probabilities
113
+ prob_hash = {}
114
+ reroll_probs.each do |v, p|
115
+ add_mapped_to_prob_hash(prob_hash, v, p)
116
+ end
117
+ elsif @rerolls
118
+ prob_hash = recursive_probabilities
119
+ elsif @maps
120
+ prob_hash = {}
121
+ @basic_die.probabilities.each do |v, p|
122
+ add_mapped_to_prob_hash(prob_hash, v, p)
123
+ end
124
+ else
125
+ @probabilities = @basic_die.probabilities
126
+ return @probabilities
127
+ end
128
+ @probabilities = GamesDice::Probabilities.from_h(prob_hash)
129
+ end
130
+
131
+ # Simulates rolling the die
132
+ # @param [Symbol] reason Assign a reason for rolling the first die.
133
+ # @return [GamesDice::DieResult] Detailed results from rolling the die, including resolution of rules.
134
+ def roll(reason = :basic)
135
+ @result = GamesDice::DieResult.new(@basic_die.roll, reason)
136
+ roll_apply_rerolls
137
+ roll_apply_maps
138
+ @result
139
+ end
140
+
141
+ private
142
+
143
+ def add_mapped_to_prob_hash(prob_hash, v, p)
144
+ m, n = calc_maps(v)
145
+ prob_hash[m] ||= 0.0
146
+ prob_hash[m] += p
147
+ end
148
+
149
+ def roll_apply_rerolls
150
+ return unless @rerolls
151
+
152
+ subtracting = false
153
+ rerolls_remaining = @rerolls.map(&:limit)
154
+
155
+ loop do
156
+ rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
157
+ break unless rule_idx
158
+
159
+ rule = @rerolls[rule_idx]
160
+ rerolls_remaining[rule_idx] -= 1
161
+ subtracting = true if rule.type == :reroll_subtract
162
+ roll_apply_reroll_rule rule, subtracting
163
+ end
164
+ end
165
+
166
+ def roll_apply_reroll_rule(rule, is_subtracting)
167
+ # Apply the rule (note reversal for additions, after a subtract)
168
+ if is_subtracting && rule.type == :reroll_add
169
+ @result.add_roll(@basic_die.roll, :reroll_subtract)
170
+ else
171
+ @result.add_roll(@basic_die.roll, rule.type)
172
+ end
173
+ end
174
+
175
+ # Find which rule, if any, is being triggered
176
+ def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining)
177
+ @rerolls.zip(rerolls_remaining).find_index do |rule, remaining|
178
+ next if rule.type == :reroll_subtract && num_rolls > 1
179
+
180
+ remaining.positive? && rule.applies?(check_value)
181
+ end
182
+ end
183
+
184
+ def roll_apply_maps
185
+ return unless @maps
186
+
187
+ m, n = calc_maps(@result.value)
188
+ @result.apply_map(m, n)
189
+ end
190
+
191
+ def calc_minmax
192
+ @min_result = probabilities.min
193
+ @max_result = probabilities.max
194
+ return if @probabilities_complete
195
+
196
+ logical_min, logical_max = logical_minmax
197
+ @min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
198
+ end
199
+
200
+ def construct_rerolls(rerolls_input)
201
+ check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
202
+ end
203
+
204
+ def construct_maps(maps_input)
205
+ check_and_construct maps_input, GamesDice::MapRule, 'maps'
206
+ end
207
+
208
+ def check_and_construct(input, klass, label)
209
+ return nil unless input
210
+ raise TypeError, "#{label} should be an Array, instead got #{input.inspect}" unless input.is_a?(Array)
211
+
212
+ input.map do |i|
213
+ case i
214
+ when Array then klass.new(*i)
215
+ when klass then i
216
+ else
217
+ raise TypeError, "items in #{label} should be #{klass.name} or Array, instead got #{i.inspect}"
218
+ end
219
+ end
220
+ end
221
+
222
+ def calc_maps(x)
223
+ y = 0
224
+ n = ''
225
+ @maps.find do |rule|
226
+ maybe_y = rule.map_from(x)
227
+ if maybe_y
228
+ y = maybe_y
229
+ n = rule.mapped_name
230
+ end
231
+ maybe_y
232
+ end
233
+ [y, n]
234
+ end
235
+
236
+ def minmax_mappings(possible_values)
237
+ possible_values.map do |x|
238
+ m, n = calc_maps(x)
239
+ m
240
+ end.minmax
241
+ end
242
+
243
+ # This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
244
+ # The inaccuracy is that min_result..max_result may contain 'holes' which have extreme map values that cannot actually
245
+ # occur. In practice it is likely a non-issue unless someone went out of their way to invent a dice scheme that broke it.
246
+ def logical_minmax
247
+ return @basic_die.minmax unless @rerolls || @maps
248
+ return minmax_mappings(@basic_die.all_values) unless @rerolls
249
+
250
+ min_result, max_result = logical_rerolls_minmax
251
+ return minmax_mappings((min_result..max_result)) if @maps
252
+
253
+ [min_result, max_result]
254
+ end
255
+
256
+ def logical_rerolls_minmax
257
+ min_result = @basic_die.min
258
+ max_result = @basic_die.max
259
+ min_subtract = find_minimum_possible_subtract
260
+ max_add = find_maximum_possible_adds
261
+ min_result = [min_subtract - max_add, min_subtract - max_result].min if min_subtract
262
+ [min_result, max_add + max_result]
263
+ end
264
+
265
+ def find_minimum_possible_subtract
266
+ min_subtract = nil
267
+ @rerolls.select { |r| r.type == :reroll_subtract }.each do |rule|
268
+ min_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.min
269
+ next unless min_reroll
270
+
271
+ min_subtract = [min_reroll, min_subtract].compact.min
272
+ end
273
+ min_subtract
274
+ end
275
+
276
+ def find_maximum_possible_adds
277
+ total_add = 0
278
+ @rerolls.select { |r| r.type == :reroll_add }.each do |rule|
279
+ max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
280
+ next unless max_reroll
281
+
282
+ total_add += max_reroll * rule.limit
283
+ end
284
+ total_add
285
+ end
286
+ end
287
+ end