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