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,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 ! 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?(Fixnum) ? 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