games_dice 0.5.0 → 0.6.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +11 -1
  3. data/.gitignore +0 -1
  4. data/.rubocop.yml +13 -48
  5. data/CHANGELOG.md +19 -0
  6. data/Gemfile +5 -3
  7. data/README.md +46 -5
  8. data/Rakefile +12 -5
  9. data/ext/games_dice/extconf.rb +0 -3
  10. data/ext/games_dice/probabilities.c +293 -193
  11. data/ext/games_dice/probabilities.h +2 -12
  12. data/games_dice.gemspec +1 -1
  13. data/lib/games_dice/bunch.rb +50 -36
  14. data/lib/games_dice/bunch_helpers.rb +35 -14
  15. data/lib/games_dice/complex_die.rb +64 -22
  16. data/lib/games_dice/complex_die_helpers.rb +122 -144
  17. data/lib/games_dice/dice.rb +61 -17
  18. data/lib/games_dice/dice_helpers.rb +37 -0
  19. data/lib/games_dice/die_result.rb +6 -8
  20. data/lib/games_dice/marshal.rb +2 -7
  21. data/lib/games_dice/parser.rb +30 -0
  22. data/lib/games_dice/probability_budget.rb +90 -0
  23. data/lib/games_dice/probability_calculation.rb +95 -0
  24. data/lib/games_dice/probability_shortcuts.rb +65 -0
  25. data/lib/games_dice/recipe_cache.rb +155 -0
  26. data/lib/games_dice/version.rb +1 -1
  27. data/lib/games_dice.rb +4 -0
  28. data/script/check_documentation.rb +10 -0
  29. data/spec/games_dice/bunch_regressions_spec.rb +46 -0
  30. data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
  31. data/spec/games_dice/complex_die_budget_spec.rb +60 -0
  32. data/spec/games_dice/complex_die_limits_spec.rb +119 -0
  33. data/spec/games_dice/complex_die_spec.rb +228 -0
  34. data/spec/games_dice/dice_mutation_spec.rb +165 -0
  35. data/spec/games_dice/dice_regressions_spec.rb +50 -0
  36. data/spec/games_dice/dice_spec.rb +61 -0
  37. data/spec/games_dice/die_result_spec.rb +221 -0
  38. data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
  39. data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
  40. data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
  41. data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
  42. data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
  43. data/spec/games_dice/probabilities_spec.rb +484 -0
  44. data/spec/games_dice/probability_budget_spec.rb +17 -0
  45. data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
  46. data/spec/games_dice_spec.rb +382 -0
  47. data/spec/helpers.rb +64 -2
  48. data/spec/simple_cov_spec.rb +42 -0
  49. data/spec/support/coverage.rb +12 -0
  50. data/spec/support/validation.rb +14 -0
  51. data/spec/yard/cli/stats_spec.rb +43 -0
  52. metadata +29 -12
  53. data/spec/complex_die_spec.rb +0 -314
  54. data/spec/dice_spec.rb +0 -33
  55. data/spec/die_result_spec.rb +0 -254
  56. data/spec/fixtures/probs_fair_die_6.dat +0 -2
  57. data/spec/probability_spec.rb +0 -584
  58. data/spec/readme_spec.rb +0 -410
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+ require 'objspace'
5
+
6
+ describe GamesDice::Probabilities, :aggregate_failures do
7
+ let(:distributions) do
8
+ {
9
+ two: described_class.for_fair_die(2), four: described_class.for_fair_die(4),
10
+ six: described_class.for_fair_die(6), ten: described_class.for_fair_die(10),
11
+ twenty: described_class.for_fair_die(20), uniform_four: described_class.new([0.25, 0.25, 0.25, 0.25], 1),
12
+ weighted_four: described_class.new([0.1, 0.2, 0.3, 0.4], 1),
13
+ left_asymmetric: described_class.new([0.7, 0.0, 0.3], 2),
14
+ right_asymmetric: described_class.new([0.5, 0.3, 0.2], 2),
15
+ offset_asymmetric: described_class.new([0.4, 0.2, 0.4], -1)
16
+ }
17
+ end
18
+
19
+ def distribution(name)
20
+ distributions.fetch(name)
21
+ end
22
+
23
+ describe 'class methods' do
24
+ describe '#new' do
25
+ it 'create a new distribution from an array and offset' do
26
+ pr = described_class.new([1.0], 1)
27
+ expect(pr).to be_a described_class
28
+ expect(pr.to_h).to be_valid_distribution
29
+ end
30
+
31
+ it 'raise an error if passed incorrect parameter types' do
32
+ expect { described_class.new([nil], 20) }.to raise_error TypeError
33
+ expect { described_class.new([0.3, nil, 0.5], 7) }.to raise_error TypeError
34
+ expect { described_class.new([0.3, 0.2, 0.5], {}) }.to raise_error TypeError
35
+ expect { described_class.new({ x: :y }, 17) }.to raise_error TypeError
36
+ end
37
+
38
+ it 'raise an error if distribution is incomplete or inaccurate' do
39
+ expect { described_class.new([0.3, 0.2, 0.6], 3) }.to raise_error ArgumentError
40
+ expect { described_class.new([], 1) }.to raise_error ArgumentError
41
+ expect { described_class.new([0.9], 1) }.to raise_error ArgumentError
42
+ expect { described_class.new([-0.9, 0.2, 0.9], 1) }.to raise_error ArgumentError
43
+ expect { described_class.new([1.1], 1) }.to raise_error ArgumentError
44
+ end
45
+ end
46
+
47
+ describe '#for_fair_die' do
48
+ it 'create a new distribution based on number of sides' do
49
+ (1..20).each { |sides| expect_fair_die(described_class, sides) }
50
+ end
51
+
52
+ it 'raise an error if number of sides is not an integer' do
53
+ expect { described_class.for_fair_die({}) }.to raise_error TypeError
54
+ end
55
+
56
+ it 'raise an error if number of sides is too low or too high' do
57
+ expect { described_class.for_fair_die(0) }.to raise_error ArgumentError
58
+ expect { described_class.for_fair_die(1_000_001) }.to raise_error ArgumentError
59
+ end
60
+ end
61
+
62
+ describe '#add_distributions' do
63
+ it 'combine two distributions to create a third one' do
64
+ d4a = described_class.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1)
65
+ d4b = described_class.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1)
66
+ pr = described_class.add_distributions(d4a, d4b)
67
+ expect(pr.to_h).to be_valid_distribution
68
+ end
69
+
70
+ it 'calculate a classic 2d6 distribution accurately' do
71
+ h = described_class.add_distributions(distribution(:six), distribution(:six)).to_h
72
+ expect(h).to be_valid_distribution
73
+ expected = (2..12).to_h { |result| [result, (6 - (7 - result).abs) / 36.0] }
74
+ expect_probability_values(h, expected, tolerance: 1e-9, total_tolerance: nil)
75
+ end
76
+
77
+ it 'raise an error if either parameter is not a GamesDice::Probabilities object' do
78
+ d10 = described_class.for_fair_die(10)
79
+ expect { described_class.add_distributions('', 6) }.to raise_error TypeError
80
+ expect { described_class.add_distributions(d10, 6) }.to raise_error TypeError
81
+ expect { described_class.add_distributions('', d10) }.to raise_error TypeError
82
+ end
83
+ end
84
+
85
+ describe '#add_distributions_mult' do
86
+ it 'combine two multiplied distributions to create a third one' do
87
+ d4a = described_class.new([1.0 / 4, 1.0 / 4, 1.0 / 4, 1.0 / 4], 1)
88
+ d4b = described_class.new([1.0 / 10, 2.0 / 10, 3.0 / 10, 4.0 / 10], 1)
89
+ pr = described_class.add_distributions_mult(2, d4a, -1, d4b)
90
+ expect(pr.to_h).to be_valid_distribution
91
+ end
92
+
93
+ it "calculate a distribution for '1d6 - 1d4' accurately" do
94
+ h = described_class.add_distributions_mult(1, distribution(:six), -1, distribution(:four)).to_h
95
+ expect(h).to be_valid_distribution
96
+ numerators = [1, 2, 3, 4, 4, 4, 3, 2, 1]
97
+ expected = (-3..5).zip(numerators.map { |numerator| numerator / 24.0 }).to_h
98
+ expect_probability_values(h, expected, tolerance: 1e-9, total_tolerance: nil)
99
+ end
100
+
101
+ it 'add asymmetric distributions accurately' do
102
+ h = described_class.add_distributions_mult(1, distribution(:left_asymmetric), 2,
103
+ distribution(:right_asymmetric)).to_h
104
+ expect(h).to be_valid_distribution
105
+ expected = { 6 => 0.7 * 0.5, 8 => (0.7 * 0.3) + (0.3 * 0.5),
106
+ 10 => (0.7 * 0.2) + (0.3 * 0.3), 12 => 0.3 * 0.2 }
107
+ expect_probability_values(h, expected, tolerance: 1e-9, total_tolerance: nil)
108
+ end
109
+
110
+ it 'raise an error if passed incorrect objects for distributions' do
111
+ d10 = described_class.for_fair_die(10)
112
+ expect { described_class.add_distributions_mult(1, '', -1, 6) }.to raise_error TypeError
113
+ expect { described_class.add_distributions_mult(2, d10, 3, 6) }.to raise_error TypeError
114
+ expect { described_class.add_distributions_mult(1, '', -1, d10) }.to raise_error TypeError
115
+ end
116
+
117
+ it 'raise an error if passed incorrect objects for multipliers' do
118
+ d10 = described_class.for_fair_die(10)
119
+ expect { described_class.add_distributions_mult({}, d10, [], d10) }.to raise_error TypeError
120
+ expect { described_class.add_distributions_mult([7], d10, 3, d10) }.to raise_error TypeError
121
+ expect { described_class.add_distributions_mult(1, d10, {}, d10) }.to raise_error TypeError
122
+ end
123
+ end
124
+
125
+ describe '#from_h' do
126
+ it 'create a Probabilities object from a valid hash' do
127
+ pr = described_class.from_h({ 7 => 0.5, 9 => 0.5 })
128
+ expect(pr).to be_a described_class
129
+ end
130
+
131
+ it 'create the same distribution when hash keys descend' do
132
+ pr = described_class.from_h({ 9 => 0.5, 7 => 0.5 })
133
+ expect(pr.to_h).to eql({ 7 => 0.5, 9 => 0.5 })
134
+ end
135
+
136
+ it 'raise an ArgumentError when called with a non-valid hash' do
137
+ expect { described_class.from_h({ 7 => 0.5, 9 => 0.6 }) }.to raise_error ArgumentError
138
+ expect { described_class.from_h({ 7 => 0.4, 9 => 0.5 }) }.to raise_error ArgumentError
139
+ end
140
+
141
+ it 'raise an TypeError when called with data that is not a hash' do
142
+ expect { described_class.from_h(:foo) }.to raise_error TypeError
143
+ end
144
+
145
+ it 'raise a TypeError when called when keys and values are not all integers and floats' do
146
+ expect { described_class.from_h({ 'x' => 0.5, 9 => 0.5 }) }.to raise_error TypeError
147
+ expect { described_class.from_h({ 7 => [], 9 => 0.5 }) }.to raise_error TypeError
148
+ end
149
+
150
+ it 'raise an ArgumentError when results are spread very far apart' do
151
+ expect { described_class.from_h({ 0 => 0.5, 2_000_000 => 0.5 }) }.to raise_error ArgumentError
152
+ end
153
+ end
154
+ end
155
+
156
+ describe 'instance methods' do
157
+ describe '#clone' do
158
+ it 'create an independent object with the same distribution' do
159
+ copy = distribution(:offset_asymmetric).clone
160
+ expect(copy).not_to equal(distribution(:offset_asymmetric))
161
+ expect(copy.to_h).to eql(distribution(:offset_asymmetric).to_h)
162
+ end
163
+ end
164
+
165
+ describe 'native memory accounting' do
166
+ it 'include allocated probability arrays' do
167
+ empty = described_class.allocate
168
+ expect(ObjectSpace.memsize_of(distribution(:ten))).to be > ObjectSpace.memsize_of(empty)
169
+ end
170
+ end
171
+
172
+ describe '#each' do
173
+ it 'iterate through all result/probability pairs' do
174
+ yielded = distribution(:four).to_enum.map { |result, probability| [result, probability] }
175
+ expect(yielded).to eql [[1, 0.25], [2, 0.25], [3, 0.25], [4, 0.25]]
176
+ end
177
+
178
+ it 'skip zero probabilities' do
179
+ pr_plus_minus = described_class.new([0.5, 0.0, 0.5], -1)
180
+ yielded = pr_plus_minus.to_enum.map { |result, probability| [result, probability] }
181
+ expect(yielded).to eql [[-1, 0.5], [1, 0.5]]
182
+ end
183
+ end
184
+
185
+ describe '#p_eql' do
186
+ it 'return probability of getting a number inside the range' do
187
+ expect(distribution(:two).p_eql(2)).to be_within(1.0e-9).of 0.5
188
+ expect(distribution(:four).p_eql(1)).to be_within(1.0e-9).of 0.25
189
+ expect(distribution(:six).p_eql(6)).to be_within(1.0e-9).of 1.0 / 6
190
+ expect(distribution(:ten).p_eql(3)).to be_within(1.0e-9).of 0.1
191
+ expect(distribution(:offset_asymmetric).p_eql(-1)).to be_within(1.0e-9).of 0.4
192
+ end
193
+
194
+ it 'return 0.0 for values not covered by distribution' do
195
+ expect(distribution(:two).p_eql(3)).to be 0.0
196
+ expect(distribution(:four).p_eql(-1)).to be 0.0
197
+ expect(distribution(:six).p_eql(8)).to be 0.0
198
+ expect(distribution(:ten).p_eql(11)).to be 0.0
199
+ expect(distribution(:offset_asymmetric).p_eql(2)).to be 0.0
200
+ end
201
+
202
+ it 'raise a TypeError if asked for probability of non-Integer' do
203
+ expect { distribution(:two).p_eql([]) }.to raise_error TypeError
204
+ end
205
+ end
206
+
207
+ describe '#p_gt' do
208
+ it 'return probability of getting a number greater than target' do
209
+ # Trying more than one, due to possibilities of caching error (in pure Ruby implementation)
210
+ cases = [[distribution(:two), 1, 0.5], [distribution(:four), 3, 0.25],
211
+ [distribution(:six), 2, 4.0 / 6], [distribution(:ten), 6, 0.4],
212
+ [distribution(:offset_asymmetric), -2, 1.0],
213
+ [distribution(:offset_asymmetric), -1, 0.6], [distribution(:offset_asymmetric), 0, 0.4],
214
+ [distribution(:offset_asymmetric), 1, 0.0]]
215
+ cases.each { |probability, target, expected| expect(probability.p_gt(target)).to be_within(1e-9).of(expected) }
216
+ end
217
+
218
+ it 'return 0.0 when the target number is equal or higher than maximum possible' do
219
+ expect(distribution(:two).p_gt(2)).to be 0.0
220
+ expect(distribution(:four).p_gt(5)).to be 0.0
221
+ expect(distribution(:six).p_gt(6)).to be 0.0
222
+ expect(distribution(:ten).p_gt(20)).to be 0.0
223
+ expect(distribution(:offset_asymmetric).p_gt(3)).to be 0.0
224
+ end
225
+
226
+ it 'return 1.0 when the target number is lower than minimum' do
227
+ expect(distribution(:two).p_gt(0)).to be 1.0
228
+ expect(distribution(:four).p_gt(-5)).to be 1.0
229
+ expect(distribution(:six).p_gt(0)).to be 1.0
230
+ expect(distribution(:ten).p_gt(-200)).to be 1.0
231
+ expect(distribution(:offset_asymmetric).p_gt(-2)).to be 1.0
232
+ end
233
+
234
+ it 'raise a TypeError if asked for probability of non-Integer' do
235
+ expect { distribution(:two).p_gt({}) }.to raise_error TypeError
236
+ end
237
+ end
238
+
239
+ describe '#p_ge' do
240
+ it 'return probability of getting a number greater than or equal to target' do
241
+ expect(distribution(:two).p_ge(2)).to be_within(1.0e-9).of 0.5
242
+ expect(distribution(:four).p_ge(3)).to be_within(1.0e-9).of 0.5
243
+ expect(distribution(:six).p_ge(2)).to be_within(1.0e-9).of 5.0 / 6
244
+ expect(distribution(:ten).p_ge(6)).to be_within(1.0e-9).of 0.5
245
+ end
246
+
247
+ it 'return 0.0 when the target number is higher than maximum possible' do
248
+ expect(distribution(:two).p_ge(6)).to be 0.0
249
+ expect(distribution(:four).p_ge(5)).to be 0.0
250
+ expect(distribution(:six).p_ge(7)).to be 0.0
251
+ expect(distribution(:ten).p_ge(20)).to be 0.0
252
+ end
253
+
254
+ it 'return 1.0 when the target number is lower than or equal to minimum possible' do
255
+ expect(distribution(:two).p_ge(1)).to be 1.0
256
+ expect(distribution(:four).p_ge(-5)).to be 1.0
257
+ expect(distribution(:six).p_ge(1)).to be 1.0
258
+ expect(distribution(:ten).p_ge(-200)).to be 1.0
259
+ end
260
+
261
+ it 'raise a TypeError if asked for probability of non-Integer' do
262
+ expect { distribution(:four).p_ge({}) }.to raise_error TypeError
263
+ end
264
+ end
265
+
266
+ describe '#p_le' do
267
+ it 'return probability of getting a number less than or equal to target' do
268
+ expect(distribution(:two).p_le(1)).to be_within(1.0e-9).of 0.5
269
+ expect(distribution(:four).p_le(2)).to be_within(1.0e-9).of 0.5
270
+ expect(distribution(:six).p_le(2)).to be_within(1.0e-9).of 2.0 / 6
271
+ expect(distribution(:ten).p_le(6)).to be_within(1.0e-9).of 0.6
272
+ end
273
+
274
+ it 'return 1.0 when the target number is higher than or equal to maximum possible' do
275
+ expect(distribution(:two).p_le(6)).to be 1.0
276
+ expect(distribution(:four).p_le(4)).to be 1.0
277
+ expect(distribution(:six).p_le(7)).to be 1.0
278
+ expect(distribution(:ten).p_le(10)).to be 1.0
279
+ end
280
+
281
+ it 'return 0.0 when the target number is lower than minimum possible' do
282
+ expect(distribution(:two).p_le(0)).to be 0.0
283
+ expect(distribution(:four).p_le(-5)).to be 0.0
284
+ expect(distribution(:six).p_le(0)).to be 0.0
285
+ expect(distribution(:ten).p_le(-200)).to be 0.0
286
+ end
287
+
288
+ it 'raise a TypeError if asked for probability of non-Integer' do
289
+ expect { distribution(:four).p_le([]) }.to raise_error TypeError
290
+ end
291
+ end
292
+
293
+ describe '#p_lt' do
294
+ it 'return probability of getting a number less than target' do
295
+ expect(distribution(:two).p_lt(2)).to be_within(1.0e-9).of 0.5
296
+ expect(distribution(:four).p_lt(3)).to be_within(1.0e-9).of 0.5
297
+ expect(distribution(:six).p_lt(2)).to be_within(1.0e-9).of 1 / 6.0
298
+ expect(distribution(:ten).p_lt(6)).to be_within(1.0e-9).of 0.5
299
+ end
300
+
301
+ it 'return 1.0 when the target number is higher than maximum possible' do
302
+ expect(distribution(:two).p_lt(6)).to be 1.0
303
+ expect(distribution(:four).p_lt(5)).to be 1.0
304
+ expect(distribution(:six).p_lt(7)).to be 1.0
305
+ expect(distribution(:ten).p_lt(20)).to be 1.0
306
+ end
307
+
308
+ it 'return 0.0 when the target number is lower than or equal to minimum possible' do
309
+ expect(distribution(:two).p_lt(1)).to be 0.0
310
+ expect(distribution(:four).p_lt(-5)).to be 0.0
311
+ expect(distribution(:six).p_lt(1)).to be 0.0
312
+ expect(distribution(:ten).p_lt(-200)).to be 0.0
313
+ end
314
+
315
+ it 'raise a TypeError if asked for probability of non-Integer' do
316
+ expect { distribution(:six).p_lt({}) }.to raise_error TypeError
317
+ end
318
+ end
319
+
320
+ describe '#to_h' do
321
+ # This is used loads in other tests
322
+ it 'represent a valid distribution with each integer result associated with its probability' do
323
+ expect(distribution(:two).to_h).to be_valid_distribution
324
+ expect(distribution(:four).to_h).to be_valid_distribution
325
+ expect(distribution(:six).to_h).to be_valid_distribution
326
+ expect(distribution(:ten).to_h).to be_valid_distribution
327
+ end
328
+ end
329
+
330
+ describe '#min' do
331
+ it 'return lowest possible result allowed by distribution' do
332
+ expect(distribution(:two).min).to be 1
333
+ expect(distribution(:four).min).to be 1
334
+ expect(distribution(:six).min).to be 1
335
+ expect(distribution(:ten).min).to be 1
336
+ expect(described_class.add_distributions(distribution(:six), distribution(:ten)).min).to be 2
337
+ end
338
+ end
339
+
340
+ describe '#max' do
341
+ it 'return highest possible result allowed by distribution' do
342
+ expect(distribution(:two).max).to be 2
343
+ expect(distribution(:four).max).to be 4
344
+ expect(distribution(:six).max).to be 6
345
+ expect(distribution(:ten).max).to be 10
346
+ expect(described_class.add_distributions(distribution(:six), distribution(:ten)).max).to be 16
347
+ end
348
+ end
349
+
350
+ describe '#expected' do
351
+ it 'return the weighted mean value' do
352
+ combined = described_class.add_distributions(distribution(:six), distribution(:ten))
353
+ cases = [[distribution(:two), 1.5], [distribution(:four), 2.5], [distribution(:six), 3.5],
354
+ [distribution(:ten), 5.5], [combined, 9.0]]
355
+ cases.each { |probability, expected| expect(probability.expected).to be_within(1e-9).of(expected) }
356
+ end
357
+ end
358
+
359
+ describe '#given_ge' do
360
+ it 'return a new distribution with probabilities calculated assuming value is >= target' do
361
+ conditioned = distribution(:ten).given_ge(4)
362
+ expect(distribution(:two).given_ge(2).to_h).to eql({ 2 => 1.0 })
363
+ expect(conditioned.to_h).to be_valid_distribution
364
+ expect(conditioned.p_eql(3)).to be 0.0
365
+ expect(conditioned.p_eql(10)).to be_within(1.0e-9).of 0.1 / 0.7
366
+ end
367
+
368
+ it 'raise a TypeError if asked for probability of non-Integer' do
369
+ expect { distribution(:ten).given_ge([]) }.to raise_error TypeError
370
+ end
371
+
372
+ it 'clamp targets below the minimum and reject targets above the maximum' do
373
+ expect(distribution(:ten).given_ge(0).to_h).to eql(distribution(:ten).to_h)
374
+ expect { distribution(:ten).given_ge(11) }.to raise_error(RuntimeError, /divide by zero/)
375
+ end
376
+ end
377
+
378
+ describe '#given_le' do
379
+ it 'return a new distribution with probabilities calculated assuming value is <= target' do
380
+ conditioned = distribution(:ten).given_le(4)
381
+ expect(distribution(:two).given_le(2).to_h).to eql({ 1 => 0.5, 2 => 0.5 })
382
+ expect(conditioned.to_h).to be_valid_distribution
383
+ expect(conditioned.p_eql(3)).to be_within(1.0e-9).of 0.1 / 0.4
384
+ expect(conditioned.p_eql(10)).to be 0.0
385
+ end
386
+
387
+ it 'raise a TypeError if asked for probability of non-Integer' do
388
+ expect { distribution(:ten).given_le({}) }.to raise_error TypeError
389
+ end
390
+
391
+ it 'clamp targets above the maximum and reject targets below the minimum' do
392
+ expect(distribution(:ten).given_le(11).to_h).to eql(distribution(:ten).to_h)
393
+ expect { distribution(:ten).given_le(0) }.to raise_error(RuntimeError, /divide by zero/)
394
+ end
395
+ end
396
+
397
+ describe '#repeat_sum' do
398
+ it 'output a valid distribution if params are valid' do
399
+ expect(distribution(:uniform_four).repeat_sum(7).to_h).to be_valid_distribution
400
+ expect(distribution(:weighted_four).repeat_sum(12).to_h).to be_valid_distribution
401
+ end
402
+
403
+ it 'raise an error if any param is unexpected type' do
404
+ d6 = described_class.for_fair_die(6)
405
+ expect { d6.repeat_sum({}) }.to raise_error TypeError
406
+ end
407
+
408
+ it 'raise an error if repetitions are not positive' do
409
+ expect { distribution(:six).repeat_sum(0) }.to raise_error(RuntimeError, /n < 1/)
410
+ end
411
+
412
+ it 'raise an error if distribution would have more than a million results' do
413
+ d1000 = described_class.for_fair_die(1000)
414
+ expect { d1000.repeat_sum(11_000) }.to raise_error(RuntimeError, /Too many probability slots/)
415
+ end
416
+
417
+ it "calculate a '3d6' distribution accurately" do
418
+ h = distribution(:six).repeat_sum(3).to_h
419
+ expect(h).to be_valid_distribution
420
+ numerators = [1, 3, 6, 10, 15, 21, 25, 27, 27, 25, 21, 15, 10, 6, 3, 1]
421
+ expected = (3..18).zip(numerators.map { |numerator| numerator / 216.0 }).to_h
422
+ expect_probability_values(h, expected, tolerance: 1e-9, total_tolerance: nil)
423
+ end
424
+ end
425
+
426
+ describe '#repeat_n_sum_k' do
427
+ it 'output a valid distribution if params are valid' do
428
+ expect(distribution(:uniform_four).repeat_n_sum_k(3, 2).to_h).to be_valid_distribution
429
+ expect(distribution(:weighted_four).repeat_n_sum_k(12, 4).to_h).to be_valid_distribution
430
+ end
431
+
432
+ it 'raise an error if any param is unexpected type' do
433
+ d6 = described_class.for_fair_die(6)
434
+ expect { d6.repeat_n_sum_k({}, 10) }.to raise_error TypeError
435
+ expect { d6.repeat_n_sum_k(10, {}) }.to raise_error TypeError
436
+ end
437
+
438
+ it 'raise an error if repetitions or keepers are not positive' do
439
+ expect { distribution(:six).repeat_n_sum_k(0, 1) }.to raise_error(RuntimeError, /n < 1/)
440
+ expect { distribution(:six).repeat_n_sum_k(2, 0) }.to raise_error(RuntimeError, /k < 1/)
441
+ end
442
+
443
+ it 'sum every repetition when the keeper count is at least the repetition count' do
444
+ expect(distribution(:six).repeat_n_sum_k(3, 3).to_h).to eql(distribution(:six).repeat_sum(3).to_h)
445
+ end
446
+
447
+ it 'raise an error if the kept distribution would have more than a million results' do
448
+ d1000 = described_class.for_fair_die(1000)
449
+ expect { d1000.repeat_n_sum_k(1003, 1002) }.to raise_error(RuntimeError, /Too many probability slots/)
450
+ end
451
+
452
+ it 'raise an error for an unknown keep mode' do
453
+ expect { distribution(:six).repeat_n_sum_k(3, 2, :middle) }.to raise_error(ArgumentError, /Keep mode/)
454
+ end
455
+
456
+ it 'raise an error if n is greater than 170' do
457
+ d6 = described_class.for_fair_die(6)
458
+ expect { d6.repeat_n_sum_k(171, 10) }.to raise_error(RuntimeError, /Too many dice/)
459
+ end
460
+
461
+ it "calculate a '4d6 keep best 3' distribution accurately" do
462
+ h = distribution(:six).repeat_n_sum_k(4, 3).to_h
463
+ expect(h).to be_valid_distribution
464
+ numerators = [1, 4, 10, 21, 38, 62, 91, 122, 148, 167, 172, 160, 131, 94, 54, 21]
465
+ expected = (3..18).zip(numerators.map { |numerator| numerator / 1296.0 }).to_h
466
+ expect_probability_values(h, expected, total_tolerance: nil)
467
+ end
468
+
469
+ it "calculate a '2d20 keep worst result' distribution accurately" do
470
+ h = distribution(:twenty).repeat_n_sum_k(2, 1, :keep_worst).to_h
471
+ expect(h).to be_valid_distribution
472
+ expected = (1..20).to_h { |result| [result, (41 - (2 * result)) / 400.0] }
473
+ expect_probability_values(h, expected, total_tolerance: nil)
474
+ end
475
+
476
+ it "calculate a '4d6 keep worst 3' distribution accurately at its bounds" do
477
+ h = distribution(:six).repeat_n_sum_k(4, 3, :keep_worst).to_h
478
+ expect(h).to be_valid_distribution
479
+ expect(h[3]).to be_within(1e-10).of 21 / 1296.0
480
+ expect(h[18]).to be_within(1e-10).of 1 / 1296.0
481
+ end
482
+ end
483
+ end
484
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::ProbabilityBudget, :aggregate_failures do
6
+ it 'rejects an aggregate certificate exceeding its requested budget' do
7
+ expect { described_class.check(1e-9, 1e-10) }
8
+ .to raise_error(GamesDice::ProbabilityLimitError, /truncation error bound/)
9
+ end
10
+
11
+ it 'rounds component union bounds outwards' do
12
+ error = 1.0 / 3
13
+ expect(described_class.scale(error, 10).to_r).to be >= 10 * error.to_r
14
+ expect(described_class.add(error, error).to_r).to be >= 2 * error.to_r
15
+ expect(described_class.share(error, 10).to_r).to be <= error.to_r / 10
16
+ end
17
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'helpers'
4
4
 
5
- describe GamesDice::RerollRule do
5
+ describe GamesDice::RerollRule, :aggregate_failures do
6
6
  describe '#new' do
7
7
  it 'accept self-consistent operator/value pairs as a trigger' do
8
8
  expect { described_class.new(5, :>, :reroll_subtract) }.not_to raise_error
@@ -37,4 +37,8 @@ describe GamesDice::RerollRule do
37
37
  expect(rule.applies?(6)).to be false
38
38
  end
39
39
  end
40
+
41
+ it 'uses the default limit when explicitly given nil' do
42
+ expect(described_class.new(6, :==, :reroll_add, nil).limit).to eq(1000)
43
+ end
40
44
  end