games_dice 0.3.9 → 0.4.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 +7 -0
- data/.rubocop.yml +15 -0
- data/.travis.yml +9 -12
- data/CHANGELOG.md +29 -13
- data/Gemfile +2 -0
- data/README.md +5 -5
- data/Rakefile +14 -11
- data/ext/games_dice/extconf.rb +4 -22
- data/ext/games_dice/probabilities.c +1 -1
- data/games_dice.gemspec +26 -28
- data/lib/games_dice/bunch.rb +241 -247
- data/lib/games_dice/complex_die.rb +287 -303
- data/lib/games_dice/complex_die_helpers.rb +68 -0
- data/lib/games_dice/constants.rb +10 -10
- data/lib/games_dice/dice.rb +146 -143
- data/lib/games_dice/die.rb +101 -97
- data/lib/games_dice/die_result.rb +193 -189
- data/lib/games_dice/map_rule.rb +72 -70
- data/lib/games_dice/marshal.rb +18 -13
- data/lib/games_dice/parser.rb +219 -218
- data/lib/games_dice/reroll_rule.rb +76 -77
- data/lib/games_dice/version.rb +3 -1
- data/lib/games_dice.rb +19 -16
- data/spec/bunch_spec.rb +399 -421
- data/spec/complex_die_spec.rb +314 -306
- data/spec/dice_spec.rb +33 -34
- data/spec/die_result_spec.rb +163 -170
- data/spec/die_spec.rb +81 -82
- data/spec/helpers.rb +26 -22
- data/spec/map_rule_spec.rb +40 -44
- data/spec/parser_spec.rb +106 -82
- data/spec/probability_spec.rb +530 -527
- data/spec/readme_spec.rb +404 -384
- data/spec/reroll_rule_spec.rb +40 -44
- metadata +63 -74
- data/lib/games_dice/probabilities.rb +0 -445
@@ -1,303 +1,287 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
#
|
7
|
-
# the
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
#
|
15
|
-
#
|
16
|
-
# d
|
17
|
-
# d.
|
18
|
-
# d.
|
19
|
-
|
20
|
-
#
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
#
|
25
|
-
#
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
@
|
38
|
-
|
39
|
-
@
|
40
|
-
@
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
prob_hash
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
rerolls_remaining
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
probabilities
|
289
|
-
end
|
290
|
-
|
291
|
-
def calc_result_so_far prior_result, rerolls_left, v, roll_reason
|
292
|
-
if prior_result
|
293
|
-
result_so_far = prior_result.clone
|
294
|
-
result_so_far.add_roll(v,roll_reason)
|
295
|
-
rerolls_remaining = rerolls_left.clone
|
296
|
-
else
|
297
|
-
result_so_far = GamesDice::DieResult.new(v,roll_reason)
|
298
|
-
rerolls_remaining = @rerolls.map { |rule| rule.limit }
|
299
|
-
end
|
300
|
-
[result_so_far, rerolls_remaining]
|
301
|
-
end
|
302
|
-
|
303
|
-
end # class ComplexDie
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'games_dice/complex_die_helpers'
|
4
|
+
|
5
|
+
module GamesDice
|
6
|
+
# This class models a die that is built up from a simpler unit by adding rules to re-roll
|
7
|
+
# and interpret the value shown.
|
8
|
+
#
|
9
|
+
# An object of this class represents a single complex die. It rolls 1..#sides, with equal weighting
|
10
|
+
# for each value. The value from a roll may be used to trigger yet more rolls that combine together.
|
11
|
+
# After any re-rolls, the value can be interpretted ("mapped") as another integer, which is used as
|
12
|
+
# the final result.
|
13
|
+
#
|
14
|
+
# @example An open-ended percentile die from a popular RPG
|
15
|
+
# d = GamesDice::ComplexDie.new( 100, :rerolls => [[96, :<=, :reroll_add],[5, :>=, :reroll_subtract]] )
|
16
|
+
# d.roll # => #<GamesDice::DieResult:0x007ff03a2415f8 @rolls=[4, 27], ...>
|
17
|
+
# d.result.value # => -23
|
18
|
+
# d.explain_result # => "[4-27] -23"
|
19
|
+
#
|
20
|
+
# @example An "exploding" six-sided die with a target number
|
21
|
+
# d = GamesDice::ComplexDie.new( 6, :rerolls => [[6, :<=, :reroll_add]], :maps => [[8, :<=, 1, 'Success']] )
|
22
|
+
# d.roll # => #<GamesDice::DieResult:0x007ff03a1e8e08 @rolls=[6, 5], ...>
|
23
|
+
# d.result.value # => 1
|
24
|
+
# d.explain_result # => "[6+5] 11 Success"
|
25
|
+
#
|
26
|
+
class ComplexDie
|
27
|
+
include GamesDice::ComplexDieHelpers
|
28
|
+
|
29
|
+
# @!visibility private
|
30
|
+
# arbitrary limit to speed up probability calculations. It should
|
31
|
+
# be larger than anything seen in real-world tabletop games.
|
32
|
+
MAX_REROLLS = 1000
|
33
|
+
|
34
|
+
# Creates new instance of GamesDice::ComplexDie
|
35
|
+
# @param [Integer] sides Number of sides on a single die, passed to GamesDice::Die's constructor
|
36
|
+
# @param [Hash] options
|
37
|
+
# @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 for the die
|
39
|
+
# @option options [#rand] :prng An alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
|
40
|
+
# @return [GamesDice::ComplexDie]
|
41
|
+
def initialize(sides, options = {})
|
42
|
+
@basic_die = GamesDice::Die.new(sides, options[:prng])
|
43
|
+
|
44
|
+
@rerolls = construct_rerolls(options[:rerolls])
|
45
|
+
@maps = construct_maps(options[:maps])
|
46
|
+
|
47
|
+
@total = nil
|
48
|
+
@result = nil
|
49
|
+
end
|
50
|
+
|
51
|
+
# The simple component used by this complex one
|
52
|
+
# @return [GamesDice::Die] Object used to make individual dice rolls for the complex die
|
53
|
+
attr_reader :basic_die
|
54
|
+
|
55
|
+
# @return [Array<GamesDice::RerollRule>, nil] Sequence of re-roll rules, or nil if re-rolls are not required.
|
56
|
+
attr_reader :rerolls
|
57
|
+
|
58
|
+
# @return [Array<GamesDice::MapRule>, nil] Sequence of map rules, or nil if mapping is not required.
|
59
|
+
attr_reader :maps
|
60
|
+
|
61
|
+
# @return [GamesDice::DieResult, nil] Result of last call to #roll, nil if no call made yet
|
62
|
+
attr_reader :result
|
63
|
+
|
64
|
+
# Whether or not #probabilities includes all possible outcomes.
|
65
|
+
# True if all possible results are represented and assigned a probability. Dice with open-ended re-rolls
|
66
|
+
# may have calculations cut short, and will result in a false value of this attribute. Even when this
|
67
|
+
# attribute is false, probabilities should still be accurate to nearest 1e-9.
|
68
|
+
# @return [Boolean, nil] Depending on completeness when generating #probabilities
|
69
|
+
attr_reader :probabilities_complete
|
70
|
+
|
71
|
+
# @!attribute [r] sides
|
72
|
+
# @return [Integer] Number of sides.
|
73
|
+
def sides
|
74
|
+
@basic_die.sides
|
75
|
+
end
|
76
|
+
|
77
|
+
# @!attribute [r] explain_result
|
78
|
+
# @return [String,nil] Explanation of result, or nil if no call to #roll yet.
|
79
|
+
def explain_result
|
80
|
+
@result.explain_value
|
81
|
+
end
|
82
|
+
|
83
|
+
# The minimum possible result from a call to #roll. This is not always the same as the theoretical
|
84
|
+
# minimum, due to limits on the maximum number of rerolls.
|
85
|
+
# @!attribute [r] min
|
86
|
+
# @return [Integer]
|
87
|
+
def min
|
88
|
+
return @min_result if @min_result
|
89
|
+
|
90
|
+
calc_minmax
|
91
|
+
@min_result
|
92
|
+
end
|
93
|
+
|
94
|
+
# @!attribute [r] max
|
95
|
+
# @return [Integer] Maximum possible result from a call to #roll
|
96
|
+
def max
|
97
|
+
return @max_result if @max_result
|
98
|
+
|
99
|
+
calc_minmax
|
100
|
+
@max_result
|
101
|
+
end
|
102
|
+
|
103
|
+
# Calculates the probability distribution for the die. For open-ended re-roll rules, there are some
|
104
|
+
# arbitrary limits imposed to prevent large amounts of recursion. Probabilities should be to nearest
|
105
|
+
# 1e-9 at worst.
|
106
|
+
# @return [GamesDice::Probabilities] Probability distribution of die.
|
107
|
+
def probabilities
|
108
|
+
return @probabilities if @probabilities
|
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)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Simulates rolling the die
|
132
|
+
# @param [Symbol] reason Assign a reason for rolling the first die.
|
133
|
+
# @return [GamesDice::DieResult] Detailed results from rolling the die, including resolution of rules.
|
134
|
+
def roll(reason = :basic)
|
135
|
+
@result = GamesDice::DieResult.new(@basic_die.roll, reason)
|
136
|
+
roll_apply_rerolls
|
137
|
+
roll_apply_maps
|
138
|
+
@result
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
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
|
+
def construct_rerolls(rerolls_input)
|
201
|
+
check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
|
202
|
+
end
|
203
|
+
|
204
|
+
def construct_maps(maps_input)
|
205
|
+
check_and_construct maps_input, GamesDice::MapRule, 'maps'
|
206
|
+
end
|
207
|
+
|
208
|
+
def check_and_construct(input, klass, label)
|
209
|
+
return nil unless input
|
210
|
+
raise TypeError, "#{label} should be an Array, instead got #{input.inspect}" unless input.is_a?(Array)
|
211
|
+
|
212
|
+
input.map do |i|
|
213
|
+
case i
|
214
|
+
when Array then klass.new(*i)
|
215
|
+
when klass then i
|
216
|
+
else
|
217
|
+
raise TypeError, "items in #{label} should be #{klass.name} or Array, instead got #{i.inspect}"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
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
|
+
end
|
287
|
+
end
|