games_dice 0.3.12 → 0.4.0

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