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,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