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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
# Raised when a probability query cannot meet its accuracy or work limits.
|
|
5
|
+
class ProbabilityLimitError < RuntimeError; end
|
|
6
|
+
|
|
7
|
+
# @api private
|
|
8
|
+
# Tracks a conservative upper bound on omitted mass, separately from floating-point roundoff.
|
|
9
|
+
class ProbabilityBudget
|
|
10
|
+
# Strictly below the decimal aggregate truncation budget.
|
|
11
|
+
LIMIT = 1e-10.prev_float
|
|
12
|
+
# Maximum evaluated faces per component calculation.
|
|
13
|
+
MAX_FACES = 100_000
|
|
14
|
+
# Maximum reroll and mapping predicate calls per component calculation.
|
|
15
|
+
MAX_PREDICATES = 1_000_000
|
|
16
|
+
# Maximum number of face draws along one enumerated history.
|
|
17
|
+
MAX_DEPTH = 64
|
|
18
|
+
|
|
19
|
+
# @return [Float] Upward-rounded omitted probability mass.
|
|
20
|
+
attr_reader :omitted
|
|
21
|
+
|
|
22
|
+
def initialize(limit, sides)
|
|
23
|
+
@limit = limit
|
|
24
|
+
@sides = sides
|
|
25
|
+
@omitted = 0.0
|
|
26
|
+
@upper = [1.0]
|
|
27
|
+
@faces = @predicates = 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Counts one evaluated face, refusing work above the cap.
|
|
31
|
+
def face
|
|
32
|
+
@faces += 1
|
|
33
|
+
work_limit if @faces > MAX_FACES
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Counts one rule invocation, including shortcut and map predicates.
|
|
37
|
+
def predicate
|
|
38
|
+
@predicates += 1
|
|
39
|
+
work_limit if @predicates > MAX_PREDICATES
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def omit?(depth, probability)
|
|
43
|
+
return false if depth < 20 && probability >= 1e-16
|
|
44
|
+
|
|
45
|
+
@upper << (@upper.last / @sides).next_float while @upper.length <= depth
|
|
46
|
+
total = (@omitted + @upper[depth]).next_float
|
|
47
|
+
if total <= @limit
|
|
48
|
+
@omitted = total
|
|
49
|
+
return true
|
|
50
|
+
end
|
|
51
|
+
return false if depth < MAX_DEPTH
|
|
52
|
+
|
|
53
|
+
raise ProbabilityLimitError,
|
|
54
|
+
'Probability calculation cannot meet truncation error bound within calculation limits'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Allocates a downward-rounded share of an aggregate error budget.
|
|
58
|
+
def self.share(limit, count)
|
|
59
|
+
(limit / count).prev_float
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Bounds a union of repeated independent component errors.
|
|
63
|
+
def self.scale(error, count)
|
|
64
|
+
error.zero? ? 0.0 : (error * count).next_float
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Adds achieved error bounds with outward rounding and exact zero handling.
|
|
68
|
+
def self.add(left, right)
|
|
69
|
+
return left if right.zero?
|
|
70
|
+
return right if left.zero?
|
|
71
|
+
|
|
72
|
+
(left + right).next_float
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Validates a complete aggregate certificate before its distribution is cached.
|
|
76
|
+
def self.check(error, limit)
|
|
77
|
+
if error > limit
|
|
78
|
+
raise ProbabilityLimitError,
|
|
79
|
+
'Probability calculation cannot meet truncation error bound within calculation limits'
|
|
80
|
+
end
|
|
81
|
+
error
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def work_limit
|
|
87
|
+
raise ProbabilityLimitError, 'Probability calculation exceeds work limit'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'games_dice/probability_shortcuts'
|
|
4
|
+
|
|
5
|
+
module GamesDice
|
|
6
|
+
# @api private
|
|
7
|
+
# A bounded enumeration of completed rolls, with exact shortcuts for simple recipes.
|
|
8
|
+
class ProbabilityCalculation
|
|
9
|
+
include ProbabilityShortcuts
|
|
10
|
+
|
|
11
|
+
State = Struct.new(:depth, :reason, :subtracting, :probability, :total, :remaining)
|
|
12
|
+
private_constant :State
|
|
13
|
+
|
|
14
|
+
# @return [Float] Certified omitted-mass bound after successful calculation.
|
|
15
|
+
attr_reader :error
|
|
16
|
+
|
|
17
|
+
def initialize(die, limit)
|
|
18
|
+
@die = die
|
|
19
|
+
@sides = die.sides
|
|
20
|
+
@rerolls = die.rerolls&.dup || []
|
|
21
|
+
@maps = die.maps&.dup
|
|
22
|
+
@budget = ProbabilityBudget.new(limit, @sides)
|
|
23
|
+
@probabilities = Hash.new(0.0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Probabilities] Normalized distribution of completed outcomes.
|
|
27
|
+
def calculate
|
|
28
|
+
exact = shortcut
|
|
29
|
+
enumerate(State.new(0, :basic, false, 1.0, nil, @rerolls.map(&:limit))) unless exact
|
|
30
|
+
@probabilities = exact if exact
|
|
31
|
+
@error = @budget.omitted
|
|
32
|
+
normalize
|
|
33
|
+
@probabilities = mapped_probabilities if @maps
|
|
34
|
+
Probabilities.from_h(@probabilities)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def enumerate(state)
|
|
40
|
+
probability = state.probability / @sides
|
|
41
|
+
depth = state.depth + 1
|
|
42
|
+
(1..@sides).each do |face|
|
|
43
|
+
@budget.face
|
|
44
|
+
visit_face(state, face, depth, probability)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visit_face(state, face, depth, probability)
|
|
49
|
+
total = next_total(state, face)
|
|
50
|
+
index = @die.__send__(:find_matching_reroll_rule, face, depth, state.remaining, @budget, @rerolls)
|
|
51
|
+
if index.nil?
|
|
52
|
+
@probabilities[total] += probability
|
|
53
|
+
elsif !@budget.omit?(depth, probability)
|
|
54
|
+
continue_branch(state, total, index, depth, probability)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def next_total(state, face)
|
|
59
|
+
case state.reason
|
|
60
|
+
when :reroll_add then state.total + face
|
|
61
|
+
when :reroll_subtract then state.total - face
|
|
62
|
+
when :reroll_use_best then [state.total, face].max
|
|
63
|
+
when :reroll_use_worst then [state.total, face].min
|
|
64
|
+
else face
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def continue_branch(state, total, index, depth, probability)
|
|
69
|
+
rule = @rerolls[index]
|
|
70
|
+
remaining = state.remaining.dup
|
|
71
|
+
remaining[index] -= 1
|
|
72
|
+
subtracting = state.subtracting || rule.type == :reroll_subtract
|
|
73
|
+
reason = state.subtracting && rule.type == :reroll_add ? :reroll_subtract : rule.type
|
|
74
|
+
enumerate(State.new(depth, reason, subtracting, probability, total, remaining))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize
|
|
78
|
+
mass = @probabilities.values.sum
|
|
79
|
+
unless mass.finite? && mass.positive? && (mass + @error - 1.0).abs <= 1e-8
|
|
80
|
+
raise ProbabilityLimitError,
|
|
81
|
+
'Probability calculation cannot meet truncation error bound within calculation limits'
|
|
82
|
+
end
|
|
83
|
+
return if @error.zero?
|
|
84
|
+
|
|
85
|
+
@probabilities.transform_values! { |probability| probability / mass }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def mapped_probabilities
|
|
89
|
+
@probabilities.each_with_object(Hash.new(0.0)) do |(value, probability), mapped|
|
|
90
|
+
result, = @die.__send__(:calc_maps, value, @budget, @maps)
|
|
91
|
+
mapped[result] += probability
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
# @!visibility private
|
|
5
|
+
# Exact distributions for inexpensive ordinary reroll recipes.
|
|
6
|
+
module ProbabilityShortcuts
|
|
7
|
+
def self.boolean_rule?(rule)
|
|
8
|
+
value = rule.trigger_value
|
|
9
|
+
operator = rule.trigger_op
|
|
10
|
+
return false unless value.method(operator).source_location.nil?
|
|
11
|
+
return %i[== != < <= > >=].include?(operator) if value.instance_of?(Integer)
|
|
12
|
+
|
|
13
|
+
value.instance_of?(Range) && value.begin.is_a?(Integer) && value.end.is_a?(Integer) &&
|
|
14
|
+
%i[include? cover?].include?(operator)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def shortcut
|
|
20
|
+
return @die.basic_die.probabilities.to_h if @rerolls.empty?
|
|
21
|
+
|
|
22
|
+
rule = shortcut_rule
|
|
23
|
+
return unless rule
|
|
24
|
+
return deterministic_add(rule) if @sides == 1 && rule.type == :reroll_add
|
|
25
|
+
return replacement(rule) if rule.type == :reroll_replace
|
|
26
|
+
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def shortcut_rule
|
|
31
|
+
return unless @rerolls.one?
|
|
32
|
+
|
|
33
|
+
rule = @rerolls.first
|
|
34
|
+
rule if rule.instance_of?(RerollRule) && ProbabilityShortcuts.boolean_rule?(rule)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def deterministic_add(rule)
|
|
38
|
+
@budget.predicate
|
|
39
|
+
count = rule.applies?(1) ? [rule.limit, 0].max : 0
|
|
40
|
+
{ 1 + count => 1.0 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def replacement(rule)
|
|
44
|
+
triggers = triggering_faces(rule)
|
|
45
|
+
count = triggers.count(true)
|
|
46
|
+
return @die.basic_die.probabilities.to_h if count.zero? || count == @sides || rule.limit <= 0
|
|
47
|
+
|
|
48
|
+
replacement_weights(triggers, count, rule.limit)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def replacement_weights(triggers, count, limit)
|
|
52
|
+
repeated = ((count.to_f / @sides)**limit) / @sides
|
|
53
|
+
terminal = (1.0 - (count * repeated)) / (@sides - count)
|
|
54
|
+
triggers.each_with_index.to_h { |trigger, index| [index + 1, trigger ? repeated : terminal] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def triggering_faces(rule)
|
|
58
|
+
Array.new(@sides) do |index|
|
|
59
|
+
@budget.face
|
|
60
|
+
@budget.predicate
|
|
61
|
+
rule.applies?(index + 1)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GamesDice
|
|
4
|
+
# @api private
|
|
5
|
+
# A structural snapshot of exposed recipe arrays. Stable reads allocate nothing.
|
|
6
|
+
class RecipeCache
|
|
7
|
+
def initialize(sources, labels)
|
|
8
|
+
@sources = sources
|
|
9
|
+
@labels = labels
|
|
10
|
+
@format = 1
|
|
11
|
+
capture
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] Whether this is a recognized snapshot format.
|
|
15
|
+
def valid?
|
|
16
|
+
@format == 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def changed?
|
|
20
|
+
index = 0
|
|
21
|
+
while index < @sources.length
|
|
22
|
+
array, = @sources[index]
|
|
23
|
+
return true if source_changed?(array, @snapshots[index])
|
|
24
|
+
|
|
25
|
+
index += 1
|
|
26
|
+
end
|
|
27
|
+
@labels != @label_copies
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Records entry references, component versions, and copies of mutable labels.
|
|
31
|
+
def capture
|
|
32
|
+
@snapshots = @sources.map do |array, klass|
|
|
33
|
+
array&.map do |entry|
|
|
34
|
+
validate(entry, klass)
|
|
35
|
+
[entry, child_version(entry), label_copy(entry)]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
@label_copies = @labels.map(&:dup)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def source_changed?(array, snapshot)
|
|
44
|
+
return false unless array
|
|
45
|
+
return true unless snapshot && array.length == snapshot.length
|
|
46
|
+
|
|
47
|
+
index = 0
|
|
48
|
+
while index < array.length
|
|
49
|
+
return true if entry_changed?(array[index], snapshot[index])
|
|
50
|
+
|
|
51
|
+
index += 1
|
|
52
|
+
end
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def entry_changed?(entry, snapshot)
|
|
57
|
+
saved, version, label = snapshot
|
|
58
|
+
return true unless entry.equal?(saved)
|
|
59
|
+
return true if version && entry.__send__(:recipe_version) != version
|
|
60
|
+
|
|
61
|
+
label && entry.mapped_name != label
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate(entry, klass)
|
|
65
|
+
raise TypeError, "Expected #{klass}, got #{entry.class}" unless entry.is_a?(klass)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def child_version(entry)
|
|
69
|
+
entry.__send__(:recipe_version) if entry.is_a?(Bunch) || entry.is_a?(ComplexDie)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def label_copy(entry)
|
|
73
|
+
entry.mapped_name.dup if entry.is_a?(MapRule)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @api private
|
|
78
|
+
# Shared invalidation and transaction boundary for recipe-dependent operations.
|
|
79
|
+
module RecipeQueries
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def initialize_copy(original)
|
|
83
|
+
super
|
|
84
|
+
@recipe_cache = nil
|
|
85
|
+
refresh_recipe
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def initialize_recipe
|
|
89
|
+
@recipe_cache = RecipeCache.new(recipe_sources, recipe_labels)
|
|
90
|
+
@recipe_version = 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def recipe_labels
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def recipe_version
|
|
98
|
+
refresh_recipe
|
|
99
|
+
@recipe_version
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def refresh_recipe
|
|
103
|
+
validate_recipe
|
|
104
|
+
restore_recipe_cache unless @recipe_cache.is_a?(RecipeCache) && @recipe_cache.valid?
|
|
105
|
+
return unless @recipe_cache.changed?
|
|
106
|
+
|
|
107
|
+
clear_derived_state
|
|
108
|
+
@recipe_version += 1
|
|
109
|
+
@recipe_cache.capture
|
|
110
|
+
rescue TypeError, ArgumentError
|
|
111
|
+
clear_derived_state
|
|
112
|
+
@recipe_version = (@recipe_version || 0) + 1
|
|
113
|
+
raise
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def restore_recipe_cache
|
|
117
|
+
previous_version = @recipe_version || 0
|
|
118
|
+
initialize_recipe
|
|
119
|
+
@recipe_version = previous_version + 1
|
|
120
|
+
clear_derived_state
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_recipe; end
|
|
124
|
+
|
|
125
|
+
def clear_derived_state
|
|
126
|
+
@probabilities = @probability_error = @probabilities_complete = nil
|
|
127
|
+
@min = @max = @min_result = @max_result = nil
|
|
128
|
+
@result = @raw_result_details = @used_result_details = nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def valid_probability_cache?(limit)
|
|
132
|
+
@probabilities && @probability_error.is_a?(Float) && @probability_error >= 0.0 && @probability_error <= limit
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def with_recipe
|
|
136
|
+
version = recipe_version
|
|
137
|
+
result = yield
|
|
138
|
+
completed = true
|
|
139
|
+
result
|
|
140
|
+
ensure
|
|
141
|
+
verify_recipe(version, completed) if version
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def verify_recipe(version, completed)
|
|
145
|
+
discard_changed_recipe(completed) if recipe_version != version
|
|
146
|
+
rescue TypeError, ArgumentError
|
|
147
|
+
discard_changed_recipe(completed)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def discard_changed_recipe(completed)
|
|
151
|
+
clear_derived_state
|
|
152
|
+
raise 'Dice recipe changed during calculation' if completed
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/games_dice/version.rb
CHANGED
data/lib/games_dice.rb
CHANGED
|
@@ -6,6 +6,8 @@ require 'games_dice/die'
|
|
|
6
6
|
require 'games_dice/die_result'
|
|
7
7
|
require 'games_dice/reroll_rule'
|
|
8
8
|
require 'games_dice/map_rule'
|
|
9
|
+
require 'games_dice/recipe_cache'
|
|
10
|
+
require 'games_dice/probability_budget'
|
|
9
11
|
require 'games_dice/complex_die'
|
|
10
12
|
require 'games_dice/bunch'
|
|
11
13
|
require 'games_dice/dice'
|
|
@@ -26,6 +28,8 @@ module GamesDice
|
|
|
26
28
|
GamesDice::Dice.new(parsed[:bunches], parsed[:offset])
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Returns the shared parser used by {.create}.
|
|
32
|
+
# @return [GamesDice::Parser] Lazily constructed dice-notation parser.
|
|
29
33
|
def self.parser
|
|
30
34
|
@parser ||= GamesDice::Parser.new
|
|
31
35
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yard'
|
|
4
|
+
|
|
5
|
+
# Match the Ruby source and visibility boundary used by the documentation task.
|
|
6
|
+
# Stats reports omissions but does not fail on them, so check the parsed objects too.
|
|
7
|
+
stats = YARD::CLI::Stats.new
|
|
8
|
+
stats.run('--list-undoc', '--fail-on-warning', '--no-cache', '--no-save', 'lib/**/*.rb')
|
|
9
|
+
abort 'Documentation check found no objects' if stats.all_objects.empty?
|
|
10
|
+
abort 'Documentation check found undocumented objects' if stats.all_objects.any? { |object| object.docstring.blank? }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'helpers'
|
|
4
|
+
|
|
5
|
+
describe GamesDice::Bunch, :aggregate_failures do
|
|
6
|
+
let(:prng) { Random.new(1) }
|
|
7
|
+
|
|
8
|
+
invalid_counts = [0, -1]
|
|
9
|
+
valid_counts = [1, 3, 4]
|
|
10
|
+
%i[keep_best keep_worst].each do |mode|
|
|
11
|
+
invalid_counts.each do |count|
|
|
12
|
+
it "rejects #{count} keepers in #{mode}" do
|
|
13
|
+
expect { described_class.new(ndice: 3, sides: 6, keep_mode: mode, keep_number: count) }
|
|
14
|
+
.to raise_error(ArgumentError, /:keep_number must be 1 or more/)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
valid_counts.each do |count|
|
|
18
|
+
it "keeps at most the whole bunch for #{count} keepers in #{mode}" do
|
|
19
|
+
bunch = described_class.new(ndice: 3, sides: 1, keep_mode: mode, keep_number: count)
|
|
20
|
+
expect(bunch.roll).to eq([count, 3].min)
|
|
21
|
+
expect(bunch.probabilities.to_h).to eq({ [count, 3].min => 1.0 })
|
|
22
|
+
expect(bunch.explain_result).to be_a(String)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
{ keep_best: [10, '1 Jackpot'], keep_worst: [1, '6 Low'] }.each do |mode, (total, kept)|
|
|
28
|
+
it "explains the actual mapped keeper for #{mode}" do
|
|
29
|
+
allow(prng).to receive(:rand).and_return(0, 5)
|
|
30
|
+
bunch = mapped_bunch(mode)
|
|
31
|
+
expect(bunch.roll).to eq(total)
|
|
32
|
+
expect(bunch.explain_result).to include("Keep: #{kept}. Successes: #{total}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'shares its truncation budget across all rolled dice even when only one is kept' do
|
|
37
|
+
bunch = described_class.new(ndice: 20, sides: 2, rerolls: [[2, :==, :reroll_add]], keep_mode: :keep_best)
|
|
38
|
+
expect(bunch.probabilities.to_h).to be_valid_distribution
|
|
39
|
+
expect(bunch.instance_variable_get(:@probability_error)).to be <= 1e-10
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mapped_bunch(mode)
|
|
43
|
+
described_class.new(ndice: 2, sides: 6, prng: prng, keep_mode: mode,
|
|
44
|
+
maps: [[1, :==, 10, 'Jackpot'], [2, :<=, 1, 'Low']])
|
|
45
|
+
end
|
|
46
|
+
end
|