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,247 +1,241 @@
1
- # This class models a number of identical dice, which may be either GamesDice::Die or
2
- # GamesDice::ComplexDie objects.
3
- #
4
- # An object of this class represents a fixed number of indentical dice that may be rolled and their
5
- # values summed to make a total for the bunch.
6
- #
7
- # @example The ubiquitous '3d6'
8
- # d = GamesDice::Bunch.new( :ndice => 3, :sides => 6 )
9
- # d.roll # => 14
10
- # d.result # => 14
11
- # d.explain_result # => "2 + 6 + 6 = 14"
12
- # d.max # => 18
13
- #
14
- # @example Roll 5d10, and keep the best 2
15
- # d = GamesDice::Bunch.new( :ndice => 5, :sides => 10 , :keep_mode => :keep_best, :keep_number => 2 )
16
- # d.roll # => 18
17
- # d.result # => 18
18
- # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
19
- #
20
-
21
- class GamesDice::Bunch
22
- # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
23
- # and decides which of those classes to instantiate.
24
- # @param [Hash] options
25
- # @option options [Integer] :ndice Number of dice in the bunch, *mandatory*
26
- # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
27
- # @option options [String] :name Optional name for the bunch
28
- # @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
29
- # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
30
- # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
31
- # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
32
- # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
33
- # @return [GamesDice::Bunch]
34
- def initialize( options )
35
- name_number_sides_from_hash( options )
36
- keep_mode_from_hash( options )
37
-
38
- if options[:prng]
39
- raise ":prng does not support the rand() method" if ! options[:prng].respond_to?(:rand)
40
- end
41
-
42
- if options[:rerolls] || options[:maps]
43
- @single_die = GamesDice::ComplexDie.new( @sides, complex_die_params_from_hash( options ) )
44
- else
45
- @single_die = GamesDice::Die.new( @sides, options[:prng] )
46
- end
47
- end
48
-
49
- # Name to help identify bunch
50
- # @return [String]
51
- attr_reader :name
52
-
53
- # Number of dice to roll
54
- # @return [Integer]
55
- attr_reader :ndice
56
-
57
- # Individual die from the bunch
58
- # @return [GamesDice::Die,GamesDice::ComplexDie]
59
- attr_reader :single_die
60
-
61
- # Can be nil, :keep_best or :keep_worst
62
- # @return [Symbol,nil]
63
- attr_reader :keep_mode
64
-
65
- # Number of "best" or "worst" results to select when #keep_mode is not nil.
66
- # @return [Integer,nil]
67
- attr_reader :keep_number
68
-
69
- # Result of most-recent roll, or nil if no roll made yet.
70
- # @return [Integer,nil]
71
- attr_reader :result
72
-
73
- # @!attribute [r] label
74
- # Description that will be used in explanations with more than one bunch
75
- # @return [String]
76
- def label
77
- return @name if @name != ''
78
- return @ndice.to_s + 'd' + @sides.to_s
79
- end
80
-
81
- # @!attribute [r] rerolls
82
- # Sequence of re-roll rules, or nil if re-rolls are not required.
83
- # @return [Array<GamesDice::RerollRule>, nil]
84
- def rerolls
85
- @single_die.rerolls
86
- end
87
-
88
- # @!attribute [r] maps
89
- # Sequence of map rules, or nil if mapping is not required.
90
- # @return [Array<GamesDice::MapRule>, nil]
91
- def maps
92
- @single_die.maps
93
- end
94
-
95
- # @!attribute [r] result_details
96
- # After calling #roll, this is an array of GamesDice::DieResult objects. There is one from each #single_die rolled,
97
- # allowing inspection of how the result was obtained.
98
- # @return [Array<GamesDice::DieResult>, nil] Sequence of GamesDice::DieResult objects.
99
- def result_details
100
- return nil unless @raw_result_details
101
- @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r }
102
- end
103
-
104
- # @!attribute [r] min
105
- # Minimum possible result from a call to #roll
106
- # @return [Integer]
107
- def min
108
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
109
- return n * @single_die.min
110
- end
111
-
112
- # @!attribute [r] max
113
- # Maximum possible result from a call to #roll
114
- # @return [Integer]
115
- def max
116
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
117
- return n * @single_die.max
118
- end
119
-
120
- # Calculates the probability distribution for the bunch. When the bunch is composed of dice with
121
- # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
122
- # recursion.
123
- # @return [GamesDice::Probabilities] Probability distribution of bunch.
124
- def probabilities
125
- return @probabilities if @probabilities
126
-
127
- if @keep_mode && @ndice > @keep_number
128
- @probabilities = @single_die.probabilities.repeat_n_sum_k( @ndice, @keep_number, @keep_mode )
129
- else
130
- @probabilities = @single_die.probabilities.repeat_sum( @ndice )
131
- end
132
-
133
- return @probabilities
134
- end
135
-
136
- # Simulates rolling the bunch of identical dice
137
- # @return [Integer] Sum of all rolled dice, or sum of all keepers
138
- def roll
139
- @result = 0
140
- @raw_result_details = []
141
-
142
- @ndice.times do
143
- @result += @single_die.roll
144
- @raw_result_details << @single_die.result
145
- end
146
-
147
- if ! @keep_mode
148
- return @result
149
- end
150
-
151
- use_dice = if @keep_mode && @keep_number < @ndice
152
- case @keep_mode
153
- when :keep_best then @raw_result_details.sort[-@keep_number..-1]
154
- when :keep_worst then @raw_result_details.sort[0..(@keep_number-1)]
155
- end
156
- else
157
- @raw_result_details
158
- end
159
-
160
- @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
161
- end
162
-
163
- # @!attribute [r] explain_result
164
- # Explanation of result, or nil if no call to #roll yet.
165
- # @return [String,nil]
166
- def explain_result
167
- return nil unless @result
168
-
169
- explanation = ''
170
-
171
- # With #keep_mode, we may need to show unused and used dice separately
172
- used_dice = result_details
173
- unused_dice = []
174
-
175
- # Pick highest numbers and their associated details
176
- if @keep_mode && @keep_number < @ndice
177
- full_dice = result_details.sort_by { |die_result| die_result.total }
178
- case @keep_mode
179
- when :keep_best then
180
- used_dice = full_dice[-@keep_number..-1]
181
- unused_dice = full_dice[0..full_dice.length-1-@keep_number]
182
- when :keep_worst then
183
- used_dice = full_dice[0..(@keep_number-1)]
184
- unused_dice = full_dice[@keep_number..(full_dice.length-1)]
185
- end
186
- end
187
-
188
- # Show unused dice (if any)
189
- if @keep_mode || @single_die.maps
190
- explanation += result_details.map do |die_result|
191
- die_result.explain_value
192
- end.join(', ')
193
- if @keep_mode
194
- separator = @single_die.maps ? ', ' : ' + '
195
- explanation += ". Keep: " + used_dice.map do |die_result|
196
- die_result.explain_total
197
- end.join( separator )
198
- end
199
- if @single_die.maps
200
- explanation += ". Successes: #{@result}"
201
- end
202
- explanation += " = #{@result}" if @keep_mode && ! @single_die.maps && @keep_number > 1
203
- else
204
- explanation += used_dice.map do |die_result|
205
- die_result.explain_value
206
- end.join(' + ')
207
- explanation += " = #{@result}" if @ndice > 1
208
- end
209
-
210
- explanation
211
- end
212
-
213
- private
214
-
215
- def name_number_sides_from_hash options
216
- @name = options[:name].to_s
217
- @ndice = Integer(options[:ndice])
218
- raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
219
- @sides = Integer(options[:sides])
220
- raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
221
- end
222
-
223
- def keep_mode_from_hash options
224
- case options[:keep_mode]
225
- when nil then
226
- @keep_mode = nil
227
- when :keep_best then
228
- @keep_mode = :keep_best
229
- @keep_number = Integer(options[:keep_number] || 1)
230
- when :keep_worst then
231
- @keep_mode = :keep_worst
232
- @keep_number = Integer(options[:keep_number] || 1)
233
- else
234
- raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
235
- end
236
- end
237
-
238
- def complex_die_params_from_hash options
239
- cd_hash = Hash.new
240
- [:maps,:rerolls].each do |k|
241
- cd_hash[k] = options[k].clone if options[k]
242
- end
243
- # We deliberately do not clone this object, it will often be intended that it is shared
244
- cd_hash[:prng] = options[:prng]
245
- cd_hash
246
- end
247
- end # class Bunch
1
+ # frozen_string_literal: true
2
+
3
+ module GamesDice
4
+ # This class models a number of identical dice, which may be either GamesDice::Die or
5
+ # GamesDice::ComplexDie objects.
6
+ #
7
+ # An object of this class represents a fixed number of indentical dice that may be rolled and their
8
+ # values summed to make a total for the bunch.
9
+ #
10
+ # @example The ubiquitous '3d6'
11
+ # d = GamesDice::Bunch.new( :ndice => 3, :sides => 6 )
12
+ # d.roll # => 14
13
+ # d.result # => 14
14
+ # d.explain_result # => "2 + 6 + 6 = 14"
15
+ # d.max # => 18
16
+ #
17
+ # @example Roll 5d10, and keep the best 2
18
+ # d = GamesDice::Bunch.new( :ndice => 5, :sides => 10 , :keep_mode => :keep_best, :keep_number => 2 )
19
+ # d.roll # => 18
20
+ # d.result # => 18
21
+ # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
22
+ #
23
+ class Bunch
24
+ # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
25
+ # and decides which of those classes to instantiate.
26
+ # @param [Hash] options
27
+ # @option options [Integer] :ndice Number of dice in the bunch, *mandatory*
28
+ # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
29
+ # @option options [String] :name Optional name for the bunch
30
+ # @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
31
+ # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
32
+ # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
33
+ # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
34
+ # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
35
+ # @return [GamesDice::Bunch]
36
+ def initialize(options)
37
+ name_number_sides_from_hash(options)
38
+ keep_mode_from_hash(options)
39
+
40
+ raise ':prng does not support the rand() method' if options[:prng] && !options[:prng].respond_to?(:rand)
41
+
42
+ @single_die = if options[:rerolls] || options[:maps]
43
+ GamesDice::ComplexDie.new(@sides, complex_die_params_from_hash(options))
44
+ else
45
+ GamesDice::Die.new(@sides, options[:prng])
46
+ end
47
+ end
48
+
49
+ # Name to help identify bunch
50
+ # @return [String]
51
+ attr_reader :name
52
+
53
+ # Number of dice to roll
54
+ # @return [Integer]
55
+ attr_reader :ndice
56
+
57
+ # Individual die from the bunch
58
+ # @return [GamesDice::Die,GamesDice::ComplexDie]
59
+ attr_reader :single_die
60
+
61
+ # Can be nil, :keep_best or :keep_worst
62
+ # @return [Symbol,nil]
63
+ attr_reader :keep_mode
64
+
65
+ # Number of "best" or "worst" results to select when #keep_mode is not nil.
66
+ # @return [Integer,nil]
67
+ attr_reader :keep_number
68
+
69
+ # Result of most-recent roll, or nil if no roll made yet.
70
+ # @return [Integer,nil]
71
+ attr_reader :result
72
+
73
+ # @!attribute [r] label
74
+ # Description that will be used in explanations with more than one bunch
75
+ # @return [String]
76
+ def label
77
+ return @name if @name != ''
78
+
79
+ "#{@ndice}d#{@sides}"
80
+ end
81
+
82
+ # @!attribute [r] rerolls
83
+ # Sequence of re-roll rules, or nil if re-rolls are not required.
84
+ # @return [Array<GamesDice::RerollRule>, nil]
85
+ def rerolls
86
+ @single_die.rerolls
87
+ end
88
+
89
+ # @!attribute [r] maps
90
+ # Sequence of map rules, or nil if mapping is not required.
91
+ # @return [Array<GamesDice::MapRule>, nil]
92
+ def maps
93
+ @single_die.maps
94
+ end
95
+
96
+ # @!attribute [r] result_details
97
+ # After calling #roll, this is an array of GamesDice::DieResult objects. There is one from each #single_die rolled,
98
+ # allowing inspection of how the result was obtained.
99
+ # @return [Array<GamesDice::DieResult>, nil] Sequence of GamesDice::DieResult objects.
100
+ def result_details
101
+ return nil unless @raw_result_details
102
+
103
+ @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r }
104
+ end
105
+
106
+ # @!attribute [r] min
107
+ # Minimum possible result from a call to #roll
108
+ # @return [Integer]
109
+ def min
110
+ n = @keep_mode ? [@keep_number, @ndice].min : @ndice
111
+ n * @single_die.min
112
+ end
113
+
114
+ # @!attribute [r] max
115
+ # Maximum possible result from a call to #roll
116
+ # @return [Integer]
117
+ def max
118
+ n = @keep_mode ? [@keep_number, @ndice].min : @ndice
119
+ n * @single_die.max
120
+ end
121
+
122
+ # Calculates the probability distribution for the bunch. When the bunch is composed of dice with
123
+ # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
124
+ # recursion.
125
+ # @return [GamesDice::Probabilities] Probability distribution of bunch.
126
+ def probabilities
127
+ return @probabilities if @probabilities
128
+
129
+ @probabilities = if @keep_mode && @ndice > @keep_number
130
+ @single_die.probabilities.repeat_n_sum_k(@ndice, @keep_number, @keep_mode)
131
+ else
132
+ @single_die.probabilities.repeat_sum(@ndice)
133
+ end
134
+
135
+ @probabilities
136
+ end
137
+
138
+ # Simulates rolling the bunch of identical dice
139
+ # @return [Integer] Sum of all rolled dice, or sum of all keepers
140
+ def roll
141
+ @result = 0
142
+ @raw_result_details = []
143
+
144
+ @ndice.times do
145
+ @result += @single_die.roll
146
+ @raw_result_details << @single_die.result
147
+ end
148
+
149
+ return @result unless @keep_mode
150
+
151
+ use_dice = if @keep_mode && @keep_number < @ndice
152
+ case @keep_mode
153
+ when :keep_best then @raw_result_details.sort[-@keep_number..]
154
+ when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
155
+ end
156
+ else
157
+ @raw_result_details
158
+ end
159
+
160
+ @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
161
+ end
162
+
163
+ # @!attribute [r] explain_result
164
+ # Explanation of result, or nil if no call to #roll yet.
165
+ # @return [String,nil]
166
+ def explain_result
167
+ return nil unless @result
168
+
169
+ explanation = ''
170
+
171
+ # With #keep_mode, we may need to show unused and used dice separately
172
+ used_dice = result_details
173
+ unused_dice = []
174
+
175
+ # Pick highest numbers and their associated details
176
+ if @keep_mode && @keep_number < @ndice
177
+ full_dice = result_details.sort_by(&:total)
178
+ case @keep_mode
179
+ when :keep_best
180
+ used_dice = full_dice[-@keep_number..]
181
+ unused_dice = full_dice[0..full_dice.length - 1 - @keep_number]
182
+ when :keep_worst
183
+ used_dice = full_dice[0..(@keep_number - 1)]
184
+ unused_dice = full_dice[@keep_number..(full_dice.length - 1)]
185
+ end
186
+ end
187
+
188
+ # Show unused dice (if any)
189
+ if @keep_mode || @single_die.maps
190
+ explanation += result_details.map(&:explain_value).join(', ')
191
+ if @keep_mode
192
+ separator = @single_die.maps ? ', ' : ' + '
193
+ explanation += ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
194
+ end
195
+ explanation += ". Successes: #{@result}" if @single_die.maps
196
+ explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
197
+ else
198
+ explanation += used_dice.map(&:explain_value).join(' + ')
199
+ explanation += " = #{@result}" if @ndice > 1
200
+ end
201
+
202
+ explanation
203
+ end
204
+
205
+ private
206
+
207
+ def name_number_sides_from_hash(options)
208
+ @name = options[:name].to_s
209
+ @ndice = Integer(options[:ndice])
210
+ raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice.positive?
211
+
212
+ @sides = Integer(options[:sides])
213
+ raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides.positive?
214
+ end
215
+
216
+ def keep_mode_from_hash(options)
217
+ case options[:keep_mode]
218
+ when nil
219
+ @keep_mode = nil
220
+ when :keep_best
221
+ @keep_mode = :keep_best
222
+ @keep_number = Integer(options[:keep_number] || 1)
223
+ when :keep_worst
224
+ @keep_mode = :keep_worst
225
+ @keep_number = Integer(options[:keep_number] || 1)
226
+ else
227
+ raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
228
+ end
229
+ end
230
+
231
+ def complex_die_params_from_hash(options)
232
+ cd_hash = {}
233
+ %i[maps rerolls].each do |k|
234
+ cd_hash[k] = options[k].clone if options[k]
235
+ end
236
+ # We deliberately do not clone this object, it will often be intended that it is shared
237
+ cd_hash[:prng] = options[:prng]
238
+ cd_hash
239
+ end
240
+ end
241
+ end