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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::Dice, :aggregate_failures do
6
+ let(:dice) { GamesDice.create('2d6x.m:>=8,1,Success.+1d4') }
7
+
8
+ def failing_mutation_trigger
9
+ lambda do |_|
10
+ dice.bunch_multipliers.clear
11
+ raise RangeError, 'callback failed'
12
+ end
13
+ end
14
+
15
+ def mutation_trigger
16
+ lambda do |_|
17
+ yield
18
+ true
19
+ end
20
+ end
21
+
22
+ def populate(recipe)
23
+ recipe.roll
24
+ recipe.minmax
25
+ recipe.probabilities
26
+ end
27
+
28
+ it 'caches the full combined distribution until a multiplier changes' do
29
+ previous = dice.probabilities
30
+ expect(dice.probabilities).to equal(previous)
31
+ dice.bunch_multipliers[0] = 0
32
+ expect(dice.probabilities.to_h).to eq(GamesDice.create('1d4').probabilities.to_h)
33
+ end
34
+
35
+ it 'clears parent history but retains unchanged child history after a multiplier edit' do
36
+ populate(dice)
37
+ child = dice.bunches.first.result
38
+ dice.bunch_multipliers[0] = -1
39
+ expect(dice.result).to be_nil
40
+ expect(dice.bunches.first.result).to eq(child)
41
+ end
42
+
43
+ it 'clears affected child and parent histories after a rule replacement' do
44
+ populate(dice)
45
+ dice.bunches.first.rerolls[0] = GamesDice::RerollRule.new(6, :==, :reroll_replace, 1)
46
+ expect(dice.explain_result).to be_nil
47
+ expect(dice.bunches.first).to have_attributes(result: nil, result_details: nil, explain_result: nil)
48
+ expect(dice.bunches.first.single_die).to have_attributes(result: nil, explain_result: nil,
49
+ probabilities_complete: nil)
50
+ end
51
+
52
+ it 'detects mutable map labels and permits rolling again after invalidation' do
53
+ populate(dice)
54
+ dice.bunches.first.maps.first.mapped_name.replace('Hit')
55
+ expect(dice.result).to be_nil
56
+ dice.roll
57
+ expect(dice.explain_result).to be_a(String)
58
+ end
59
+
60
+ it 'detects paired insertion and deletion without replacing the exposed arrays' do
61
+ original = dice.bunches
62
+ dice.bunches << GamesDice::Bunch.new(ndice: 1, sides: 1)
63
+ dice.bunch_multipliers << 2
64
+ expect(dice.bunches).to equal(original)
65
+ expect(dice.minmax).to eq([3, 8])
66
+ end
67
+
68
+ it 'rejects mismatched array lengths deliberately and permits later repair' do
69
+ dice.bunch_multipliers.pop
70
+ expect { dice.result }.to raise_error(ArgumentError, 'bunches and bunch_multipliers must have the same length')
71
+ dice.bunches.pop
72
+ expect(dice.minmax).to eq([0, 2])
73
+ end
74
+
75
+ it 'rejects invalid entry types and recovers when they are replaced' do
76
+ dice.bunches[0] = 'invalid'
77
+ expect { dice.probabilities }.to raise_error(TypeError, /Expected GamesDice::Bunch/)
78
+ dice.bunches[0] = GamesDice::Bunch.new(ndice: 1, sides: 1)
79
+ expect(dice.minmax).to eq([2, 5])
80
+ end
81
+
82
+ it 'invalidates every parent of a shared bunch' do
83
+ other = GamesDice.create('1d4')
84
+ other.bunches[0] = dice.bunches.first
85
+ [dice, other].each { |recipe| populate(recipe) }
86
+ dice.bunches.first.maps.clear
87
+ expect([dice.result, other.result]).to eq([nil, nil])
88
+ end
89
+
90
+ it 'tracks reorder and clear operations' do
91
+ dice.probabilities
92
+ dice.bunches.reverse!
93
+ dice.bunch_multipliers.replace([0, 1])
94
+ expect(dice.minmax).to eq([0, 2])
95
+ end
96
+
97
+ it 'tracks removal of every bunch' do
98
+ dice.bunches.clear
99
+ dice.bunch_multipliers.clear
100
+ expect(dice.probabilities.to_h).to eq({ 0 => 1.0 })
101
+ end
102
+
103
+ it 'invalidates bounds and histories after mutable bunch names change' do
104
+ name = +'Original'
105
+ dice.bunches[0] = GamesDice::Bunch.new(ndice: 1, sides: 6, name: name)
106
+ populate(dice)
107
+ name.replace('Changed')
108
+ expect(dice.result).to be_nil
109
+ end
110
+
111
+ it 'keeps clone cache metadata independent while retaining shared recipe arrays' do
112
+ copy = dice.clone
113
+ populate(dice)
114
+ populate(copy)
115
+ dice.bunch_multipliers[0] = -1
116
+ expect([dice.result, copy.result]).to eq([nil, nil])
117
+ end
118
+
119
+ it 'detects mutation from a custom predicate before publishing a result' do
120
+ trigger = mutation_trigger { dice.bunch_multipliers[0] = -1 }
121
+ dice.bunches.first.maps.replace([GamesDice::MapRule.new(trigger, :call, 1)])
122
+ expect { dice.probabilities }.to raise_error(RuntimeError, 'Dice recipe changed during calculation')
123
+ expect(dice.result).to be_nil
124
+ end
125
+
126
+ it 'detects rule-array edits from callbacks during a roll' do
127
+ die = dice.bunches.first.single_die
128
+ trigger = mutation_trigger { die.maps.clear }
129
+ die.maps.replace([GamesDice::MapRule.new(trigger, :call, 1)])
130
+ expect { dice.roll }.to raise_error(RuntimeError, 'Dice recipe changed during calculation')
131
+ expect(dice.result).to be_nil
132
+ end
133
+
134
+ it 'keeps returned roll records unchanged when a shared label changes' do
135
+ label = +'Original'
136
+ die = GamesDice::ComplexDie.new(1, maps: [[1, :==, 1, label]])
137
+ record = die.roll
138
+ label.replace('Changed')
139
+ expect(record.explain_value).to eq('1 Original')
140
+ end
141
+
142
+ it 'discards calculations when callbacks leave an invalid recipe' do
143
+ trigger = mutation_trigger { dice.bunch_multipliers.clear }
144
+ dice.bunches.first.maps.replace([GamesDice::MapRule.new(trigger, :call, 1)])
145
+ expect { dice.probabilities }.to raise_error(RuntimeError, 'Dice recipe changed during calculation')
146
+ end
147
+
148
+ it 'preserves callback exceptions even when the callback also changes the recipe' do
149
+ trigger = failing_mutation_trigger
150
+ dice.bunches.first.maps.replace([GamesDice::MapRule.new(trigger, :call, 1)])
151
+ expect { dice.probabilities }.to raise_error(RangeError, 'callback failed')
152
+ end
153
+
154
+ it 'recomputes unrecognized restored recipe metadata' do
155
+ populate(dice)
156
+ dice.instance_variable_set(:@recipe_cache, nil)
157
+ expect(marshal_round_trip(dice).result).to be_nil
158
+ end
159
+
160
+ it 'invalidates a restored parent when a child snapshot is missing' do
161
+ populate(dice)
162
+ dice.bunches.first.single_die.instance_variable_set(:@recipe_cache, nil)
163
+ expect(marshal_round_trip(dice).result).to be_nil
164
+ end
165
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::Dice, :aggregate_failures do
6
+ let(:prng) { Random.new(1) }
7
+
8
+ { '-1d6+2' => [-4, 1], '2d6-1d4' => [-2, 11], '-2d6' => [-12, -2] }.each do |notation, bounds|
9
+ it "reverses negative terms in bounds for #{notation}" do
10
+ dice = GamesDice.create(notation)
11
+ expect(dice.minmax).to eq(bounds)
12
+ expect(dice.probabilities).to have_attributes(min: bounds.first, max: bounds.last)
13
+ end
14
+ end
15
+
16
+ it 'skips zero-weight components that cannot calculate a distribution' do
17
+ dice = described_class.new([{ ndice: 1, sides: 4, rerolls: [[3, :<=, :reroll_add]], multiplier: 0 }], 5)
18
+ expect(dice.minmax).to eq([5, 5])
19
+ expect(dice.probabilities.to_h).to eq({ 5 => 1.0 })
20
+ end
21
+
22
+ { '-1d6+2' => '1d6: 1. -1 + 2 = 1', '-1d6' => '1d6: 1. -1 = -1' }.each do |notation, explanation|
23
+ it "explains the weighted result for #{notation}" do
24
+ allow(prng).to receive(:rand).and_return(0)
25
+ dice = GamesDice.create(notation, prng)
26
+ dice.roll
27
+ expect(dice.explain_result).to eq(explanation)
28
+ end
29
+ end
30
+
31
+ it 'shares one truncation budget across repeated dice and negatively weighted bunches' do
32
+ dice = GamesDice.create('3d2x-2d2x')
33
+ expect(dice.probabilities.to_h).to be_valid_distribution
34
+ expect(dice.instance_variable_get(:@probability_error)).to be <= 1e-10
35
+ end
36
+
37
+ it 'preserves the default repeated shorthand reroll' do
38
+ allow(prng).to receive(:rand).and_return(0, 0, 4)
39
+ dice = GamesDice.create('1d6r1', prng)
40
+ expect(dice.roll).to eq(5)
41
+ expect(dice.bunches.first.result_details.first.rolls).to eq([1, 1, 5])
42
+ end
43
+
44
+ it 'supports an explicit single replacement reroll' do
45
+ allow(prng).to receive(:rand).and_return(0, 0, 4)
46
+ dice = GamesDice.create('1d6r:1,replace,1.', prng)
47
+ expect(dice.roll).to eq(1)
48
+ expect(dice.bunches.first.result_details.first.rolls).to eq([1, 1])
49
+ end
50
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::Dice, :aggregate_failures do
6
+ describe 'dice scheme' do
7
+ before do
8
+ srand(67_809)
9
+ end
10
+
11
+ describe '1d10+2' do
12
+ let(:dice) { described_class.new([{ sides: 10, ndice: 1 }], 2) }
13
+
14
+ it 'simulate rolling a ten-sided die, and adding two to each result' do
15
+ [5, 4, 10, 10, 7, 5, 9].each do |expected_total|
16
+ expect(dice.roll).to eql expected_total
17
+ expect(dice.result).to eql expected_total
18
+ end
19
+ end
20
+ end
21
+
22
+ describe '2d6+6' do
23
+ let(:dice) { described_class.new([{ sides: 6, ndice: 2 }], 6) }
24
+
25
+ it 'simulate rolling two six-sided dice and adding six to the result' do
26
+ [15, 12, 17, 15, 13, 13, 16].each do |expected_total|
27
+ expect(dice.roll).to eql expected_total
28
+ expect(dice.result).to eql expected_total
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ it 'explains constant-only recipes' do
35
+ dice = described_class.new([], -3)
36
+ expect(dice.roll).to eq(-3)
37
+ expect(dice.explain_result).to eq('-3')
38
+ expect(dice.probabilities.to_h).to eq(-3 => 1.0)
39
+ end
40
+
41
+ it 'explains a single bunch with an offset' do
42
+ dice = described_class.new([{ sides: 6, ndice: 1, prng: TestPRNGMax.new }], 2)
43
+ expect(dice.roll).to eq(8)
44
+ expect(dice.explain_result).to eq('1d6: 6. 6 + 2 = 8')
45
+ end
46
+
47
+ describe 'multiple weighted bunch explanations' do
48
+ let(:bunches) do
49
+ [{ sides: 6, ndice: 1, prng: TestPRNGMax.new },
50
+ { sides: 4, ndice: 1, multiplier: -1, prng: TestPRNGMax.new }]
51
+ end
52
+
53
+ { 0 => '6 - 4 = 2', 3 => '6 - 4 + 3 = 5', -3 => '6 - 4 - 3 = -1' }.each do |offset, sum|
54
+ it "includes an offset of #{offset}" do
55
+ dice = described_class.new(bunches, offset)
56
+ expect(dice.roll).to eq(2 + offset)
57
+ expect(dice.explain_result).to eq("1d6: 6. 1d4: 4. #{sum}")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helpers'
4
+
5
+ describe GamesDice::DieResult, :aggregate_failures do
6
+ let(:seven_result) { described_class.new(7) }
7
+ let(:twelve_result) do
8
+ result = described_class.new(6)
9
+ result.add_roll(6, :reroll_add)
10
+ result
11
+ end
12
+
13
+ describe '.new' do
14
+ it "work without parameters to represent 'no results yet'" do
15
+ die_result = described_class.new
16
+ expect(die_result.value).to be_nil
17
+ expect(die_result.rolls).to eql []
18
+ expect(die_result.roll_reasons).to eql []
19
+ end
20
+
21
+ it 'work with a single Integer param to represent an initial result' do
22
+ die_result = described_class.new(8)
23
+ expect(die_result.value).to be 8
24
+ expect(die_result.rolls).to eql [8]
25
+ expect(die_result.roll_reasons).to eql [:basic]
26
+ end
27
+
28
+ it 'not accept a param that cannot be coerced to Integer' do
29
+ expect { described_class.new([]) }.to raise_error(TypeError)
30
+ expect { described_class.new('N') }.to raise_error(ArgumentError)
31
+ end
32
+
33
+ it 'not accept unknown reasons for making a roll' do
34
+ expect { described_class.new(8, 'wooo') }.to raise_error(ArgumentError)
35
+ expect { described_class.new(8, :frabulous) }.to raise_error(ArgumentError)
36
+ end
37
+ end
38
+
39
+ describe '#add_roll' do
40
+ context "when starting from 'no results yet'" do
41
+ let(:die_result) { described_class.new }
42
+
43
+ it 'create an initial result' do
44
+ die_result.add_roll(5)
45
+ expect(die_result.value).to be 5
46
+ expect(die_result.rolls).to eql [5]
47
+ expect(die_result.roll_reasons).to eql [:basic]
48
+ end
49
+
50
+ it 'accept non-basic reasons for the first roll' do
51
+ die_result.add_roll(4, :reroll_subtract)
52
+ expect(die_result.value).to be(-4)
53
+ expect(die_result.rolls).to eql [4]
54
+ expect(die_result.roll_reasons).to eql [:reroll_subtract]
55
+ end
56
+
57
+ it 'not accept a first param that cannot be coerced to Integer' do
58
+ expect { die_result.add_roll([]) }.to raise_error(TypeError)
59
+ expect { die_result.add_roll('N') }.to raise_error(ArgumentError)
60
+ end
61
+
62
+ it 'not accept an unsupported second param' do
63
+ expect { die_result.add_roll(5, []) }.to raise_error(ArgumentError)
64
+ expect { die_result.add_roll(15, :bam) }.to raise_error(ArgumentError)
65
+ end
66
+ end
67
+
68
+ context 'when starting with an initial result' do
69
+ let(:die_result) { described_class.new(7) }
70
+
71
+ it 'not accept a first param that cannot be coerced to Integer' do
72
+ expect { die_result.add_roll([]) }.to raise_error(TypeError)
73
+ expect { die_result.add_roll('N') }.to raise_error(ArgumentError)
74
+ end
75
+
76
+ it 'not accept an unsupported second param' do
77
+ expect { die_result.add_roll(5, []) }.to raise_error(ArgumentError)
78
+ expect { die_result.add_roll(15, :bam) }.to raise_error(ArgumentError)
79
+ end
80
+
81
+ it 'replace an initial result, as if the die were re-rolled' do
82
+ die_result.add_roll(5)
83
+ expect(die_result.value).to be 5
84
+ expect(die_result.rolls).to eql [7, 5]
85
+ expect(die_result.roll_reasons).to eql %i[basic basic]
86
+ end
87
+
88
+ it 'add to value when exploding up' do
89
+ die_result.add_roll(6, :reroll_add)
90
+ expect(die_result.value).to be 13
91
+ expect(die_result.rolls).to eql [7, 6]
92
+ expect(die_result.roll_reasons).to eql %i[basic reroll_add]
93
+ end
94
+
95
+ it 'subtract from value when exploding down' do
96
+ die_result.add_roll(4, :reroll_subtract)
97
+ expect(die_result.value).to be 3
98
+ expect(die_result.rolls).to eql [7, 4]
99
+ expect(die_result.roll_reasons).to eql %i[basic reroll_subtract]
100
+ end
101
+
102
+ it 'optionally replace roll unconditionally' do
103
+ steps = [[[2, :reroll_replace], 2, [7, 2], %i[basic reroll_replace]],
104
+ [[5, :reroll_replace], 5, [7, 2, 5], %i[basic reroll_replace reroll_replace]]]
105
+ steps.each do |arguments, value, rolls, reasons|
106
+ die_result.add_roll(*arguments)
107
+ expect_die_result_state(die_result, value: value, rolls: rolls, reasons: reasons)
108
+ end
109
+ end
110
+
111
+ it 'optionally use best roll' do
112
+ steps = [[[2, :reroll_use_best], 7, [7, 2], %i[basic reroll_use_best]],
113
+ [[9, :reroll_use_best], 9, [7, 2, 9], %i[basic reroll_use_best reroll_use_best]]]
114
+ steps.each do |arguments, value, rolls, reasons|
115
+ die_result.add_roll(*arguments)
116
+ expect_die_result_state(die_result, value: value, rolls: rolls, reasons: reasons)
117
+ end
118
+ end
119
+
120
+ it 'optionally use worst roll' do
121
+ steps = [[[4, :reroll_use_worst], 4, [7, 4], %i[basic reroll_use_worst]],
122
+ [[5, :reroll_use_worst], 4, [7, 4, 5], %i[basic reroll_use_worst reroll_use_worst]]]
123
+ steps.each do |arguments, value, rolls, reasons|
124
+ die_result.add_roll(*arguments)
125
+ expect_die_result_state(die_result, value: value, rolls: rolls, reasons: reasons)
126
+ end
127
+ end
128
+
129
+ it 'correctly handle valid reasons for extra rolls in combination' do
130
+ steps = [[[[10, :reroll_add], [3, :reroll_subtract]], 14, [7, 10, 3], %i[basic reroll_add reroll_subtract]],
131
+ [[[12, :reroll_replace]], 12, [7, 10, 3, 12], %i[basic reroll_add reroll_subtract reroll_replace]],
132
+ [[[9, :reroll_use_best]], 12, [7, 10, 3, 12, 9],
133
+ %i[basic reroll_add reroll_subtract reroll_replace reroll_use_best]],
134
+ [[[15, :reroll_add]], 27, [7, 10, 3, 12, 9, 15],
135
+ %i[basic reroll_add reroll_subtract reroll_replace reroll_use_best reroll_add]]]
136
+ steps.each do |rolls_to_add, value, rolls, reasons|
137
+ rolls_to_add.each { |arguments| die_result.add_roll(*arguments) }
138
+ expect_die_result_state(die_result, value: value, rolls: rolls, reasons: reasons)
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ describe '#explain_value' do
145
+ let(:die_result) { described_class.new }
146
+
147
+ it "be empty string for 'no results yet'" do
148
+ expect(die_result.explain_value).to eql ''
149
+ end
150
+
151
+ it 'be a simple stringified number when there is one die roll' do
152
+ die_result.add_roll(3)
153
+ expect(die_result.explain_value).to eql '3'
154
+ end
155
+
156
+ it 'describe all single rolls made and how they combine' do
157
+ steps = [[[6], '6'], [[5, :reroll_add], '[6+5] 11'], [[2, :reroll_replace], '[6+5|2] 2'],
158
+ [[7, :reroll_subtract], '[6+5|2-7] -5'], [[4, :reroll_use_worst], '[6+5|2-7\\4] -5'],
159
+ [[3, :reroll_use_best], '[6+5|2-7\\4/3] 3']]
160
+ steps.each do |arguments, expected|
161
+ die_result.add_roll(*arguments)
162
+ expect(die_result.explain_value).to eq(expected)
163
+ end
164
+ end
165
+ end
166
+
167
+ it 'combine via +,- and * intuitively based on #value' do
168
+ cases = [[seven_result + 3, 10], [4 + seven_result, 11], [seven_result - 2, 5], [9 - seven_result, 2],
169
+ [seven_result + 7.7, 14.7], [4.1 + seven_result, 11.1], [seven_result * 2, 14], [1 * seven_result, 7],
170
+ [seven_result + twelve_result, 19], [twelve_result - seven_result, 5]]
171
+ cases.each { |actual, expected| expect(actual).to eq(expected) }
172
+ end
173
+
174
+ it 'support comparison with >,<,>=,<= as if it were an integer, based on #value' do
175
+ cases = [[seven_result > 3, true], [seven_result < 14, true], [seven_result >= 7, true],
176
+ [seven_result <= 9.5, true], [seven_result < 3, false], [seven_result > 14, false],
177
+ [seven_result <= 8, true], [seven_result >= 14, false], [seven_result > twelve_result, false],
178
+ [twelve_result > seven_result, true], [seven_result >= twelve_result, false],
179
+ [twelve_result >= seven_result, true], [seven_result < twelve_result, true],
180
+ [twelve_result < seven_result, false], [seven_result <= twelve_result, true],
181
+ [twelve_result <= seven_result, false]]
182
+ cases.each { |actual, expected| expect(actual).to be(expected) }
183
+ end
184
+
185
+ it 'sort, based on #value' do
186
+ die_results = [
187
+ described_class.new(7), described_class.new(5), described_class.new(8), described_class.new(3)
188
+ ]
189
+
190
+ die_results.sort!
191
+
192
+ expect(die_results.map(&:value)).to eq([3, 5, 7, 8])
193
+ end
194
+
195
+ [nil, ''].each do |label|
196
+ it "explains mapped results with a #{label.inspect} label" do
197
+ result = described_class.new(6)
198
+ result.apply_map(1, label)
199
+ expect(result).to have_attributes(value: 1, explain_value: '6', explain_total: '6')
200
+ end
201
+ end
202
+
203
+ describe 'cloning mapped results' do
204
+ let(:original) do
205
+ result = described_class.new(6)
206
+ result.apply_map(1, 'Success')
207
+ result
208
+ end
209
+ let(:copy) { original.clone }
210
+
211
+ it 'preserves the mapped state and label' do
212
+ expect(copy).to have_attributes(value: 1, total: 6, mapped: true, explain_value: '6 Success')
213
+ end
214
+
215
+ it 'does not share roll history with the original' do
216
+ copy.add_roll(2, :reroll_replace)
217
+ expect(copy).to have_attributes(value: 2, mapped: false, rolls: [6, 2])
218
+ expect(original).to have_attributes(value: 1, mapped: true, rolls: [6])
219
+ end
220
+ end
221
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'helpers'
4
4
 
5
- describe GamesDice::Die do
5
+ describe GamesDice::Die, :aggregate_failures do
6
6
  before do
7
7
  # Set state of default PRNG
8
8
  srand(4567)
@@ -17,8 +17,7 @@ describe GamesDice::Die do
17
17
  end
18
18
 
19
19
  it 'accept any object with a rand(Integer) method as the second param' do
20
- prng = TestPRNG.new
21
- die = described_class.new(20, prng)
20
+ die = described_class.new(20, TestPRNG.new)
22
21
  [16, 7, 3, 11, 16, 18, 20, 7].each do |expected|
23
22
  expect(die.roll).to eql expected
24
23
  expect(die.result).to eql expected
@@ -46,19 +45,10 @@ describe GamesDice::Die do
46
45
 
47
46
  describe '#probabilities' do
48
47
  it "return the die's probability distribution as a GamesDice::Probabilities object" do
49
- die = described_class.new(6)
50
- probs = die.probabilities
48
+ probs = described_class.new(6).probabilities
51
49
  expect(probs).to be_a GamesDice::Probabilities
52
-
53
50
  expect(probs.to_h).to be_valid_distribution
54
-
55
- expect(probs.p_eql(1)).to be_within(1e-10).of 1 / 6.0
56
- expect(probs.p_eql(2)).to be_within(1e-10).of 1 / 6.0
57
- expect(probs.p_eql(3)).to be_within(1e-10).of 1 / 6.0
58
- expect(probs.p_eql(4)).to be_within(1e-10).of 1 / 6.0
59
- expect(probs.p_eql(5)).to be_within(1e-10).of 1 / 6.0
60
- expect(probs.p_eql(6)).to be_within(1e-10).of 1 / 6.0
61
-
51
+ expect_probability_values(probs.to_h, (1..6).to_h { |result| [result, 1 / 6.0] })
62
52
  expect(probs.expected).to be_within(1e-10).of 3.5
63
53
  end
64
54
  end
@@ -78,4 +68,15 @@ describe GamesDice::Die do
78
68
  expect(arr).to eql [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
79
69
  end
80
70
  end
71
+
72
+ it 'rejects nonpositive sides and an invalid random generator' do
73
+ [0, -1].each do |sides|
74
+ expect { described_class.new(sides) }.to raise_error(ArgumentError, /sides value/)
75
+ end
76
+ expect { described_class.new(6, Object.new) }.to raise_error(ArgumentError, /prng/)
77
+ end
78
+
79
+ it 'exposes no reroll or map rules for a simple die' do
80
+ expect(described_class.new(6)).to have_attributes(rerolls: nil, maps: nil)
81
+ end
81
82
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'helpers'
4
4
 
5
- describe GamesDice::MapRule do
5
+ describe GamesDice::MapRule, :aggregate_failures do
6
6
  describe '#new' do
7
7
  it 'accept self-consistent operator/value pairs as a trigger' do
8
8
  expect { described_class.new(5, :>, 1) }.not_to raise_error
@@ -37,4 +37,11 @@ describe GamesDice::MapRule do
37
37
  expect(rule.map_from(6)).to be_nil
38
38
  end
39
39
  end
40
+
41
+ it 'uses a nonboolean trigger result as the mapped value' do
42
+ rule = described_class.new({ 1 => 0, 6 => 3 }, :[], 99)
43
+ expect(rule.map_from(1)).to eq(0)
44
+ expect(rule.map_from(6)).to eq(3)
45
+ expect(rule.map_from(2)).to be_nil
46
+ end
40
47
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'helpers'
4
4
 
5
- describe GamesDice::Parser do
5
+ describe GamesDice::Parser, :aggregate_failures do
6
6
  describe '#parse' do
7
7
  let(:parser) { described_class.new }
8
8
 
@@ -30,7 +30,7 @@ describe GamesDice::Parser do
30
30
  end
31
31
  end
32
32
 
33
- it "parse 'NdXrY' as 'roll N times X-sided dice, re-roll and replace a Y or less (once)'" do
33
+ it "parse 'NdXrY' as 'roll N times X-sided dice, re-roll and replace a Y or less (default limit)'" do
34
34
  variations = {
35
35
  '1d6r1' => { bunches: [{ ndice: 1, sides: 6, multiplier: 1, rerolls: [[1, :>=, :reroll_replace]] }],
36
36
  offset: 0 },
@@ -103,4 +103,14 @@ describe GamesDice::Parser do
103
103
  end
104
104
  end
105
105
  end
106
+
107
+ it 'rejects multiple keeper modifiers on the same bunch' do
108
+ expect { described_class.new.parse('4d6k3k2') }.to raise_error(RuntimeError, /keepers.*twice/)
109
+ end
110
+
111
+ it 'collects bare dice terms without optional signs or modifiers' do
112
+ tree = { bunches: [{ ndice: '2', sides: '6' }, { constant: '3', op: '+' }] }
113
+ expect(described_class::ParseTreeProcessor.collect_bunches(tree)).to eq([{ ndice: 2, sides: 6 }])
114
+ expect(described_class::ParseTreeProcessor.collect_offset(tree)).to eq(3)
115
+ end
106
116
  end