games_dice 0.0.3 → 0.0.5

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