games_dice 0.0.2 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/neilslater/games_dice.png?branch=master)](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
|