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