games_dice 0.0.3 → 0.0.5

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.
@@ -0,0 +1,16 @@
1
+ module GamesDice
2
+
3
+ # reasons for making a reroll, and text explanation symbols for them
4
+ REROLL_TYPES = {
5
+ :basic => ',',
6
+ :reroll_add => '+',
7
+ :reroll_subtract => '-',
8
+ :reroll_replace => '|',
9
+ :reroll_use_best => '/',
10
+ :reroll_use_worst => '\\',
11
+ # These are not yet implemented:
12
+ # :reroll_new_die => '*',
13
+ # :reroll_new_keeper => '*',
14
+ }
15
+
16
+ end
@@ -0,0 +1,43 @@
1
+ # models any combination of zero or more Bunches, plus a constant offset, summing them
2
+ # to create a total result when rolled
3
+ class GamesDice::Dice
4
+ # bunches is an Array of Hashes, each of which describes either a GamesDice::Bunch
5
+ # a Hash in the Array that describes a Bunch may contain any of the keys that can be used to initialize
6
+ # the Bunch, plus the following optional key:
7
+ # :multiplier => any Integer, but typically 1 or -1 to describe whether the Bunch total is to be added or subtracted
8
+ # offset is an Integer which will be added to the result when rolling all the bunches
9
+ # name can be any String, and is used to identify the dice being rolled.
10
+ def initialize( bunches, offset = 0, name = '' )
11
+ @name = name
12
+ @offset = offset
13
+ @bunches = bunches.map { |b| GamesDice::Bunch.new( b ) }
14
+ @bunch_multipliers = bunches.map { |b| b[:multiplier] || 1 }
15
+ @result = nil
16
+ end
17
+
18
+ # the string name as provided to the constructor, it will appear in explain_result
19
+ attr_reader :name
20
+
21
+ # an array of GamesDice::Bunch objects that together describe all the dice and roll-altering
22
+ # rules that apply to the GamesDice::Dice object
23
+ attr_reader :bunches
24
+
25
+ # an array of Integers, used to multiply result from each bunch when total results are summed
26
+ attr_reader :bunch_multipliers
27
+
28
+ # the integer offset that is added to the total result from all bunches
29
+ attr_reader :offset
30
+
31
+ # after calling #roll, this is set to the total integer value as calculated by simulating all the
32
+ # defined dice and their rules
33
+ attr_reader :result
34
+
35
+ # simulate dice roll. Returns integer final total, and also stores same value in #result
36
+ def roll
37
+ @result = @offset + @bunch_multipliers.zip(@bunches).inject(0) do |total,mb|
38
+ m,b = mb
39
+ total += m * b.roll
40
+ end
41
+ end
42
+
43
+ end # class Dice
@@ -1,92 +1,58 @@
1
- module GamesDice
2
- # basic die that rolls 1..N, typically with equal weighting for each value
3
- # d = Die.new(6)
4
- # d.roll # => Integer in range 1..6
5
- # d.result # => same Integer value as returned by d.roll
6
- class Die
7
- # sides is e.g. 6 for traditional cubic die, or 20 for icosahedron.
8
- # It can take non-traditional values, such as 7, but must be at least 1.
9
- # prng is an object that has a rand(x) method. If provided, it will be called as
10
- # prng.rand(sides), and is expected to return an integer in range 0...sides
11
- def initialize( sides, prng=nil )
12
- @sides = Integer(sides)
13
- raise ArgumentError, "sides value #{sides} is too low, it must be 1 or greater" if @sides < 1
14
- raise ArgumentError, "prng does not support the rand() method" if prng && ! prng.respond_to?(:rand)
15
- @prng = prng
16
- @result = nil
17
- end
18
-
19
- # number of sides as set by #new
20
- attr_reader :sides
21
-
22
- # integer result of last call to #roll, nil if no call made yet
23
- attr_reader :result
24
-
25
- # minimum possible value
26
- def min
27
- 1
28
- end
29
-
30
- # maximum possible value
31
- def max
32
- @sides
33
- end
34
-
35
- # returns a hash of value (Integer) => probability (Float) pairs
36
- def probabilities
37
- return @probabilities if @probabilities
38
- density = 1.0/@sides
39
- @probabilities = (1..@sides).inject({}) { |h,x| h[x] = density; h }
40
- end
41
-
42
- # returns mean expected value as a Float
43
- def expected_result
44
- 0.5 * (1 + @sides)
45
- end
46
-
47
- # returns probability than a roll will produce a number greater than target integer
48
- def probability_gt target
49
- probability_ge( Integer(target) + 1 )
50
- end
51
-
52
- # returns probability than a roll will produce a number greater than or equal to target integer
53
- def probability_ge target
54
- target = Integer(target)
55
- return 1.0 if target <= 1
56
- return 0.0 if target > @sides
57
- return 1.0 * (1.0 + @sides - target )/@sides
58
- end
59
-
60
- # returns probability than a roll will produce a number less than or equal to target integer
61
- def probability_le target
62
- target = Integer(target)
63
- return 1.0 if target >= @sides
64
- return 0.0 if target < 1
65
- return 1.0 * target/@sides
66
- end
67
-
68
- # returns probability than a roll will produce a number less than target integer
69
- def probability_lt target
70
- probability_le( Integer(target) - 1 )
71
- end
72
-
73
- # generates Integer between #min and #max, using rand()
74
- def roll
75
- if @prng
76
- @result = @prng.rand(@sides) + 1
77
- else
78
- @result = rand(@sides) + 1
79
- end
80
- end
81
-
82
- # always nil, available for compatibility with ComplexDie
83
- def rerolls
84
- nil
85
- end
86
-
87
- # always nil, available for compatibility with ComplexDie
88
- def maps
89
- nil
90
- end
91
- end # class Die
92
- end # module GamesDice
1
+ # basic die that rolls 1..N, typically with equal weighting for each value
2
+ # d = Die.new(6)
3
+ # d.roll # => Integer in range 1..6
4
+ # d.result # => same Integer value as returned by d.roll
5
+ class GamesDice::Die
6
+ # sides is e.g. 6 for traditional cubic die, or 20 for icosahedron.
7
+ # It can take non-traditional values, such as 7, but must be at least 1.
8
+ # prng is an object that has a rand(x) method. If provided, it will be called as
9
+ # prng.rand(sides), and is expected to return an integer in range 0...sides
10
+ def initialize( sides, prng=nil )
11
+ @sides = Integer(sides)
12
+ raise ArgumentError, "sides value #{sides} is too low, it must be 1 or greater" if @sides < 1
13
+ raise ArgumentError, "prng does not support the rand() method" if prng && ! prng.respond_to?(:rand)
14
+ @prng = prng
15
+ @result = nil
16
+ end
17
+
18
+ # number of sides as set by #new
19
+ attr_reader :sides
20
+
21
+ # integer result of last call to #roll, nil if no call made yet
22
+ attr_reader :result
23
+
24
+ # minimum possible value
25
+ def min
26
+ 1
27
+ end
28
+
29
+ # maximum possible value
30
+ def max
31
+ @sides
32
+ end
33
+
34
+ # returns a GamesDice::Probabilities object that models distribution of the die
35
+ def probabilities
36
+ return @probabilities if @probabilities
37
+ @probabilities = GamesDice::Probabilities.for_fair_die( @sides )
38
+ end
39
+
40
+ # generates Integer between #min and #max, using rand()
41
+ def roll
42
+ if @prng
43
+ @result = @prng.rand(@sides) + 1
44
+ else
45
+ @result = rand(@sides) + 1
46
+ end
47
+ end
48
+
49
+ # always nil, available for compatibility with ComplexDie
50
+ def rerolls
51
+ nil
52
+ end
53
+
54
+ # always nil, available for compatibility with ComplexDie
55
+ def maps
56
+ nil
57
+ end
58
+ end # class Die
@@ -10,21 +10,9 @@
10
10
  class GamesDice::DieResult
11
11
  include Comparable
12
12
 
13
- # allowed reasons for making a roll, and symbol to use before number in #explain
14
- REASONS = {
15
- :basic => ',',
16
- :reroll_add => '+',
17
- :reroll_new_die => '*', # TODO: This needs to be flagged *before* value, and maybe linked to cause
18
- :reroll_new_keeper => '*',
19
- :reroll_subtract => '-',
20
- :reroll_replace => '|',
21
- :reroll_use_best => '/',
22
- :reroll_use_worst => '\\',
23
- }
24
-
25
13
  # first_roll_result is optional value of first roll of the die
26
14
  def initialize( first_roll_result=nil, first_roll_reason=:basic )
27
- unless REASONS.has_key?(first_roll_reason)
15
+ unless GamesDice::REROLL_TYPES.has_key?(first_roll_reason)
28
16
  raise ArgumentError, "Unrecognised reason for roll #{first_roll_reason}"
29
17
  end
30
18
 
@@ -63,7 +51,7 @@ class GamesDice::DieResult
63
51
  # roll_reason is an optional symbol description of why the roll was made
64
52
  # #total and #value are calculated based on roll_reason
65
53
  def add_roll( roll_result, roll_reason=:basic )
66
- unless REASONS.has_key?(roll_reason)
54
+ unless GamesDice::REROLL_TYPES.has_key?(roll_reason)
67
55
  raise ArgumentError, "Unrecognised reason for roll #{roll_reason}"
68
56
  end
69
57
  @rolls << Integer(roll_result)
@@ -109,7 +97,7 @@ class GamesDice::DieResult
109
97
  text = @total.to_s
110
98
  else
111
99
  text = '[' + @rolls[0].to_s
112
- text = (1..@rolls.length-1).inject( text ) { |so_far,i| so_far + REASONS[@roll_reasons[i]] + @rolls[i].to_s }
100
+ text = (1..@rolls.length-1).inject( text ) { |so_far,i| so_far + GamesDice::REROLL_TYPES[@roll_reasons[i]] + @rolls[i].to_s }
113
101
  text += '] ' + @total.to_s
114
102
  end
115
103
  text += ' ' + @map_description if @mapped && @map_description && @map_description.length > 0
@@ -1,46 +1,44 @@
1
- module GamesDice
2
- # convert integer die result into value used in a game (e.g. 1 for a 'success')
3
- class MapRule
4
-
5
- # trigger_op, trigger_value, mapped_value and mapped_name set the attributes of the same name
6
- # rule = RPGMapRule.new( 6, :<=, 1, 'Success' ) # score 1 for a result of 6 or more
7
- def initialize trigger_value, trigger_op, mapped_value=0, mapped_name=''
8
-
9
- if ! trigger_value.respond_to?( trigger_op )
10
- raise ArgumentError, "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}"
11
- end
12
-
13
- @trigger_value = trigger_value
14
- @trigger_op = trigger_op
15
- raise TypeError if ! mapped_value.is_a? Numeric
16
- @mapped_value = Integer(mapped_value)
17
- @mapped_name = mapped_name.to_s
1
+ # helps model complex dice systems such as "count number of dice showing X or more"
2
+ class GamesDice::MapRule
3
+
4
+ # trigger_op, trigger_value, mapped_value and mapped_name set the attributes of the same name
5
+ # rule = RPGMapRule.new( 6, :<=, 1, 'Success' ) # score 1 for a result of 6 or more
6
+ def initialize trigger_value, trigger_op, mapped_value=0, mapped_name=''
7
+
8
+ if ! trigger_value.respond_to?( trigger_op )
9
+ raise ArgumentError, "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}"
18
10
  end
19
11
 
20
- # an Integer value or Range that will be mapped to a single value. #trigger_op is called against it
21
- attr_reader :trigger_value
22
-
23
- # a valid symbol for a method, which will be called against #trigger_value with the current
24
- # die result as a param. If the operator returns true for a specific die result, then the
25
- # mapped_value will be used in its stead. If the operator returns nil or false, the map is not
26
- # triggered. All other values will be returned as the result of the map (allowing you to
27
- # specify any method that takes an integer as input and returns something else as the end result)
28
- attr_reader :trigger_op
29
-
30
- # an integer value
31
- attr_reader :mapped_value
32
-
33
- # a string description of the mapping, e.g. 'S' for a success
34
- attr_reader :mapped_name
35
-
36
- # runs the rule against test_value, returning either a new value, or nil if the rule does not apply
37
- def map_from test_value
38
- op_result = @trigger_value.send( @trigger_op, test_value )
39
- return nil unless op_result
40
- if op_result == true
41
- return @mapped_value
42
- end
43
- return op_result
12
+ @trigger_value = trigger_value
13
+ @trigger_op = trigger_op
14
+ raise TypeError if ! mapped_value.is_a? Numeric
15
+ @mapped_value = Integer(mapped_value)
16
+ @mapped_name = mapped_name.to_s
17
+ end
18
+
19
+ # an Integer value or Range that will be mapped to a single value. #trigger_op is called against it
20
+ attr_reader :trigger_value
21
+
22
+ # a valid symbol for a method, which will be called against #trigger_value with the current
23
+ # die result as a param. If the operator returns true for a specific die result, then the
24
+ # mapped_value will be used in its stead. If the operator returns nil or false, the map is not
25
+ # triggered. All other values will be returned as the result of the map (allowing you to
26
+ # specify any method that takes an integer as input and returns something else as the end result)
27
+ attr_reader :trigger_op
28
+
29
+ # an integer value
30
+ attr_reader :mapped_value
31
+
32
+ # a string description of the mapping, e.g. 'S' for a success
33
+ attr_reader :mapped_name
34
+
35
+ # runs the rule against test_value, returning either a new value, or nil if the rule does not apply
36
+ def map_from test_value
37
+ op_result = @trigger_value.send( @trigger_op, test_value )
38
+ return nil unless op_result
39
+ if op_result == true
40
+ return @mapped_value
44
41
  end
45
- end # class MapRule
46
- end # module GamesDice
42
+ return op_result
43
+ end
44
+ end # class MapRule
@@ -0,0 +1,97 @@
1
+ # utility class for calculating with probabilities for reuslts from GamesDice objects
2
+ class GamesDice::Probabilities
3
+ # prob_hash is a Hash with each key as an Integer, and the associated value being the probability
4
+ # of getting that value. It is not validated. Avoid using the default constructor if
5
+ # one of the factory methods or calculation methods already does what you need.
6
+ def initialize( prob_hash = { 0 => 1.0 } )
7
+ # This should *probably* be validated in future
8
+ @ph = prob_hash
9
+ end
10
+
11
+ # the Hash representation of probabilities. TODO: Hide this from public interface, but make it available
12
+ # to factory methods
13
+ attr_reader :ph
14
+
15
+ # a clone of probability data (as provided to constructor), safe to pass to methods that modify in place
16
+ def to_h
17
+ @ph.clone
18
+ end
19
+
20
+ def min
21
+ (@minmax ||= @ph.keys.minmax )[0]
22
+ end
23
+
24
+ def max
25
+ (@minmax ||= @ph.keys.minmax )[1]
26
+ end
27
+
28
+ # returns mean expected value as a Float
29
+ def expected
30
+ @expected ||= @ph.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
31
+ end
32
+
33
+ # returns Float probability fram Range (0.0..1.0) that a value chosen from the distribution will
34
+ # be equal to target integer
35
+ def p_eql target
36
+ @ph[ Integer(target) ] || 0.0
37
+ end
38
+
39
+ # returns Float probability fram Range (0.0..1.0) that a value chosen from the distribution will
40
+ # be a number greater than target integer
41
+ def p_gt target
42
+ p_ge( Integer(target) + 1 )
43
+ end
44
+
45
+ # returns Float probability fram Range (0.0..1.0) that a value chosen from the distribution will
46
+ # be a number greater than or equal to target integer
47
+ def p_ge target
48
+ target = Integer(target)
49
+ return @prob_ge[target] if @prob_ge && @prob_ge[target]
50
+ @prob_ge = {} unless @prob_ge
51
+
52
+ return 1.0 if target <= min
53
+ return 0.0 if target > max
54
+ @prob_ge[target] = @ph.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
55
+ end
56
+
57
+ # returns probability than a roll will produce a number less than or equal to target integer
58
+ def p_le target
59
+ target = Integer(target)
60
+ return @prob_le[target] if @prob_le && @prob_le[target]
61
+ @prob_le = {} unless @prob_le
62
+
63
+ return 1.0 if target >= max
64
+ return 0.0 if target < min
65
+ @prob_le[target] = @ph.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
66
+ end
67
+
68
+ # returns probability than a roll will produce a number less than target integer
69
+ def p_lt target
70
+ p_le( Integer(target) - 1 )
71
+ end
72
+
73
+ # constructor returns probability distrubution for a simple fair die
74
+ def self.for_fair_die sides
75
+ sides = Integer(sides)
76
+ raise ArgumentError, "sides must be at least 1" unless sides > 0
77
+ h = {}
78
+ p = 1.0/sides
79
+ (1..sides).each { |x| h[x] = p }
80
+ GamesDice::Probabilities.new( h )
81
+ end
82
+
83
+ # adding two probability distributions calculates a new distribution, representing what would
84
+ # happen if you created a random number using the sum of numbers from both distributions
85
+ def self.add_distributions pd_a, pd_b
86
+ h = {}
87
+ pd_a.ph.each do |ka,pa|
88
+ pd_b.ph.each do |kb,pb|
89
+ kc = ka + kb
90
+ pc = pa * pb
91
+ h[kc] = h[kc] ? h[kc] + pc : pc
92
+ end
93
+ end
94
+ GamesDice::Probabilities.new( h )
95
+ end
96
+
97
+ end # class Dice
@@ -1,64 +1,47 @@
1
- module GamesDice
1
+ # specifies when and how a ComplexDie should be re-rolled
2
+ class GamesDice::RerollRule
2
3
 
3
- # specifies when and how a ComplexDie should be re-rolled
4
- class RerollRule
4
+ # trigger_op, trigger_value, type and limit set the attributes of the same name
5
+ # rule = GamesDice::RerollRule.new( 10, :<=, :reroll_add ) # an 'exploding' die
6
+ def initialize trigger_value, trigger_op, type, limit=nil
5
7
 
6
- # allowed reasons for making a reroll
7
- TYPES = {
8
- :reroll_add => '+',
9
- :reroll_new_die => '*',
10
- :reroll_new_keeper => '*',
11
- :reroll_subtract => '-',
12
- :reroll_replace => '|',
13
- :reroll_use_best => '/',
14
- :reroll_use_worst => '\\',
15
- }
16
-
17
- # trigger_op, trigger_value, type and limit set the attributes of the same name
18
- # rule = GamesDice::RerollRule.new( 10, :<=, :reroll_add ) # an 'exploding' die
19
- def initialize trigger_value, trigger_op, type, limit=nil
20
-
21
- if ! trigger_value.respond_to?( trigger_op )
22
- raise ArgumentError, "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}"
23
- end
24
-
25
- unless TYPES.has_key?(type)
26
- raise ArgumentError, "Unrecognised reason for a re-roll #{type}"
27
- end
28
-
29
- @trigger_value = trigger_value
30
- @trigger_op = trigger_op
31
- @type = type
32
- @limit = limit ? Integer(limit) : 1000
33
- @limit = 1 if @type == :reroll_subtract
8
+ if ! trigger_value.respond_to?( trigger_op )
9
+ raise ArgumentError, "trigger_value #{trigger_value.inspect} cannot respond to trigger_op #{trigger_value.inspect}"
34
10
  end
35
11
 
36
- # a valid symbol for a method, which will be called against #trigger_value with the current
37
- # die result as a param. It should return true or false
38
- attr_reader :trigger_op
39
-
40
- # an Integer value or Range that will cause the reroll to occur. #trigger_op is called against it
41
- attr_reader :trigger_value
42
-
43
- # a symbol, should be one of the following:
44
- # :reroll_add - add result of reroll to running total, and ignore :reroll_subtract for this die
45
- # :reroll_new_die - roll a new die of the same type
46
- # :reroll_new_keeper - roll a new die of the same type, and keep the result
47
- # :reroll_subtract - subtract result of reroll from running total, and reverse sense of any further :reroll_add results
48
- # :reroll_replace - use the new value in place of existing value for the die
49
- # :reroll_use_best - use the new value if it is higher than the erxisting value
50
- # :reroll_use_worst - use the new value if it is higher than the existing value
51
- attr_reader :type
52
-
53
- # maximum number of times this rule should be applied to a single die. If type is:reroll_subtract,
54
- # this value is always 1. A default value of 100 is used if not set in the constructor
55
- attr_reader :limit
56
-
57
- # runs the rule against a test value, returning truth value from calling the trigger_op method
58
- def applies? test_value
59
- @trigger_value.send( @trigger_op, test_value ) ? true : false
12
+ unless GamesDice::REROLL_TYPES.has_key?(type)
13
+ raise ArgumentError, "Unrecognised reason for a re-roll #{type}"
60
14
  end
61
15
 
62
- end # class RerollRule
63
-
64
- end # module GamesDice
16
+ @trigger_value = trigger_value
17
+ @trigger_op = trigger_op
18
+ @type = type
19
+ @limit = limit ? Integer(limit) : 1000
20
+ @limit = 1 if @type == :reroll_subtract
21
+ end
22
+
23
+ # a valid symbol for a method, which will be called against #trigger_value with the current
24
+ # die result as a param. It should return true or false
25
+ attr_reader :trigger_op
26
+
27
+ # an Integer value or Range that will cause the reroll to occur. #trigger_op is called against it
28
+ attr_reader :trigger_value
29
+
30
+ # a symbol, should be one of the following:
31
+ # :reroll_add - add result of reroll to running total, and ignore :reroll_subtract for this die
32
+ # :reroll_subtract - subtract result of reroll from running total, and reverse sense of any further :reroll_add results
33
+ # :reroll_replace - use the new value in place of existing value for the die
34
+ # :reroll_use_best - use the new value if it is higher than the erxisting value
35
+ # :reroll_use_worst - use the new value if it is higher than the existing value
36
+ attr_reader :type
37
+
38
+ # maximum number of times this rule should be applied to a single die. If type is:reroll_subtract,
39
+ # this value is always 1. A default value of 100 is used if not set in the constructor
40
+ attr_reader :limit
41
+
42
+ # runs the rule against a test value, returning truth value from calling the trigger_op method
43
+ def applies? test_value
44
+ @trigger_value.send( @trigger_op, test_value ) ? true : false
45
+ end
46
+
47
+ end # class RerollRule