games_dice 0.0.2 → 0.0.3
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.
- data/README.md +2 -0
- data/Rakefile +9 -0
- data/games_dice.gemspec +2 -1
- data/lib/games_dice.rb +4 -0
- data/lib/games_dice/bunch.rb +367 -0
- data/lib/games_dice/complex_die.rb +295 -0
- data/lib/games_dice/map_rule.rb +46 -0
- data/lib/games_dice/reroll_rule.rb +64 -0
- data/lib/games_dice/version.rb +1 -1
- data/spec/bunch_spec.rb +414 -0
- data/spec/complex_die_spec.rb +281 -0
- data/spec/map_rule_spec.rb +44 -0
- data/spec/reroll_rule_spec.rb +44 -0
- data/travis.yml +10 -0
- metadata +37 -3
data/README.md
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# GamesDice
|
2
2
|
|
3
|
+
[](http://travis-ci.org/neilslater/games_dice)
|
4
|
+
|
3
5
|
A library for simulating dice, intended for constructing a variety of dice systems as used in
|
4
6
|
role-playing and board games.
|
5
7
|
|
data/Rakefile
CHANGED
data/games_dice.gemspec
CHANGED
@@ -11,9 +11,10 @@ Gem::Specification.new do |gem|
|
|
11
11
|
gem.description = %q{A simulated-dice library, with flexible rules that allow dice systems from
|
12
12
|
many board and roleplay games to be built, run and reported.}
|
13
13
|
gem.summary = %q{Simulates and explains dice rolls from a variety of game systems.}
|
14
|
-
gem.homepage = ""
|
14
|
+
gem.homepage = "https://github.com/neilslater/games_dice"
|
15
15
|
|
16
16
|
gem.add_development_dependency "rspec"
|
17
|
+
gem.add_development_dependency "rake"
|
17
18
|
|
18
19
|
gem.files = `git ls-files`.split($/)
|
19
20
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
data/lib/games_dice.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
require "games_dice/version"
|
2
2
|
require "games_dice/die"
|
3
3
|
require "games_dice/die_result"
|
4
|
+
require "games_dice/reroll_rule"
|
5
|
+
require "games_dice/map_rule"
|
6
|
+
require "games_dice/complex_die"
|
7
|
+
require "games_dice/bunch"
|
4
8
|
|
5
9
|
module GamesDice
|
6
10
|
# TODO: Factory methods for various dice schemes
|
@@ -0,0 +1,367 @@
|
|
1
|
+
module GamesDice
|
2
|
+
|
3
|
+
# models a set of identical dice, that can be "rolled" and combined into a simple integer result. The
|
4
|
+
# dice are identical in number of sides, and any rolling rules that apply to them
|
5
|
+
class Bunch
|
6
|
+
# attributes is a hash of symbols used to set attributes of the new Bunch object. Each
|
7
|
+
# attribute is explained in more detail in its own section. The following hash keys and values
|
8
|
+
# are mandatory:
|
9
|
+
# :ndice
|
10
|
+
# :sides
|
11
|
+
# The following are optional, and modify the behaviour of the Bunch object
|
12
|
+
# :name
|
13
|
+
# :prng
|
14
|
+
# :rerolls
|
15
|
+
# :maps
|
16
|
+
# :keep_mode
|
17
|
+
# :keep_number
|
18
|
+
# Any other keys provided to the constructor are ignored
|
19
|
+
def initialize( attributes )
|
20
|
+
@name = attributes[:name].to_s
|
21
|
+
@ndice = Integer(attributes[:ndice])
|
22
|
+
raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
|
23
|
+
@sides = Integer(attributes[:sides])
|
24
|
+
raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
|
25
|
+
|
26
|
+
options = Hash.new
|
27
|
+
|
28
|
+
if attributes[:prng]
|
29
|
+
# We deliberately do not clone this object, it will often be intended that it is shared
|
30
|
+
prng = attributes[:prng]
|
31
|
+
raise ":prng does not support the rand() method" if ! prng.respond_to?(:rand)
|
32
|
+
end
|
33
|
+
|
34
|
+
needs_complex_die = false
|
35
|
+
|
36
|
+
if attributes[:rerolls]
|
37
|
+
needs_complex_die = true
|
38
|
+
options[:rerolls] = attributes[:rerolls].clone
|
39
|
+
end
|
40
|
+
|
41
|
+
if attributes[:maps]
|
42
|
+
needs_complex_die = true
|
43
|
+
options[:maps] = attributes[:maps].clone
|
44
|
+
end
|
45
|
+
|
46
|
+
if needs_complex_die
|
47
|
+
options[:prng] = prng
|
48
|
+
@single_die = GamesDice::ComplexDie.new( @sides, options )
|
49
|
+
else
|
50
|
+
@single_die = GamesDice::Die.new( @sides, prng )
|
51
|
+
end
|
52
|
+
|
53
|
+
case attributes[:keep_mode]
|
54
|
+
when nil then
|
55
|
+
@keep_mode = nil
|
56
|
+
when :keep_best then
|
57
|
+
@keep_mode = :keep_best
|
58
|
+
@keep_number = Integer(attributes[:keep_number] || 1)
|
59
|
+
when :keep_worst then
|
60
|
+
@keep_mode = :keep_worst
|
61
|
+
@keep_number = Integer(attributes[:keep_number] || 1)
|
62
|
+
else
|
63
|
+
raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{attributes[:keep_mode].inspect}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# the string name as provided to the constructor, it will appear in explain_result
|
68
|
+
attr_reader :name
|
69
|
+
|
70
|
+
# integer number of dice to roll (initially, before re-rolls etc)
|
71
|
+
attr_reader :ndice
|
72
|
+
|
73
|
+
# individual die that will be rolled, #ndice times, an GamesDice::Die or GamesDice::ComplexDie object.
|
74
|
+
attr_reader :single_die
|
75
|
+
|
76
|
+
# may be nil, :keep_best or :keep_worst
|
77
|
+
attr_reader :keep_mode
|
78
|
+
|
79
|
+
# number of "best" or "worst" results to select when #keep_mode is not nil. This attribute is
|
80
|
+
# 1 by default if :keep_mode is supplied, or nil by default otherwise.
|
81
|
+
attr_reader :keep_number
|
82
|
+
|
83
|
+
# after calling #roll, this is set to the final integer value from using the dice as specified
|
84
|
+
attr_reader :result
|
85
|
+
|
86
|
+
# either nil, or an array of GamesDice::RerollRule objects that are assessed on each roll of #single_die
|
87
|
+
# Reroll types :reroll_new_die and :reroll_new_keeper do not affect the #single_die, but are instead
|
88
|
+
# assessed in this container object
|
89
|
+
def rerolls
|
90
|
+
@single_die.rerolls
|
91
|
+
end
|
92
|
+
|
93
|
+
# either nil, or an array of GamesDice::MapRule objects that are assessed on each result of #single_die (after rerolls are completed)
|
94
|
+
def maps
|
95
|
+
@single_die.rerolls
|
96
|
+
end
|
97
|
+
|
98
|
+
# after calling #roll, this is an array of GamesDice::DieResult objects, one from each #single_die rolled,
|
99
|
+
# allowing inspection of how the result was obtained.
|
100
|
+
def result_details
|
101
|
+
return nil unless @raw_result_details
|
102
|
+
@raw_result_details.map { |r| r.is_a?(Fixnum) ? GamesDice::DieResult.new(r) : r }
|
103
|
+
end
|
104
|
+
|
105
|
+
# minimum possible integer value
|
106
|
+
def min
|
107
|
+
n = @keep_mode ? [@keep_number,@ndice].min : @ndice
|
108
|
+
return n * @single_die.min
|
109
|
+
end
|
110
|
+
|
111
|
+
# maximum possible integer value
|
112
|
+
def max
|
113
|
+
n = @keep_mode ? [@keep_number,@ndice].min : @ndice
|
114
|
+
return n * @single_die.max
|
115
|
+
end
|
116
|
+
|
117
|
+
# returns a hash of value (Integer) => probability (Float) pairs. Warning: Some dice schemes
|
118
|
+
# cause this method to take a long time, and use a lot of memory. The worst-case offenders are
|
119
|
+
# dice schemes with a #keep_mode of :keep_best or :keep_worst.
|
120
|
+
def probabilities
|
121
|
+
return @probabilities if @probabilities
|
122
|
+
@probabilities_complete = true
|
123
|
+
|
124
|
+
# TODO: It is possible to optimise this slightly by combining already-calculated values
|
125
|
+
# Adding dice is same as multiplying probability sets for that number of dice
|
126
|
+
# Combine(probabililities_3_dice, probabililities_single_die) == Combine(probabililities_2_dice, probabililities_2_dice)
|
127
|
+
# It is possible to minimise the total number of multiplications, gaining about 30% efficiency, with careful choices
|
128
|
+
single_roll_probs = @single_die.probabilities
|
129
|
+
if @keep_mode && @ndice > @keep_number
|
130
|
+
preadd_probs = {}
|
131
|
+
single_roll_probs.each { |k,v| preadd_probs[k.to_s] = v }
|
132
|
+
|
133
|
+
(@keep_number-1).times do
|
134
|
+
preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs
|
135
|
+
end
|
136
|
+
extra_dice = @ndice - @keep_number
|
137
|
+
extra_dice.times do
|
138
|
+
preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs, @keep_mode
|
139
|
+
end
|
140
|
+
combined_probs = {}
|
141
|
+
preadd_probs.each do |k,v|
|
142
|
+
total = k.split(';').map { |s| s.to_i }.inject(:+)
|
143
|
+
combined_probs[total] ||= 0.0
|
144
|
+
combined_probs[total] += v
|
145
|
+
end
|
146
|
+
else
|
147
|
+
combined_probs = single_roll_probs.clone
|
148
|
+
(@ndice-1).times do
|
149
|
+
combined_probs = prob_accumulate combined_probs, single_roll_probs
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
@probabilities = combined_probs
|
154
|
+
@probabilities_min, @probabilities_max = @probabilities.keys.minmax
|
155
|
+
@prob_ge = {}
|
156
|
+
@prob_le = {}
|
157
|
+
@probabilities
|
158
|
+
end
|
159
|
+
|
160
|
+
# returns probability than a roll will produce a number greater than target integer
|
161
|
+
def probability_gt target
|
162
|
+
probability_ge( Integer(target) + 1 )
|
163
|
+
end
|
164
|
+
|
165
|
+
# returns probability than a roll will produce a number greater than or equal to target integer
|
166
|
+
def probability_ge target
|
167
|
+
target = Integer(target)
|
168
|
+
return @prob_ge[target] if @prob_ge && @prob_ge[target]
|
169
|
+
|
170
|
+
# Force caching if not already done
|
171
|
+
probabilities
|
172
|
+
return 1.0 if target <= @probabilities_min
|
173
|
+
return 0.0 if target > @probabilities_max
|
174
|
+
@prob_ge[target] = probabilities.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
|
175
|
+
end
|
176
|
+
|
177
|
+
# returns probability than a roll will produce a number less than or equal to target integer
|
178
|
+
def probability_le target
|
179
|
+
target = Integer(target)
|
180
|
+
return @prob_le[target] if @prob_le && @prob_le[target]
|
181
|
+
|
182
|
+
# Force caching of probability table if not already done
|
183
|
+
probabilities
|
184
|
+
return 1.0 if target >= @probabilities_max
|
185
|
+
return 0.0 if target < @probabilities_min
|
186
|
+
@prob_le[target] = probabilities.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
|
187
|
+
end
|
188
|
+
|
189
|
+
# returns probability than a roll will produce a number less than target integer
|
190
|
+
def probability_lt target
|
191
|
+
probability_le( Integer(target) - 1 )
|
192
|
+
end
|
193
|
+
|
194
|
+
# returns mean expected value as a Float
|
195
|
+
def expected_result
|
196
|
+
@expected_result ||= probabilities.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
|
197
|
+
end
|
198
|
+
|
199
|
+
# simulate dice roll according to spec. Returns integer final total, and also stores it in #result
|
200
|
+
def roll
|
201
|
+
@result = 0
|
202
|
+
@raw_result_details = []
|
203
|
+
|
204
|
+
@ndice.times do
|
205
|
+
@result += @single_die.roll
|
206
|
+
@raw_result_details << @single_die.result
|
207
|
+
end
|
208
|
+
|
209
|
+
if ! @keep_mode
|
210
|
+
return @result
|
211
|
+
end
|
212
|
+
|
213
|
+
use_dice = if @keep_mode && @keep_number < @ndice
|
214
|
+
case @keep_mode
|
215
|
+
when :keep_best then @raw_result_details.sort[-@keep_number..-1]
|
216
|
+
when :keep_worst then @raw_result_details.sort[0..(@keep_number-1)]
|
217
|
+
end
|
218
|
+
else
|
219
|
+
@raw_result_details
|
220
|
+
end
|
221
|
+
|
222
|
+
@result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
|
223
|
+
end
|
224
|
+
|
225
|
+
def explain_result
|
226
|
+
return nil unless @result
|
227
|
+
|
228
|
+
explanation = ''
|
229
|
+
|
230
|
+
# With #keep_mode, we may need to show unused and used dice separately
|
231
|
+
used_dice = result_details
|
232
|
+
unused_dice = []
|
233
|
+
|
234
|
+
# Pick highest numbers and their associated details
|
235
|
+
if @keep_mode && @keep_number < @ndice
|
236
|
+
full_dice = result_details.sort_by { |die_result| die_result.total }
|
237
|
+
case @keep_mode
|
238
|
+
when :keep_best then
|
239
|
+
used_dice = full_dice[-@keep_number..-1]
|
240
|
+
unused_dice = full_dice[0..full_dice.length-1-@keep_number]
|
241
|
+
when :keep_worst then
|
242
|
+
used_dice = full_dice[0..(@keep_number-1)]
|
243
|
+
unused_dice = full_dice[@keep_number..(full_dice.length-1)]
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
# Show unused dice (if any)
|
248
|
+
if @keep_mode || @single_die.maps
|
249
|
+
explanation += result_details.map do |die_result|
|
250
|
+
die_result.explain_value
|
251
|
+
end.join(', ')
|
252
|
+
if @keep_mode
|
253
|
+
separator = @single_die.maps ? ', ' : ' + '
|
254
|
+
explanation += ". Keep: " + used_dice.map do |die_result|
|
255
|
+
die_result.explain_total
|
256
|
+
end.join( separator )
|
257
|
+
end
|
258
|
+
if @single_die.maps
|
259
|
+
explanation += ". Successes: #{@result}"
|
260
|
+
end
|
261
|
+
explanation += " = #{@result}" if @keep_mode && ! @single_die.maps && @keep_number > 1
|
262
|
+
else
|
263
|
+
explanation += used_dice.map do |die_result|
|
264
|
+
die_result.explain_value
|
265
|
+
end.join(' + ')
|
266
|
+
explanation += " = #{@result}" if @ndice > 1
|
267
|
+
end
|
268
|
+
|
269
|
+
explanation
|
270
|
+
end
|
271
|
+
|
272
|
+
private
|
273
|
+
|
274
|
+
# combines two sets of probabilities where the end result is the first set of keys plus
|
275
|
+
# the second set of keys, at the associated probailities of the values
|
276
|
+
def prob_accumulate first_probs, second_probs
|
277
|
+
accumulator = Hash.new
|
278
|
+
|
279
|
+
first_probs.each do |v1,p1|
|
280
|
+
second_probs.each do |v2,p2|
|
281
|
+
v3 = v1 + v2
|
282
|
+
p3 = p1 * p2
|
283
|
+
accumulator[v3] ||= 0.0
|
284
|
+
accumulator[v3] += p3
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
accumulator
|
289
|
+
end
|
290
|
+
|
291
|
+
# combines two sets of probabilities, as above, except tracking unique permutations
|
292
|
+
def prob_accumulate_combinations so_far, die_probs, keep_rule = nil
|
293
|
+
accumulator = Hash.new
|
294
|
+
|
295
|
+
so_far.each do |sig,p1|
|
296
|
+
combo = sig.split(';').map { |s| s.to_i }
|
297
|
+
|
298
|
+
case keep_rule
|
299
|
+
when nil then
|
300
|
+
die_probs.each do |v2,p2|
|
301
|
+
new_sig = (combo + [v2]).sort.join(';')
|
302
|
+
p3 = p1 * p2
|
303
|
+
accumulator[new_sig] ||= 0.0
|
304
|
+
accumulator[new_sig] += p3
|
305
|
+
end
|
306
|
+
when :keep_best then
|
307
|
+
need_more_than = combo.min
|
308
|
+
die_probs.each do |v2,p2|
|
309
|
+
if v2 > need_more_than
|
310
|
+
new_sig = (combo + [v2]).sort[1..combo.size].join(';')
|
311
|
+
else
|
312
|
+
new_sig = sig
|
313
|
+
end
|
314
|
+
p3 = p1 * p2
|
315
|
+
accumulator[new_sig] ||= 0.0
|
316
|
+
accumulator[new_sig] += p3
|
317
|
+
end
|
318
|
+
when :keep_worst then
|
319
|
+
need_less_than = combo.max
|
320
|
+
die_probs.each do |v2,p2|
|
321
|
+
if v2 < need_less_than
|
322
|
+
new_sig = (combo + [v2]).sort[0..(combo.size-1)].join(';')
|
323
|
+
else
|
324
|
+
new_sig = sig
|
325
|
+
end
|
326
|
+
p3 = p1 * p2
|
327
|
+
accumulator[new_sig] ||= 0.0
|
328
|
+
accumulator[new_sig] += p3
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
accumulator
|
334
|
+
end
|
335
|
+
|
336
|
+
# Generates all sets of [throw_away,may_keep_exactly,keep_preferentially,combinations] that meet
|
337
|
+
# criteria for correct total number of dice and keep dice. These then need to be assessed for every
|
338
|
+
# die value by the caller to get a full set of probabilities
|
339
|
+
def generate_item_counts total_dice, keep_dice
|
340
|
+
# Constraints are:
|
341
|
+
# may_keep_exactly must be at least 1, and at most is all the dice
|
342
|
+
# keep_preferentially plus may_keep_exactly must be >= keep_dice, but keep_preferentially < keep dice
|
343
|
+
# sum of all three always == total_dice
|
344
|
+
item_counts = []
|
345
|
+
(1..total_dice).each do |may_keep_exactly|
|
346
|
+
min_kp = [keep_dice - may_keep_exactly, 0].max
|
347
|
+
max_kp = [keep_dice - 1, total_dice - may_keep_exactly].min
|
348
|
+
(min_kp..max_kp).each do |keep_preferentially|
|
349
|
+
counts = [ total_dice - may_keep_exactly - keep_preferentially, may_keep_exactly, keep_preferentially ]
|
350
|
+
counts << combinations(counts)
|
351
|
+
item_counts << counts
|
352
|
+
end
|
353
|
+
end
|
354
|
+
item_counts
|
355
|
+
end
|
356
|
+
|
357
|
+
# How many unique ways can a set of items, some of which are identical, be arranged?
|
358
|
+
def combinations item_counts
|
359
|
+
item_counts = item_counts.map { |i| Integer(i) }.select { |i| i > 0 }
|
360
|
+
total_items = item_counts.inject(:+)
|
361
|
+
numerator = 1.upto(total_items).inject(:*)
|
362
|
+
denominator = item_counts.map { |i| 1.upto(i).inject(:*) }.inject(:*)
|
363
|
+
numerator / denominator
|
364
|
+
end
|
365
|
+
|
366
|
+
end # class Bunch
|
367
|
+
end # module GamesDice
|
@@ -0,0 +1,295 @@
|
|
1
|
+
module GamesDice
|
2
|
+
|
3
|
+
# complex die that rolls 1..N, and may re-roll and adjust final value based on
|
4
|
+
# parameters it is instantiated with
|
5
|
+
# d = GamesDice::DieExploding.new( 6, :explode_up => true ) # 'exploding' die
|
6
|
+
# d.roll # => GamesDice::DieResult of rolling die
|
7
|
+
# d.result # => same GamesDice::DieResult as returned by d.roll
|
8
|
+
class ComplexDie
|
9
|
+
|
10
|
+
# arbitrary limit to simplify calculations and stay in Integer range for convenience. It should
|
11
|
+
# be much larger than anything seen in real-world tabletop games.
|
12
|
+
MAX_REROLLS = 1000
|
13
|
+
|
14
|
+
# sides is e.g. 6 for traditional cubic die, or 20 for icosahedron.
|
15
|
+
# It can take non-traditional values, such as 7, but must be at least 1.
|
16
|
+
#
|
17
|
+
# options_hash may contain keys setting following attributes
|
18
|
+
# :rerolls => an array of rules that cause the die to roll again, see #rerolls
|
19
|
+
# :maps => an array of rules to convert a value into a final result for the die, see #maps
|
20
|
+
# :prng => any object that has a rand(x) method, which will be used instead of internal rand()
|
21
|
+
def initialize(sides, options_hash = {})
|
22
|
+
@basic_die = GamesDice::Die.new(sides, options_hash[:prng])
|
23
|
+
|
24
|
+
@rerolls = options_hash[:rerolls]
|
25
|
+
validate_rerolls
|
26
|
+
@maps = options_hash[:maps]
|
27
|
+
validate_maps
|
28
|
+
|
29
|
+
@total = nil
|
30
|
+
@result = nil
|
31
|
+
end
|
32
|
+
|
33
|
+
# underlying GamesDice::Die object, used to generate all individual rolls
|
34
|
+
attr_reader :basic_die
|
35
|
+
|
36
|
+
# may be nil, in which case no re-rolls are triggered, or an array of GamesDice::RerollRule objects
|
37
|
+
attr_reader :rerolls
|
38
|
+
|
39
|
+
# may be nil, in which case no mappings apply, or an array of GamesDice::MapRule objects
|
40
|
+
attr_reader :maps
|
41
|
+
|
42
|
+
# result of last call to #roll, nil if no call made yet
|
43
|
+
attr_reader :result
|
44
|
+
|
45
|
+
# true if probability calculation did not hit any limitations, so has covered all possible scenarios
|
46
|
+
# false if calculation was cut short and probabilities are an approximation
|
47
|
+
# nil if probabilities have not been calculated yet
|
48
|
+
attr_reader :probabilities_complete
|
49
|
+
|
50
|
+
# number of sides, same as #basic_die.sides
|
51
|
+
def sides
|
52
|
+
@basic_die.sides
|
53
|
+
end
|
54
|
+
|
55
|
+
# string explanation of roll, including any re-rolls etc, same as #result.explain_value
|
56
|
+
def explain_result
|
57
|
+
@result.explain_value
|
58
|
+
end
|
59
|
+
|
60
|
+
# minimum possible value
|
61
|
+
def min
|
62
|
+
return @min_result if @min_result
|
63
|
+
@min_result, @max_result = probabilities.keys.minmax
|
64
|
+
return @min_result if @probabilities_complete
|
65
|
+
logical_min, logical_max = logical_minmax
|
66
|
+
@min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
|
67
|
+
@min_result
|
68
|
+
end
|
69
|
+
|
70
|
+
# maximum possible value. A ComplexDie with open-ended additive re-rolls will calculate roughly 1001 times the
|
71
|
+
# maximum of #basic_die.max (although the true value is infinite)
|
72
|
+
def max
|
73
|
+
return @max_result if @max_result
|
74
|
+
@min_result, @max_result = probabilities.keys.minmax
|
75
|
+
return @max_result if @probabilities_complete
|
76
|
+
logical_min, logical_max = logical_minmax
|
77
|
+
@min_result, @max_result = [@min_result, @max_result, logical_min, logical_max].minmax
|
78
|
+
@max_result
|
79
|
+
end
|
80
|
+
|
81
|
+
# returns a hash of value (Integer) => probability (Float) pairs. For efficiency with re-rolls, the calculation may cut
|
82
|
+
# short based on depth of recursion or closeness to total 1.0 probability. Therefore low probabilities
|
83
|
+
# (less than one in a billion) in open-ended re-rolls are not always represented in the hash.
|
84
|
+
def probabilities
|
85
|
+
return @probabilities if @probabilities
|
86
|
+
@probabilities_complete = true
|
87
|
+
if @rerolls && @maps
|
88
|
+
reroll_probs = recursive_probabilities
|
89
|
+
@probabilities = {}
|
90
|
+
reroll_probs.each do |v,p|
|
91
|
+
m, n = calc_maps(v)
|
92
|
+
@probabilities[m] ||= 0.0
|
93
|
+
@probabilities[m] += p
|
94
|
+
end
|
95
|
+
elsif @rerolls
|
96
|
+
@probabilities = recursive_probabilities
|
97
|
+
elsif @maps
|
98
|
+
probs = @basic_die.probabilities
|
99
|
+
@probabilities = {}
|
100
|
+
probs.each do |v,p|
|
101
|
+
m, n = calc_maps(v)
|
102
|
+
@probabilities[m] ||= 0.0
|
103
|
+
@probabilities[m] += p
|
104
|
+
end
|
105
|
+
else
|
106
|
+
@probabilities = @basic_die.probabilities
|
107
|
+
end
|
108
|
+
@probabilities_min, @probabilities_max = @probabilities.keys.minmax
|
109
|
+
@prob_ge = {}
|
110
|
+
@prob_le = {}
|
111
|
+
@probabilities
|
112
|
+
end
|
113
|
+
|
114
|
+
# returns mean expected value as a Float
|
115
|
+
def expected_result
|
116
|
+
@expected_result ||= probabilities.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
|
117
|
+
end
|
118
|
+
|
119
|
+
# returns probability than a roll will produce a number greater than target integer
|
120
|
+
def probability_gt target
|
121
|
+
probability_ge( Integer(target) + 1 )
|
122
|
+
end
|
123
|
+
|
124
|
+
# returns probability than a roll will produce a number greater than or equal to target integer
|
125
|
+
def probability_ge target
|
126
|
+
target = Integer(target)
|
127
|
+
return @prob_ge[target] if @prob_ge && @prob_ge[target]
|
128
|
+
|
129
|
+
# Force caching if not already done
|
130
|
+
probabilities
|
131
|
+
return 1.0 if target <= @probabilities_min
|
132
|
+
return 0.0 if target > @probabilities_max
|
133
|
+
@prob_ge[target] = probabilities.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
|
134
|
+
end
|
135
|
+
|
136
|
+
# returns probability than a roll will produce a number less than or equal to target integer
|
137
|
+
def probability_le target
|
138
|
+
target = Integer(target)
|
139
|
+
return @prob_le[target] if @prob_le && @prob_le[target]
|
140
|
+
|
141
|
+
# Force caching of probability table if not already done
|
142
|
+
probabilities
|
143
|
+
return 1.0 if target >= @probabilities_max
|
144
|
+
return 0.0 if target < @probabilities_min
|
145
|
+
@prob_le[target] = probabilities.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
|
146
|
+
end
|
147
|
+
|
148
|
+
# returns probability than a roll will produce a number less than target integer
|
149
|
+
def probability_lt target
|
150
|
+
probability_le( Integer(target) - 1 )
|
151
|
+
end
|
152
|
+
|
153
|
+
# generates Integer between #min and #max, using rand()
|
154
|
+
# first roll reason can be over-ridden, required for re-roll types that spawn new dice
|
155
|
+
def roll( reason = :basic )
|
156
|
+
# Important bit - actually roll the die
|
157
|
+
@result = GamesDice::DieResult.new( @basic_die.roll, reason )
|
158
|
+
|
159
|
+
if @rerolls
|
160
|
+
subtracting = false
|
161
|
+
rerolls_remaining = @rerolls.map { |rule| rule.limit }
|
162
|
+
loop do
|
163
|
+
# Find which rule, if any, is being triggered
|
164
|
+
rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
|
165
|
+
next if rule.type == :reroll_subtract && @result.rolls.length > 1
|
166
|
+
remaining > 0 && rule.applies?( @basic_die.result )
|
167
|
+
end
|
168
|
+
break unless rule_idx
|
169
|
+
|
170
|
+
rule = @rerolls[ rule_idx ]
|
171
|
+
rerolls_remaining[ rule_idx ] -= 1
|
172
|
+
subtracting = true if rule.type == :reroll_subtract
|
173
|
+
|
174
|
+
# Apply the rule (note reversal for additions, after a subtract)
|
175
|
+
if subtracting && rule.type == :reroll_add
|
176
|
+
@result.add_roll( @basic_die.roll, :reroll_subtract )
|
177
|
+
else
|
178
|
+
@result.add_roll( @basic_die.roll, rule.type )
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# apply any mapping
|
184
|
+
if @maps
|
185
|
+
m, n = calc_maps(@result.value)
|
186
|
+
@result.apply_map( m, n )
|
187
|
+
end
|
188
|
+
|
189
|
+
@result
|
190
|
+
end
|
191
|
+
|
192
|
+
private
|
193
|
+
|
194
|
+
def calc_maps x
|
195
|
+
y, n = 0, ''
|
196
|
+
@maps.find do |rule|
|
197
|
+
maybe_y = rule.map_from( x )
|
198
|
+
if maybe_y
|
199
|
+
y = maybe_y
|
200
|
+
n = rule.mapped_name
|
201
|
+
end
|
202
|
+
maybe_y
|
203
|
+
end
|
204
|
+
[y, n]
|
205
|
+
end
|
206
|
+
|
207
|
+
def validate_rerolls
|
208
|
+
return unless @rerolls
|
209
|
+
raise TypeError, "rerolls should be an Array, instead got #{@rerolls.inspect}" unless @rerolls.is_a?(Array)
|
210
|
+
@rerolls.each do |rule|
|
211
|
+
raise TypeError, "items in rerolls should be GamesDice::RerollRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::RerollRule)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def validate_maps
|
216
|
+
return unless @maps
|
217
|
+
raise TypeError, "maps should be an Array, instead got #{@maps.inspect}" unless @maps.is_a?(Array)
|
218
|
+
@maps.each do |rule|
|
219
|
+
raise TypeError, "items in maps should be GamesDice::MapRule, instead got #{rule.inspect}" unless rule.is_a?(GamesDice::MapRule)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def minmax_mappings possible_values
|
224
|
+
possible_values.map { |x| m, n = calc_maps( x ); m }.minmax
|
225
|
+
end
|
226
|
+
|
227
|
+
# This isn't 100% accurate, but does cover most "normal" scenarios, and we're only falling back to it when we have to
|
228
|
+
def logical_minmax
|
229
|
+
min_result = 1
|
230
|
+
max_result = @basic_die.sides
|
231
|
+
return [min_result,max_result] unless @rerolls || @maps
|
232
|
+
return minmax_mappings( (min_result..max_result) ) unless @rerolls
|
233
|
+
can_subtract = false
|
234
|
+
@rerolls.each do |rule|
|
235
|
+
next unless rule.type == :reroll_add || rule.type == :reroll_subtract
|
236
|
+
min_reroll,max_reroll = (1..@basic_die.sides).select { |v| rule.applies?( v ) }.minmax
|
237
|
+
next unless min_reroll && max_reroll
|
238
|
+
if rule.type == :reroll_subtract
|
239
|
+
can_subtract=true
|
240
|
+
min_result = min_reroll - @basic_die.sides
|
241
|
+
else
|
242
|
+
max_result += max_reroll * rule.limit
|
243
|
+
end
|
244
|
+
end
|
245
|
+
if can_subtract
|
246
|
+
min_result -= max_result + @basic_die.sides
|
247
|
+
end
|
248
|
+
return minmax_mappings( (min_result..max_result) ) if @maps
|
249
|
+
return [min_result,max_result]
|
250
|
+
end
|
251
|
+
|
252
|
+
def recursive_probabilities probabilities={},prior_probability=1.0,depth=0,prior_result=nil,rerolls_left=nil,roll_reason=:basic,subtracting=false
|
253
|
+
each_probability = prior_probability / @basic_die.sides
|
254
|
+
depth += 1
|
255
|
+
if depth >= 20 || each_probability < 1.0e-12
|
256
|
+
@probabilities_complete = false
|
257
|
+
stop_recursing = true
|
258
|
+
end
|
259
|
+
|
260
|
+
(1..@basic_die.sides).each do |v|
|
261
|
+
# calculate value, recurse if there is a reroll
|
262
|
+
result_so_far = prior_result ? prior_result.clone : GamesDice::DieResult.new(v,roll_reason)
|
263
|
+
result_so_far.add_roll(v,roll_reason) if prior_result
|
264
|
+
rerolls_remaining = rerolls_left ? rerolls_left.clone : @rerolls.map { |rule| rule.limit }
|
265
|
+
|
266
|
+
# Find which rule, if any, is being triggered
|
267
|
+
rule_idx = @rerolls.zip(rerolls_remaining).find_index do |rule,remaining|
|
268
|
+
next if rule.type == :reroll_subtract && result_so_far.rolls.length > 1
|
269
|
+
remaining > 0 && rule.applies?( v )
|
270
|
+
end
|
271
|
+
|
272
|
+
if rule_idx && ! stop_recursing
|
273
|
+
rule = @rerolls[ rule_idx ]
|
274
|
+
rerolls_remaining[ rule_idx ] -= 1
|
275
|
+
is_subtracting = true if subtracting || rule.type == :reroll_subtract
|
276
|
+
|
277
|
+
# Apply the rule (note reversal for additions, after a subtract)
|
278
|
+
if subtracting && rule.type == :reroll_add
|
279
|
+
recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,:reroll_subtract,is_subtracting
|
280
|
+
else
|
281
|
+
recursive_probabilities probabilities,each_probability,depth,result_so_far,rerolls_remaining,rule.type,is_subtracting
|
282
|
+
end
|
283
|
+
# just accumulate value on a regular roll
|
284
|
+
else
|
285
|
+
t = result_so_far.total
|
286
|
+
probabilities[ t ] ||= 0.0
|
287
|
+
probabilities[ t ] += each_probability
|
288
|
+
end
|
289
|
+
|
290
|
+
end
|
291
|
+
probabilities.clone
|
292
|
+
end
|
293
|
+
|
294
|
+
end # class ComplexDie
|
295
|
+
end # module GamesDice
|