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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +69 -5
- data/.gitignore +0 -1
- data/.rubocop.yml +13 -48
- data/CHANGELOG.md +26 -0
- data/Gemfile +6 -3
- data/README.md +46 -5
- data/Rakefile +90 -2
- data/ext/games_dice/extconf.rb +26 -0
- data/ext/games_dice/games_dice.c +1 -1
- data/ext/games_dice/probabilities.c +341 -211
- data/ext/games_dice/probabilities.h +3 -29
- data/games_dice.gemspec +1 -1
- data/lib/games_dice/bunch.rb +50 -36
- data/lib/games_dice/bunch_helpers.rb +35 -14
- data/lib/games_dice/complex_die.rb +64 -22
- data/lib/games_dice/complex_die_helpers.rb +122 -144
- data/lib/games_dice/dice.rb +61 -17
- data/lib/games_dice/dice_helpers.rb +37 -0
- data/lib/games_dice/die_result.rb +6 -8
- data/lib/games_dice/marshal.rb +2 -7
- data/lib/games_dice/parser.rb +30 -0
- data/lib/games_dice/probability_budget.rb +90 -0
- data/lib/games_dice/probability_calculation.rb +95 -0
- data/lib/games_dice/probability_shortcuts.rb +65 -0
- data/lib/games_dice/recipe_cache.rb +155 -0
- data/lib/games_dice/version.rb +1 -1
- data/lib/games_dice.rb +4 -0
- data/script/check_documentation.rb +10 -0
- data/spec/games_dice/bunch_regressions_spec.rb +46 -0
- data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
- data/spec/games_dice/complex_die_budget_spec.rb +60 -0
- data/spec/games_dice/complex_die_limits_spec.rb +119 -0
- data/spec/games_dice/complex_die_spec.rb +228 -0
- data/spec/games_dice/dice_mutation_spec.rb +165 -0
- data/spec/games_dice/dice_regressions_spec.rb +50 -0
- data/spec/games_dice/dice_spec.rb +61 -0
- data/spec/games_dice/die_result_spec.rb +221 -0
- data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
- data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
- data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
- data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
- data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
- data/spec/games_dice/probabilities_spec.rb +484 -0
- data/spec/games_dice/probability_budget_spec.rb +17 -0
- data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
- data/spec/games_dice_spec.rb +382 -0
- data/spec/helpers.rb +64 -2
- data/spec/simple_cov_spec.rb +42 -0
- data/spec/support/coverage.rb +12 -0
- data/spec/support/validation.rb +14 -0
- data/spec/yard/cli/stats_spec.rb +43 -0
- metadata +30 -13
- data/spec/complex_die_spec.rb +0 -314
- data/spec/dice_spec.rb +0 -33
- data/spec/die_result_spec.rb +0 -254
- data/spec/fixtures/probs_fair_die_6.dat +0 -2
- data/spec/probability_spec.rb +0 -522
- 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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|