games_dice 0.4.0 → 0.5.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 +4 -4
- data/.github/workflows/ci.yml +92 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +41 -3
- data/.yardopts +1 -1
- data/CHANGELOG.md +19 -0
- data/Gemfile +11 -0
- data/README.md +11 -33
- data/Rakefile +79 -14
- data/ext/games_dice/extconf.rb +29 -0
- data/ext/games_dice/games_dice.c +2 -2
- data/ext/games_dice/probabilities.c +178 -29
- data/ext/games_dice/probabilities.h +1 -17
- data/games_dice.gemspec +11 -19
- data/lib/games_dice/bunch.rb +27 -65
- data/lib/games_dice/bunch_helpers.rb +68 -0
- data/lib/games_dice/complex_die.rb +10 -146
- data/lib/games_dice/complex_die_helpers.rb +238 -46
- data/lib/games_dice/dice.rb +17 -10
- data/lib/games_dice/die.rb +2 -2
- data/lib/games_dice/die_result.rb +84 -71
- data/lib/games_dice/marshal.rb +26 -3
- data/lib/games_dice/parser.rb +128 -102
- data/lib/games_dice/version.rb +2 -1
- data/lib/games_dice.rb +7 -9
- data/spec/bunch_spec.rb +72 -72
- data/spec/complex_die_spec.rb +158 -158
- data/spec/dice_spec.rb +5 -5
- data/spec/die_result_spec.rb +98 -98
- data/spec/die_spec.rb +19 -19
- data/spec/helpers.rb +2 -4
- data/spec/map_rule_spec.rb +17 -17
- data/spec/parser_spec.rb +7 -7
- data/spec/probability_spec.rb +248 -194
- data/spec/readme_spec.rb +28 -22
- data/spec/reroll_rule_spec.rb +15 -15
- metadata +16 -139
- data/.travis.yml +0 -11
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
class Bunch
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# Private extension methods for GamesDice::Bunch keep rules
|
|
7
|
+
module KeepHelpers
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def keep_mode_from_hash(options)
|
|
11
|
+
@keep_mode = options[:keep_mode]
|
|
12
|
+
case @keep_mode
|
|
13
|
+
when nil
|
|
14
|
+
@keep_mode = nil
|
|
15
|
+
when :keep_best, :keep_worst
|
|
16
|
+
@keep_number = Integer(options[:keep_number] || 1)
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find_used_dice_due_to_keep_mode(used_dice, unused_dice = [])
|
|
23
|
+
full_dice = result_details.sort_by(&:total)
|
|
24
|
+
case @keep_mode
|
|
25
|
+
when :keep_best
|
|
26
|
+
used_dice = full_dice[-@keep_number..]
|
|
27
|
+
unused_dice = full_dice[0..(full_dice.length - 1 - @keep_number)]
|
|
28
|
+
when :keep_worst
|
|
29
|
+
used_dice = full_dice[0..(@keep_number - 1)]
|
|
30
|
+
unused_dice = full_dice[@keep_number..]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
[used_dice, unused_dice]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def explain_kept_dice(used_dice)
|
|
37
|
+
separator = @single_die.maps ? ', ' : ' + '
|
|
38
|
+
". Keep: #{used_dice.map(&:explain_total).join(separator)}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @!visibility private
|
|
43
|
+
# Private extension methods for GamesDice::Bunch explaining
|
|
44
|
+
module ExplainHelpers
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_explanation(used_dice)
|
|
48
|
+
if @keep_mode || @single_die.maps
|
|
49
|
+
explanation = explain_with_keep_or_map(used_dice)
|
|
50
|
+
else
|
|
51
|
+
explanation = used_dice.map(&:explain_value).join(' + ')
|
|
52
|
+
explanation += " = #{@result}" if @ndice > 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
explanation
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def explain_with_keep_or_map(used_dice)
|
|
59
|
+
explanation = result_details.map(&:explain_value).join(', ')
|
|
60
|
+
explanation += explain_kept_dice(used_dice) if @keep_mode
|
|
61
|
+
explanation += ". Successes: #{@result}" if @single_die.maps
|
|
62
|
+
explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
|
|
63
|
+
|
|
64
|
+
explanation
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -24,7 +24,9 @@ module GamesDice
|
|
|
24
24
|
# d.explain_result # => "[6+5] 11 Success"
|
|
25
25
|
#
|
|
26
26
|
class ComplexDie
|
|
27
|
-
include
|
|
27
|
+
include RollHelpers
|
|
28
|
+
include ProbabilityHelpers
|
|
29
|
+
include MinMaxHelpers
|
|
28
30
|
|
|
29
31
|
# @!visibility private
|
|
30
32
|
# arbitrary limit to speed up probability calculations. It should
|
|
@@ -35,8 +37,10 @@ module GamesDice
|
|
|
35
37
|
# @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor
|
|
36
38
|
# @param [Hash] options
|
|
37
39
|
# @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
|
|
39
|
-
#
|
|
40
|
+
# @option options [Array<GamesDice::MapRule,Array>] :maps The rules to convert a value into a final result
|
|
41
|
+
# for the die
|
|
42
|
+
# @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to
|
|
43
|
+
# GamesDice::Die's constructor
|
|
40
44
|
# @return [GamesDice::ComplexDie]
|
|
41
45
|
def initialize(sides, options = {})
|
|
42
46
|
@basic_die = GamesDice::Die.new(sides, options[:prng])
|
|
@@ -46,6 +50,8 @@ module GamesDice
|
|
|
46
50
|
|
|
47
51
|
@total = nil
|
|
48
52
|
@result = nil
|
|
53
|
+
|
|
54
|
+
@probabilities_complete = true
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
# The simple component used by this complex one
|
|
@@ -105,27 +111,7 @@ module GamesDice
|
|
|
105
111
|
# 1e-9 at worst.
|
|
106
112
|
# @return [GamesDice::Probabilities] Probability distribution of die.
|
|
107
113
|
def probabilities
|
|
108
|
-
|
|
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)
|
|
114
|
+
@probabilities ||= calculate_probabilities
|
|
129
115
|
end
|
|
130
116
|
|
|
131
117
|
# Simulates rolling the die
|
|
@@ -140,63 +126,6 @@ module GamesDice
|
|
|
140
126
|
|
|
141
127
|
private
|
|
142
128
|
|
|
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
129
|
def construct_rerolls(rerolls_input)
|
|
201
130
|
check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
|
|
202
131
|
end
|
|
@@ -218,70 +147,5 @@ module GamesDice
|
|
|
218
147
|
end
|
|
219
148
|
end
|
|
220
149
|
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
150
|
end
|
|
287
151
|
end
|
|
@@ -1,68 +1,260 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# @!visibility private
|
|
4
3
|
module GamesDice
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
class ComplexDie
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# Private extension methods for GamesDice::ComplexDie probability calculations
|
|
7
|
+
module ProbabilityHelpers
|
|
8
|
+
private
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
15
20
|
end
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
20
29
|
end
|
|
21
|
-
probabilities
|
|
22
|
-
end
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
86
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
35
94
|
t = result_so_far.total
|
|
36
|
-
probabilities[t] ||= 0.0
|
|
37
|
-
probabilities[t] +=
|
|
95
|
+
stack.probabilities[t] ||= 0.0
|
|
96
|
+
stack.probabilities[t] += stack.prior_probability
|
|
38
97
|
end
|
|
39
|
-
end
|
|
40
98
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
45
111
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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]
|
|
53
125
|
end
|
|
54
126
|
end
|
|
55
127
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
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
|
|
64
257
|
end
|
|
65
|
-
[result_so_far, rerolls_remaining]
|
|
66
258
|
end
|
|
67
259
|
end
|
|
68
260
|
end
|
data/lib/games_dice/dice.rb
CHANGED
|
@@ -28,11 +28,14 @@ module GamesDice
|
|
|
28
28
|
# @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
|
|
29
29
|
# @option bunches [String] :name Optional name for the bunch
|
|
30
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
|
-
#
|
|
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
|
|
33
35
|
# @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
|
|
34
36
|
# @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
|
|
35
|
-
# @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the
|
|
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
|
|
36
39
|
# @return [GamesDice::Dice]
|
|
37
40
|
def initialize(bunches, offset = 0, name = '')
|
|
38
41
|
@name = name
|
|
@@ -110,14 +113,11 @@ module GamesDice
|
|
|
110
113
|
|
|
111
114
|
explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
|
|
112
115
|
|
|
113
|
-
return @offset.to_s if explanations.
|
|
116
|
+
return @offset.to_s if explanations.none?
|
|
114
117
|
|
|
115
|
-
return simple_explanation(explanations.first) if explanations.
|
|
118
|
+
return simple_explanation(explanations.first) if explanations.one?
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
bunch_values << @offset if @offset != 0
|
|
119
|
-
explanations << array_to_sum(bunch_values)
|
|
120
|
-
explanations.join('. ')
|
|
120
|
+
multi_explanations(explanations)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
private
|
|
@@ -128,8 +128,15 @@ module GamesDice
|
|
|
128
128
|
"#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}"
|
|
129
129
|
end
|
|
130
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
|
+
|
|
131
138
|
def array_to_sum(array)
|
|
132
|
-
(numbers_to_strings(array) + ['=', array.
|
|
139
|
+
(numbers_to_strings(array) + ['=', array.sum]).join(' ')
|
|
133
140
|
end
|
|
134
141
|
|
|
135
142
|
def numbers_to_strings(array)
|
data/lib/games_dice/die.rb
CHANGED
|
@@ -74,8 +74,8 @@ module GamesDice
|
|
|
74
74
|
# Iterates through all possible results on die.
|
|
75
75
|
# @yieldparam [Integer] result A potential result from the die
|
|
76
76
|
# @return [GamesDice::Die] this object
|
|
77
|
-
def each_value(&
|
|
78
|
-
(1..@sides).each(&
|
|
77
|
+
def each_value(&)
|
|
78
|
+
(1..@sides).each(&)
|
|
79
79
|
self
|
|
80
80
|
end
|
|
81
81
|
|