games_dice 0.3.9 → 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 +7 -0
- data/.rubocop.yml +15 -0
- data/.travis.yml +9 -12
- data/CHANGELOG.md +29 -13
- data/Gemfile +2 -0
- data/README.md +5 -5
- 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 -28
- data/lib/games_dice/bunch.rb +241 -247
- data/lib/games_dice/complex_die.rb +287 -303
- data/lib/games_dice/complex_die_helpers.rb +68 -0
- 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 -16
- data/spec/bunch_spec.rb +399 -421
- data/spec/complex_die_spec.rb +314 -306
- data/spec/dice_spec.rb +33 -34
- data/spec/die_result_spec.rb +163 -170
- data/spec/die_spec.rb +81 -82
- data/spec/helpers.rb +26 -22
- data/spec/map_rule_spec.rb +40 -44
- data/spec/parser_spec.rb +106 -82
- data/spec/probability_spec.rb +530 -527
- data/spec/readme_spec.rb +404 -384
- data/spec/reroll_rule_spec.rb +40 -44
- metadata +63 -74
- data/lib/games_dice/probabilities.rb +0 -445
@@ -0,0 +1,68 @@
|
|
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
|
data/lib/games_dice/die.rb
CHANGED
@@ -1,97 +1,101 @@
|
|
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
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
@result =
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GamesDice
|
4
|
+
# This class models the simplest, most-familiar kind of die.
|
5
|
+
#
|
6
|
+
# An object of the class represents a basic die that rolls 1..#sides, with equal weighting for each value.
|
7
|
+
#
|
8
|
+
# @example Create a 6-sided die, and roll it
|
9
|
+
# d = GamesDice::Die.new( 6 )
|
10
|
+
# d.roll # => Integer in range 1..6
|
11
|
+
# d.result # => same Integer value as just returned by d.roll
|
12
|
+
#
|
13
|
+
# @example Create a 10-sided die, that rolls using a monkey-patch to SecureRandom
|
14
|
+
# module SecureRandom
|
15
|
+
# def self.rand n
|
16
|
+
# random_number( n )
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
# d = GamesDice::Die.new( 10, SecureRandom )
|
20
|
+
# d.roll # => (secure) Integer in range 1..10
|
21
|
+
# d.result # => same Integer value as just returned by d.roll
|
22
|
+
#
|
23
|
+
class Die
|
24
|
+
# Creates new instance of GamesDice::Die
|
25
|
+
# @param [Integer] sides the number of sides
|
26
|
+
# @param [#rand] prng random number generator, GamesDice::Die will use Ruby's built-in #rand() by default
|
27
|
+
# @return [GamesDice::Die]
|
28
|
+
def initialize(sides, prng = nil)
|
29
|
+
@sides = Integer(sides)
|
30
|
+
raise ArgumentError, "sides value #{sides} is too low, it must be 1 or greater" if @sides < 1
|
31
|
+
raise ArgumentError, 'prng does not support the rand() method' if prng && !prng.respond_to?(:rand)
|
32
|
+
|
33
|
+
@prng = prng
|
34
|
+
@result = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Integer] number of sides on simulated die
|
38
|
+
attr_reader :sides
|
39
|
+
|
40
|
+
# @return [Integer] result of last call to #roll, nil if no call made yet
|
41
|
+
attr_reader :result
|
42
|
+
|
43
|
+
# @return [Object] random number generator as supplied to constructor, may be nil
|
44
|
+
attr_reader :prng
|
45
|
+
|
46
|
+
# @!attribute [r] min
|
47
|
+
# @return [Integer] minimum possible result from a call to #roll
|
48
|
+
def min
|
49
|
+
1
|
50
|
+
end
|
51
|
+
|
52
|
+
# @!attribute [r] max
|
53
|
+
# @return [Integer] maximum possible result from a call to #roll
|
54
|
+
def max
|
55
|
+
@sides
|
56
|
+
end
|
57
|
+
|
58
|
+
# Calculates probability distribution for this die.
|
59
|
+
# @return [GamesDice::Probabilities] probability distribution of the die
|
60
|
+
def probabilities
|
61
|
+
@probabilities ||= GamesDice::Probabilities.for_fair_die(@sides)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Simulates rolling the die
|
65
|
+
# @return [Integer] selected value between 1 and #sides inclusive
|
66
|
+
def roll
|
67
|
+
@result = if @prng
|
68
|
+
@prng.rand(@sides) + 1
|
69
|
+
else
|
70
|
+
rand(@sides) + 1
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Iterates through all possible results on die.
|
75
|
+
# @yieldparam [Integer] result A potential result from the die
|
76
|
+
# @return [GamesDice::Die] this object
|
77
|
+
def each_value(&block)
|
78
|
+
(1..@sides).each(&block)
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [Array<Integer>] All potential results from the die
|
83
|
+
def all_values
|
84
|
+
(1..@sides).to_a
|
85
|
+
end
|
86
|
+
|
87
|
+
# @!attribute [r] rerolls
|
88
|
+
# Rules for when to re-roll this die.
|
89
|
+
# @return [nil] always nil, available for interface equivalence with GamesDice::ComplexDie
|
90
|
+
def rerolls
|
91
|
+
nil
|
92
|
+
end
|
93
|
+
|
94
|
+
# @!attribute [r] maps
|
95
|
+
# Rules for when to map return value of this die.
|
96
|
+
# @return [nil] always nil, available for interface equivalence with GamesDice::ComplexDie
|
97
|
+
def maps
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|