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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +11 -1
- data/.gitignore +0 -1
- data/.rubocop.yml +13 -48
- data/CHANGELOG.md +19 -0
- data/Gemfile +5 -3
- data/README.md +46 -5
- data/Rakefile +12 -5
- data/ext/games_dice/extconf.rb +0 -3
- data/ext/games_dice/probabilities.c +293 -193
- data/ext/games_dice/probabilities.h +2 -12
- 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 +29 -12
- 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 -584
- data/spec/readme_spec.rb +0 -410
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'helpers'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
describe GamesDice::Probabilities, :aggregate_failures do
|
|
7
|
+
let(:int_min) { -(2**31) }
|
|
8
|
+
let(:int_max) { (2**31) - 1 }
|
|
9
|
+
|
|
10
|
+
def singleton(value)
|
|
11
|
+
described_class.new([1.0], value)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def child_with_deadline(&)
|
|
15
|
+
child = fork { run_child(&) }
|
|
16
|
+
_, status = Timeout.timeout(5) { Process.wait2(child) }
|
|
17
|
+
status.success?
|
|
18
|
+
ensure
|
|
19
|
+
reap_timed_out_child(child) if child && !status
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_child
|
|
23
|
+
yield
|
|
24
|
+
exit! 0
|
|
25
|
+
rescue StandardError
|
|
26
|
+
exit! 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reap_timed_out_child(child)
|
|
30
|
+
Process.kill('KILL', child)
|
|
31
|
+
Process.wait(child)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_large_repetitions
|
|
35
|
+
values = [-1, 0, 1]
|
|
36
|
+
[2**29, 2**30, int_max].each do |count|
|
|
37
|
+
values.each { |value| raise unless singleton(value).repeat_sum(count).to_h == { value * count => 1.0 } }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'terminates large repetition counts, including the native positive integer limit' do
|
|
42
|
+
expect(child_with_deadline { validate_large_repetitions }).to be(true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def expect_endpoint(distribution, endpoint)
|
|
46
|
+
expect(distribution).to have_attributes(min: endpoint, max: endpoint)
|
|
47
|
+
expect(distribution.p_eql(endpoint)).to eq(1.0)
|
|
48
|
+
expect_aggregate_probabilities(distribution, { p_ge: { int_min => 1.0 }, p_lt: { int_min => 0.0 } })
|
|
49
|
+
%i[given_ge given_le].each do |operation|
|
|
50
|
+
expect(distribution.public_send(operation, endpoint).to_h).to eq({ endpoint => 1.0 })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'queries both extreme endpoints without overflowing threshold arithmetic' do
|
|
55
|
+
[int_min, int_max].each do |endpoint|
|
|
56
|
+
expect_endpoint(singleton(endpoint), endpoint)
|
|
57
|
+
end
|
|
58
|
+
expect(singleton(int_min).p_eql(int_max)).to eq(0.0)
|
|
59
|
+
expect(singleton(int_max).p_le(int_min)).to eq(0.0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'permits cancelling weighted products outside the outcome range' do
|
|
63
|
+
positive = singleton(int_max)
|
|
64
|
+
negative = singleton(-int_max)
|
|
65
|
+
expect(described_class.add_distributions_mult(int_max, positive, int_max, negative).to_h).to eq({ 0 => 1.0 })
|
|
66
|
+
expect(described_class.add_distributions_mult(0, positive, 0, negative).to_h).to eq({ 0 => 1.0 })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'rejects unrepresentable endpoints before allocating or indexing' do
|
|
70
|
+
expect { described_class.new([0.5, 0.5], int_max) }.to raise_error(RangeError, /outside native integer range/)
|
|
71
|
+
expect { singleton(int_max).repeat_sum(2) }.to raise_error(RangeError)
|
|
72
|
+
expect { described_class.add_distributions(singleton(int_min), singleton(-1)) }.to raise_error(RangeError)
|
|
73
|
+
expect { described_class.add_distributions_mult(int_min, singleton(int_min), int_min, singleton(int_min)) }
|
|
74
|
+
.to raise_error(RangeError)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'rejects a sparse hash spanning the whole integer range' do
|
|
78
|
+
expect { described_class.from_h({ int_min => 0.5, int_max => 0.5 }) }.to raise_error(ArgumentError)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'rejects overflowing keeper endpoints' do
|
|
82
|
+
expect { singleton(int_max).repeat_n_sum_k(3, 2) }.to raise_error(RangeError)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'supports the inclusive million-slot boundary with cheap sparse construction' do
|
|
86
|
+
[999_999, 1_000_000].each do |slots|
|
|
87
|
+
distribution = described_class.from_h({ 0 => 0.5, slots - 1 => 0.5 })
|
|
88
|
+
expect(distribution.max).to eq(slots - 1)
|
|
89
|
+
expect(described_class.add_distributions_mult(0, distribution, 1, singleton(0)).to_h).to eq({ 0 => 1.0 })
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'rejects more than a million probability slots' do
|
|
94
|
+
expect { described_class.from_h({ 0 => 0.5, 1_000_000 => 0.5 }) }.to raise_error(ArgumentError)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'validates finite individual probabilities equally for array and hash constructors' do
|
|
98
|
+
[Float::NAN, -Float::INFINITY, Float::INFINITY, -0.5, 1.5].each do |invalid|
|
|
99
|
+
expect { described_class.new([invalid], 0) }.to raise_error(ArgumentError)
|
|
100
|
+
expect { described_class.from_h({ 0 => invalid }) }.to raise_error(ArgumentError)
|
|
101
|
+
end
|
|
102
|
+
expect { described_class.from_h({ 0 => -0.5, 1 => 1.5 }) }.to raise_error(ArgumentError)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'preserves the existing total-mass tolerance without normalizing caller data' do
|
|
106
|
+
[1.0 - 5e-9, 1.0].each do |mass|
|
|
107
|
+
expect(described_class.new([mass], 0).to_h).to eq({ 0 => mass })
|
|
108
|
+
expect(described_class.from_h({ 0 => mass }).to_h).to eq({ 0 => mass })
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'rejects total mass outside the existing tolerance' do
|
|
113
|
+
expect { described_class.new([1.0 - 2e-8], 0) }.to raise_error(ArgumentError, /less than/)
|
|
114
|
+
expect { described_class.from_h({ 0 => 0.5, 1 => 0.5 + 2e-8 }) }.to raise_error(ArgumentError, /greater than/)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'coerces each key and value only once' do
|
|
118
|
+
key, value = coercion_pair
|
|
119
|
+
expect(described_class.from_h({ key => value }).to_h).to eq({ 4 => 1.0 })
|
|
120
|
+
expect(key).to have_received(:to_int).once
|
|
121
|
+
expect(value).to have_received(:to_f).once
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def coercion_pair
|
|
125
|
+
key = instance_double(Float)
|
|
126
|
+
value = instance_double(Float)
|
|
127
|
+
allow(key).to receive(:to_int).and_return(4, int_max)
|
|
128
|
+
allow(value).to receive(:to_f).and_return(1.0, Float::NAN)
|
|
129
|
+
[key, value]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'uses the actual array length' do
|
|
133
|
+
array = [1.0]
|
|
134
|
+
allow(array).to receive(:count).and_return(int_max)
|
|
135
|
+
expect(described_class.new(array, -1).to_h).to eq({ -1 => 1.0 })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'retains last-write semantics for keys coercing to the same integer' do
|
|
139
|
+
expect(described_class.from_h({ 1 => 0.5, 1.1 => 1.0 }).to_h).to eq({ 1 => 1.0 })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'replaces native storage atomically across initialization and copying failures' do
|
|
143
|
+
distribution = singleton(3)
|
|
144
|
+
expect { distribution.__send__(:initialize, [Float::NAN], 0) }.to raise_error(ArgumentError)
|
|
145
|
+
expect { distribution.__send__(:initialize_copy, Object.new) }.to raise_error(TypeError)
|
|
146
|
+
expect(distribution.to_h).to eq({ 3 => 1.0 })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'replaces and frees previously initialized native storage' do
|
|
150
|
+
distribution = singleton(3)
|
|
151
|
+
distribution.__send__(:initialize, [0.25, 0.75], -1)
|
|
152
|
+
expect(distribution.to_h).to eq({ -1 => 0.25, 0 => 0.75 })
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'replaces and frees storage on an explicit copy' do
|
|
156
|
+
distribution = singleton(3)
|
|
157
|
+
distribution.__send__(:initialize_copy, singleton(7))
|
|
158
|
+
GC.start
|
|
159
|
+
expect(distribution.to_h).to eq({ 7 => 1.0 })
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'helpers'
|
|
4
|
+
|
|
5
|
+
describe GamesDice::Probabilities, :aggregate_failures do
|
|
6
|
+
def expect_usable_restoration(original, restored)
|
|
7
|
+
expect(restored.repeat_sum(2).expected).to be_within(1e-10).of(2 * original.expected)
|
|
8
|
+
GC.start
|
|
9
|
+
expect(restored.p_ge(restored.min)).to eq(1.0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def restored_dice
|
|
13
|
+
original = GamesDice.create('2d6x.m:>=8,1,Success.+1d4')
|
|
14
|
+
expected = original.probabilities.to_h
|
|
15
|
+
marshal_round_trip(original).tap { |restored| expect(restored.probabilities.to_h).to eq(expected) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reload_during_iteration(distribution)
|
|
19
|
+
values = []
|
|
20
|
+
distribution.each do |pair|
|
|
21
|
+
values << pair
|
|
22
|
+
distribution.marshal_load({ 9 => 1.0 })
|
|
23
|
+
GC.start
|
|
24
|
+
end
|
|
25
|
+
values.to_h
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe 'current Marshal protocol' do
|
|
29
|
+
[{ -3 => 0.25, 4 => 0.75 }, { 0 => 1.0 }, (1..6).to_h { |face| [face, 1.0 / 6] }].each do |values|
|
|
30
|
+
it "round trips #{values.inspect} into usable independent native storage" do
|
|
31
|
+
original = described_class.from_h(values)
|
|
32
|
+
restored = marshal_round_trip(original)
|
|
33
|
+
expect(restored.clone.to_h).to eq(values)
|
|
34
|
+
expect_usable_restoration(original, restored)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'round trips dice with cached probabilities and refreshes their mutable recipes' do
|
|
39
|
+
restored = restored_dice
|
|
40
|
+
restored.bunch_multipliers[0] = 0
|
|
41
|
+
expect(restored.probabilities.to_h).to eq(GamesDice.create('1d4').probabilities.to_h)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'rejects invalid payloads without damaging the previous distribution' do
|
|
45
|
+
distribution = described_class.new([1.0], -2)
|
|
46
|
+
[[nil, TypeError], [{}, ArgumentError], [{ 1 => Float::NAN }, ArgumentError]].each do |invalid, error|
|
|
47
|
+
expect { distribution.marshal_load(invalid) }.to raise_error(error)
|
|
48
|
+
end
|
|
49
|
+
expect(distribution.to_h).to eq({ -2 => 1.0 })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'keeps iteration storage alive when a callback replaces the distribution' do
|
|
53
|
+
distribution = described_class.new([0.25, 0.75], 1)
|
|
54
|
+
expect(reload_during_iteration(distribution)).to eq({ 1 => 0.25, 2 => 0.75 })
|
|
55
|
+
expect(distribution.to_h).to eq({ 9 => 1.0 })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'does not expose the obsolete legacy decoder' do
|
|
59
|
+
expect(described_class).not_to respond_to(:_load)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|