games_dice 0.3.12 → 0.4.2
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 +5 -5
- data/.github/workflows/ci.yml +38 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +53 -0
- data/.yardopts +1 -1
- data/CHANGELOG.md +18 -0
- data/Gemfile +12 -0
- data/README.md +11 -33
- data/Rakefile +10 -23
- data/ext/games_dice/extconf.rb +4 -22
- data/ext/games_dice/games_dice.c +1 -1
- data/ext/games_dice/probabilities.c +128 -9
- data/ext/games_dice/probabilities.h +1 -1
- data/games_dice.gemspec +22 -36
- data/lib/games_dice/bunch.rb +203 -247
- data/lib/games_dice/bunch_helpers.rb +68 -0
- data/lib/games_dice/complex_die.rb +151 -270
- data/lib/games_dice/complex_die_helpers.rb +260 -60
- data/lib/games_dice/constants.rb +10 -10
- data/lib/games_dice/dice.rb +153 -143
- data/lib/games_dice/die.rb +101 -97
- data/lib/games_dice/die_result.rb +206 -189
- data/lib/games_dice/map_rule.rb +72 -70
- data/lib/games_dice/marshal.rb +41 -13
- data/lib/games_dice/parser.rb +245 -218
- data/lib/games_dice/reroll_rule.rb +76 -77
- data/lib/games_dice/version.rb +4 -1
- data/lib/games_dice.rb +23 -25
- data/spec/bunch_spec.rb +399 -420
- data/spec/complex_die_spec.rb +314 -305
- data/spec/dice_spec.rb +33 -34
- data/spec/die_result_spec.rb +174 -181
- data/spec/die_spec.rb +81 -81
- data/spec/helpers.rb +25 -25
- data/spec/map_rule_spec.rb +40 -44
- data/spec/parser_spec.rb +106 -82
- data/spec/probability_spec.rb +522 -526
- data/spec/readme_spec.rb +410 -390
- data/spec/reroll_rule_spec.rb +40 -44
- metadata +17 -129
- data/.travis.yml +0 -14
- data/lib/games_dice/prob_helpers.rb +0 -259
- data/lib/games_dice/probabilities.rb +0 -244
|
@@ -1,60 +1,260 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
class ComplexDie
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# Private extension methods for GamesDice::ComplexDie probability calculations
|
|
7
|
+
module ProbabilityHelpers
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def calculate_probabilities
|
|
11
|
+
if @rerolls && @maps
|
|
12
|
+
GamesDice::Probabilities.from_h(prob_hash_with_rerolls_and_maps)
|
|
13
|
+
elsif @rerolls
|
|
14
|
+
GamesDice::Probabilities.from_h(recursive_probabilities)
|
|
15
|
+
elsif @maps
|
|
16
|
+
GamesDice::Probabilities.from_h(prob_hash_with_just_maps)
|
|
17
|
+
else
|
|
18
|
+
@basic_die.probabilities
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def prob_hash_with_rerolls_and_maps
|
|
23
|
+
prob_hash = {}
|
|
24
|
+
reroll_probs = recursive_probabilities
|
|
25
|
+
reroll_probs.each do |v, p|
|
|
26
|
+
add_mapped_to_prob_hash(prob_hash, v, p)
|
|
27
|
+
end
|
|
28
|
+
prob_hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def prob_hash_with_just_maps
|
|
32
|
+
prob_hash = {}
|
|
33
|
+
@basic_die.probabilities.each do |v, p|
|
|
34
|
+
add_mapped_to_prob_hash(prob_hash, v, p)
|
|
35
|
+
end
|
|
36
|
+
prob_hash
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_mapped_to_prob_hash(prob_hash, orig_val, prob)
|
|
40
|
+
mapped_val, = calc_maps(orig_val)
|
|
41
|
+
prob_hash[mapped_val] ||= 0.0
|
|
42
|
+
prob_hash[mapped_val] += prob
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RecurseStack = Struct.new(:depth, :roll_reason, :subtracting, :probabilities, :prior_probability, :prior_result,
|
|
46
|
+
:rerolls_left) do
|
|
47
|
+
def initialize
|
|
48
|
+
self.depth = 0
|
|
49
|
+
self.roll_reason = :basic
|
|
50
|
+
self.subtracting = false
|
|
51
|
+
self.probabilities = {}
|
|
52
|
+
self.prior_probability = 1.0
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_constant :RecurseStack
|
|
56
|
+
|
|
57
|
+
def recursive_probabilities(stack = RecurseStack.new)
|
|
58
|
+
stack.prior_probability = stack.prior_probability / @basic_die.sides
|
|
59
|
+
stack.depth += 1
|
|
60
|
+
|
|
61
|
+
@basic_die.each_value do |die_val|
|
|
62
|
+
recurse_probs_for_value(die_val, stack)
|
|
63
|
+
end
|
|
64
|
+
stack.probabilities
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def recurse_probs_for_value(die_val, stack)
|
|
68
|
+
result_so_far, rerolls_remaining = calc_result_so_far(die_val, stack)
|
|
69
|
+
rule_idx = find_matching_reroll_rule(die_val, result_so_far.rolls.length, rerolls_remaining)
|
|
70
|
+
|
|
71
|
+
if conintue_recursing?(stack, rule_idx)
|
|
72
|
+
continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx)
|
|
73
|
+
else
|
|
74
|
+
end_recursion_store_probs(stack, result_so_far)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def conintue_recursing?(stack, rule_idx)
|
|
79
|
+
if stack.depth >= 20 || stack.prior_probability < 1.0e-16
|
|
80
|
+
@probabilities_complete = false
|
|
81
|
+
return false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
!rule_idx.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def continue_recursion(stack, result_so_far, rerolls_remaining, rule_idx)
|
|
88
|
+
rule = @rerolls[rule_idx]
|
|
89
|
+
rerolls_remaining[rule_idx] -= 1
|
|
90
|
+
recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def end_recursion_store_probs(stack, result_so_far)
|
|
94
|
+
t = result_so_far.total
|
|
95
|
+
stack.probabilities[t] ||= 0.0
|
|
96
|
+
stack.probabilities[t] += stack.prior_probability
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule)
|
|
100
|
+
next_stack = stack.clone
|
|
101
|
+
next_stack.prior_result = result_so_far
|
|
102
|
+
next_stack.rerolls_left = rerolls_remaining
|
|
103
|
+
next_stack.subtracting = true if stack.subtracting || rule.type == :reroll_subtract
|
|
104
|
+
|
|
105
|
+
# Apply the rule (note reversal for additions, after a subtract)
|
|
106
|
+
next_stack.roll_reason = if stack.subtracting && rule.type == :reroll_add
|
|
107
|
+
:reroll_subtract
|
|
108
|
+
else
|
|
109
|
+
rule.type
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
recursive_probabilities next_stack
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def calc_result_so_far(die_val, stack)
|
|
116
|
+
if stack.prior_result
|
|
117
|
+
result_so_far = stack.prior_result.clone
|
|
118
|
+
rerolls_remaining = stack.rerolls_left.clone
|
|
119
|
+
result_so_far.add_roll(die_val, stack.roll_reason)
|
|
120
|
+
else
|
|
121
|
+
rerolls_remaining = @rerolls.map(&:limit)
|
|
122
|
+
result_so_far = GamesDice::DieResult.new(die_val, stack.roll_reason)
|
|
123
|
+
end
|
|
124
|
+
[result_so_far, rerolls_remaining]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @!visibility private
|
|
129
|
+
# Private extension methods for GamesDice::ComplexDie simulating rolls
|
|
130
|
+
module RollHelpers
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def roll_apply_rerolls
|
|
134
|
+
return unless @rerolls
|
|
135
|
+
|
|
136
|
+
subtracting = false
|
|
137
|
+
rerolls_remaining = @rerolls.map(&:limit)
|
|
138
|
+
|
|
139
|
+
rerolls_loop(subtracting, rerolls_remaining)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rerolls_loop(subtracting, rerolls_remaining)
|
|
143
|
+
loop do
|
|
144
|
+
rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
|
|
145
|
+
break unless rule_idx
|
|
146
|
+
|
|
147
|
+
rule = @rerolls[rule_idx]
|
|
148
|
+
rerolls_remaining[rule_idx] -= 1
|
|
149
|
+
subtracting = true if rule.type == :reroll_subtract
|
|
150
|
+
roll_apply_reroll_rule rule, subtracting
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def roll_apply_reroll_rule(rule, is_subtracting)
|
|
155
|
+
# Apply the rule (note reversal for additions, after a subtract)
|
|
156
|
+
if is_subtracting && rule.type == :reroll_add
|
|
157
|
+
@result.add_roll(@basic_die.roll, :reroll_subtract)
|
|
158
|
+
else
|
|
159
|
+
@result.add_roll(@basic_die.roll, rule.type)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Find which rule, if any, is being triggered
|
|
164
|
+
def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining)
|
|
165
|
+
@rerolls.zip(rerolls_remaining).find_index do |rule, remaining|
|
|
166
|
+
next if rule.type == :reroll_subtract && num_rolls > 1
|
|
167
|
+
|
|
168
|
+
remaining.positive? && rule.applies?(check_value)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def roll_apply_maps
|
|
173
|
+
return unless @maps
|
|
174
|
+
|
|
175
|
+
m, n = calc_maps(@result.value)
|
|
176
|
+
@result.apply_map(m, n)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def calc_maps(original_value)
|
|
180
|
+
y = 0
|
|
181
|
+
n = ''
|
|
182
|
+
@maps.find do |rule|
|
|
183
|
+
if (maybe_y = rule.map_from(original_value))
|
|
184
|
+
y = maybe_y
|
|
185
|
+
n = rule.mapped_name
|
|
186
|
+
end
|
|
187
|
+
maybe_y
|
|
188
|
+
end
|
|
189
|
+
[y, n]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @!visibility private
|
|
194
|
+
# Private extension methods for GamesDice::ComplexDie calculating min and max (which is surprisingly complex)
|
|
195
|
+
module MinMaxHelpers
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def calc_minmax
|
|
199
|
+
@min_result = probabilities.min
|
|
200
|
+
@max_result = probabilities.max
|
|
201
|
+
return if @probabilities_complete
|
|
202
|
+
|
|
203
|
+
logical_min, logical_max = logical_minmax
|
|
204
|
+
@min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def minmax_mappings(possible_values)
|
|
208
|
+
possible_values.map do |x|
|
|
209
|
+
map_val, = calc_maps(x)
|
|
210
|
+
map_val
|
|
211
|
+
end.minmax
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we
|
|
215
|
+
# have to. The inaccuracy is that min_result..max_result may contain 'holes' which have extreme map values that
|
|
216
|
+
# cannot actually occur. In practice it is likely a non-issue unless someone went out of their way to invent a
|
|
217
|
+
# dice schem that broke it.
|
|
218
|
+
def logical_minmax
|
|
219
|
+
return @basic_die.minmax unless @rerolls || @maps
|
|
220
|
+
return minmax_mappings(@basic_die.all_values) unless @rerolls
|
|
221
|
+
|
|
222
|
+
min_result, max_result = logical_rerolls_minmax
|
|
223
|
+
return minmax_mappings(min_result..max_result) if @maps
|
|
224
|
+
|
|
225
|
+
[min_result, max_result]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def logical_rerolls_minmax
|
|
229
|
+
min_result = @basic_die.min
|
|
230
|
+
max_result = @basic_die.max
|
|
231
|
+
min_subtract = find_minimum_possible_subtract
|
|
232
|
+
max_add = find_maximum_possible_adds
|
|
233
|
+
min_result = [min_subtract - max_add, min_subtract - max_result].min if min_subtract
|
|
234
|
+
[min_result, max_add + max_result]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def find_minimum_possible_subtract
|
|
238
|
+
min_subtract = nil
|
|
239
|
+
@rerolls.select { |r| r.type == :reroll_subtract }.each do |rule|
|
|
240
|
+
min_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.min
|
|
241
|
+
next unless min_reroll
|
|
242
|
+
|
|
243
|
+
min_subtract = [min_reroll, min_subtract].compact.min
|
|
244
|
+
end
|
|
245
|
+
min_subtract
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def find_maximum_possible_adds
|
|
249
|
+
total_add = 0
|
|
250
|
+
@rerolls.select { |r| r.type == :reroll_add }.each do |rule|
|
|
251
|
+
max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
|
|
252
|
+
next unless max_reroll
|
|
253
|
+
|
|
254
|
+
total_add += max_reroll * rule.limit
|
|
255
|
+
end
|
|
256
|
+
total_add
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
data/lib/games_dice/constants.rb
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module GamesDice
|
|
3
4
|
# Reasons for making a reroll, and text explanation symbols for them
|
|
4
5
|
REROLL_TYPES = {
|
|
5
|
-
:
|
|
6
|
-
:
|
|
7
|
-
:
|
|
8
|
-
:
|
|
9
|
-
:
|
|
10
|
-
:
|
|
6
|
+
basic: ',',
|
|
7
|
+
reroll_add: '+',
|
|
8
|
+
reroll_subtract: '-',
|
|
9
|
+
reroll_replace: '|',
|
|
10
|
+
reroll_use_best: '/',
|
|
11
|
+
reroll_use_worst: '\\'
|
|
11
12
|
# These are not yet implemented:
|
|
12
13
|
# :reroll_new_die => '*',
|
|
13
14
|
# :reroll_new_keeper => '*',
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
end
|
|
15
|
+
}.freeze
|
|
16
|
+
end
|
data/lib/games_dice/dice.rb
CHANGED
|
@@ -1,143 +1,153 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# d.
|
|
11
|
-
# d.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# d
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
# d.
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@bunches
|
|
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
|
-
end
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
# This class models a combination of GamesDice::Bunch objects plus a fixed offset.
|
|
5
|
+
#
|
|
6
|
+
# An object of this class is a dice "recipe" that specifies the numbers and types of
|
|
7
|
+
# dice that can be rolled to generate an integer value.
|
|
8
|
+
#
|
|
9
|
+
# @example '3d6+6' hitpoints, whatever that means in the game you are playing
|
|
10
|
+
# d = GamesDice::Dice.new( [{:ndice => 3, :sides => 6}], 6, 'Hit points' )
|
|
11
|
+
# d.roll # => 20
|
|
12
|
+
# d.result # => 20
|
|
13
|
+
# d.explain_result # => "3d6: 3 + 5 + 6 = 14. 14 + 6 = 20"
|
|
14
|
+
# d.probabilities.expected # => 16.5
|
|
15
|
+
#
|
|
16
|
+
# @example Roll d20 twice, take best result, and add 5.
|
|
17
|
+
# d = GamesDice::Dice.new( [{:ndice => 2, :sides => 20 , :keep_mode => :keep_best, :keep_number => 1}], 5 )
|
|
18
|
+
# d.roll # => 21
|
|
19
|
+
# d.result # => 21
|
|
20
|
+
# d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
|
|
21
|
+
#
|
|
22
|
+
class Dice
|
|
23
|
+
# The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
|
|
24
|
+
# @param [Array<Hash>] bunches Array of options for creating bunches
|
|
25
|
+
# @param [Integer] offset Total offset
|
|
26
|
+
# @param [String] name Optional label for the dice
|
|
27
|
+
# @option bunches [Integer] :ndice Number of dice in the bunch, *mandatory*
|
|
28
|
+
# @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
|
|
29
|
+
# @option bunches [String] :name Optional name for the bunch
|
|
30
|
+
# @option bunches [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
|
|
31
|
+
# @option bunches [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result
|
|
32
|
+
# for the die
|
|
33
|
+
# @option bunches [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to
|
|
34
|
+
# GamesDice::Die's constructor
|
|
35
|
+
# @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
|
|
36
|
+
# @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
|
|
37
|
+
# @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the
|
|
38
|
+
# Bunch total is to be added or subtracted
|
|
39
|
+
# @return [GamesDice::Dice]
|
|
40
|
+
def initialize(bunches, offset = 0, name = '')
|
|
41
|
+
@name = name
|
|
42
|
+
@offset = offset
|
|
43
|
+
@bunches = bunches.map { |b| GamesDice::Bunch.new(b) }
|
|
44
|
+
@bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
|
|
45
|
+
@result = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Name to help identify dice
|
|
49
|
+
# @return [String]
|
|
50
|
+
attr_reader :name
|
|
51
|
+
|
|
52
|
+
# Bunches of dice that are components of the object
|
|
53
|
+
# @return [Array<GamesDice::Bunch>]
|
|
54
|
+
attr_reader :bunches
|
|
55
|
+
|
|
56
|
+
# Multipliers for each bunch of identical dice. Typically 1 or -1 to represent groups of dice that
|
|
57
|
+
# are either added or subtracted from the total.
|
|
58
|
+
# @return [Array<Integer>]
|
|
59
|
+
attr_reader :bunch_multipliers
|
|
60
|
+
|
|
61
|
+
# Fixed offset added to sum of all bunches.
|
|
62
|
+
# @return [Integer]
|
|
63
|
+
attr_reader :offset
|
|
64
|
+
|
|
65
|
+
# Result of most-recent roll, or nil if no roll made yet.
|
|
66
|
+
# @return [Integer,nil]
|
|
67
|
+
attr_reader :result
|
|
68
|
+
|
|
69
|
+
# Simulates rolling dice
|
|
70
|
+
# @return [Integer] Sum of all rolled dice
|
|
71
|
+
def roll
|
|
72
|
+
@result = @offset + bunches_weighted_sum(:roll)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @!attribute [r] min
|
|
76
|
+
# Minimum possible result from a call to #roll
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def min
|
|
79
|
+
@min ||= @offset + bunches_weighted_sum(:min)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @!attribute [r] max
|
|
83
|
+
# Maximum possible result from a call to #roll
|
|
84
|
+
# @return [Integer]
|
|
85
|
+
def max
|
|
86
|
+
@max ||= @offset + bunches_weighted_sum(:max)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @!attribute [r] minmax
|
|
90
|
+
# Convenience method, same as [dice.min, dice.max]
|
|
91
|
+
# @return [Array<Integer>]
|
|
92
|
+
def minmax
|
|
93
|
+
[min, max]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Calculates the probability distribution for the dice. When the dice include components with
|
|
97
|
+
# open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
|
|
98
|
+
# recursion.
|
|
99
|
+
# @return [GamesDice::Probabilities] Probability distribution of dice.
|
|
100
|
+
def probabilities
|
|
101
|
+
return @probabilities if @probabilities
|
|
102
|
+
|
|
103
|
+
@bunch_multipliers.zip(@bunches).inject(GamesDice::Probabilities.new([1.0], @offset)) do |probs, mb|
|
|
104
|
+
m, b = mb
|
|
105
|
+
GamesDice::Probabilities.add_distributions_mult(1, probs, m, b.probabilities)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @!attribute [r] explain_result
|
|
110
|
+
# @return [String,nil] Explanation of result, or nil if no call to #roll yet.
|
|
111
|
+
def explain_result
|
|
112
|
+
return nil unless @result
|
|
113
|
+
|
|
114
|
+
explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
|
|
115
|
+
|
|
116
|
+
return @offset.to_s if explanations.none?
|
|
117
|
+
|
|
118
|
+
return simple_explanation(explanations.first) if explanations.one?
|
|
119
|
+
|
|
120
|
+
multi_explanations(explanations)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def simple_explanation(explanation)
|
|
126
|
+
return explanation if @offset.zero?
|
|
127
|
+
|
|
128
|
+
"#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def multi_explanations(explanations)
|
|
132
|
+
bunch_values = @bunch_multipliers.zip(@bunches).map { |m, b| m * b.result }
|
|
133
|
+
bunch_values << @offset if @offset != 0
|
|
134
|
+
explanations << array_to_sum(bunch_values)
|
|
135
|
+
explanations.join('. ')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def array_to_sum(array)
|
|
139
|
+
(numbers_to_strings(array) + ['=', array.sum]).join(' ')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def numbers_to_strings(array)
|
|
143
|
+
[array.first.to_s] + array.drop(1).map { |n| n.negative? ? "- #{n.abs}" : "+ #{n}" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def bunches_weighted_sum(summed_method)
|
|
147
|
+
@bunch_multipliers.zip(@bunches).inject(0) do |total, mb|
|
|
148
|
+
m, b = mb
|
|
149
|
+
total + (m * b.send(summed_method))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|