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
data/lib/games_dice/bunch.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'games_dice/bunch_helpers'
|
|
4
|
+
|
|
3
5
|
module GamesDice
|
|
4
6
|
# This class models a number of identical dice, which may be either GamesDice::Die or
|
|
5
7
|
# GamesDice::ComplexDie objects.
|
|
@@ -21,6 +23,9 @@ module GamesDice
|
|
|
21
23
|
# d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
|
|
22
24
|
#
|
|
23
25
|
class Bunch
|
|
26
|
+
include KeepHelpers
|
|
27
|
+
include ExplainHelpers
|
|
28
|
+
|
|
24
29
|
# The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
|
|
25
30
|
# and decides which of those classes to instantiate.
|
|
26
31
|
# @param [Hash] options
|
|
@@ -28,8 +33,10 @@ module GamesDice
|
|
|
28
33
|
# @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
|
|
29
34
|
# @option options [String] :name Optional name for the bunch
|
|
30
35
|
# @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
|
|
31
|
-
# @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result
|
|
32
|
-
#
|
|
36
|
+
# @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result
|
|
37
|
+
# for the die
|
|
38
|
+
# @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to
|
|
39
|
+
# GamesDice::Die's constructor
|
|
33
40
|
# @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
|
|
34
41
|
# @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
|
|
35
42
|
# @return [GamesDice::Bunch]
|
|
@@ -138,26 +145,15 @@ module GamesDice
|
|
|
138
145
|
# Simulates rolling the bunch of identical dice
|
|
139
146
|
# @return [Integer] Sum of all rolled dice, or sum of all keepers
|
|
140
147
|
def roll
|
|
141
|
-
|
|
142
|
-
@
|
|
148
|
+
generate_raw_results
|
|
149
|
+
return @result if !@keep_mode || @keep_number.to_i >= @ndice
|
|
143
150
|
|
|
144
|
-
@
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
return @result unless @keep_mode
|
|
150
|
-
|
|
151
|
-
use_dice = if @keep_mode && @keep_number < @ndice
|
|
152
|
-
case @keep_mode
|
|
153
|
-
when :keep_best then @raw_result_details.sort[-@keep_number..]
|
|
154
|
-
when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
|
|
155
|
-
end
|
|
156
|
-
else
|
|
157
|
-
@raw_result_details
|
|
151
|
+
use_dice = case @keep_mode
|
|
152
|
+
when :keep_best then @raw_result_details.sort[-@keep_number..]
|
|
153
|
+
when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
|
|
158
154
|
end
|
|
159
155
|
|
|
160
|
-
@result = use_dice.
|
|
156
|
+
@result = use_dice.sum
|
|
161
157
|
end
|
|
162
158
|
|
|
163
159
|
# @!attribute [r] explain_result
|
|
@@ -166,44 +162,25 @@ module GamesDice
|
|
|
166
162
|
def explain_result
|
|
167
163
|
return nil unless @result
|
|
168
164
|
|
|
169
|
-
explanation = ''
|
|
170
|
-
|
|
171
165
|
# With #keep_mode, we may need to show unused and used dice separately
|
|
172
166
|
used_dice = result_details
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# Pick highest numbers and their associated details
|
|
176
|
-
if @keep_mode && @keep_number < @ndice
|
|
177
|
-
full_dice = result_details.sort_by(&:total)
|
|
178
|
-
case @keep_mode
|
|
179
|
-
when :keep_best
|
|
180
|
-
used_dice = full_dice[-@keep_number..]
|
|
181
|
-
unused_dice = full_dice[0..full_dice.length - 1 - @keep_number]
|
|
182
|
-
when :keep_worst
|
|
183
|
-
used_dice = full_dice[0..(@keep_number - 1)]
|
|
184
|
-
unused_dice = full_dice[@keep_number..(full_dice.length - 1)]
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Show unused dice (if any)
|
|
189
|
-
if @keep_mode || @single_die.maps
|
|
190
|
-
explanation += result_details.map(&:explain_value).join(', ')
|
|
191
|
-
if @keep_mode
|
|
192
|
-
separator = @single_die.maps ? ', ' : ' + '
|
|
193
|
-
explanation += ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
|
|
194
|
-
end
|
|
195
|
-
explanation += ". Successes: #{@result}" if @single_die.maps
|
|
196
|
-
explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
|
|
197
|
-
else
|
|
198
|
-
explanation += used_dice.map(&:explain_value).join(' + ')
|
|
199
|
-
explanation += " = #{@result}" if @ndice > 1
|
|
200
|
-
end
|
|
167
|
+
used_dice, = find_used_dice_due_to_keep_mode(result_details) if @keep_mode && @keep_number < @ndice
|
|
201
168
|
|
|
202
|
-
|
|
169
|
+
build_explanation(used_dice)
|
|
203
170
|
end
|
|
204
171
|
|
|
205
172
|
private
|
|
206
173
|
|
|
174
|
+
def generate_raw_results
|
|
175
|
+
@result = 0
|
|
176
|
+
@raw_result_details = []
|
|
177
|
+
|
|
178
|
+
@ndice.times do
|
|
179
|
+
@result += @single_die.roll
|
|
180
|
+
@raw_result_details << @single_die.result
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
207
184
|
def name_number_sides_from_hash(options)
|
|
208
185
|
@name = options[:name].to_s
|
|
209
186
|
@ndice = Integer(options[:ndice])
|
|
@@ -213,21 +190,6 @@ module GamesDice
|
|
|
213
190
|
raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides.positive?
|
|
214
191
|
end
|
|
215
192
|
|
|
216
|
-
def keep_mode_from_hash(options)
|
|
217
|
-
case options[:keep_mode]
|
|
218
|
-
when nil
|
|
219
|
-
@keep_mode = nil
|
|
220
|
-
when :keep_best
|
|
221
|
-
@keep_mode = :keep_best
|
|
222
|
-
@keep_number = Integer(options[:keep_number] || 1)
|
|
223
|
-
when :keep_worst
|
|
224
|
-
@keep_mode = :keep_worst
|
|
225
|
-
@keep_number = Integer(options[:keep_number] || 1)
|
|
226
|
-
else
|
|
227
|
-
raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
193
|
def complex_die_params_from_hash(options)
|
|
232
194
|
cd_hash = {}
|
|
233
195
|
%i[maps rerolls].each do |k|
|
|
@@ -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
|