lib/games_dice/bunch.rb in games_dice-0.0.3 vs lib/games_dice/bunch.rb in games_dice-0.0.5
- old
+ new
@@ -1,367 +1,322 @@
-module GamesDice
+# models a set of identical dice, that can be "rolled" and combined into a simple integer result. The
+# dice are identical in number of sides, and any re-roll or mapping rules that apply to them
+class GamesDice::Bunch
+ # attributes is a hash of symbols used to set attributes of the new Bunch object. Each
+ # attribute is explained in more detail in its own section. The following hash keys and values
+ # are mandatory:
+ # :ndice
+ # :sides
+ # The following are optional, and modify the behaviour of the Bunch object
+ # :name
+ # :prng
+ # :rerolls
+ # :maps
+ # :keep_mode
+ # :keep_number
+ # Any other keys provided to the constructor are ignored
+ def initialize( attributes )
+ @name = attributes[:name].to_s
+ @ndice = Integer(attributes[:ndice])
+ raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
+ @sides = Integer(attributes[:sides])
+ raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
- # models a set of identical dice, that can be "rolled" and combined into a simple integer result. The
- # dice are identical in number of sides, and any rolling rules that apply to them
- class Bunch
- # attributes is a hash of symbols used to set attributes of the new Bunch object. Each
- # attribute is explained in more detail in its own section. The following hash keys and values
- # are mandatory:
- # :ndice
- # :sides
- # The following are optional, and modify the behaviour of the Bunch object
- # :name
- # :prng
- # :rerolls
- # :maps
- # :keep_mode
- # :keep_number
- # Any other keys provided to the constructor are ignored
- def initialize( attributes )
- @name = attributes[:name].to_s
- @ndice = Integer(attributes[:ndice])
- raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
- @sides = Integer(attributes[:sides])
- raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
+ options = Hash.new
- options = Hash.new
+ if attributes[:prng]
+ # We deliberately do not clone this object, it will often be intended that it is shared
+ prng = attributes[:prng]
+ raise ":prng does not support the rand() method" if ! prng.respond_to?(:rand)
+ end
- if attributes[:prng]
- # We deliberately do not clone this object, it will often be intended that it is shared
- prng = attributes[:prng]
- raise ":prng does not support the rand() method" if ! prng.respond_to?(:rand)
- end
+ needs_complex_die = false
- needs_complex_die = false
+ if attributes[:rerolls]
+ needs_complex_die = true
+ options[:rerolls] = attributes[:rerolls].clone
+ end
- if attributes[:rerolls]
- needs_complex_die = true
- options[:rerolls] = attributes[:rerolls].clone
- end
+ if attributes[:maps]
+ needs_complex_die = true
+ options[:maps] = attributes[:maps].clone
+ end
- if attributes[:maps]
- needs_complex_die = true
- options[:maps] = attributes[:maps].clone
- end
+ if needs_complex_die
+ options[:prng] = prng
+ @single_die = GamesDice::ComplexDie.new( @sides, options )
+ else
+ @single_die = GamesDice::Die.new( @sides, prng )
+ end
- if needs_complex_die
- options[:prng] = prng
- @single_die = GamesDice::ComplexDie.new( @sides, options )
- else
- @single_die = GamesDice::Die.new( @sides, prng )
- end
-
- case attributes[:keep_mode]
- when nil then
- @keep_mode = nil
- when :keep_best then
- @keep_mode = :keep_best
- @keep_number = Integer(attributes[:keep_number] || 1)
- when :keep_worst then
- @keep_mode = :keep_worst
- @keep_number = Integer(attributes[:keep_number] || 1)
- else
- raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{attributes[:keep_mode].inspect}"
- end
+ case attributes[:keep_mode]
+ when nil then
+ @keep_mode = nil
+ when :keep_best then
+ @keep_mode = :keep_best
+ @keep_number = Integer(attributes[:keep_number] || 1)
+ when :keep_worst then
+ @keep_mode = :keep_worst
+ @keep_number = Integer(attributes[:keep_number] || 1)
+ else
+ raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{attributes[:keep_mode].inspect}"
end
+ end
- # the string name as provided to the constructor, it will appear in explain_result
- attr_reader :name
+ # the string name as provided to the constructor, it will appear in explain_result
+ attr_reader :name
- # integer number of dice to roll (initially, before re-rolls etc)
- attr_reader :ndice
+ # integer number of dice to roll (initially, before re-rolls etc)
+ attr_reader :ndice
- # individual die that will be rolled, #ndice times, an GamesDice::Die or GamesDice::ComplexDie object.
- attr_reader :single_die
+ # individual die that will be rolled, #ndice times, an GamesDice::Die or GamesDice::ComplexDie object.
+ attr_reader :single_die
- # may be nil, :keep_best or :keep_worst
- attr_reader :keep_mode
+ # may be nil, :keep_best or :keep_worst
+ attr_reader :keep_mode
- # number of "best" or "worst" results to select when #keep_mode is not nil. This attribute is
- # 1 by default if :keep_mode is supplied, or nil by default otherwise.
- attr_reader :keep_number
+ # number of "best" or "worst" results to select when #keep_mode is not nil. This attribute is
+ # 1 by default if :keep_mode is supplied, or nil by default otherwise.
+ attr_reader :keep_number
- # after calling #roll, this is set to the final integer value from using the dice as specified
- attr_reader :result
+ # after calling #roll, this is set to the final integer value from using the dice as specified
+ attr_reader :result
- # either nil, or an array of GamesDice::RerollRule objects that are assessed on each roll of #single_die
- # Reroll types :reroll_new_die and :reroll_new_keeper do not affect the #single_die, but are instead
- # assessed in this container object
- def rerolls
- @single_die.rerolls
- end
+ # either nil, or an array of GamesDice::RerollRule objects that are assessed on each roll of #single_die
+ # Reroll types :reroll_new_die and :reroll_new_keeper do not affect the #single_die, but are instead
+ # assessed in this container object
+ def rerolls
+ @single_die.rerolls
+ end
- # either nil, or an array of GamesDice::MapRule objects that are assessed on each result of #single_die (after rerolls are completed)
- def maps
- @single_die.rerolls
- end
+ # either nil, or an array of GamesDice::MapRule objects that are assessed on each result of #single_die (after rerolls are completed)
+ def maps
+ @single_die.rerolls
+ end
- # after calling #roll, this is an array of GamesDice::DieResult objects, one from each #single_die rolled,
- # allowing inspection of how the result was obtained.
- def result_details
- return nil unless @raw_result_details
- @raw_result_details.map { |r| r.is_a?(Fixnum) ? GamesDice::DieResult.new(r) : r }
- end
+ # after calling #roll, this is an array of GamesDice::DieResult objects, one from each #single_die rolled,
+ # allowing inspection of how the result was obtained.
+ def result_details
+ return nil unless @raw_result_details
+ @raw_result_details.map { |r| r.is_a?(Fixnum) ? GamesDice::DieResult.new(r) : r }
+ end
- # minimum possible integer value
- def min
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
- return n * @single_die.min
- end
+ # minimum possible integer value
+ def min
+ n = @keep_mode ? [@keep_number,@ndice].min : @ndice
+ return n * @single_die.min
+ end
- # maximum possible integer value
- def max
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
- return n * @single_die.max
- end
+ # maximum possible integer value
+ def max
+ n = @keep_mode ? [@keep_number,@ndice].min : @ndice
+ return n * @single_die.max
+ end
- # returns a hash of value (Integer) => probability (Float) pairs. Warning: Some dice schemes
- # cause this method to take a long time, and use a lot of memory. The worst-case offenders are
- # dice schemes with a #keep_mode of :keep_best or :keep_worst.
- def probabilities
- return @probabilities if @probabilities
- @probabilities_complete = true
+ # returns a hash of value (Integer) => probability (Float) pairs. Warning: Some dice schemes
+ # cause this method to take a long time, and use a lot of memory. The worst-case offenders are
+ # dice schemes with a #keep_mode of :keep_best or :keep_worst.
+ def probabilities
+ return @probabilities if @probabilities
+ @probabilities_complete = true
- # TODO: It is possible to optimise this slightly by combining already-calculated values
- # Adding dice is same as multiplying probability sets for that number of dice
- # Combine(probabililities_3_dice, probabililities_single_die) == Combine(probabililities_2_dice, probabililities_2_dice)
- # It is possible to minimise the total number of multiplications, gaining about 30% efficiency, with careful choices
- single_roll_probs = @single_die.probabilities
- if @keep_mode && @ndice > @keep_number
- preadd_probs = {}
- single_roll_probs.each { |k,v| preadd_probs[k.to_s] = v }
+ # TODO: It is possible to optimise this slightly by combining already-calculated values
+ # Adding dice is same as multiplying probability sets for that number of dice
+ # Combine(probabililities_3_dice, probabililities_single_die) == Combine(probabililities_2_dice, probabililities_2_dice)
+ # It is possible to minimise the total number of multiplications, gaining about 30% efficiency, with careful choices
+ single_roll_probs = @single_die.probabilities.to_h
+ if @keep_mode && @ndice > @keep_number
+ preadd_probs = {}
+ single_roll_probs.each { |k,v| preadd_probs[k.to_s] = v }
- (@keep_number-1).times do
- preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs
- end
- extra_dice = @ndice - @keep_number
- extra_dice.times do
- preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs, @keep_mode
- end
- combined_probs = {}
- preadd_probs.each do |k,v|
- total = k.split(';').map { |s| s.to_i }.inject(:+)
- combined_probs[total] ||= 0.0
- combined_probs[total] += v
- end
- else
- combined_probs = single_roll_probs.clone
- (@ndice-1).times do
- combined_probs = prob_accumulate combined_probs, single_roll_probs
- end
+ (@keep_number-1).times do
+ preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs
end
-
- @probabilities = combined_probs
- @probabilities_min, @probabilities_max = @probabilities.keys.minmax
- @prob_ge = {}
- @prob_le = {}
- @probabilities
+ extra_dice = @ndice - @keep_number
+ extra_dice.times do
+ preadd_probs = prob_accumulate_combinations preadd_probs, single_roll_probs, @keep_mode
+ end
+ combined_probs = {}
+ preadd_probs.each do |k,v|
+ total = k.split(';').map { |s| s.to_i }.inject(:+)
+ combined_probs[total] ||= 0.0
+ combined_probs[total] += v
+ end
+ else
+ combined_probs = single_roll_probs.clone
+ (@ndice-1).times do
+ combined_probs = prob_accumulate combined_probs, single_roll_probs
+ end
end
- # returns probability than a roll will produce a number greater than target integer
- def probability_gt target
- probability_ge( Integer(target) + 1 )
- end
+ @probabilities_min, @probabilities_max = combined_probs.keys.minmax
+ @probabilities = GamesDice::Probabilities.new( combined_probs )
+ end
- # returns probability than a roll will produce a number greater than or equal to target integer
- def probability_ge target
- target = Integer(target)
- return @prob_ge[target] if @prob_ge && @prob_ge[target]
+ # simulate dice roll according to spec. Returns integer final total, and also stores it in #result
+ def roll
+ @result = 0
+ @raw_result_details = []
- # Force caching if not already done
- probabilities
- return 1.0 if target <= @probabilities_min
- return 0.0 if target > @probabilities_max
- @prob_ge[target] = probabilities.select {|k,v| target <= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
+ @ndice.times do
+ @result += @single_die.roll
+ @raw_result_details << @single_die.result
end
- # returns probability than a roll will produce a number less than or equal to target integer
- def probability_le target
- target = Integer(target)
- return @prob_le[target] if @prob_le && @prob_le[target]
-
- # Force caching of probability table if not already done
- probabilities
- return 1.0 if target >= @probabilities_max
- return 0.0 if target < @probabilities_min
- @prob_le[target] = probabilities.select {|k,v| target >= k}.inject(0.0) {|so_far,pv| so_far + pv[1] }
+ if ! @keep_mode
+ return @result
end
- # returns probability than a roll will produce a number less than target integer
- def probability_lt target
- probability_le( Integer(target) - 1 )
+ use_dice = if @keep_mode && @keep_number < @ndice
+ case @keep_mode
+ when :keep_best then @raw_result_details.sort[-@keep_number..-1]
+ when :keep_worst then @raw_result_details.sort[0..(@keep_number-1)]
+ end
+ else
+ @raw_result_details
end
- # returns mean expected value as a Float
- def expected_result
- @expected_result ||= probabilities.inject(0.0) { |accumulate,p| accumulate + p[0] * p[1] }
- end
+ @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
+ end
- # simulate dice roll according to spec. Returns integer final total, and also stores it in #result
- def roll
- @result = 0
- @raw_result_details = []
+ def explain_result
+ return nil unless @result
- @ndice.times do
- @result += @single_die.roll
- @raw_result_details << @single_die.result
- end
+ explanation = ''
- if ! @keep_mode
- return @result
- end
+ # With #keep_mode, we may need to show unused and used dice separately
+ used_dice = result_details
+ unused_dice = []
- use_dice = if @keep_mode && @keep_number < @ndice
- case @keep_mode
- when :keep_best then @raw_result_details.sort[-@keep_number..-1]
- when :keep_worst then @raw_result_details.sort[0..(@keep_number-1)]
- end
- else
- @raw_result_details
+ # Pick highest numbers and their associated details
+ if @keep_mode && @keep_number < @ndice
+ full_dice = result_details.sort_by { |die_result| die_result.total }
+ case @keep_mode
+ when :keep_best then
+ used_dice = full_dice[-@keep_number..-1]
+ unused_dice = full_dice[0..full_dice.length-1-@keep_number]
+ when :keep_worst then
+ used_dice = full_dice[0..(@keep_number-1)]
+ unused_dice = full_dice[@keep_number..(full_dice.length-1)]
end
-
- @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
end
- def explain_result
- return nil unless @result
-
- explanation = ''
-
- # With #keep_mode, we may need to show unused and used dice separately
- used_dice = result_details
- unused_dice = []
-
- # Pick highest numbers and their associated details
- if @keep_mode && @keep_number < @ndice
- full_dice = result_details.sort_by { |die_result| die_result.total }
- case @keep_mode
- when :keep_best then
- used_dice = full_dice[-@keep_number..-1]
- unused_dice = full_dice[0..full_dice.length-1-@keep_number]
- when :keep_worst then
- used_dice = full_dice[0..(@keep_number-1)]
- unused_dice = full_dice[@keep_number..(full_dice.length-1)]
- end
+ # Show unused dice (if any)
+ if @keep_mode || @single_die.maps
+ explanation += result_details.map do |die_result|
+ die_result.explain_value
+ end.join(', ')
+ if @keep_mode
+ separator = @single_die.maps ? ', ' : ' + '
+ explanation += ". Keep: " + used_dice.map do |die_result|
+ die_result.explain_total
+ end.join( separator )
end
-
- # Show unused dice (if any)
- if @keep_mode || @single_die.maps
- explanation += result_details.map do |die_result|
- die_result.explain_value
- end.join(', ')
- if @keep_mode
- separator = @single_die.maps ? ', ' : ' + '
- explanation += ". Keep: " + used_dice.map do |die_result|
- die_result.explain_total
- end.join( separator )
- end
- if @single_die.maps
- explanation += ". Successes: #{@result}"
- end
- explanation += " = #{@result}" if @keep_mode && ! @single_die.maps && @keep_number > 1
- else
- explanation += used_dice.map do |die_result|
- die_result.explain_value
- end.join(' + ')
- explanation += " = #{@result}" if @ndice > 1
+ if @single_die.maps
+ explanation += ". Successes: #{@result}"
end
-
- explanation
+ explanation += " = #{@result}" if @keep_mode && ! @single_die.maps && @keep_number > 1
+ else
+ explanation += used_dice.map do |die_result|
+ die_result.explain_value
+ end.join(' + ')
+ explanation += " = #{@result}" if @ndice > 1
end
- private
+ explanation
+ end
- # combines two sets of probabilities where the end result is the first set of keys plus
- # the second set of keys, at the associated probailities of the values
- def prob_accumulate first_probs, second_probs
- accumulator = Hash.new
+ private
- first_probs.each do |v1,p1|
- second_probs.each do |v2,p2|
- v3 = v1 + v2
- p3 = p1 * p2
- accumulator[v3] ||= 0.0
- accumulator[v3] += p3
- end
- end
+ # combines two sets of probabilities where the end result is the first set of keys plus
+ # the second set of keys, at the associated probailities of the values
+ def prob_accumulate first_probs, second_probs
+ accumulator = Hash.new
- accumulator
+ first_probs.each do |v1,p1|
+ second_probs.each do |v2,p2|
+ v3 = v1 + v2
+ p3 = p1 * p2
+ accumulator[v3] ||= 0.0
+ accumulator[v3] += p3
+ end
end
- # combines two sets of probabilities, as above, except tracking unique permutations
- def prob_accumulate_combinations so_far, die_probs, keep_rule = nil
- accumulator = Hash.new
+ accumulator
+ end
- so_far.each do |sig,p1|
- combo = sig.split(';').map { |s| s.to_i }
+ # combines two sets of probabilities, as above, except tracking unique permutations
+ def prob_accumulate_combinations so_far, die_probs, keep_rule = nil
+ accumulator = Hash.new
- case keep_rule
- when nil then
- die_probs.each do |v2,p2|
- new_sig = (combo + [v2]).sort.join(';')
- p3 = p1 * p2
- accumulator[new_sig] ||= 0.0
- accumulator[new_sig] += p3
+ so_far.each do |sig,p1|
+ combo = sig.split(';').map { |s| s.to_i }
+
+ case keep_rule
+ when nil then
+ die_probs.each do |v2,p2|
+ new_sig = (combo + [v2]).sort.join(';')
+ p3 = p1 * p2
+ accumulator[new_sig] ||= 0.0
+ accumulator[new_sig] += p3
+ end
+ when :keep_best then
+ need_more_than = combo.min
+ die_probs.each do |v2,p2|
+ if v2 > need_more_than
+ new_sig = (combo + [v2]).sort[1..combo.size].join(';')
+ else
+ new_sig = sig
end
- when :keep_best then
- need_more_than = combo.min
- die_probs.each do |v2,p2|
- if v2 > need_more_than
- new_sig = (combo + [v2]).sort[1..combo.size].join(';')
- else
- new_sig = sig
- end
- p3 = p1 * p2
- accumulator[new_sig] ||= 0.0
- accumulator[new_sig] += p3
+ p3 = p1 * p2
+ accumulator[new_sig] ||= 0.0
+ accumulator[new_sig] += p3
+ end
+ when :keep_worst then
+ need_less_than = combo.max
+ die_probs.each do |v2,p2|
+ if v2 < need_less_than
+ new_sig = (combo + [v2]).sort[0..(combo.size-1)].join(';')
+ else
+ new_sig = sig
end
- when :keep_worst then
- need_less_than = combo.max
- die_probs.each do |v2,p2|
- if v2 < need_less_than
- new_sig = (combo + [v2]).sort[0..(combo.size-1)].join(';')
- else
- new_sig = sig
- end
- p3 = p1 * p2
- accumulator[new_sig] ||= 0.0
- accumulator[new_sig] += p3
- end
+ p3 = p1 * p2
+ accumulator[new_sig] ||= 0.0
+ accumulator[new_sig] += p3
end
end
-
- accumulator
end
- # Generates all sets of [throw_away,may_keep_exactly,keep_preferentially,combinations] that meet
- # criteria for correct total number of dice and keep dice. These then need to be assessed for every
- # die value by the caller to get a full set of probabilities
- def generate_item_counts total_dice, keep_dice
- # Constraints are:
- # may_keep_exactly must be at least 1, and at most is all the dice
- # keep_preferentially plus may_keep_exactly must be >= keep_dice, but keep_preferentially < keep dice
- # sum of all three always == total_dice
- item_counts = []
- (1..total_dice).each do |may_keep_exactly|
- min_kp = [keep_dice - may_keep_exactly, 0].max
- max_kp = [keep_dice - 1, total_dice - may_keep_exactly].min
- (min_kp..max_kp).each do |keep_preferentially|
- counts = [ total_dice - may_keep_exactly - keep_preferentially, may_keep_exactly, keep_preferentially ]
- counts << combinations(counts)
- item_counts << counts
- end
+ accumulator
+ end
+
+ # Generates all sets of [throw_away,may_keep_exactly,keep_preferentially,combinations] that meet
+ # criteria for correct total number of dice and keep dice. These then need to be assessed for every
+ # die value by the caller to get a full set of probabilities
+ def generate_item_counts total_dice, keep_dice
+ # Constraints are:
+ # may_keep_exactly must be at least 1, and at most is all the dice
+ # keep_preferentially plus may_keep_exactly must be >= keep_dice, but keep_preferentially < keep dice
+ # sum of all three always == total_dice
+ item_counts = []
+ (1..total_dice).each do |may_keep_exactly|
+ min_kp = [keep_dice - may_keep_exactly, 0].max
+ max_kp = [keep_dice - 1, total_dice - may_keep_exactly].min
+ (min_kp..max_kp).each do |keep_preferentially|
+ counts = [ total_dice - may_keep_exactly - keep_preferentially, may_keep_exactly, keep_preferentially ]
+ counts << combinations(counts)
+ item_counts << counts
end
- item_counts
end
+ item_counts
+ end
- # How many unique ways can a set of items, some of which are identical, be arranged?
- def combinations item_counts
- item_counts = item_counts.map { |i| Integer(i) }.select { |i| i > 0 }
- total_items = item_counts.inject(:+)
- numerator = 1.upto(total_items).inject(:*)
- denominator = item_counts.map { |i| 1.upto(i).inject(:*) }.inject(:*)
- numerator / denominator
- end
+ # How many unique ways can a set of items, some of which are identical, be arranged?
+ def combinations item_counts
+ item_counts = item_counts.map { |i| Integer(i) }.select { |i| i > 0 }
+ total_items = item_counts.inject(:+)
+ numerator = 1.upto(total_items).inject(:*)
+ denominator = item_counts.map { |i| 1.upto(i).inject(:*) }.inject(:*)
+ numerator / denominator
+ end
- end # class Bunch
-end # module GamesDice
+end # class Bunch