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.
- checksums.yaml +7 -0
- data/.rubocop.yml +15 -0
- data/.travis.yml +9 -12
- data/CHANGELOG.md +29 -13
- data/Gemfile +2 -0
- data/README.md +5 -5
- data/Rakefile +14 -11
- data/ext/games_dice/extconf.rb +4 -22
- data/ext/games_dice/probabilities.c +1 -1
- data/games_dice.gemspec +26 -28
- data/lib/games_dice/bunch.rb +241 -247
- data/lib/games_dice/complex_die.rb +287 -303
- data/lib/games_dice/complex_die_helpers.rb +68 -0
- data/lib/games_dice/constants.rb +10 -10
- data/lib/games_dice/dice.rb +146 -143
- data/lib/games_dice/die.rb +101 -97
- data/lib/games_dice/die_result.rb +193 -189
- data/lib/games_dice/map_rule.rb +72 -70
- data/lib/games_dice/marshal.rb +18 -13
- data/lib/games_dice/parser.rb +219 -218
- data/lib/games_dice/reroll_rule.rb +76 -77
- data/lib/games_dice/version.rb +3 -1
- data/lib/games_dice.rb +19 -16
- data/spec/bunch_spec.rb +399 -421
- data/spec/complex_die_spec.rb +314 -306
- data/spec/dice_spec.rb +33 -34
- data/spec/die_result_spec.rb +163 -170
- data/spec/die_spec.rb +81 -82
- data/spec/helpers.rb +26 -22
- data/spec/map_rule_spec.rb +40 -44
- data/spec/parser_spec.rb +106 -82
- data/spec/probability_spec.rb +530 -527
- data/spec/readme_spec.rb +404 -384
- data/spec/reroll_rule_spec.rb +40 -44
- metadata +63 -74
- data/lib/games_dice/probabilities.rb +0 -445
data/lib/games_dice/bunch.rb
CHANGED
@@ -1,247 +1,241 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
#
|
9
|
-
|
10
|
-
|
11
|
-
# d.
|
12
|
-
# d.
|
13
|
-
#
|
14
|
-
#
|
15
|
-
# d
|
16
|
-
|
17
|
-
|
18
|
-
# d.
|
19
|
-
#
|
20
|
-
|
21
|
-
|
22
|
-
#
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
@
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
@
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
explanation += ".
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
explanation
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
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
|