games_dice 0.5.0 → 0.6.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 +11 -1
- data/.gitignore +0 -1
- data/.rubocop.yml +13 -48
- data/CHANGELOG.md +19 -0
- data/Gemfile +5 -3
- data/README.md +46 -5
- data/Rakefile +12 -5
- data/ext/games_dice/extconf.rb +0 -3
- data/ext/games_dice/probabilities.c +293 -193
- data/ext/games_dice/probabilities.h +2 -12
- data/games_dice.gemspec +1 -1
- data/lib/games_dice/bunch.rb +50 -36
- data/lib/games_dice/bunch_helpers.rb +35 -14
- data/lib/games_dice/complex_die.rb +64 -22
- data/lib/games_dice/complex_die_helpers.rb +122 -144
- data/lib/games_dice/dice.rb +61 -17
- data/lib/games_dice/dice_helpers.rb +37 -0
- data/lib/games_dice/die_result.rb +6 -8
- data/lib/games_dice/marshal.rb +2 -7
- data/lib/games_dice/parser.rb +30 -0
- data/lib/games_dice/probability_budget.rb +90 -0
- data/lib/games_dice/probability_calculation.rb +95 -0
- data/lib/games_dice/probability_shortcuts.rb +65 -0
- data/lib/games_dice/recipe_cache.rb +155 -0
- data/lib/games_dice/version.rb +1 -1
- data/lib/games_dice.rb +4 -0
- data/script/check_documentation.rb +10 -0
- data/spec/games_dice/bunch_regressions_spec.rb +46 -0
- data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
- data/spec/games_dice/complex_die_budget_spec.rb +60 -0
- data/spec/games_dice/complex_die_limits_spec.rb +119 -0
- data/spec/games_dice/complex_die_spec.rb +228 -0
- data/spec/games_dice/dice_mutation_spec.rb +165 -0
- data/spec/games_dice/dice_regressions_spec.rb +50 -0
- data/spec/games_dice/dice_spec.rb +61 -0
- data/spec/games_dice/die_result_spec.rb +221 -0
- data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
- data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
- data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
- data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
- data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
- data/spec/games_dice/probabilities_spec.rb +484 -0
- data/spec/games_dice/probability_budget_spec.rb +17 -0
- data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
- data/spec/games_dice_spec.rb +382 -0
- data/spec/helpers.rb +64 -2
- data/spec/simple_cov_spec.rb +42 -0
- data/spec/support/coverage.rb +12 -0
- data/spec/support/validation.rb +14 -0
- data/spec/yard/cli/stats_spec.rb +43 -0
- metadata +29 -12
- data/spec/complex_die_spec.rb +0 -314
- data/spec/dice_spec.rb +0 -33
- data/spec/die_result_spec.rb +0 -254
- data/spec/fixtures/probs_fair_die_6.dat +0 -2
- data/spec/probability_spec.rb +0 -584
- data/spec/readme_spec.rb +0 -410
|
@@ -3,125 +3,32 @@
|
|
|
3
3
|
module GamesDice
|
|
4
4
|
class ComplexDie
|
|
5
5
|
# @!visibility private
|
|
6
|
-
# Private
|
|
6
|
+
# Private cache handling for bounded probability calculations.
|
|
7
7
|
module ProbabilityHelpers
|
|
8
8
|
private
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
10
|
+
def probabilities_for_budget(limit)
|
|
11
|
+
refresh_recipe
|
|
12
|
+
return cached_probabilities if valid_probability_cache?(limit)
|
|
77
13
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@probabilities_complete = false
|
|
81
|
-
return false
|
|
14
|
+
with_recipe do
|
|
15
|
+
cache_calculation(ProbabilityCalculation.new(self, limit))
|
|
82
16
|
end
|
|
83
|
-
|
|
84
|
-
|
|
17
|
+
rescue StandardError
|
|
18
|
+
@probabilities_complete = nil
|
|
19
|
+
raise
|
|
85
20
|
end
|
|
86
21
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
recurse_probs_with_rule(stack, result_so_far, rerolls_remaining, rule)
|
|
22
|
+
def cached_probabilities
|
|
23
|
+
@probabilities_complete = @probability_error.zero?
|
|
24
|
+
@probabilities
|
|
91
25
|
end
|
|
92
26
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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]
|
|
27
|
+
def cache_calculation(calculation)
|
|
28
|
+
distribution = calculation.calculate
|
|
29
|
+
@probability_error = calculation.error
|
|
30
|
+
@probabilities_complete = @probability_error.zero?
|
|
31
|
+
@probabilities = distribution
|
|
125
32
|
end
|
|
126
33
|
end
|
|
127
34
|
|
|
@@ -130,21 +37,21 @@ module GamesDice
|
|
|
130
37
|
module RollHelpers
|
|
131
38
|
private
|
|
132
39
|
|
|
133
|
-
def roll_apply_rerolls
|
|
134
|
-
return unless
|
|
40
|
+
def roll_apply_rerolls(rules)
|
|
41
|
+
return unless rules
|
|
135
42
|
|
|
136
43
|
subtracting = false
|
|
137
|
-
rerolls_remaining =
|
|
44
|
+
rerolls_remaining = rules.map(&:limit)
|
|
138
45
|
|
|
139
|
-
rerolls_loop(subtracting, rerolls_remaining)
|
|
46
|
+
rerolls_loop(subtracting, rerolls_remaining, rules)
|
|
140
47
|
end
|
|
141
48
|
|
|
142
|
-
def rerolls_loop(subtracting, rerolls_remaining)
|
|
49
|
+
def rerolls_loop(subtracting, rerolls_remaining, rules)
|
|
143
50
|
loop do
|
|
144
|
-
rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining)
|
|
51
|
+
rule_idx = find_matching_reroll_rule(@basic_die.result, @result.rolls.length, rerolls_remaining, nil, rules)
|
|
145
52
|
break unless rule_idx
|
|
146
53
|
|
|
147
|
-
rule =
|
|
54
|
+
rule = rules[rule_idx]
|
|
148
55
|
rerolls_remaining[rule_idx] -= 1
|
|
149
56
|
subtracting = true if rule.type == :reroll_subtract
|
|
150
57
|
roll_apply_reroll_rule rule, subtracting
|
|
@@ -161,32 +68,36 @@ module GamesDice
|
|
|
161
68
|
end
|
|
162
69
|
|
|
163
70
|
# Find which rule, if any, is being triggered
|
|
164
|
-
def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining)
|
|
165
|
-
|
|
166
|
-
|
|
71
|
+
def find_matching_reroll_rule(check_value, num_rolls, rerolls_remaining, budget = nil, rules = @rerolls)
|
|
72
|
+
return unless rules
|
|
73
|
+
|
|
74
|
+
rules.each_with_index do |rule, index|
|
|
75
|
+
next unless available_reroll?(rule, num_rolls, rerolls_remaining[index])
|
|
167
76
|
|
|
168
|
-
|
|
77
|
+
budget&.predicate
|
|
78
|
+
return index if rule.applies?(check_value)
|
|
169
79
|
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def available_reroll?(rule, num_rolls, remaining)
|
|
84
|
+
remaining.positive? && !(rule.type == :reroll_subtract && num_rolls > 1)
|
|
170
85
|
end
|
|
171
86
|
|
|
172
|
-
def roll_apply_maps
|
|
173
|
-
return unless
|
|
87
|
+
def roll_apply_maps(maps)
|
|
88
|
+
return unless maps
|
|
174
89
|
|
|
175
|
-
m, n = calc_maps(@result.value)
|
|
176
|
-
@result.apply_map(m, n)
|
|
90
|
+
m, n = calc_maps(@result.value, nil, maps)
|
|
91
|
+
@result.apply_map(m, n.dup)
|
|
177
92
|
end
|
|
178
93
|
|
|
179
|
-
def calc_maps(original_value)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
y = maybe_y
|
|
185
|
-
n = rule.mapped_name
|
|
186
|
-
end
|
|
187
|
-
maybe_y
|
|
94
|
+
def calc_maps(original_value, budget = nil, maps = @maps)
|
|
95
|
+
maps.each do |rule|
|
|
96
|
+
budget&.predicate
|
|
97
|
+
mapped = rule.map_from(original_value)
|
|
98
|
+
return [mapped, rule.mapped_name] if mapped
|
|
188
99
|
end
|
|
189
|
-
[
|
|
100
|
+
[0, '']
|
|
190
101
|
end
|
|
191
102
|
end
|
|
192
103
|
|
|
@@ -196,12 +107,66 @@ module GamesDice
|
|
|
196
107
|
private
|
|
197
108
|
|
|
198
109
|
def calc_minmax
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
110
|
+
cheap = cheap_minmax
|
|
111
|
+
if cheap
|
|
112
|
+
@min_result, @max_result = cheap
|
|
113
|
+
else
|
|
114
|
+
calculate_minmax
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def calculate_minmax
|
|
119
|
+
distribution = probabilities
|
|
120
|
+
if @probabilities_complete
|
|
121
|
+
@min_result = distribution.min
|
|
122
|
+
@max_result = distribution.max
|
|
123
|
+
else
|
|
124
|
+
@min_result, @max_result = logical_minmax
|
|
125
|
+
end
|
|
126
|
+
rescue ProbabilityLimitError
|
|
127
|
+
@min_result, @max_result = safe_minmax
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cheap_minmax
|
|
131
|
+
return if @maps
|
|
132
|
+
return [1, sides] if !@rerolls || @rerolls.empty?
|
|
133
|
+
return unless @rerolls.one?
|
|
134
|
+
|
|
135
|
+
rule = @rerolls.first
|
|
136
|
+
return unless rule.instance_of?(RerollRule) && ProbabilityShortcuts.boolean_rule?(rule)
|
|
137
|
+
|
|
138
|
+
cheap_rule_minmax(rule)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cheap_rule_minmax(rule)
|
|
142
|
+
return [1, sides] if rule.type == :reroll_replace
|
|
143
|
+
return unless sides == 1 && rule.type == :reroll_add
|
|
202
144
|
|
|
203
|
-
|
|
204
|
-
|
|
145
|
+
value = 1 + (rule.applies?(1) ? [rule.limit, 0].max : 0)
|
|
146
|
+
[value, value]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def safe_minmax
|
|
150
|
+
return safe_mapped_minmax if @maps
|
|
151
|
+
|
|
152
|
+
adds = positive_rule_limits(:reroll_add)
|
|
153
|
+
subtracts = positive_rule_limits(:reroll_subtract)
|
|
154
|
+
return [1, sides * (1 + adds)] if subtracts.zero?
|
|
155
|
+
|
|
156
|
+
maximum = sides * (1 + adds + subtracts)
|
|
157
|
+
[-maximum, maximum]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def safe_mapped_minmax
|
|
161
|
+
unless @maps.all? { |rule| rule.instance_of?(MapRule) && ProbabilityShortcuts.boolean_rule?(rule) }
|
|
162
|
+
raise ProbabilityLimitError, 'Cannot establish finite dice bounds within calculation limits'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
[0, *@maps.map(&:mapped_value)].minmax
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def positive_rule_limits(type)
|
|
169
|
+
(@rerolls || []).sum { |rule| rule.type == type ? [rule.limit, 0].max : 0 }
|
|
205
170
|
end
|
|
206
171
|
|
|
207
172
|
def minmax_mappings(possible_values)
|
|
@@ -216,15 +181,28 @@ module GamesDice
|
|
|
216
181
|
# cannot actually occur. In practice it is likely a non-issue unless someone went out of their way to invent a
|
|
217
182
|
# dice schem that broke it.
|
|
218
183
|
def logical_minmax
|
|
219
|
-
return
|
|
220
|
-
return minmax_mappings(@basic_die.all_values) unless @rerolls
|
|
184
|
+
return safe_minmax unless inexpensive_bounds?
|
|
221
185
|
|
|
222
186
|
min_result, max_result = logical_rerolls_minmax
|
|
223
|
-
|
|
187
|
+
if @maps
|
|
188
|
+
return safe_minmax if (max_result - min_result + 1) * @maps.length > ProbabilityBudget::MAX_PREDICATES
|
|
189
|
+
|
|
190
|
+
return minmax_mappings(min_result..max_result)
|
|
191
|
+
end
|
|
224
192
|
|
|
225
193
|
[min_result, max_result]
|
|
226
194
|
end
|
|
227
195
|
|
|
196
|
+
def inexpensive_bounds?
|
|
197
|
+
rules = [*(@rerolls || []), *(@maps || [])]
|
|
198
|
+
rules.all? { |rule| ordinary_boolean_rule?(rule) } &&
|
|
199
|
+
sides * rules.length <= ProbabilityBudget::MAX_FACES
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def ordinary_boolean_rule?(rule)
|
|
203
|
+
(rule.instance_of?(RerollRule) || rule.instance_of?(MapRule)) && ProbabilityShortcuts.boolean_rule?(rule)
|
|
204
|
+
end
|
|
205
|
+
|
|
228
206
|
def logical_rerolls_minmax
|
|
229
207
|
min_result = @basic_die.min
|
|
230
208
|
max_result = @basic_die.max
|
|
@@ -251,7 +229,7 @@ module GamesDice
|
|
|
251
229
|
max_reroll = @basic_die.all_values.select { |v| rule.applies?(v) }.max
|
|
252
230
|
next unless max_reroll
|
|
253
231
|
|
|
254
|
-
total_add += max_reroll * rule.limit
|
|
232
|
+
total_add += max_reroll * [rule.limit, 0].max
|
|
255
233
|
end
|
|
256
234
|
total_add
|
|
257
235
|
end
|
data/lib/games_dice/dice.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'games_dice/dice_helpers'
|
|
4
|
+
|
|
3
5
|
module GamesDice
|
|
4
6
|
# This class models a combination of GamesDice::Bunch objects plus a fixed offset.
|
|
5
7
|
#
|
|
@@ -20,6 +22,9 @@ module GamesDice
|
|
|
20
22
|
# d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
|
|
21
23
|
#
|
|
22
24
|
class Dice
|
|
25
|
+
include RecipeQueries
|
|
26
|
+
include ProbabilityHelpers
|
|
27
|
+
|
|
23
28
|
# The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
|
|
24
29
|
# @param [Array<Hash>] bunches Array of options for creating bunches
|
|
25
30
|
# @param [Integer] offset Total offset
|
|
@@ -43,6 +48,7 @@ module GamesDice
|
|
|
43
48
|
@bunches = bunches.map { |b| GamesDice::Bunch.new(b) }
|
|
44
49
|
@bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
|
|
45
50
|
@result = nil
|
|
51
|
+
initialize_recipe
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
# Name to help identify dice
|
|
@@ -64,26 +70,35 @@ module GamesDice
|
|
|
64
70
|
|
|
65
71
|
# Result of most-recent roll, or nil if no roll made yet.
|
|
66
72
|
# @return [Integer,nil]
|
|
67
|
-
|
|
73
|
+
def result
|
|
74
|
+
refresh_recipe
|
|
75
|
+
@result
|
|
76
|
+
end
|
|
68
77
|
|
|
69
78
|
# Simulates rolling dice
|
|
70
79
|
# @return [Integer] Sum of all rolled dice
|
|
71
80
|
def roll
|
|
72
|
-
@result = @offset + bunches_weighted_sum(:roll)
|
|
81
|
+
with_recipe { @result = @offset + bunches_weighted_sum(:roll) }
|
|
73
82
|
end
|
|
74
83
|
|
|
75
84
|
# @!attribute [r] min
|
|
76
|
-
#
|
|
85
|
+
# Lower bound on the value returned by #roll. No roll will be below this value.
|
|
86
|
+
# For some complex dice, this may be lower than any value that can actually
|
|
87
|
+
# be rolled, because calculating the exact minimum would take too much work.
|
|
77
88
|
# @return [Integer]
|
|
78
89
|
def min
|
|
79
|
-
|
|
90
|
+
refresh_recipe
|
|
91
|
+
@min ||= with_recipe { @offset + weighted_bound(:min, :max) }
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
# @!attribute [r] max
|
|
83
|
-
#
|
|
95
|
+
# Upper bound on the value returned by #roll. No roll will exceed this value.
|
|
96
|
+
# For some complex dice, this may be higher than any value that can actually
|
|
97
|
+
# be rolled, because calculating the exact maximum would take too much work.
|
|
84
98
|
# @return [Integer]
|
|
85
99
|
def max
|
|
86
|
-
|
|
100
|
+
refresh_recipe
|
|
101
|
+
@max ||= with_recipe { @offset + weighted_bound(:max, :min) }
|
|
87
102
|
end
|
|
88
103
|
|
|
89
104
|
# @!attribute [r] minmax
|
|
@@ -93,22 +108,27 @@ module GamesDice
|
|
|
93
108
|
[min, max]
|
|
94
109
|
end
|
|
95
110
|
|
|
96
|
-
# Calculates the probability distribution for the
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
111
|
+
# Calculates the probability distribution for the combined dice result.
|
|
112
|
+
#
|
|
113
|
+
# Very unlikely sequences of rerolls may be left out to keep calculation
|
|
114
|
+
# time reasonable. This changes the probability of any combined result by
|
|
115
|
+
# at most 1e-10 (one in ten billion), in addition to normal floating-point
|
|
116
|
+
# rounding. This limit covers all the bunches together.
|
|
117
|
+
#
|
|
118
|
+
# Raises ProbabilityLimitError if this accuracy cannot be achieved within
|
|
119
|
+
# the calculation's work limits.
|
|
120
|
+
# @return [GamesDice::Probabilities] Probability distribution of the dice.
|
|
100
121
|
def probabilities
|
|
101
|
-
|
|
122
|
+
refresh_recipe
|
|
123
|
+
return @probabilities if valid_probability_cache?(ProbabilityBudget::LIMIT)
|
|
102
124
|
|
|
103
|
-
@
|
|
104
|
-
m, b = mb
|
|
105
|
-
GamesDice::Probabilities.add_distributions_mult(1, probs, m, b.probabilities)
|
|
106
|
-
end
|
|
125
|
+
@probabilities = with_recipe { combined_probabilities }
|
|
107
126
|
end
|
|
108
127
|
|
|
109
128
|
# @!attribute [r] explain_result
|
|
110
129
|
# @return [String,nil] Explanation of result, or nil if no call to #roll yet.
|
|
111
130
|
def explain_result
|
|
131
|
+
refresh_recipe
|
|
112
132
|
return nil unless @result
|
|
113
133
|
|
|
114
134
|
explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
|
|
@@ -122,10 +142,34 @@ module GamesDice
|
|
|
122
142
|
|
|
123
143
|
private
|
|
124
144
|
|
|
145
|
+
def recipe_sources
|
|
146
|
+
[[@bunches, Bunch], [@bunch_multipliers, Numeric]]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def recipe_labels
|
|
150
|
+
[@name]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_recipe
|
|
154
|
+
return if @bunches.length == @bunch_multipliers.length
|
|
155
|
+
|
|
156
|
+
raise ArgumentError, 'bunches and bunch_multipliers must have the same length'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def weighted_bound(positive, negative)
|
|
160
|
+
@bunches.zip(@bunch_multipliers).sum do |bunch, multiplier|
|
|
161
|
+
next 0 if multiplier.zero?
|
|
162
|
+
|
|
163
|
+
multiplier * bunch.public_send(multiplier.negative? ? negative : positive)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
125
167
|
def simple_explanation(explanation)
|
|
126
|
-
return explanation if @offset.zero?
|
|
168
|
+
return explanation if @offset.zero? && @bunch_multipliers[0] == 1
|
|
127
169
|
|
|
128
|
-
|
|
170
|
+
values = [@bunch_multipliers[0] * @bunches[0].result]
|
|
171
|
+
values << @offset unless @offset.zero?
|
|
172
|
+
"#{explanation}. #{array_to_sum(values)}"
|
|
129
173
|
end
|
|
130
174
|
|
|
131
175
|
def multi_explanations(explanations)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
class Dice
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# Private combination helpers sharing one truncation budget across bunches.
|
|
7
|
+
module ProbabilityHelpers
|
|
8
|
+
Combination = Struct.new(:distribution, :error)
|
|
9
|
+
private_constant :Combination
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def combined_probabilities
|
|
14
|
+
per_die = ProbabilityBudget.share(ProbabilityBudget::LIMIT, [probability_dice_count, 1].max)
|
|
15
|
+
result = Combination.new(Probabilities.new([1.0], @offset), 0.0)
|
|
16
|
+
@bunches.zip(@bunch_multipliers).each do |bunch, multiplier|
|
|
17
|
+
next if multiplier.zero?
|
|
18
|
+
|
|
19
|
+
component = component_probabilities(bunch, per_die, result)
|
|
20
|
+
result.distribution = Probabilities.add_distributions_mult(1, result.distribution, multiplier, component)
|
|
21
|
+
end
|
|
22
|
+
@probability_error = ProbabilityBudget.check(result.error, ProbabilityBudget::LIMIT)
|
|
23
|
+
result.distribution
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def probability_dice_count
|
|
27
|
+
@bunches.each_with_index.sum { |bunch, index| @bunch_multipliers[index].zero? ? 0 : bunch.ndice }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def component_probabilities(bunch, per_die, result)
|
|
31
|
+
component = bunch.__send__(:probabilities_for_budget, (per_die * bunch.ndice).prev_float)
|
|
32
|
+
result.error = ProbabilityBudget.add(result.error, bunch.instance_variable_get(:@probability_error))
|
|
33
|
+
component
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -164,9 +164,6 @@ module GamesDice
|
|
|
164
164
|
|
|
165
165
|
private
|
|
166
166
|
|
|
167
|
-
# Splitting this method up further, or flattening it will not make it read better. If
|
|
168
|
-
# we had a few more reroll reasons, they could maybe be grouped and method split up.
|
|
169
|
-
# rubocop:disable Metrics/MethodLength
|
|
170
167
|
def apply_roll_to_total(roll_reason, roll_result)
|
|
171
168
|
case roll_reason
|
|
172
169
|
when :basic, :reroll_new_die, :reroll_new_keeper, :reroll_replace
|
|
@@ -175,13 +172,14 @@ module GamesDice
|
|
|
175
172
|
@total += roll_result
|
|
176
173
|
when :reroll_subtract
|
|
177
174
|
@total -= roll_result
|
|
178
|
-
when :reroll_use_best
|
|
179
|
-
@total =
|
|
180
|
-
when :reroll_use_worst
|
|
181
|
-
@total = [@value, roll_result].min
|
|
175
|
+
when :reroll_use_best, :reroll_use_worst
|
|
176
|
+
@total = select_roll(roll_reason, roll_result)
|
|
182
177
|
end
|
|
183
178
|
end
|
|
184
|
-
|
|
179
|
+
|
|
180
|
+
def select_roll(roll_reason, roll_result)
|
|
181
|
+
roll_reason == :reroll_use_best ? [@value, roll_result].max : [@value, roll_result].min
|
|
182
|
+
end
|
|
185
183
|
|
|
186
184
|
def init_with_result(first_roll_result, first_roll_reason)
|
|
187
185
|
@rolls = [Integer(first_roll_result)]
|
data/lib/games_dice/marshal.rb
CHANGED
|
@@ -29,13 +29,8 @@ module GamesDice
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# @!visibility private
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
# it for backwards-compatibility.
|
|
35
|
-
# rubocop:disable Security/MarshalLoad
|
|
36
|
-
h = Marshal.load buf
|
|
37
|
-
# rubocop:enable Security/MarshalLoad
|
|
38
|
-
from_h h
|
|
32
|
+
def marshal_load(distribution)
|
|
33
|
+
initialize_copy(self.class.from_h(distribution))
|
|
39
34
|
end
|
|
40
35
|
end
|
|
41
36
|
end
|
data/lib/games_dice/parser.rb
CHANGED
|
@@ -92,6 +92,9 @@ module GamesDice
|
|
|
92
92
|
# @!visibility private
|
|
93
93
|
class ParseTreeProcessor
|
|
94
94
|
class << self
|
|
95
|
+
# Collects dice terms into constructor options, leaving constant terms for {collect_offset}.
|
|
96
|
+
# @param [Hash] dice_expressions Parsed expression tree.
|
|
97
|
+
# @return [Array<Hash>] Options for each GamesDice::Bunch.
|
|
95
98
|
def collect_bunches(dice_expressions)
|
|
96
99
|
dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash|
|
|
97
100
|
out_hash = {}
|
|
@@ -106,6 +109,10 @@ module GamesDice
|
|
|
106
109
|
end
|
|
107
110
|
end
|
|
108
111
|
|
|
112
|
+
# Copies the parsed number of dice and sides into constructor options as integers.
|
|
113
|
+
# @param [Hash] in_hash Parsed dice term.
|
|
114
|
+
# @param [Hash] out_hash Constructor options to update.
|
|
115
|
+
# @return [Array<Symbol>] The processed field names.
|
|
109
116
|
def collect_bunch_basics(in_hash, out_hash)
|
|
110
117
|
%i[ndice sides].each do |s|
|
|
111
118
|
next unless in_hash[s]
|
|
@@ -114,6 +121,10 @@ module GamesDice
|
|
|
114
121
|
end
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
# Converts a term's sign to its numeric multiplier.
|
|
125
|
+
# @param [Hash] in_hash Parsed dice term with an :op entry.
|
|
126
|
+
# @param [Hash] out_hash Constructor options to update.
|
|
127
|
+
# @return [Integer,nil] Multiplier, or nil for an unknown operator.
|
|
117
128
|
def collect_bunch_multiplier(in_hash, out_hash)
|
|
118
129
|
optype = in_hash[:op].to_s
|
|
119
130
|
out_hash[:multiplier] = case optype
|
|
@@ -122,6 +133,10 @@ module GamesDice
|
|
|
122
133
|
end
|
|
123
134
|
end
|
|
124
135
|
|
|
136
|
+
# Dispatches a parsed modifier to its alias, keeper, map, or reroll collector.
|
|
137
|
+
# @param [Hash] mod Parsed modifier.
|
|
138
|
+
# @param [Hash] out_hash Constructor options to update.
|
|
139
|
+
# @return [Object] Result of the selected collector, or nil for an unknown modifier.
|
|
125
140
|
def collect_bunch_modifier(mod, out_hash)
|
|
126
141
|
if mod[:alias]
|
|
127
142
|
ParseTreeBunchModifier.collect_alias_modifier mod, out_hash
|
|
@@ -134,6 +149,9 @@ module GamesDice
|
|
|
134
149
|
end
|
|
135
150
|
end
|
|
136
151
|
|
|
152
|
+
# Sums signed constant terms separately from dice terms.
|
|
153
|
+
# @param [Hash] dice_expressions Parsed expression tree.
|
|
154
|
+
# @return [Integer] Fixed offset for GamesDice::Dice.
|
|
137
155
|
def collect_offset(dice_expressions)
|
|
138
156
|
dice_expressions[:bunches].select { |h| h[:constant] }.inject(0) do |total, in_hash|
|
|
139
157
|
c = in_hash[:constant].to_i
|
|
@@ -173,6 +191,11 @@ module GamesDice
|
|
|
173
191
|
collect_complex_reroll_rule(reroll_mod, out_hash)
|
|
174
192
|
end
|
|
175
193
|
|
|
194
|
+
# Appends a reroll rule, reversing the comparison and preserving an explicit limit.
|
|
195
|
+
# Omitted types default to replacement; an omitted limit uses RerollRule's default.
|
|
196
|
+
# @param [Hash] reroll_mod Parsed long-form reroll modifier.
|
|
197
|
+
# @param [Hash] out_hash Constructor options with an initialized :rerolls array.
|
|
198
|
+
# @return [Array<Array>] Updated reroll rules.
|
|
176
199
|
def collect_complex_reroll_rule(reroll_mod, out_hash)
|
|
177
200
|
# Typical reroll_mod: {:reroll=>"r"@5, :condition=>{:compare_num=>"10"@7}, :type=>"add"@10}
|
|
178
201
|
op = get_op_symbol(reroll_mod[:condition][:comparison] || '==')
|
|
@@ -212,6 +235,10 @@ module GamesDice
|
|
|
212
235
|
collect_complex_map_rule(map_mod, out_hash)
|
|
213
236
|
end
|
|
214
237
|
|
|
238
|
+
# Appends a map rule, defaulting to a threshold comparison and a mapped value of one.
|
|
239
|
+
# @param [Hash] map_mod Parsed long-form map modifier, optionally including a label.
|
|
240
|
+
# @param [Hash] out_hash Constructor options with an initialized :maps array.
|
|
241
|
+
# @return [Array<Array>] Updated map rules.
|
|
215
242
|
def collect_complex_map_rule(map_mod, out_hash)
|
|
216
243
|
# Typical map_mod: {:map=>"m"@4, :condition=>{:compare_num=>"5"@6}, :num=>"2"@8, :output=>"Qwerty"@10}
|
|
217
244
|
op = get_op_symbol(map_mod[:condition][:comparison] || '>=')
|
|
@@ -236,6 +263,9 @@ module GamesDice
|
|
|
236
263
|
'<=' => :>=
|
|
237
264
|
}.freeze
|
|
238
265
|
|
|
266
|
+
# Reverses a notation comparison for rules that send the operator to the trigger value.
|
|
267
|
+
# @param [#to_s] parsed_op_string Comparison from the parse tree.
|
|
268
|
+
# @return [Symbol,nil] Reversed operator, or nil for an unknown comparison.
|
|
239
269
|
def get_op_symbol(parsed_op_string)
|
|
240
270
|
OP_CONVERSION[parsed_op_string.to_s]
|
|
241
271
|
end
|