games_dice 0.4.0 → 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 +4 -4
- data/.github/workflows/ci.yml +38 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +41 -3
- data/.yardopts +1 -1
- data/CHANGELOG.md +12 -0
- data/Gemfile +10 -0
- data/README.md +11 -33
- data/Rakefile +2 -18
- data/ext/games_dice/games_dice.c +1 -1
- data/ext/games_dice/probabilities.c +127 -8
- data/ext/games_dice/probabilities.h +1 -1
- 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 +188 -196
- data/spec/readme_spec.rb +28 -22
- data/spec/reroll_rule_spec.rb +15 -15
- metadata +16 -139
- data/.travis.yml +0 -11
|
@@ -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
|
|
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module GamesDice
|
|
4
|
+
# Module supports using DieResults directly in calculations
|
|
5
|
+
# @!visibility private
|
|
6
|
+
module ExpressionHelpers
|
|
7
|
+
# all coercions simply use #value (i.e. nil or a Integer)
|
|
8
|
+
def coerce(thing)
|
|
9
|
+
@value.coerce(thing)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# addition uses #value
|
|
13
|
+
def +(other)
|
|
14
|
+
@value + other
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# subtraction uses #value
|
|
18
|
+
def -(other)
|
|
19
|
+
@value - other
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# multiplication uses #value
|
|
23
|
+
def *(other)
|
|
24
|
+
@value * other
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# comparison <=> uses #value
|
|
28
|
+
def <=>(other)
|
|
29
|
+
value <=> other
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
4
33
|
# This class models the output of GamesDice::ComplexDie.
|
|
5
34
|
#
|
|
6
35
|
# An object of the class represents the results of a roll of a ComplexDie, including any re-rolls and
|
|
@@ -29,6 +58,7 @@ module GamesDice
|
|
|
29
58
|
#
|
|
30
59
|
class DieResult
|
|
31
60
|
include Comparable
|
|
61
|
+
include ExpressionHelpers
|
|
32
62
|
|
|
33
63
|
# Creates new instance of GamesDice::DieResult. The object can be initialised "empty" or with a first result.
|
|
34
64
|
# @param [Integer,nil] first_roll_result Value for first roll of the die.
|
|
@@ -40,13 +70,9 @@ module GamesDice
|
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
if first_roll_result
|
|
43
|
-
|
|
44
|
-
@roll_reasons = [first_roll_reason]
|
|
45
|
-
@total = @rolls[0]
|
|
73
|
+
init_with_result(first_roll_result, first_roll_reason)
|
|
46
74
|
else
|
|
47
|
-
|
|
48
|
-
@roll_reasons = []
|
|
49
|
-
@total = nil
|
|
75
|
+
init_empty
|
|
50
76
|
end
|
|
51
77
|
@mapped = false
|
|
52
78
|
@value = @total
|
|
@@ -78,32 +104,13 @@ module GamesDice
|
|
|
78
104
|
# @param [Symbol] roll_reason Reason for rolling the die.
|
|
79
105
|
# @return [Integer] Total so far
|
|
80
106
|
def add_roll(roll_result, roll_reason = :basic)
|
|
81
|
-
unless GamesDice::REROLL_TYPES.key?(roll_reason)
|
|
82
|
-
raise ArgumentError, "Unrecognised reason for roll #{roll_reason}"
|
|
83
|
-
end
|
|
107
|
+
raise ArgumentError, "Unrecognised roll reason #{roll_reason}" unless GamesDice::REROLL_TYPES.key?(roll_reason)
|
|
84
108
|
|
|
85
109
|
@rolls << Integer(roll_result)
|
|
86
110
|
@roll_reasons << roll_reason
|
|
87
111
|
@total = 0 if @rolls.length == 1
|
|
88
112
|
|
|
89
|
-
|
|
90
|
-
when :basic
|
|
91
|
-
@total = roll_result
|
|
92
|
-
when :reroll_add
|
|
93
|
-
@total += roll_result
|
|
94
|
-
when :reroll_subtract
|
|
95
|
-
@total -= roll_result
|
|
96
|
-
when :reroll_new_die
|
|
97
|
-
@total = roll_result
|
|
98
|
-
when :reroll_new_keeper
|
|
99
|
-
@total = roll_result
|
|
100
|
-
when :reroll_replace
|
|
101
|
-
@total = roll_result
|
|
102
|
-
when :reroll_use_best
|
|
103
|
-
@total = [@value, roll_result].max
|
|
104
|
-
when :reroll_use_worst
|
|
105
|
-
@total = [@value, roll_result].min
|
|
106
|
-
end
|
|
113
|
+
apply_roll_to_total(roll_reason, roll_result)
|
|
107
114
|
|
|
108
115
|
@mapped = false
|
|
109
116
|
@value = @total
|
|
@@ -125,17 +132,12 @@ module GamesDice
|
|
|
125
132
|
# map description, but does not include the mapped value.
|
|
126
133
|
# @return [String] Explanation of #value.
|
|
127
134
|
def explain_value
|
|
128
|
-
text =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
so_far + GamesDice::REROLL_TYPES[@roll_reasons[i]] + @rolls[i].to_s
|
|
135
|
-
end
|
|
136
|
-
text += "] #{@total}"
|
|
137
|
-
end
|
|
138
|
-
text += " #{@map_description}" if @mapped && @map_description && @map_description.length.positive?
|
|
135
|
+
text = if @rolls.length < 2
|
|
136
|
+
@total.to_s
|
|
137
|
+
else
|
|
138
|
+
explain_value_multiple_rolls
|
|
139
|
+
end
|
|
140
|
+
text += " #{@map_description}" if @mapped && @map_description&.length&.positive?
|
|
139
141
|
text
|
|
140
142
|
end
|
|
141
143
|
|
|
@@ -143,51 +145,62 @@ module GamesDice
|
|
|
143
145
|
# This is mis-named, it doesn't explain the total at all! It is used to generate summaries of keeper dice.
|
|
144
146
|
def explain_total
|
|
145
147
|
text = @total.to_s
|
|
146
|
-
text += " #{@map_description}" if @mapped && @map_description
|
|
148
|
+
text += " #{@map_description}" if @mapped && @map_description&.length&.positive?
|
|
147
149
|
text
|
|
148
150
|
end
|
|
149
151
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
def
|
|
153
|
-
|
|
152
|
+
# This is a deep clone, all attributes are also cloned.
|
|
153
|
+
# @return [GamesDice::DieResult]
|
|
154
|
+
def clone
|
|
155
|
+
cloned = GamesDice::DieResult.new
|
|
156
|
+
cloned.instance_variable_set(:@rolls, @rolls.clone)
|
|
157
|
+
cloned.instance_variable_set(:@roll_reasons, @roll_reasons.clone)
|
|
158
|
+
cloned.instance_variable_set(:@total, @total)
|
|
159
|
+
cloned.instance_variable_set(:@value, @value)
|
|
160
|
+
cloned.instance_variable_set(:@mapped, @mapped)
|
|
161
|
+
cloned.instance_variable_set(:@map_description, @map_description)
|
|
162
|
+
cloned
|
|
154
163
|
end
|
|
155
164
|
|
|
156
|
-
|
|
157
|
-
# addition uses #value
|
|
158
|
-
def +(other)
|
|
159
|
-
@value + other
|
|
160
|
-
end
|
|
165
|
+
private
|
|
161
166
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
def apply_roll_to_total(roll_reason, roll_result)
|
|
171
|
+
case roll_reason
|
|
172
|
+
when :basic, :reroll_new_die, :reroll_new_keeper, :reroll_replace
|
|
173
|
+
@total = roll_result
|
|
174
|
+
when :reroll_add
|
|
175
|
+
@total += roll_result
|
|
176
|
+
when :reroll_subtract
|
|
177
|
+
@total -= roll_result
|
|
178
|
+
when :reroll_use_best
|
|
179
|
+
@total = [@value, roll_result].max
|
|
180
|
+
when :reroll_use_worst
|
|
181
|
+
@total = [@value, roll_result].min
|
|
182
|
+
end
|
|
166
183
|
end
|
|
184
|
+
# rubocop:enable Metrics/MethodLength
|
|
167
185
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
@
|
|
186
|
+
def init_with_result(first_roll_result, first_roll_reason)
|
|
187
|
+
@rolls = [Integer(first_roll_result)]
|
|
188
|
+
@roll_reasons = [first_roll_reason]
|
|
189
|
+
@total = @rolls[0]
|
|
172
190
|
end
|
|
173
191
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
def init_empty
|
|
193
|
+
@rolls = []
|
|
194
|
+
@roll_reasons = []
|
|
195
|
+
@total = nil
|
|
178
196
|
end
|
|
179
197
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
cloned.instance_variable_set('@total', @total)
|
|
187
|
-
cloned.instance_variable_set('@value', @value)
|
|
188
|
-
cloned.instance_variable_set('@mapped', @mapped)
|
|
189
|
-
cloned.instance_variable_set('@map_description', @map_description)
|
|
190
|
-
cloned
|
|
198
|
+
def explain_value_multiple_rolls
|
|
199
|
+
text = "[#{@rolls[0]}"
|
|
200
|
+
text = (1..(@rolls.length - 1)).inject(text) do |so_far, i|
|
|
201
|
+
so_far + GamesDice::REROLL_TYPES[@roll_reasons[i]] + @rolls[i].to_s
|
|
202
|
+
end
|
|
203
|
+
text + "] #{@total}"
|
|
191
204
|
end
|
|
192
205
|
end
|
|
193
206
|
end
|
data/lib/games_dice/marshal.rb
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module GamesDice
|
|
4
|
-
#
|
|
4
|
+
# This class models probability distributions for dice systems.
|
|
5
|
+
#
|
|
6
|
+
# An object of this class represents a single distribution, which might be the result of a complex
|
|
7
|
+
# combination of dice.
|
|
8
|
+
#
|
|
9
|
+
# @example Distribution for a six-sided die
|
|
10
|
+
# probs = GamesDice::Probabilities.for_fair_die( 6 )
|
|
11
|
+
# probs.min # => 1
|
|
12
|
+
# probs.max # => 6
|
|
13
|
+
# probs.expected # => 3.5
|
|
14
|
+
# probs.p_ge( 4 ) # => 0.5
|
|
15
|
+
#
|
|
16
|
+
# @example Adding two distributions
|
|
17
|
+
# pd6 = GamesDice::Probabilities.for_fair_die( 6 )
|
|
18
|
+
# probs = GamesDice::Probabilities.add_distributions( pd6, pd6 )
|
|
19
|
+
# probs.min # => 2
|
|
20
|
+
# probs.max # => 12
|
|
21
|
+
# probs.expected # => 7.0
|
|
22
|
+
# probs.p_ge( 10 ) # => 0.16666666666666669
|
|
23
|
+
#
|
|
5
24
|
class Probabilities
|
|
6
25
|
# @!visibility private
|
|
7
26
|
# Adds support for Marshal, via to_h and from_h methods
|
|
8
|
-
def
|
|
9
|
-
|
|
27
|
+
def marshal_dump
|
|
28
|
+
to_h
|
|
10
29
|
end
|
|
11
30
|
|
|
12
31
|
# @!visibility private
|
|
13
32
|
def self._load(buf)
|
|
33
|
+
# Use of Marshal for general-purpose object serialisation is discouraged. However, this class does support
|
|
34
|
+
# it for backwards-compatibility.
|
|
35
|
+
# rubocop:disable Security/MarshalLoad
|
|
14
36
|
h = Marshal.load buf
|
|
37
|
+
# rubocop:enable Security/MarshalLoad
|
|
15
38
|
from_h h
|
|
16
39
|
end
|
|
17
40
|
end
|