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,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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module GamesDice
4
4
  # Current version of the gem.
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
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