games_dice 0.3.12 → 0.4.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 +5 -5
- data/.rubocop.yml +15 -0
- data/.travis.yml +7 -10
- data/CHANGELOG.md +6 -0
- data/Gemfile +2 -0
- data/Rakefile +14 -11
- data/ext/games_dice/extconf.rb +4 -22
- data/ext/games_dice/probabilities.c +1 -1
- data/games_dice.gemspec +26 -32
- data/lib/games_dice/bunch.rb +241 -247
- data/lib/games_dice/complex_die.rb +287 -270
- data/lib/games_dice/complex_die_helpers.rb +68 -60
- data/lib/games_dice/constants.rb +10 -10
- data/lib/games_dice/dice.rb +146 -143
- data/lib/games_dice/die.rb +101 -97
- data/lib/games_dice/die_result.rb +193 -189
- data/lib/games_dice/map_rule.rb +72 -70
- data/lib/games_dice/marshal.rb +18 -13
- data/lib/games_dice/parser.rb +219 -218
- data/lib/games_dice/reroll_rule.rb +76 -77
- data/lib/games_dice/version.rb +3 -1
- data/lib/games_dice.rb +19 -19
- data/spec/bunch_spec.rb +399 -420
- data/spec/complex_die_spec.rb +314 -305
- data/spec/dice_spec.rb +33 -34
- data/spec/die_result_spec.rb +162 -169
- data/spec/die_spec.rb +81 -81
- data/spec/helpers.rb +23 -21
- data/spec/map_rule_spec.rb +40 -44
- data/spec/parser_spec.rb +106 -82
- data/spec/probability_spec.rb +530 -526
- data/spec/readme_spec.rb +404 -390
- data/spec/reroll_rule_spec.rb +40 -44
- metadata +39 -28
- data/lib/games_dice/prob_helpers.rb +0 -259
- data/lib/games_dice/probabilities.rb +0 -244
@@ -1,60 +1,68 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# @!visibility private
|
4
|
+
module GamesDice
|
5
|
+
# Private extension methods for GamesDice::ComplexDie
|
6
|
+
module ComplexDieHelpers
|
7
|
+
private
|
8
|
+
|
9
|
+
def recursive_probabilities(probabilities = {}, prior_probability = 1.0, depth = 0, prior_result = nil, rerolls_left = nil, roll_reason = :basic, subtracting = false)
|
10
|
+
each_probability = prior_probability / @basic_die.sides
|
11
|
+
depth += 1
|
12
|
+
if depth >= 20 || each_probability < 1.0e-16
|
13
|
+
@probabilities_complete = false
|
14
|
+
stop_recursing = true
|
15
|
+
end
|
16
|
+
|
17
|
+
@basic_die.each_value do |v|
|
18
|
+
recurse_probs_for_value(v, roll_reason, probabilities, each_probability, depth, prior_result, rerolls_left,
|
19
|
+
subtracting, stop_recursing)
|
20
|
+
end
|
21
|
+
probabilities
|
22
|
+
end
|
23
|
+
|
24
|
+
def recurse_probs_for_value(v, roll_reason, probabilities, each_probability, depth, prior_result, rerolls_left, subtracting, stop_recursing)
|
25
|
+
# calculate value, recurse if there is a reroll
|
26
|
+
result_so_far, rerolls_remaining = calc_result_so_far(prior_result, rerolls_left, v, roll_reason)
|
27
|
+
|
28
|
+
# Find which rule, if any, is being triggered
|
29
|
+
rule_idx = find_matching_reroll_rule(v, result_so_far.rolls.length, rerolls_remaining)
|
30
|
+
|
31
|
+
if rule_idx && !stop_recursing
|
32
|
+
recurse_probs_with_rule(probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule_idx,
|
33
|
+
subtracting)
|
34
|
+
else
|
35
|
+
t = result_so_far.total
|
36
|
+
probabilities[t] ||= 0.0
|
37
|
+
probabilities[t] += each_probability
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def recurse_probs_with_rule(probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule_idx, subtracting)
|
42
|
+
rule = @rerolls[rule_idx]
|
43
|
+
rerolls_remaining[rule_idx] -= 1
|
44
|
+
is_subtracting = true if subtracting || rule.type == :reroll_subtract
|
45
|
+
|
46
|
+
# Apply the rule (note reversal for additions, after a subtract)
|
47
|
+
if subtracting && rule.type == :reroll_add
|
48
|
+
recursive_probabilities probabilities, each_probability, depth, result_so_far, rerolls_remaining,
|
49
|
+
:reroll_subtract, is_subtracting
|
50
|
+
else
|
51
|
+
recursive_probabilities probabilities, each_probability, depth, result_so_far, rerolls_remaining, rule.type,
|
52
|
+
is_subtracting
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def calc_result_so_far(prior_result, rerolls_left, v, roll_reason)
|
57
|
+
if prior_result
|
58
|
+
result_so_far = prior_result.clone
|
59
|
+
result_so_far.add_roll(v, roll_reason)
|
60
|
+
rerolls_remaining = rerolls_left.clone
|
61
|
+
else
|
62
|
+
result_so_far = GamesDice::DieResult.new(v, roll_reason)
|
63
|
+
rerolls_remaining = @rerolls.map(&:limit)
|
64
|
+
end
|
65
|
+
[result_so_far, rerolls_remaining]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/games_dice/constants.rb
CHANGED
@@ -1,16 +1,16 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
+
module GamesDice
|
3
4
|
# Reasons for making a reroll, and text explanation symbols for them
|
4
5
|
REROLL_TYPES = {
|
5
|
-
:
|
6
|
-
:
|
7
|
-
:
|
8
|
-
:
|
9
|
-
:
|
10
|
-
:
|
6
|
+
basic: ',',
|
7
|
+
reroll_add: '+',
|
8
|
+
reroll_subtract: '-',
|
9
|
+
reroll_replace: '|',
|
10
|
+
reroll_use_best: '/',
|
11
|
+
reroll_use_worst: '\\'
|
11
12
|
# These are not yet implemented:
|
12
13
|
# :reroll_new_die => '*',
|
13
14
|
# :reroll_new_keeper => '*',
|
14
|
-
}
|
15
|
-
|
16
|
-
end
|
15
|
+
}.freeze
|
16
|
+
end
|
data/lib/games_dice/dice.rb
CHANGED
@@ -1,143 +1,146 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
|
9
|
-
|
10
|
-
# d.
|
11
|
-
# d.
|
12
|
-
#
|
13
|
-
#
|
14
|
-
# d
|
15
|
-
|
16
|
-
#
|
17
|
-
# d.
|
18
|
-
#
|
19
|
-
|
20
|
-
#
|
21
|
-
#
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
@
|
36
|
-
@
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
[min,max]
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
return @
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
end
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GamesDice
|
4
|
+
# This class models a combination of GamesDice::Bunch objects plus a fixed offset.
|
5
|
+
#
|
6
|
+
# An object of this class is a dice "recipe" that specifies the numbers and types of
|
7
|
+
# dice that can be rolled to generate an integer value.
|
8
|
+
#
|
9
|
+
# @example '3d6+6' hitpoints, whatever that means in the game you are playing
|
10
|
+
# d = GamesDice::Dice.new( [{:ndice => 3, :sides => 6}], 6, 'Hit points' )
|
11
|
+
# d.roll # => 20
|
12
|
+
# d.result # => 20
|
13
|
+
# d.explain_result # => "3d6: 3 + 5 + 6 = 14. 14 + 6 = 20"
|
14
|
+
# d.probabilities.expected # => 16.5
|
15
|
+
#
|
16
|
+
# @example Roll d20 twice, take best result, and add 5.
|
17
|
+
# d = GamesDice::Dice.new( [{:ndice => 2, :sides => 20 , :keep_mode => :keep_best, :keep_number => 1}], 5 )
|
18
|
+
# d.roll # => 21
|
19
|
+
# d.result # => 21
|
20
|
+
# d.explain_result # => "2d20: 4, 16. Keep: 16. 16 + 5 = 21"
|
21
|
+
#
|
22
|
+
class Dice
|
23
|
+
# The first parameter is an array of values that are passed to GamesDice::Bunch constructors.
|
24
|
+
# @param [Array<Hash>] bunches Array of options for creating bunches
|
25
|
+
# @param [Integer] offset Total offset
|
26
|
+
# @param [String] name Optional label for the dice
|
27
|
+
# @option bunches [Integer] :ndice Number of dice in the bunch, *mandatory*
|
28
|
+
# @option bunches [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
|
29
|
+
# @option bunches [String] :name Optional name for the bunch
|
30
|
+
# @option bunches [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
|
31
|
+
# @option bunches [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
|
32
|
+
# @option bunches [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
|
33
|
+
# @option bunches [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
|
34
|
+
# @option bunches [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
|
35
|
+
# @option bunches [Integer] :multiplier Optional, defaults to 1, and typically 1 or -1 to describe whether the Bunch total is to be added or subtracted
|
36
|
+
# @return [GamesDice::Dice]
|
37
|
+
def initialize(bunches, offset = 0, name = '')
|
38
|
+
@name = name
|
39
|
+
@offset = offset
|
40
|
+
@bunches = bunches.map { |b| GamesDice::Bunch.new(b) }
|
41
|
+
@bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
|
42
|
+
@result = nil
|
43
|
+
end
|
44
|
+
|
45
|
+
# Name to help identify dice
|
46
|
+
# @return [String]
|
47
|
+
attr_reader :name
|
48
|
+
|
49
|
+
# Bunches of dice that are components of the object
|
50
|
+
# @return [Array<GamesDice::Bunch>]
|
51
|
+
attr_reader :bunches
|
52
|
+
|
53
|
+
# Multipliers for each bunch of identical dice. Typically 1 or -1 to represent groups of dice that
|
54
|
+
# are either added or subtracted from the total.
|
55
|
+
# @return [Array<Integer>]
|
56
|
+
attr_reader :bunch_multipliers
|
57
|
+
|
58
|
+
# Fixed offset added to sum of all bunches.
|
59
|
+
# @return [Integer]
|
60
|
+
attr_reader :offset
|
61
|
+
|
62
|
+
# Result of most-recent roll, or nil if no roll made yet.
|
63
|
+
# @return [Integer,nil]
|
64
|
+
attr_reader :result
|
65
|
+
|
66
|
+
# Simulates rolling dice
|
67
|
+
# @return [Integer] Sum of all rolled dice
|
68
|
+
def roll
|
69
|
+
@result = @offset + bunches_weighted_sum(:roll)
|
70
|
+
end
|
71
|
+
|
72
|
+
# @!attribute [r] min
|
73
|
+
# Minimum possible result from a call to #roll
|
74
|
+
# @return [Integer]
|
75
|
+
def min
|
76
|
+
@min ||= @offset + bunches_weighted_sum(:min)
|
77
|
+
end
|
78
|
+
|
79
|
+
# @!attribute [r] max
|
80
|
+
# Maximum possible result from a call to #roll
|
81
|
+
# @return [Integer]
|
82
|
+
def max
|
83
|
+
@max ||= @offset + bunches_weighted_sum(:max)
|
84
|
+
end
|
85
|
+
|
86
|
+
# @!attribute [r] minmax
|
87
|
+
# Convenience method, same as [dice.min, dice.max]
|
88
|
+
# @return [Array<Integer>]
|
89
|
+
def minmax
|
90
|
+
[min, max]
|
91
|
+
end
|
92
|
+
|
93
|
+
# Calculates the probability distribution for the dice. When the dice include components with
|
94
|
+
# open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
|
95
|
+
# recursion.
|
96
|
+
# @return [GamesDice::Probabilities] Probability distribution of dice.
|
97
|
+
def probabilities
|
98
|
+
return @probabilities if @probabilities
|
99
|
+
|
100
|
+
@bunch_multipliers.zip(@bunches).inject(GamesDice::Probabilities.new([1.0], @offset)) do |probs, mb|
|
101
|
+
m, b = mb
|
102
|
+
GamesDice::Probabilities.add_distributions_mult(1, probs, m, b.probabilities)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# @!attribute [r] explain_result
|
107
|
+
# @return [String,nil] Explanation of result, or nil if no call to #roll yet.
|
108
|
+
def explain_result
|
109
|
+
return nil unless @result
|
110
|
+
|
111
|
+
explanations = @bunches.map { |bunch| "#{bunch.label}: #{bunch.explain_result}" }
|
112
|
+
|
113
|
+
return @offset.to_s if explanations.count.zero?
|
114
|
+
|
115
|
+
return simple_explanation(explanations.first) if explanations.count == 1
|
116
|
+
|
117
|
+
bunch_values = @bunch_multipliers.zip(@bunches).map { |m, b| m * b.result }
|
118
|
+
bunch_values << @offset if @offset != 0
|
119
|
+
explanations << array_to_sum(bunch_values)
|
120
|
+
explanations.join('. ')
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def simple_explanation(explanation)
|
126
|
+
return explanation if @offset.zero?
|
127
|
+
|
128
|
+
"#{explanation}. #{array_to_sum([@bunches[0].result, @offset])}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def array_to_sum(array)
|
132
|
+
(numbers_to_strings(array) + ['=', array.inject(:+)]).join(' ')
|
133
|
+
end
|
134
|
+
|
135
|
+
def numbers_to_strings(array)
|
136
|
+
[array.first.to_s] + array.drop(1).map { |n| n.negative? ? "- #{n.abs}" : "+ #{n}" }
|
137
|
+
end
|
138
|
+
|
139
|
+
def bunches_weighted_sum(summed_method)
|
140
|
+
@bunch_multipliers.zip(@bunches).inject(0) do |total, mb|
|
141
|
+
m, b = mb
|
142
|
+
total + (m * b.send(summed_method))
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|