games_dice 0.4.2 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +69 -5
  3. data/.gitignore +0 -1
  4. data/.rubocop.yml +13 -48
  5. data/CHANGELOG.md +26 -0
  6. data/Gemfile +6 -3
  7. data/README.md +46 -5
  8. data/Rakefile +90 -2
  9. data/ext/games_dice/extconf.rb +26 -0
  10. data/ext/games_dice/games_dice.c +1 -1
  11. data/ext/games_dice/probabilities.c +341 -211
  12. data/ext/games_dice/probabilities.h +3 -29
  13. data/games_dice.gemspec +1 -1
  14. data/lib/games_dice/bunch.rb +50 -36
  15. data/lib/games_dice/bunch_helpers.rb +35 -14
  16. data/lib/games_dice/complex_die.rb +64 -22
  17. data/lib/games_dice/complex_die_helpers.rb +122 -144
  18. data/lib/games_dice/dice.rb +61 -17
  19. data/lib/games_dice/dice_helpers.rb +37 -0
  20. data/lib/games_dice/die_result.rb +6 -8
  21. data/lib/games_dice/marshal.rb +2 -7
  22. data/lib/games_dice/parser.rb +30 -0
  23. data/lib/games_dice/probability_budget.rb +90 -0
  24. data/lib/games_dice/probability_calculation.rb +95 -0
  25. data/lib/games_dice/probability_shortcuts.rb +65 -0
  26. data/lib/games_dice/recipe_cache.rb +155 -0
  27. data/lib/games_dice/version.rb +1 -1
  28. data/lib/games_dice.rb +4 -0
  29. data/script/check_documentation.rb +10 -0
  30. data/spec/games_dice/bunch_regressions_spec.rb +46 -0
  31. data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
  32. data/spec/games_dice/complex_die_budget_spec.rb +60 -0
  33. data/spec/games_dice/complex_die_limits_spec.rb +119 -0
  34. data/spec/games_dice/complex_die_spec.rb +228 -0
  35. data/spec/games_dice/dice_mutation_spec.rb +165 -0
  36. data/spec/games_dice/dice_regressions_spec.rb +50 -0
  37. data/spec/games_dice/dice_spec.rb +61 -0
  38. data/spec/games_dice/die_result_spec.rb +221 -0
  39. data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
  40. data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
  41. data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
  42. data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
  43. data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
  44. data/spec/games_dice/probabilities_spec.rb +484 -0
  45. data/spec/games_dice/probability_budget_spec.rb +17 -0
  46. data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
  47. data/spec/games_dice_spec.rb +382 -0
  48. data/spec/helpers.rb +64 -2
  49. data/spec/simple_cov_spec.rb +42 -0
  50. data/spec/support/coverage.rb +12 -0
  51. data/spec/support/validation.rb +14 -0
  52. data/spec/yard/cli/stats_spec.rb +43 -0
  53. metadata +30 -13
  54. data/spec/complex_die_spec.rb +0 -314
  55. data/spec/dice_spec.rb +0 -33
  56. data/spec/die_result_spec.rb +0 -254
  57. data/spec/fixtures/probs_fair_die_6.dat +0 -2
  58. data/spec/probability_spec.rb +0 -522
  59. data/spec/readme_spec.rb +0 -410
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'helpers'
4
4
 
5
- describe GamesDice::Bunch do
5
+ describe GamesDice::Bunch, :aggregate_failures do
6
6
  describe 'dice scheme' do
7
7
  before do
8
8
  srand(67_809)
@@ -35,18 +35,8 @@ describe GamesDice::Bunch do
35
35
  end
36
36
 
37
37
  it 'calculate probabilities correctly' do
38
- prob_hash = bunch.probabilities.to_h
39
- expect(prob_hash[1]).to be_within(1e-10).of 0.1
40
- expect(prob_hash[2]).to be_within(1e-10).of 0.1
41
- expect(prob_hash[3]).to be_within(1e-10).of 0.1
42
- expect(prob_hash[4]).to be_within(1e-10).of 0.1
43
- expect(prob_hash[5]).to be_within(1e-10).of 0.1
44
- expect(prob_hash[6]).to be_within(1e-10).of 0.1
45
- expect(prob_hash[7]).to be_within(1e-10).of 0.1
46
- expect(prob_hash[8]).to be_within(1e-10).of 0.1
47
- expect(prob_hash[9]).to be_within(1e-10).of 0.1
48
- expect(prob_hash[10]).to be_within(1e-10).of 0.1
49
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
38
+ expected = (1..10).to_h { |result| [result, 0.1] }
39
+ expect_probability_values(bunch.probabilities.to_h, expected)
50
40
  end
51
41
  end
52
42
 
@@ -78,19 +68,8 @@ describe GamesDice::Bunch do
78
68
  end
79
69
 
80
70
  it 'calculate probabilities correctly' do
81
- prob_hash = bunch.probabilities.to_h
82
- expect(prob_hash[2]).to be_within(1e-10).of 1 / 36.0
83
- expect(prob_hash[3]).to be_within(1e-10).of 2 / 36.0
84
- expect(prob_hash[4]).to be_within(1e-10).of 3 / 36.0
85
- expect(prob_hash[5]).to be_within(1e-10).of 4 / 36.0
86
- expect(prob_hash[6]).to be_within(1e-10).of 5 / 36.0
87
- expect(prob_hash[7]).to be_within(1e-10).of 6 / 36.0
88
- expect(prob_hash[8]).to be_within(1e-10).of 5 / 36.0
89
- expect(prob_hash[9]).to be_within(1e-10).of 4 / 36.0
90
- expect(prob_hash[10]).to be_within(1e-10).of 3 / 36.0
91
- expect(prob_hash[11]).to be_within(1e-10).of 2 / 36.0
92
- expect(prob_hash[12]).to be_within(1e-10).of 1 / 36.0
93
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
71
+ expected = (2..12).to_h { |result| [result, (6 - (7 - result).abs) / 36.0] }
72
+ expect_probability_values(bunch.probabilities.to_h, expected)
94
73
  end
95
74
  end
96
75
 
@@ -162,24 +141,9 @@ describe GamesDice::Bunch do
162
141
  end
163
142
 
164
143
  it 'calculate probabilities correctly' do
165
- prob_hash = bunch.probabilities.to_h
166
- expect(prob_hash[3]).to be_within(1e-10).of 1 / 1296.0
167
- expect(prob_hash[4]).to be_within(1e-10).of 4 / 1296.0
168
- expect(prob_hash[5]).to be_within(1e-10).of 10 / 1296.0
169
- expect(prob_hash[6]).to be_within(1e-10).of 21 / 1296.0
170
- expect(prob_hash[7]).to be_within(1e-10).of 38 / 1296.0
171
- expect(prob_hash[8]).to be_within(1e-10).of 62 / 1296.0
172
- expect(prob_hash[9]).to be_within(1e-10).of 91 / 1296.0
173
- expect(prob_hash[10]).to be_within(1e-10).of 122 / 1296.0
174
- expect(prob_hash[11]).to be_within(1e-10).of 148 / 1296.0
175
- expect(prob_hash[12]).to be_within(1e-10).of 167 / 1296.0
176
- expect(prob_hash[13]).to be_within(1e-10).of 172 / 1296.0
177
- expect(prob_hash[14]).to be_within(1e-10).of 160 / 1296.0
178
- expect(prob_hash[15]).to be_within(1e-10).of 131 / 1296.0
179
- expect(prob_hash[16]).to be_within(1e-10).of 94 / 1296.0
180
- expect(prob_hash[17]).to be_within(1e-10).of 54 / 1296.0
181
- expect(prob_hash[18]).to be_within(1e-10).of 21 / 1296.0
182
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
144
+ numerators = [1, 4, 10, 21, 38, 62, 91, 122, 148, 167, 172, 160, 131, 94, 54, 21]
145
+ expected = (3..18).zip(numerators.map { |numerator| numerator / 1296.0 }).to_h
146
+ expect_probability_values(bunch.probabilities.to_h, expected)
183
147
  end
184
148
  end
185
149
 
@@ -213,18 +177,12 @@ describe GamesDice::Bunch do
213
177
  end
214
178
 
215
179
  it 'calculate probabilities correctly' do
216
- prob_hash = bunch.probabilities.to_h
217
- expect(prob_hash[1]).to be_within(1e-10).of 0.6513215599
218
- expect(prob_hash[2]).to be_within(1e-10).of 0.2413042577
219
- expect(prob_hash[3]).to be_within(1e-10).of 0.0791266575
220
- expect(prob_hash[4]).to be_within(1e-10).of 0.0222009073
221
- expect(prob_hash[5]).to be_within(1e-10).of 0.0050700551
222
- expect(prob_hash[6]).to be_within(1e-10).of 0.0008717049
223
- expect(prob_hash[7]).to be_within(1e-10).of 0.0000989527
224
- expect(prob_hash[8]).to be_within(1e-10).of 0.0000058025
225
- expect(prob_hash[9]).to be_within(1e-10).of 0.0000001023
226
- expect(prob_hash[10]).to be_within(1e-18).of 1e-10
227
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
180
+ expected = {
181
+ 1 => 0.6513215599, 2 => 0.2413042577, 3 => 0.0791266575, 4 => 0.0222009073,
182
+ 5 => 0.0050700551, 6 => 0.0008717049, 7 => 0.0000989527, 8 => 0.0000058025,
183
+ 9 => 0.0000001023, 10 => 1e-10
184
+ }
185
+ expect_probability_values(bunch.probabilities.to_h, expected, tolerances: { 10 => 1e-18 })
228
186
  end
229
187
  end
230
188
 
@@ -263,18 +221,11 @@ describe GamesDice::Bunch do
263
221
  end
264
222
 
265
223
  it 'calculate probabilities correctly' do
266
- prob_hash = bunch.probabilities.to_h
267
- probs = prob_hash.values_at(*2..30)
268
- expected_probs = [0.00001, 0.00005, 0.00031, 0.00080, 0.00211, 0.00405, 0.00781, 0.01280, 0.02101, 0.0312,
269
- 0.045715, 0.060830, 0.077915, 0.090080, 0.097935, 0.091230, 0.070015, 0.020480, 0.032805,
270
- 0.0328, 0.0334626451, 0.0338904805, 0.0338098781, 0.0328226480, 0.0304393461, 0.0260456005,
271
- 0.0189361531, 0.0082804480, 0.0103524151]
272
-
273
- probs.zip(expected_probs) do |got_prob, expected_prob|
274
- expect(got_prob).to be_within(1e-10).of(expected_prob)
275
- end
276
-
277
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
224
+ probabilities = [0.00001, 0.00005, 0.00031, 0.00080, 0.00211, 0.00405, 0.00781, 0.01280, 0.02101, 0.0312,
225
+ 0.045715, 0.060830, 0.077915, 0.090080, 0.097935, 0.091230, 0.070015, 0.020480, 0.032805,
226
+ 0.0328, 0.0334626451, 0.0338904805, 0.0338098781, 0.0328226480, 0.0304393461, 0.0260456005,
227
+ 0.0189361531, 0.0082804480, 0.0103524151]
228
+ expect_probability_values(bunch.probabilities.to_h, (2..30).zip(probabilities).to_h)
278
229
  end
279
230
  end
280
231
 
@@ -312,28 +263,8 @@ describe GamesDice::Bunch do
312
263
  end
313
264
 
314
265
  it 'calculate probabilities correctly' do
315
- prob_hash = bunch.probabilities.to_h
316
- expect(prob_hash[1]).to be_within(1e-10).of 1 / 400.0
317
- expect(prob_hash[2]).to be_within(1e-10).of 3 / 400.0
318
- expect(prob_hash[3]).to be_within(1e-10).of 5 / 400.0
319
- expect(prob_hash[4]).to be_within(1e-10).of 7 / 400.0
320
- expect(prob_hash[5]).to be_within(1e-10).of 9 / 400.0
321
- expect(prob_hash[6]).to be_within(1e-10).of 11 / 400.0
322
- expect(prob_hash[7]).to be_within(1e-10).of 13 / 400.0
323
- expect(prob_hash[8]).to be_within(1e-10).of 15 / 400.0
324
- expect(prob_hash[9]).to be_within(1e-10).of 17 / 400.0
325
- expect(prob_hash[10]).to be_within(1e-10).of 19 / 400.0
326
- expect(prob_hash[11]).to be_within(1e-10).of 21 / 400.0
327
- expect(prob_hash[12]).to be_within(1e-10).of 23 / 400.0
328
- expect(prob_hash[13]).to be_within(1e-10).of 25 / 400.0
329
- expect(prob_hash[14]).to be_within(1e-10).of 27 / 400.0
330
- expect(prob_hash[15]).to be_within(1e-10).of 29 / 400.0
331
- expect(prob_hash[16]).to be_within(1e-10).of 31 / 400.0
332
- expect(prob_hash[17]).to be_within(1e-10).of 33 / 400.0
333
- expect(prob_hash[18]).to be_within(1e-10).of 35 / 400.0
334
- expect(prob_hash[19]).to be_within(1e-10).of 37 / 400.0
335
- expect(prob_hash[20]).to be_within(1e-10).of 39 / 400.0
336
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
266
+ expected = (1..20).to_h { |result| [result, ((2 * result) - 1) / 400.0] }
267
+ expect_probability_values(bunch.probabilities.to_h, expected)
337
268
  end
338
269
  end
339
270
 
@@ -371,29 +302,51 @@ describe GamesDice::Bunch do
371
302
  end
372
303
 
373
304
  it 'calculate probabilities correctly' do
374
- prob_hash = bunch.probabilities.to_h
375
- expect(prob_hash[1]).to be_within(1e-10).of 39 / 400.0
376
- expect(prob_hash[2]).to be_within(1e-10).of 37 / 400.0
377
- expect(prob_hash[3]).to be_within(1e-10).of 35 / 400.0
378
- expect(prob_hash[4]).to be_within(1e-10).of 33 / 400.0
379
- expect(prob_hash[5]).to be_within(1e-10).of 31 / 400.0
380
- expect(prob_hash[6]).to be_within(1e-10).of 29 / 400.0
381
- expect(prob_hash[7]).to be_within(1e-10).of 27 / 400.0
382
- expect(prob_hash[8]).to be_within(1e-10).of 25 / 400.0
383
- expect(prob_hash[9]).to be_within(1e-10).of 23 / 400.0
384
- expect(prob_hash[10]).to be_within(1e-10).of 21 / 400.0
385
- expect(prob_hash[11]).to be_within(1e-10).of 19 / 400.0
386
- expect(prob_hash[12]).to be_within(1e-10).of 17 / 400.0
387
- expect(prob_hash[13]).to be_within(1e-10).of 15 / 400.0
388
- expect(prob_hash[14]).to be_within(1e-10).of 13 / 400.0
389
- expect(prob_hash[15]).to be_within(1e-10).of 11 / 400.0
390
- expect(prob_hash[16]).to be_within(1e-10).of 9 / 400.0
391
- expect(prob_hash[17]).to be_within(1e-10).of 7 / 400.0
392
- expect(prob_hash[18]).to be_within(1e-10).of 5 / 400.0
393
- expect(prob_hash[19]).to be_within(1e-10).of 3 / 400.0
394
- expect(prob_hash[20]).to be_within(1e-10).of 1 / 400.0
395
- expect(prob_hash.values.sum).to be_within(1e-9).of 1.0
305
+ expected = (1..20).to_h { |result| [result, (41 - (2 * result)) / 400.0] }
306
+ expect_probability_values(bunch.probabilities.to_h, expected)
396
307
  end
397
308
  end
398
309
  end
310
+
311
+ it 'rejects invalid dice counts, side counts, keeper modes and random generators' do
312
+ defaults = { sides: 6, ndice: 2 }
313
+ [{ ndice: 0 }, { sides: 0 }, { keep_mode: :unknown }].each do |options|
314
+ expect { described_class.new(defaults.merge(options)) }.to raise_error(ArgumentError)
315
+ end
316
+ expect { described_class.new(sides: 6, ndice: 2, prng: Object.new) }.to raise_error(RuntimeError, /prng/)
317
+ end
318
+
319
+ it 'has no result details or explanation before rolling' do
320
+ expect(described_class.new(sides: 6, ndice: 2)).to have_attributes(result_details: nil, explain_result: nil)
321
+ end
322
+
323
+ it 'uses the supplied name as its label and exposes its rules' do
324
+ bunch = described_class.new(sides: 6, ndice: 2, name: 'Damage', rerolls: [[6, :==, :reroll_add]])
325
+ expect(bunch.label).to eq('Damage')
326
+ expect(bunch.rerolls.first).to have_attributes(type: :reroll_add, trigger_value: 6)
327
+ expect(bunch.maps).to be_nil
328
+ end
329
+
330
+ it 'reuses its calculated probability distribution' do
331
+ bunch = described_class.new(sides: 6, ndice: 2)
332
+ probabilities = bunch.probabilities
333
+ expect(bunch.probabilities).to equal(probabilities)
334
+ expect(probabilities.expected).to be_within(1e-10).of(7.0)
335
+ end
336
+
337
+ [2, 3].product(%i[keep_best keep_worst]).each do |number, mode|
338
+ it "keeps all dice when #{mode} requests #{number} of two dice" do
339
+ bunch = described_class.new(sides: 6, ndice: 2, keep_mode: mode, keep_number: number, prng: TestPRNGMax.new)
340
+ expect(bunch.roll).to eq(12)
341
+ expect(bunch.explain_result).to eq('6, 6. Keep: 6 + 6 = 12')
342
+ expect(bunch.probabilities.expected).to be_within(1e-10).of(7.0)
343
+ end
344
+ end
345
+
346
+ it 'explains mapped keeper results with their labels' do
347
+ bunch = described_class.new(sides: 6, ndice: 3, keep_mode: :keep_best, keep_number: 2,
348
+ maps: [[4, :<=, 1, 'Success']], prng: TestPRNGMax.new)
349
+ expect(bunch.roll).to eq(2)
350
+ expect(bunch.explain_result).to eq('6 Success, 6 Success, 6 Success. Keep: 6 Success, 6 Success. Successes: 2')
351
+ end
399
352
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::ComplexDie, :aggregate_failures do
6
+ def observed_omissions(counts)
7
+ allow(GamesDice::ProbabilityBudget).to receive(:new).and_wrap_original do |constructor, *arguments|
8
+ constructor.call(*arguments).tap do |budget|
9
+ allow(budget).to receive(:omit?).and_wrap_original do |original, depth, probability|
10
+ original.call(depth, probability).tap { |omitted| counts[depth] += 1 if omitted }
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ [1, 10, 1000, 1_000_000].each do |count|
17
+ it "certifies the exact omitted mass when sharing a budget across #{count} dice" do
18
+ counts = Hash.new(0)
19
+ observed_omissions(counts)
20
+ die = described_class.new(2, rerolls: [[2, :==, :reroll_add]])
21
+ die.__send__(:probabilities_for_budget, GamesDice::ProbabilityBudget.share(1e-10.prev_float, count))
22
+ expect_certified_omission(die, counts, count)
23
+ end
24
+ end
25
+
26
+ def expect_certified_omission(die, counts, count)
27
+ exact = counts.sum { |depth, branches| Rational(branches, 2**depth) }
28
+ expect(exact).to be <= die.instance_variable_get(:@probability_error).to_r
29
+ expect(count * exact).to be <= Rational(1, 10**10)
30
+ end
31
+
32
+ it 'handles near-certain replacement triggers without traversing the reroll limit' do
33
+ die = described_class.new(100_000, rerolls: [[99_999, :>=, :reroll_replace, 1_000_000]])
34
+ expect(die.probabilities.to_h).to be_valid_distribution
35
+ expect(die.probabilities.p_eql(100_000)).to be_within(1e-12).of(1 - ((0.99999**1_000_000) * 0.99999))
36
+ expect(die.probabilities_complete).to be(true)
37
+ end
38
+
39
+ it 'provides cheap exact bounds even when a deterministic outcome exceeds native storage' do
40
+ die = described_class.new(1, rerolls: [[1, :==, :reroll_add, 2**31]])
41
+ expect(die).to have_attributes(min: (2**31) + 1, max: (2**31) + 1)
42
+ expect { die.probabilities }.to raise_error(RangeError)
43
+ expect(die.probabilities_complete).to be_nil
44
+ end
45
+
46
+ it 'preserves a previous roll while recomputing a tighter probability certificate' do
47
+ die = described_class.new(2, rerolls: [[2, :==, :reroll_add]])
48
+ previous = die.roll
49
+ die.probabilities
50
+ die.__send__(:probabilities_for_budget, 1e-14)
51
+ expect(die.result).to equal(previous)
52
+ end
53
+
54
+ it 'treats absent numerical certificates as requiring recomputation after restoration' do
55
+ die = described_class.new(2, rerolls: [[2, :==, :reroll_add]])
56
+ die.probabilities
57
+ die.remove_instance_variable(:@probability_error)
58
+ expect(marshal_round_trip(die).probabilities.to_h).to be_valid_distribution
59
+ end
60
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::ComplexDie, :aggregate_failures do
6
+ def exploding(sides, limit = 1000)
7
+ described_class.new(sides, rerolls: [[sides, :==, :reroll_add, limit]])
8
+ end
9
+
10
+ [2, 3, 6, 10].each do |sides|
11
+ it "bounds the omitted mass for an exploding d#{sides}" do
12
+ die = exploding(sides)
13
+ probabilities = die.probabilities
14
+ expect(probabilities.p_eql(1)).to be_within(1e-10).of(1.0 / sides)
15
+ expect(probabilities.to_h.keys).not_to include(sides)
16
+ expect(die.probabilities_complete).to be(false)
17
+ end
18
+ end
19
+
20
+ it 'treats terminal branches at the old depth cutoff as complete' do
21
+ die = exploding(2, 19)
22
+ expect(die.probabilities.p_eql(40)).to eq(2.0**-20)
23
+ expect(die.probabilities_complete).to be(true)
24
+ end
25
+
26
+ it 'calculates a deterministic singleton without enumerating its roll history' do
27
+ die = exploding(1, 1_000_000)
28
+ expect(die.probabilities.to_h).to eq({ 1_000_001 => 1.0 })
29
+ expect(die.probabilities_complete).to be(true)
30
+ expect(die).to have_attributes(min: 1_000_001, max: 1_000_001)
31
+ end
32
+
33
+ it 'keeps deterministic additions exact for nonmatching and nonpositive rules' do
34
+ [-1, 0].each { |limit| expect(exploding(1, limit).probabilities.to_h).to eq({ 1 => 1.0 }) }
35
+ expect(described_class.new(1, rerolls: [[2, :==, :reroll_add]]).probabilities.to_h).to eq({ 1 => 1.0 })
36
+ end
37
+
38
+ it 'maps an exact singleton after completing its additive rule' do
39
+ die = described_class.new(1, rerolls: [[1, :==, :reroll_add]], maps: [[1001, :==, 7]])
40
+ expect(die.probabilities.to_h).to eq({ 7 => 1.0 })
41
+ end
42
+
43
+ it 'uses the finite replacement formula for more than one triggering face' do
44
+ die = described_class.new(6, rerolls: [[1..2, :cover?, :reroll_replace, 3]])
45
+ probabilities = die.probabilities
46
+ expect(probabilities.p_eql(1)).to be_within(1e-15).of(Rational(8, 1296).to_f)
47
+ expect(probabilities.p_eql(6)).to be_within(1e-15).of(Rational(320, 1296).to_f)
48
+ expect(die.probabilities_complete).to be(true)
49
+ end
50
+
51
+ [[0, :>=, 1000], [6, :>=, 1000], [1, :==, 0], [1, :==, -3]].each do |trigger, op, limit|
52
+ it "keeps replacement edge case #{[trigger, op, limit]} exactly uniform" do
53
+ die = described_class.new(6, rerolls: [[trigger, op, :reroll_replace, limit]])
54
+ expect_fair_distribution(die.probabilities.to_h, 6)
55
+ expect(die.probabilities_complete).to be(true)
56
+ end
57
+ end
58
+
59
+ it 'refuses a branching recipe at the depth limit and leaves no partial distribution' do
60
+ die = described_class.new(4, rerolls: [[3, :<=, :reroll_add]])
61
+ expect { die.probabilities }.to raise_error(GamesDice::ProbabilityLimitError, /truncation error bound/)
62
+ expect(die.probabilities_complete).to be_nil
63
+ expect(die).to have_attributes(min: 1, max: 4004)
64
+ end
65
+
66
+ it 'refuses excessive work for the open-ended percentile example' do
67
+ die = described_class.new(100, rerolls: [[96, :<=, :reroll_add]])
68
+ expect { die.probabilities }.to raise_error(GamesDice::ProbabilityLimitError, /work limit/)
69
+ expect(die).to have_attributes(min: 1, max: 100_100)
70
+ end
71
+
72
+ it 'bounds subtracting recipes even when their distribution is refused' do
73
+ die = described_class.new(4, rerolls: [[3, :<=, :reroll_add], [1, :==, :reroll_subtract]])
74
+ expect { die.probabilities }.to raise_error(GamesDice::ProbabilityLimitError)
75
+ expect(die).to have_attributes(min: -4008, max: 4008)
76
+ end
77
+
78
+ it 'encloses every ordinary mapped value when the distribution is refused' do
79
+ die = described_class.new(4, rerolls: [[3, :<=, :reroll_add]], maps: [[1, :==, -4], [2, :<=, 7]])
80
+ expect(die).to have_attributes(min: -4, max: 7)
81
+ end
82
+
83
+ it 'refuses unprovable bounds for custom numeric mappings' do
84
+ die = described_class.new(4, rerolls: [[3, :<=, :reroll_add]], maps: [[->(value) { value * 2 }, :call]])
85
+ expect { die.min }.to raise_error(GamesDice::ProbabilityLimitError, /Cannot establish finite dice bounds/)
86
+ end
87
+
88
+ it 'enumerates custom predicates rather than assuming they return booleans' do
89
+ die = described_class.new(2, rerolls: [[->(face) { face == 1 }, :call, :reroll_replace, 1]])
90
+ expect(die.probabilities.to_h).to eq({ 1 => 0.25, 2 => 0.75 })
91
+ end
92
+
93
+ it 'propagates exceptions raised by custom callbacks' do
94
+ die = described_class.new(2, maps: [[->(_) { raise RangeError, 'custom failure' }, :call]])
95
+ expect { die.min }.to raise_error(RangeError, 'custom failure')
96
+ end
97
+
98
+ it 'reuses a tight cache for loose requests and recomputes for tighter budgets' do
99
+ die = exploding(2)
100
+ loose = die.probabilities
101
+ tight = die.__send__(:probabilities_for_budget, 1e-14)
102
+ expect(tight).not_to equal(loose)
103
+ expect(die.probabilities).to equal(tight)
104
+ end
105
+
106
+ it 'does not return a loose cache when a tighter request fails' do
107
+ die = exploding(2)
108
+ loose = die.probabilities
109
+ expect { die.__send__(:probabilities_for_budget, 1e-30) }.to raise_error(GamesDice::ProbabilityLimitError)
110
+ expect(die.probabilities_complete).to be_nil
111
+ expect(die.probabilities).to equal(loose)
112
+ end
113
+
114
+ it 'counts mapping predicates towards the work limit' do
115
+ stub_const('GamesDice::ProbabilityBudget::MAX_PREDICATES', 3)
116
+ die = described_class.new(6, maps: [[1, :==, 1]])
117
+ expect { die.probabilities }.to raise_error(GamesDice::ProbabilityLimitError, /work limit/)
118
+ end
119
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::ComplexDie, :aggregate_failures do
6
+ before do
7
+ # Set state of default PRNG
8
+ srand(4567)
9
+ end
10
+
11
+ it 'represent a basic die as an object' do
12
+ die = described_class.new(6)
13
+ expect(die.min).to be 1
14
+ expect(die.max).to be 6
15
+ expect(die.sides).to be 6
16
+ end
17
+
18
+ it "return results based on Ruby's internal rand() by default" do
19
+ die = described_class.new(10)
20
+ [5, 4, 10, 4, 7, 8, 1, 9].each do |expected|
21
+ expect(die.roll.value).to eql expected
22
+ expect(die.result.value).to eql expected
23
+ end
24
+ end
25
+
26
+ it 'use any object with a rand(Integer) method' do
27
+ die = described_class.new(20, prng: TestPRNG.new)
28
+ [16, 7, 3, 11, 16, 18, 20, 7].each do |expected|
29
+ expect(die.roll.value).to eql expected
30
+ expect(die.result.value).to eql expected
31
+ end
32
+ end
33
+
34
+ it 'optionally accept a rerolls param' do
35
+ valid = [[], [GamesDice::RerollRule.new(6, :<=, :reroll_add)],
36
+ [GamesDice::RerollRule.new(6, :<=, :reroll_add), GamesDice::RerollRule.new(1, :>=, :reroll_subtract)],
37
+ [[6, :<=, :reroll_add]], [[6, :<=, :reroll_add], [1, :>=, :reroll_subtract]]]
38
+ invalid = [[7, TypeError], [['hello'], TypeError],
39
+ [[GamesDice::RerollRule.new(6, :<=, :reroll_add), :reroll_add], TypeError], [[7], TypeError],
40
+ [[['hello']], ArgumentError], [[[6, :<=, :reroll_add], :reroll_add], TypeError]]
41
+ expect_rule_arguments(described_class, :rerolls, valid: valid, invalid: invalid)
42
+ end
43
+
44
+ it 'optionally accept a maps param' do
45
+ valid = [[], [GamesDice::MapRule.new(7, :<=, 1)],
46
+ [GamesDice::MapRule.new(7, :<=, 1), GamesDice::MapRule.new(1, :>, -1)],
47
+ [[7, :<=, 1]], [[7, :<=, 1], [1, :>, -1]]]
48
+ invalid = [[7, TypeError], [[7], TypeError], [[[7]], ArgumentError], [['hello'], TypeError],
49
+ [[GamesDice::MapRule.new(7, :<=, 1), GamesDice::RerollRule.new(6, :<=, :reroll_add)], TypeError]]
50
+ expect_rule_arguments(described_class, :maps, valid: valid, invalid: invalid)
51
+ end
52
+
53
+ describe 'with rerolls' do
54
+ it 'calculate correct minimum and maximum results' do
55
+ cases = [[[GamesDice::RerollRule.new(10, :<=, :reroll_add, 3)], [1, 40]],
56
+ [[[1, :>=, :reroll_subtract]], [-9, 10]],
57
+ [[GamesDice::RerollRule.new(10, :<=, :reroll_add)], [1, 10_010]]]
58
+ cases.each do |rerolls, expected|
59
+ die = described_class.new(10, rerolls: rerolls)
60
+ expect(die).to have_attributes(min: expected.first, max: expected.last)
61
+ end
62
+ end
63
+
64
+ it 'simulate a d10 that rerolls and adds on a result of 10' do
65
+ die = described_class.new(10, rerolls: [[10, :<=, :reroll_add]])
66
+ [5, 4, 14, 7, 8, 1, 9].each do |expected|
67
+ expect(die.roll.value).to eql expected
68
+ expect(die.result.value).to eql expected
69
+ end
70
+ end
71
+
72
+ it 'explain how it got results outside range 1 to 10 on a d10' do
73
+ die = described_class.new(10, rerolls: [[10, :<=, :reroll_add], [1, :>=, :reroll_subtract]])
74
+ ['5', '4', '[10+4] 14', '7', '8', '[1-9] -8'].each do |expected|
75
+ die.roll
76
+ expect(die.explain_result).to eql expected
77
+ end
78
+ end
79
+
80
+ it 'calculate an expected result' do
81
+ cases = [[10, [[10, :<=, :reroll_add], [1, :>=, :reroll_subtract]], 5.5],
82
+ [10, [GamesDice::RerollRule.new(1, :<=, :reroll_use_best, 1)], 7.15],
83
+ [10, [GamesDice::RerollRule.new(1, :<=, :reroll_use_worst, 2)], 3.025],
84
+ [6, [GamesDice::RerollRule.new(6, :<=, :reroll_add)], 4.2],
85
+ [8, [GamesDice::RerollRule.new(1, :>=, :reroll_use_best)], 5.0],
86
+ [4, [GamesDice::RerollRule.new(1, :>=, :reroll_replace, 1)], 2.875]]
87
+ cases.each do |sides, rerolls, expected|
88
+ die = described_class.new(sides, rerolls: rerolls)
89
+ expect(die.probabilities.expected).to be_within(1e-10).of(expected)
90
+ end
91
+ end
92
+
93
+ it 'calculate probabilities of each possible result' do
94
+ cases = [[6, [GamesDice::RerollRule.new(7, :>, :reroll_add, 1)], { 11 => 2 / 36.0, 8 => 5 / 36.0 }],
95
+ [10, [GamesDice::RerollRule.new(10, :<=, :reroll_add)],
96
+ { 8 => 0.1, 10 => nil, 13 => 0.01, 27 => 0.001 }],
97
+ [6, [GamesDice::RerollRule.new(1, :>=, :reroll_replace, 1)], { 1 => 1 / 36.0, 2 => 7 / 36.0 }]]
98
+ cases.each do |sides, rerolls, expected|
99
+ probabilities = described_class.new(sides, rerolls: rerolls).probabilities.to_h
100
+ expect_probability_values(probabilities, expected)
101
+ end
102
+ end
103
+
104
+ it 'calculate aggregate probabilities' do
105
+ die = described_class.new(6, rerolls: [GamesDice::RerollRule.new(7, :>, :reroll_add, 1)])
106
+ probs = die.probabilities
107
+ expected = { p_gt: { 7 => 15 / 36.0, -10 => 1.0, 12 => 0.0 },
108
+ p_ge: { 7 => 21 / 36.0, 2 => 1.0, 15 => 0.0 },
109
+ p_lt: { 7 => 15 / 36.0, -10 => 0.0, 13 => 1.0 },
110
+ p_le: { 7 => 21 / 36.0, 1 => 0.0, 12 => 1.0 } }
111
+ expect_aggregate_probabilities(probs, expected)
112
+ end
113
+ end
114
+
115
+ describe 'with maps' do
116
+ it 'calculate correct minimum and maximum results' do
117
+ die = described_class.new(10, maps: [GamesDice::MapRule.new(7, :<=, 1, 'S')])
118
+ expect(die.min).to be 0
119
+ expect(die.max).to be 1
120
+ end
121
+
122
+ it 'simulate a d10 that scores 1 for success on a value of 7 or more' do
123
+ die = described_class.new(10, maps: [[7, :<=, 1, 'S']])
124
+ [0, 0, 1, 0, 1, 1, 0, 1].each do |expected|
125
+ expect(die.roll.value).to eql expected
126
+ expect(die.result.value).to eql expected
127
+ end
128
+ end
129
+
130
+ it 'label the mappings applied with the provided names' do
131
+ die = described_class.new(10, maps: [[7, :<=, 1, 'S'], [1, :>=, -1, 'F']])
132
+ ['5', '4', '10 S', '4', '7 S', '8 S', '1 F', '9 S'].each do |expected|
133
+ die.roll
134
+ expect(die.explain_result).to eql expected
135
+ end
136
+ end
137
+
138
+ it 'calculate an expected result' do
139
+ die = described_class.new(10,
140
+ maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'),
141
+ GamesDice::MapRule.new(1, :>=, -1, 'F')])
142
+ expect(die.probabilities.expected).to be_within(1e-10).of 0.3
143
+ end
144
+
145
+ it 'calculate probabilities of each possible result' do
146
+ die = described_class.new(10,
147
+ maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'),
148
+ GamesDice::MapRule.new(1, :>=, -1, 'F')])
149
+ expect_probability_values(die.probabilities.to_h, { 1 => 0.4, 0 => 0.5, -1 => 0.1 })
150
+ end
151
+
152
+ it 'calculate aggregate probabilities' do
153
+ die = described_class.new(10,
154
+ maps: [GamesDice::MapRule.new(7, :<=, 1, 'S'),
155
+ GamesDice::MapRule.new(1, :>=, -1, 'F')])
156
+ probs = die.probabilities
157
+ expected = { p_gt: { -2 => 1.0, -1 => 0.9, 1 => 0.0 }, p_ge: { 1 => 0.4, -1 => 1.0, 2 => 0.0 },
158
+ p_lt: { 1 => 0.6, -1 => 0.0, 2 => 1.0 }, p_le: { -1 => 0.1, -2 => 0.0, 1 => 1.0 } }
159
+ expect_aggregate_probabilities(probs, expected)
160
+ end
161
+ end
162
+
163
+ describe 'with rerolls and maps together' do
164
+ let(:die) do
165
+ described_class.new(6,
166
+ rerolls: [[6, :<=, :reroll_add]],
167
+ maps: [GamesDice::MapRule.new(9, :<=, 1, 'Success')])
168
+ end
169
+
170
+ it 'calculate correct minimum and maximum results' do
171
+ expect(die.min).to be 0
172
+ expect(die.max).to be 1
173
+ end
174
+
175
+ it 'calculate an expected result' do
176
+ expect(die.probabilities.expected).to be_within(1e-10).of 4 / 36.0
177
+ end
178
+
179
+ it 'calculate probabilities of each possible result' do
180
+ expect_probability_values(die.probabilities.to_h, { 1 => 4 / 36.0, 0 => 32 / 36.0 })
181
+ end
182
+
183
+ it 'calculate aggregate probabilities' do
184
+ expected = { p_gt: { 0 => 4 / 36.0, -2 => 1.0, 1 => 0.0 }, p_ge: { 1 => 4 / 36.0, -1 => 1.0, 2 => 0.0 },
185
+ p_lt: { 1 => 32 / 36.0, 0 => 0.0, 2 => 1.0 }, p_le: { 0 => 32 / 36.0, -1 => 0.0, 1 => 1.0 } }
186
+ expect_aggregate_probabilities(die.probabilities, expected)
187
+ end
188
+
189
+ it 'apply mapping to final re-rolled result' do
190
+ [0, 1, 0, 0].each do |expected|
191
+ expect(die.roll.value).to eql expected
192
+ expect(die.result.value).to eql expected
193
+ end
194
+ end
195
+
196
+ it 'explain how it got each result' do
197
+ ['5', '[6+4] 10 Success', '[6+2] 8', '5'].each do |expected|
198
+ die.roll
199
+ expect(die.explain_result).to eql expected
200
+ end
201
+ end
202
+ end
203
+
204
+ it 'calculates bounds when maximum is queried first and reuses them' do
205
+ die = described_class.new(6)
206
+ expect(die.max).to eq(6)
207
+ expect(die.min).to eq(1)
208
+ expect(die.min).to eq(1)
209
+ expect(die.max).to eq(6)
210
+ end
211
+
212
+ it 'subtracts subsequent explosions after an initial subtracting reroll' do
213
+ prng = instance_double(TestPRNG)
214
+ allow(prng).to receive(:rand).with(6).and_return(0, 5, 3)
215
+ die = described_class.new(6, prng: prng, rerolls: [[1, :==, :reroll_subtract], [6, :==, :reroll_add]])
216
+ expect(die.roll).to have_attributes(value: -9, rolls: [1, 6, 4],
217
+ roll_reasons: %i[basic reroll_subtract reroll_subtract])
218
+ expect(die.explain_result).to eq('[1-6-4] -9')
219
+ end
220
+
221
+ it 'bounds open-ended additions and subtractions while ignoring impossible triggers' do
222
+ die = described_class.new(6, rerolls: [[1, :==, :reroll_subtract], [0, :==, :reroll_subtract],
223
+ [6, :==, :reroll_add], [7, :==, :reroll_add]])
224
+ expect(die.min).to eq(-5999)
225
+ expect(die.max).to eq(6006)
226
+ expect(die.probabilities_complete).to be(false)
227
+ end
228
+ end