games_dice 0.3.12 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|