games_dice 0.4.2 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +69 -5
  3. data/.gitignore +0 -1
  4. data/.rubocop.yml +13 -48
  5. data/CHANGELOG.md +26 -0
  6. data/Gemfile +6 -3
  7. data/README.md +46 -5
  8. data/Rakefile +90 -2
  9. data/ext/games_dice/extconf.rb +26 -0
  10. data/ext/games_dice/games_dice.c +1 -1
  11. data/ext/games_dice/probabilities.c +341 -211
  12. data/ext/games_dice/probabilities.h +3 -29
  13. data/games_dice.gemspec +1 -1
  14. data/lib/games_dice/bunch.rb +50 -36
  15. data/lib/games_dice/bunch_helpers.rb +35 -14
  16. data/lib/games_dice/complex_die.rb +64 -22
  17. data/lib/games_dice/complex_die_helpers.rb +122 -144
  18. data/lib/games_dice/dice.rb +61 -17
  19. data/lib/games_dice/dice_helpers.rb +37 -0
  20. data/lib/games_dice/die_result.rb +6 -8
  21. data/lib/games_dice/marshal.rb +2 -7
  22. data/lib/games_dice/parser.rb +30 -0
  23. data/lib/games_dice/probability_budget.rb +90 -0
  24. data/lib/games_dice/probability_calculation.rb +95 -0
  25. data/lib/games_dice/probability_shortcuts.rb +65 -0
  26. data/lib/games_dice/recipe_cache.rb +155 -0
  27. data/lib/games_dice/version.rb +1 -1
  28. data/lib/games_dice.rb +4 -0
  29. data/script/check_documentation.rb +10 -0
  30. data/spec/games_dice/bunch_regressions_spec.rb +46 -0
  31. data/spec/{bunch_spec.rb → games_dice/bunch_spec.rb} +65 -112
  32. data/spec/games_dice/complex_die_budget_spec.rb +60 -0
  33. data/spec/games_dice/complex_die_limits_spec.rb +119 -0
  34. data/spec/games_dice/complex_die_spec.rb +228 -0
  35. data/spec/games_dice/dice_mutation_spec.rb +165 -0
  36. data/spec/games_dice/dice_regressions_spec.rb +50 -0
  37. data/spec/games_dice/dice_spec.rb +61 -0
  38. data/spec/games_dice/die_result_spec.rb +221 -0
  39. data/spec/{die_spec.rb → games_dice/die_spec.rb} +15 -14
  40. data/spec/{map_rule_spec.rb → games_dice/map_rule_spec.rb} +8 -1
  41. data/spec/{parser_spec.rb → games_dice/parser_spec.rb} +12 -2
  42. data/spec/games_dice/probabilities_boundaries_spec.rb +161 -0
  43. data/spec/games_dice/probabilities_marshal_spec.rb +62 -0
  44. data/spec/games_dice/probabilities_spec.rb +484 -0
  45. data/spec/games_dice/probability_budget_spec.rb +17 -0
  46. data/spec/{reroll_rule_spec.rb → games_dice/reroll_rule_spec.rb} +5 -1
  47. data/spec/games_dice_spec.rb +382 -0
  48. data/spec/helpers.rb +64 -2
  49. data/spec/simple_cov_spec.rb +42 -0
  50. data/spec/support/coverage.rb +12 -0
  51. data/spec/support/validation.rb +14 -0
  52. data/spec/yard/cli/stats_spec.rb +43 -0
  53. metadata +30 -13
  54. data/spec/complex_die_spec.rb +0 -314
  55. data/spec/dice_spec.rb +0 -33
  56. data/spec/die_result_spec.rb +0 -254
  57. data/spec/fixtures/probs_fair_die_6.dat +0 -2
  58. data/spec/probability_spec.rb +0 -522
  59. data/spec/readme_spec.rb +0 -410
@@ -7,41 +7,15 @@
7
7
 
8
8
  #include <ruby.h>
9
9
 
10
- void init_probabilities_class();
10
+ void init_probabilities_class(void);
11
11
 
12
12
  typedef struct _pd {
13
+ struct _pd *next;
14
+ struct _pd **owner_link;
13
15
  int offset;
14
16
  int slots;
15
17
  double *probs;
16
18
  double *cumulative;
17
19
  } ProbabilityList;
18
20
 
19
- inline int pl_min( ProbabilityList *pl );
20
-
21
- inline int pl_max( ProbabilityList *pl );
22
-
23
- ProbabilityList *pl_add_distributions( ProbabilityList *pl_a, ProbabilityList *pl_b );
24
-
25
- ProbabilityList *pl_add_distributions_mult( int mul_a, ProbabilityList *pl_a, int mul_b, ProbabilityList *pl_b );
26
-
27
- inline double pl_p_eql( ProbabilityList *pl, int target );
28
-
29
- inline double pl_p_gt( ProbabilityList *pl, int target );
30
-
31
- inline double pl_p_lt( ProbabilityList *pl, int target );
32
-
33
- inline double pl_p_le( ProbabilityList *pl, int target );
34
-
35
- inline double pl_p_ge( ProbabilityList *pl, int target );
36
-
37
- inline double pl_expected( ProbabilityList *pl );
38
-
39
- ProbabilityList *pl_given_ge( ProbabilityList *pl, int target );
40
-
41
- ProbabilityList *pl_given_le( ProbabilityList *pl, int target );
42
-
43
- ProbabilityList *pl_repeat_sum( ProbabilityList *pl, int n );
44
-
45
- ProbabilityList *pl_repeat_n_sum_k( ProbabilityList *pl, int n, int k, int kbest );
46
-
47
21
  #endif
data/games_dice.gemspec CHANGED
@@ -24,7 +24,7 @@ Gem::Specification.new do |gem|
24
24
 
25
25
  gem.add_dependency 'parslet', '~> 2.0'
26
26
 
27
- gem.files = `git ls-files -z`.split("\0").select { |file| File.file?(file) }
27
+ gem.files = `git ls-files -z`.split("\0").select { |file| File.file?(file) && file != 'Gemfile.lock' }
28
28
  gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
29
29
  gem.extensions = gem.files.grep(%r{/extconf\.rb$})
30
30
  gem.require_paths = ['lib']
@@ -23,6 +23,8 @@ module GamesDice
23
23
  # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
24
24
  #
25
25
  class Bunch
26
+ include RecipeQueries
27
+ include ProbabilityHelpers
26
28
  include KeepHelpers
27
29
  include ExplainHelpers
28
30
 
@@ -51,6 +53,7 @@ module GamesDice
51
53
  else
52
54
  GamesDice::Die.new(@sides, options[:prng])
53
55
  end
56
+ initialize_recipe
54
57
  end
55
58
 
56
59
  # Name to help identify bunch
@@ -75,7 +78,10 @@ module GamesDice
75
78
 
76
79
  # Result of most-recent roll, or nil if no roll made yet.
77
80
  # @return [Integer,nil]
78
- attr_reader :result
81
+ def result
82
+ refresh_recipe
83
+ @result
84
+ end
79
85
 
80
86
  # @!attribute [r] label
81
87
  # Description that will be used in explanations with more than one bunch
@@ -105,80 +111,88 @@ module GamesDice
105
111
  # allowing inspection of how the result was obtained.
106
112
  # @return [Array<GamesDice::DieResult>, nil] Sequence of GamesDice::DieResult objects.
107
113
  def result_details
114
+ refresh_recipe
108
115
  return nil unless @raw_result_details
109
116
 
110
117
  @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r }
111
118
  end
112
119
 
113
120
  # @!attribute [r] min
114
- # Minimum possible result from a call to #roll
121
+ # Lower bound on the value returned by #roll. No roll will be below this value.
122
+ # For some complex dice, this may be lower than any value that can actually
123
+ # be rolled, because calculating the exact minimum would take too much work.
115
124
  # @return [Integer]
116
125
  def min
126
+ refresh_recipe
117
127
  n = @keep_mode ? [@keep_number, @ndice].min : @ndice
118
128
  n * @single_die.min
119
129
  end
120
130
 
121
131
  # @!attribute [r] max
122
- # Maximum possible result from a call to #roll
132
+ # Upper bound on the value returned by #roll. No roll will exceed this value.
133
+ # For some complex dice, this may be higher than any value that can actually
134
+ # be rolled, because calculating the exact maximum would take too much work.
123
135
  # @return [Integer]
124
136
  def max
137
+ refresh_recipe
125
138
  n = @keep_mode ? [@keep_number, @ndice].min : @ndice
126
139
  n * @single_die.max
127
140
  end
128
141
 
129
- # Calculates the probability distribution for the bunch. When the bunch is composed of dice with
130
- # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
131
- # recursion.
132
- # @return [GamesDice::Probabilities] Probability distribution of bunch.
142
+ # Calculates the probability distribution for the bunch.
143
+ #
144
+ # Very unlikely sequences of rerolls may be left out to keep calculation
145
+ # time reasonable. This changes the probability of any result by at most
146
+ # 1e-10 (one in ten billion) across the whole bunch, in addition to normal
147
+ # floating-point rounding.
148
+ #
149
+ # Raises ProbabilityLimitError if this accuracy cannot be achieved within
150
+ # the calculation's work limits.
151
+ # @return [GamesDice::Probabilities] Probability distribution of the bunch.
133
152
  def probabilities
134
- return @probabilities if @probabilities
135
-
136
- @probabilities = if @keep_mode && @ndice > @keep_number
137
- @single_die.probabilities.repeat_n_sum_k(@ndice, @keep_number, @keep_mode)
138
- else
139
- @single_die.probabilities.repeat_sum(@ndice)
140
- end
141
-
142
- @probabilities
153
+ probabilities_for_budget(ProbabilityBudget::LIMIT)
143
154
  end
144
155
 
145
156
  # Simulates rolling the bunch of identical dice
146
157
  # @return [Integer] Sum of all rolled dice, or sum of all keepers
147
158
  def roll
148
- generate_raw_results
149
- return @result if !@keep_mode || @keep_number.to_i >= @ndice
150
-
151
- use_dice = case @keep_mode
152
- when :keep_best then @raw_result_details.sort[-@keep_number..]
153
- when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
154
- end
155
-
156
- @result = use_dice.sum
159
+ with_recipe do
160
+ generate_raw_results
161
+ @used_result_details = select_keepers
162
+ @result = @used_result_details.sum
163
+ end
157
164
  end
158
165
 
159
166
  # @!attribute [r] explain_result
160
167
  # Explanation of result, or nil if no call to #roll yet.
161
168
  # @return [String,nil]
162
169
  def explain_result
170
+ refresh_recipe
163
171
  return nil unless @result
164
172
 
165
- # With #keep_mode, we may need to show unused and used dice separately
166
- used_dice = result_details
167
- used_dice, = find_used_dice_due_to_keep_mode(result_details) if @keep_mode && @keep_number < @ndice
168
-
173
+ used_dice = @used_result_details.map { |result| result.is_a?(Integer) ? DieResult.new(result) : result }
169
174
  build_explanation(used_dice)
170
175
  end
171
176
 
172
177
  private
173
178
 
174
- def generate_raw_results
175
- @result = 0
176
- @raw_result_details = []
179
+ def recipe_sources
180
+ [[[@single_die], @single_die.class]]
181
+ end
177
182
 
178
- @ndice.times do
179
- @result += @single_die.roll
180
- @raw_result_details << @single_die.result
181
- end
183
+ def recipe_labels
184
+ [@name]
185
+ end
186
+
187
+ def select_keepers
188
+ return @raw_result_details if !@keep_mode || @keep_number >= @ndice
189
+ return @raw_result_details.sort[-@keep_number..] if @keep_mode == :keep_best
190
+
191
+ @raw_result_details.sort[0...@keep_number]
192
+ end
193
+
194
+ def generate_raw_results
195
+ @raw_result_details = Array.new(@ndice) { @single_die.roll }
182
196
  end
183
197
 
184
198
  def name_number_sides_from_hash(options)
@@ -2,6 +2,40 @@
2
2
 
3
3
  module GamesDice
4
4
  class Bunch
5
+ # @!visibility private
6
+ # Private distribution and aggregate-error helpers.
7
+ module ProbabilityHelpers
8
+ private
9
+
10
+ def probabilities_for_budget(limit)
11
+ refresh_recipe
12
+ return @probabilities if valid_probability_cache?(limit)
13
+
14
+ with_recipe do
15
+ distribution = single_distribution(limit)
16
+ result = repeated_distribution(distribution)
17
+ @probability_error = ProbabilityBudget.check(ProbabilityBudget.scale(single_error, @ndice), limit)
18
+ @probabilities = result
19
+ end
20
+ end
21
+
22
+ def repeated_distribution(distribution)
23
+ return distribution.repeat_sum(@ndice) unless @keep_mode && @ndice > @keep_number
24
+
25
+ distribution.repeat_n_sum_k(@ndice, @keep_number, @keep_mode)
26
+ end
27
+
28
+ def single_error
29
+ @single_die.is_a?(ComplexDie) ? @single_die.instance_variable_get(:@probability_error) : 0.0
30
+ end
31
+
32
+ def single_distribution(limit)
33
+ return @single_die.probabilities unless @single_die.is_a?(ComplexDie)
34
+
35
+ @single_die.__send__(:probabilities_for_budget, ProbabilityBudget.share(limit, @ndice))
36
+ end
37
+ end
38
+
5
39
  # @!visibility private
6
40
  # Private extension methods for GamesDice::Bunch keep rules
7
41
  module KeepHelpers
@@ -14,25 +48,12 @@ module GamesDice
14
48
  @keep_mode = nil
15
49
  when :keep_best, :keep_worst
16
50
  @keep_number = Integer(options[:keep_number] || 1)
51
+ raise ArgumentError, ":keep_number must be 1 or more, but got #{@keep_number}" unless @keep_number.positive?
17
52
  else
18
53
  raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
19
54
  end
20
55
  end
21
56
 
22
- def find_used_dice_due_to_keep_mode(used_dice, unused_dice = [])
23
- full_dice = result_details.sort_by(&:total)
24
- case @keep_mode
25
- when :keep_best
26
- used_dice = full_dice[-@keep_number..]
27
- unused_dice = full_dice[0..(full_dice.length - 1 - @keep_number)]
28
- when :keep_worst
29
- used_dice = full_dice[0..(@keep_number - 1)]
30
- unused_dice = full_dice[@keep_number..]
31
- end
32
-
33
- [used_dice, unused_dice]
34
- end
35
-
36
57
  def explain_kept_dice(used_dice)
37
58
  separator = @single_die.maps ? ', ' : ' + '
38
59
  ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'games_dice/complex_die_helpers'
4
+ require 'games_dice/probability_calculation'
4
5
 
5
6
  module GamesDice
6
7
  # This class models a die that is built up from a simpler unit by adding rules to re-roll
@@ -25,6 +26,7 @@ module GamesDice
25
26
  #
26
27
  class ComplexDie
27
28
  include RollHelpers
29
+ include RecipeQueries
28
30
  include ProbabilityHelpers
29
31
  include MinMaxHelpers
30
32
 
@@ -51,6 +53,7 @@ module GamesDice
51
53
  @total = nil
52
54
  @result = nil
53
55
 
56
+ initialize_recipe
54
57
  @probabilities_complete = true
55
58
  end
56
59
 
@@ -65,14 +68,25 @@ module GamesDice
65
68
  attr_reader :maps
66
69
 
67
70
  # @return [GamesDice::DieResult, nil] Result of last call to #roll, nil if no call made yet
68
- attr_reader :result
71
+ def result
72
+ refresh_recipe
73
+ @result
74
+ end
69
75
 
70
- # Whether or not #probabilities includes all possible outcomes.
71
- # True if all possible results are represented and assigned a probability. Dice with open-ended re-rolls
72
- # may have calculations cut short, and will result in a false value of this attribute. Even when this
73
- # attribute is false, probabilities should still be accurate to nearest 1e-9.
74
- # @return [Boolean, nil] Depending on completeness when generating #probabilities
75
- attr_reader :probabilities_complete
76
+ # Whether the probability calculation included all possible sequences of rerolls.
77
+ # After calling #probabilities, true means none were left out. False means
78
+ # very unlikely sequences were skipped to keep calculation time reasonable.
79
+ # Even when false, this changes the probability of any result by at most
80
+ # 1e-10 (one in ten billion), in addition to normal floating-point rounding.
81
+ #
82
+ # Call #probabilities before checking this value. A new die starts with true,
83
+ # even though no calculation has run yet.
84
+ # @return [Boolean, nil] Whether the calculation is complete, or nil if its
85
+ # result was cleared after a recipe change or a failed calculation.
86
+ def probabilities_complete
87
+ refresh_recipe
88
+ @probabilities_complete
89
+ end
76
90
 
77
91
  # @!attribute [r] sides
78
92
  # @return [Integer] Number of sides.
@@ -83,49 +97,77 @@ module GamesDice
83
97
  # @!attribute [r] explain_result
84
98
  # @return [String,nil] Explanation of result, or nil if no call to #roll yet.
85
99
  def explain_result
86
- @result.explain_value
100
+ refresh_recipe
101
+ @result&.explain_value
87
102
  end
88
103
 
89
- # The minimum possible result from a call to #roll. This is not always the same as the theoretical
90
- # minimum, due to limits on the maximum number of rerolls.
104
+ # Lower bound on the numeric value of a roll (#roll.value).
105
+ # No roll will have a value below this bound.
106
+ # For some complex dice, this may be lower than any value that can actually
107
+ # be rolled, because calculating the exact minimum would take too much work.
108
+ #
109
+ # Raises ProbabilityLimitError if a lower bound cannot be found within
110
+ # the calculation's work limits.
91
111
  # @!attribute [r] min
92
112
  # @return [Integer]
93
113
  def min
114
+ refresh_recipe
94
115
  return @min_result if @min_result
95
116
 
96
- calc_minmax
117
+ with_recipe { calc_minmax }
97
118
  @min_result
98
119
  end
99
120
 
121
+ # Upper bound on the numeric value of a roll (#roll.value).
122
+ # No roll will have a value above this bound.
123
+ # For some complex dice, this may be higher than any value that can actually
124
+ # be rolled, because calculating the exact maximum would take too much work.
125
+ #
126
+ # Raises ProbabilityLimitError if an upper bound cannot be found within
127
+ # the calculation's work limits.
100
128
  # @!attribute [r] max
101
- # @return [Integer] Maximum possible result from a call to #roll
129
+ # @return [Integer]
102
130
  def max
131
+ refresh_recipe
103
132
  return @max_result if @max_result
104
133
 
105
- calc_minmax
134
+ with_recipe { calc_minmax }
106
135
  @max_result
107
136
  end
108
137
 
109
- # Calculates the probability distribution for the die. For open-ended re-roll rules, there are some
110
- # arbitrary limits imposed to prevent large amounts of recursion. Probabilities should be to nearest
111
- # 1e-9 at worst.
112
- # @return [GamesDice::Probabilities] Probability distribution of die.
138
+ # Calculates the probability distribution for the die.
139
+ #
140
+ # Very unlikely sequences of rerolls may be left out to keep calculation
141
+ # time reasonable. This changes the probability of any result by at most
142
+ # 1e-10 (one in ten billion), in addition to normal floating-point rounding.
143
+ #
144
+ # Raises ProbabilityLimitError if this accuracy cannot be achieved within
145
+ # the calculation's work limits.
146
+ # @return [GamesDice::Probabilities] Probability distribution of the die.
113
147
  def probabilities
114
- @probabilities ||= calculate_probabilities
148
+ probabilities_for_budget(ProbabilityBudget::LIMIT)
115
149
  end
116
150
 
117
151
  # Simulates rolling the die
118
152
  # @param [Symbol] reason Assign a reason for rolling the first die.
119
153
  # @return [GamesDice::DieResult] Detailed results from rolling the die, including resolution of rules.
120
154
  def roll(reason = :basic)
121
- @result = GamesDice::DieResult.new(@basic_die.roll, reason)
122
- roll_apply_rerolls
123
- roll_apply_maps
124
- @result
155
+ with_recipe do
156
+ rerolls = @rerolls&.dup
157
+ maps = @maps&.dup
158
+ @result = GamesDice::DieResult.new(@basic_die.roll, reason)
159
+ roll_apply_rerolls(rerolls)
160
+ roll_apply_maps(maps)
161
+ @result
162
+ end
125
163
  end
126
164
 
127
165
  private
128
166
 
167
+ def recipe_sources
168
+ [[@rerolls, RerollRule], [@maps, MapRule]]
169
+ end
170
+
129
171
  def construct_rerolls(rerolls_input)
130
172
  check_and_construct rerolls_input, GamesDice::RerollRule, 'rerolls'
131
173
  end