lib/games_dice/bunch.rb in games_dice-0.3.12 vs lib/games_dice/bunch.rb in games_dice-0.4.0
- old
+ new
@@ -1,247 +1,241 @@
-# This class models a number of identical dice, which may be either GamesDice::Die or
-# GamesDice::ComplexDie objects.
-#
-# An object of this class represents a fixed number of indentical dice that may be rolled and their
-# values summed to make a total for the bunch.
-#
-# @example The ubiquitous '3d6'
-# d = GamesDice::Bunch.new( :ndice => 3, :sides => 6 )
-# d.roll # => 14
-# d.result # => 14
-# d.explain_result # => "2 + 6 + 6 = 14"
-# d.max # => 18
-#
-# @example Roll 5d10, and keep the best 2
-# d = GamesDice::Bunch.new( :ndice => 5, :sides => 10 , :keep_mode => :keep_best, :keep_number => 2 )
-# d.roll # => 18
-# d.result # => 18
-# d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
-#
-
-class GamesDice::Bunch
- # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
- # and decides which of those classes to instantiate.
- # @param [Hash] options
- # @option options [Integer] :ndice Number of dice in the bunch, *mandatory*
- # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
- # @option options [String] :name Optional name for the bunch
- # @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
- # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
- # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
- # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
- # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
- # @return [GamesDice::Bunch]
- def initialize( options )
- name_number_sides_from_hash( options )
- keep_mode_from_hash( options )
-
- if options[:prng]
- raise ":prng does not support the rand() method" if ! options[:prng].respond_to?(:rand)
- end
-
- if options[:rerolls] || options[:maps]
- @single_die = GamesDice::ComplexDie.new( @sides, complex_die_params_from_hash( options ) )
- else
- @single_die = GamesDice::Die.new( @sides, options[:prng] )
- end
- end
-
- # Name to help identify bunch
- # @return [String]
- attr_reader :name
-
- # Number of dice to roll
- # @return [Integer]
- attr_reader :ndice
-
- # Individual die from the bunch
- # @return [GamesDice::Die,GamesDice::ComplexDie]
- attr_reader :single_die
-
- # Can be nil, :keep_best or :keep_worst
- # @return [Symbol,nil]
- attr_reader :keep_mode
-
- # Number of "best" or "worst" results to select when #keep_mode is not nil.
- # @return [Integer,nil]
- attr_reader :keep_number
-
- # Result of most-recent roll, or nil if no roll made yet.
- # @return [Integer,nil]
- attr_reader :result
-
- # @!attribute [r] label
- # Description that will be used in explanations with more than one bunch
- # @return [String]
- def label
- return @name if @name != ''
- return @ndice.to_s + 'd' + @sides.to_s
- end
-
- # @!attribute [r] rerolls
- # Sequence of re-roll rules, or nil if re-rolls are not required.
- # @return [Array<GamesDice::RerollRule>, nil]
- def rerolls
- @single_die.rerolls
- end
-
- # @!attribute [r] maps
- # Sequence of map rules, or nil if mapping is not required.
- # @return [Array<GamesDice::MapRule>, nil]
- def maps
- @single_die.maps
- end
-
- # @!attribute [r] result_details
- # After calling #roll, this is an array of GamesDice::DieResult objects. There is one from each #single_die rolled,
- # allowing inspection of how the result was obtained.
- # @return [Array<GamesDice::DieResult>, nil] Sequence of GamesDice::DieResult objects.
- def result_details
- return nil unless @raw_result_details
- @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r }
- end
-
- # @!attribute [r] min
- # Minimum possible result from a call to #roll
- # @return [Integer]
- def min
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
- return n * @single_die.min
- end
-
- # @!attribute [r] max
- # Maximum possible result from a call to #roll
- # @return [Integer]
- def max
- n = @keep_mode ? [@keep_number,@ndice].min : @ndice
- return n * @single_die.max
- end
-
- # Calculates the probability distribution for the bunch. When the bunch is composed of dice with
- # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
- # recursion.
- # @return [GamesDice::Probabilities] Probability distribution of bunch.
- def probabilities
- return @probabilities if @probabilities
-
- if @keep_mode && @ndice > @keep_number
- @probabilities = @single_die.probabilities.repeat_n_sum_k( @ndice, @keep_number, @keep_mode )
- else
- @probabilities = @single_die.probabilities.repeat_sum( @ndice )
- end
-
- return @probabilities
- end
-
- # Simulates rolling the bunch of identical dice
- # @return [Integer] Sum of all rolled dice, or sum of all keepers
- def roll
- @result = 0
- @raw_result_details = []
-
- @ndice.times do
- @result += @single_die.roll
- @raw_result_details << @single_die.result
- end
-
- if ! @keep_mode
- return @result
- end
-
- 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
-
- @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
- end
-
- # @!attribute [r] explain_result
- # Explanation of result, or nil if no call to #roll yet.
- # @return [String,nil]
- 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
- 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
- end
-
- explanation
- end
-
- private
-
- def name_number_sides_from_hash options
- @name = options[:name].to_s
- @ndice = Integer(options[:ndice])
- raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice > 0
- @sides = Integer(options[:sides])
- raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides > 0
- end
-
- def keep_mode_from_hash options
- case options[:keep_mode]
- when nil then
- @keep_mode = nil
- when :keep_best then
- @keep_mode = :keep_best
- @keep_number = Integer(options[:keep_number] || 1)
- when :keep_worst then
- @keep_mode = :keep_worst
- @keep_number = Integer(options[:keep_number] || 1)
- else
- raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
- end
- end
-
- def complex_die_params_from_hash options
- cd_hash = Hash.new
- [:maps,:rerolls].each do |k|
- cd_hash[k] = options[k].clone if options[k]
- end
- # We deliberately do not clone this object, it will often be intended that it is shared
- cd_hash[:prng] = options[:prng]
- cd_hash
- end
-end # class Bunch
+# frozen_string_literal: true
+
+module GamesDice
+ # This class models a number of identical dice, which may be either GamesDice::Die or
+ # GamesDice::ComplexDie objects.
+ #
+ # An object of this class represents a fixed number of indentical dice that may be rolled and their
+ # values summed to make a total for the bunch.
+ #
+ # @example The ubiquitous '3d6'
+ # d = GamesDice::Bunch.new( :ndice => 3, :sides => 6 )
+ # d.roll # => 14
+ # d.result # => 14
+ # d.explain_result # => "2 + 6 + 6 = 14"
+ # d.max # => 18
+ #
+ # @example Roll 5d10, and keep the best 2
+ # d = GamesDice::Bunch.new( :ndice => 5, :sides => 10 , :keep_mode => :keep_best, :keep_number => 2 )
+ # d.roll # => 18
+ # d.result # => 18
+ # d.explain_result # => "4, 9, 2, 9, 1. Keep: 9 + 9 = 18"
+ #
+ class Bunch
+ # The constructor accepts parameters that are suitable for either GamesDice::Die or GamesDice::ComplexDie
+ # and decides which of those classes to instantiate.
+ # @param [Hash] options
+ # @option options [Integer] :ndice Number of dice in the bunch, *mandatory*
+ # @option options [Integer] :sides Number of sides on a single die in the bunch, *mandatory*
+ # @option options [String] :name Optional name for the bunch
+ # @option options [Array<GamesDice::RerollRule,Array>] :rerolls Optional rules that cause the die to roll again
+ # @option options [Array<GamesDice::MapRule,Array>] :maps Optional rules to convert a value into a final result for the die
+ # @option options [#rand] :prng Optional alternative source of randomness to Ruby's built-in #rand, passed to GamesDice::Die's constructor
+ # @option options [Symbol] :keep_mode Optional, either *:keep_best* or *:keep_worst*
+ # @option options [Integer] :keep_number Optional number of dice to keep when :keep_mode is not nil
+ # @return [GamesDice::Bunch]
+ def initialize(options)
+ name_number_sides_from_hash(options)
+ keep_mode_from_hash(options)
+
+ raise ':prng does not support the rand() method' if options[:prng] && !options[:prng].respond_to?(:rand)
+
+ @single_die = if options[:rerolls] || options[:maps]
+ GamesDice::ComplexDie.new(@sides, complex_die_params_from_hash(options))
+ else
+ GamesDice::Die.new(@sides, options[:prng])
+ end
+ end
+
+ # Name to help identify bunch
+ # @return [String]
+ attr_reader :name
+
+ # Number of dice to roll
+ # @return [Integer]
+ attr_reader :ndice
+
+ # Individual die from the bunch
+ # @return [GamesDice::Die,GamesDice::ComplexDie]
+ attr_reader :single_die
+
+ # Can be nil, :keep_best or :keep_worst
+ # @return [Symbol,nil]
+ attr_reader :keep_mode
+
+ # Number of "best" or "worst" results to select when #keep_mode is not nil.
+ # @return [Integer,nil]
+ attr_reader :keep_number
+
+ # Result of most-recent roll, or nil if no roll made yet.
+ # @return [Integer,nil]
+ attr_reader :result
+
+ # @!attribute [r] label
+ # Description that will be used in explanations with more than one bunch
+ # @return [String]
+ def label
+ return @name if @name != ''
+
+ "#{@ndice}d#{@sides}"
+ end
+
+ # @!attribute [r] rerolls
+ # Sequence of re-roll rules, or nil if re-rolls are not required.
+ # @return [Array<GamesDice::RerollRule>, nil]
+ def rerolls
+ @single_die.rerolls
+ end
+
+ # @!attribute [r] maps
+ # Sequence of map rules, or nil if mapping is not required.
+ # @return [Array<GamesDice::MapRule>, nil]
+ def maps
+ @single_die.maps
+ end
+
+ # @!attribute [r] result_details
+ # After calling #roll, this is an array of GamesDice::DieResult objects. There is one from each #single_die rolled,
+ # allowing inspection of how the result was obtained.
+ # @return [Array<GamesDice::DieResult>, nil] Sequence of GamesDice::DieResult objects.
+ def result_details
+ return nil unless @raw_result_details
+
+ @raw_result_details.map { |r| r.is_a?(Integer) ? GamesDice::DieResult.new(r) : r }
+ end
+
+ # @!attribute [r] min
+ # Minimum possible result from a call to #roll
+ # @return [Integer]
+ def min
+ n = @keep_mode ? [@keep_number, @ndice].min : @ndice
+ n * @single_die.min
+ end
+
+ # @!attribute [r] max
+ # Maximum possible result from a call to #roll
+ # @return [Integer]
+ def max
+ n = @keep_mode ? [@keep_number, @ndice].min : @ndice
+ n * @single_die.max
+ end
+
+ # Calculates the probability distribution for the bunch. When the bunch is composed of dice with
+ # open-ended re-roll rules, there are some arbitrary limits imposed to prevent large amounts of
+ # recursion.
+ # @return [GamesDice::Probabilities] Probability distribution of bunch.
+ def probabilities
+ return @probabilities if @probabilities
+
+ @probabilities = if @keep_mode && @ndice > @keep_number
+ @single_die.probabilities.repeat_n_sum_k(@ndice, @keep_number, @keep_mode)
+ else
+ @single_die.probabilities.repeat_sum(@ndice)
+ end
+
+ @probabilities
+ end
+
+ # Simulates rolling the bunch of identical dice
+ # @return [Integer] Sum of all rolled dice, or sum of all keepers
+ def roll
+ @result = 0
+ @raw_result_details = []
+
+ @ndice.times do
+ @result += @single_die.roll
+ @raw_result_details << @single_die.result
+ end
+
+ return @result unless @keep_mode
+
+ use_dice = if @keep_mode && @keep_number < @ndice
+ case @keep_mode
+ when :keep_best then @raw_result_details.sort[-@keep_number..]
+ when :keep_worst then @raw_result_details.sort[0..(@keep_number - 1)]
+ end
+ else
+ @raw_result_details
+ end
+
+ @result = use_dice.inject(0) { |so_far, die_result| so_far + die_result }
+ end
+
+ # @!attribute [r] explain_result
+ # Explanation of result, or nil if no call to #roll yet.
+ # @return [String,nil]
+ 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(&:total)
+ case @keep_mode
+ when :keep_best
+ used_dice = full_dice[-@keep_number..]
+ unused_dice = full_dice[0..full_dice.length - 1 - @keep_number]
+ when :keep_worst
+ used_dice = full_dice[0..(@keep_number - 1)]
+ unused_dice = full_dice[@keep_number..(full_dice.length - 1)]
+ end
+ end
+
+ # Show unused dice (if any)
+ if @keep_mode || @single_die.maps
+ explanation += result_details.map(&:explain_value).join(', ')
+ if @keep_mode
+ separator = @single_die.maps ? ', ' : ' + '
+ explanation += ". Keep: #{used_dice.map(&:explain_total).join(separator)}"
+ end
+ explanation += ". Successes: #{@result}" if @single_die.maps
+ explanation += " = #{@result}" if @keep_mode && !@single_die.maps && @keep_number > 1
+ else
+ explanation += used_dice.map(&:explain_value).join(' + ')
+ explanation += " = #{@result}" if @ndice > 1
+ end
+
+ explanation
+ end
+
+ private
+
+ def name_number_sides_from_hash(options)
+ @name = options[:name].to_s
+ @ndice = Integer(options[:ndice])
+ raise ArgumentError, ":ndice must be 1 or more, but got #{@ndice}" unless @ndice.positive?
+
+ @sides = Integer(options[:sides])
+ raise ArgumentError, ":sides must be 1 or more, but got #{@sides}" unless @sides.positive?
+ end
+
+ def keep_mode_from_hash(options)
+ case options[:keep_mode]
+ when nil
+ @keep_mode = nil
+ when :keep_best
+ @keep_mode = :keep_best
+ @keep_number = Integer(options[:keep_number] || 1)
+ when :keep_worst
+ @keep_mode = :keep_worst
+ @keep_number = Integer(options[:keep_number] || 1)
+ else
+ raise ArgumentError, ":keep_mode can be nil, :keep_best or :keep_worst. Got #{options[:keep_mode].inspect}"
+ end
+ end
+
+ def complex_die_params_from_hash(options)
+ cd_hash = {}
+ %i[maps rerolls].each do |k|
+ cd_hash[k] = options[k].clone if options[k]
+ end
+ # We deliberately do not clone this object, it will often be intended that it is shared
+ cd_hash[:prng] = options[:prng]
+ cd_hash
+ end
+ end
+end