• Stars
    star
    2
  • Language
    Ruby
  • License
    MIT License
  • Created almost 4 years ago
  • Updated 7 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A Ruby Gem for rolling polyhedral dice.

Coverage Status Build Maintainability

NerdDice

Nerd dice allows you to roll polyhedral dice and add bonuses as you would in a tabletop roleplaying game. You can choose to roll multiple dice and keep a specified number of dice such as rolling 4d6 and dropping the lowest for ability scores or rolling with advantage and disadvantage if those mechanics exist in your game.

Educational Videos By Stateless Code

The end-to-end process of developing this gem has been captured as instructional videos. The videos are in a one-take style so that the mistakes along the way have troubleshooting and the concepts used to develop the gem are explained as they are covered.

Installation

Add this line to your application's Gemfile:

gem 'nerd_dice'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install nerd_dice

Usage

After the gem is installed, you can require it as you would any other gem.

require 'nerd_dice'

Module methods or a dynamic method_missing DSL

There are two main patterns for using NerdDice in your project. You can invoke the module-level methods like NerdDice.total_dice or you can include the NerdDice::ConvenienceMethods module to your class (or IRB ). Once mixed in, you can dynamically invoke methods like roll_d20_with_advantage or total_3d8_plus5. See the Convenience Methods Mixin section for usage details.

Configuration

You can customize the behavior of NerdDice via a configuration block as below or by assigning an individual property via the NerdDice.configuration.property = value syntax (where property is the config property and value is the value you want to assign). The available configuration options as well as their defaults, if applicable, are listed in the example configuration block below:

NerdDice.configure do | config|

  # number of ability scores to place in an ability score array
  config.ability_score_array_size = 6 # must duck-type to positive Integer

  # number of sides for each ability score Die
  config.ability_score_number_of_sides = 6 # must duck-type to positive Integer

  # total number of dice rolled for each ability score
  config.ability_score_dice_rolled = 4 # must duck-type to positive Integer

  # highest(n) dice from the total number of dice rolled
  # that are included in the ability scoretotal
  #
  # CANNOT EXCEED ability_score_dice_rolled see Note below
  config.ability_score_dice_kept = 3 # must duck-type to positive Integer

  # randomization technique options are:
    # :securerandom => Uses SecureRandom.rand(). Good entropy, medium speed.
    # :random_rand => Uses Random.rand(). Class method. Poor entropy, fastest speed.
      # (Seed is shared with other processes. Too predictable)
    # :random_object => Uses Random.new() and calls rand()
      # Medium entropy, fastest speed. (Performs the best under speed benchmark)
    # :randomized =>
    #  Uses a random choice of the :securerandom, :rand, and :random_new_interval options above
  config.randomization_technique = :random_object # fast with independent seed

  # Number of iterations to use on a generator before refreshing the seed
    # 1 very slow and heavy pressure on processor and memory but very high entropy
    # 1000 would refresh the object every 1000 times you call rand()
  config.refresh_seed_interval = nil # don't refresh the seed
  # Background and foreground die colors are string values.
  # By default these correspond to the constants in the class
    # Defaults: DEFAULT_BACKGROUND_COLOR = "#0000DD" DEFAULT_FOREGROUND_COLOR = "#DDDDDD"
    # It is recommended but not enforced that these should be valid CSS color property attributes
  config.die_background_color = "red"
  config.die_foreground_color = "#000"
end

Note: You cannot set ability_score_dice_kept greater than ability_score_dice_rolled. If you try to set ability_score_dice_kept higher than ability_score_dice_rolled, an error will be raised. If you set ability_score_dice_rolled lower than the existing value of ability_score_dice_kept, no error will be thrown, but ability_score_dice_kept will be modified to match ability_score_dice_rolled and a warning will be printed.

Rolling a number of dice and adding a bonus

You can use two different methods to roll dice. The total_dice method returns an Integer representing the total of the dice plus any applicable bonuses. The total_dice method does not support chaining additional methods like highest, lowest, with_advantage, with_disadvantage. The roll_dice method returns a DiceSet collection object, and allows for chaining the methods mentioned above and iterating over the individual Die objects. NerdDice.roll_dice.total and NerdDice.total_dice are roughly equivalent.

# roll a single d4
NerdDice.total_dice(4) # => Integer: between 1-4
NerdDice.roll_dice(4) # => DiceSet: with one 4-sided Die with a value between 1-4
NerdDice.roll_dice(4).total # => Integer: between 1-4

# roll 3d6
NerdDice.total_dice(6, 3) # => Integer: total of three 6-sided dice
NerdDice.roll_dice(6, 3) # => DiceSet: three 6-sided Die objects, each with values between 1-6
NerdDice.roll_dice(6, 3).total # => Integer: total of three 6-sided dice

# roll a d20 and add 5 to the value
NerdDice.total_dice(20, bonus: 5) # => Integer: roll a d20 and add the bonus to the total
NerdDice.roll_dice(20, bonus: 5) # => DiceSet: one 20-sided Die and bonus of 5
NerdDice.roll_dice(20, bonus: 5).total # => Integer: roll a d20 and add the bonus to the total

# without changing the config at the module level
# roll a d20 and overide the configured randomization_technique one time
NerdDice.total_dice(20, randomization_technique: :randomized) # => Integer
# roll a d20 and overide the configured randomization_technique for the DiceSet
# object will persist on the DiceSet object for subsequent rerolls
NerdDice.roll_dice(20, randomization_technique: :randomized) # => DiceSet with :randomized

NOTE: If provided, the bonus must respond to :to_i or an ArgumentError will be raised

Taking actions on the dice as objects using the DiceSet object

The NerdDice.roll_dice method or the NerdDice::DiceSet.new methods return a collection object with an array of one or more Die objects. There are properties on both the DiceSet object and the Die object. Applicable properties are cascaded from the DiceSet to the Die objects in the collection by default.

# These are equivalent. Both return a NerdDice::DiceSet
dice_set = NerdDice.roll_dice(6, 3, bonus: 2, randomization_technique: :randomized,
                       damage_type: 'psychic', foreground_color: '#FFF', background_color: '#0FF')

dice_set = NerdDice::DiceSet.new(6, 3, bonus: 2, randomization_technique: :randomized,
                       damage_type: 'psychic', foreground_color: '#FFF', background_color: '#0FF')

Available options for NerdDice::DiceSet objects

There are a number of options that can be provided when initializing a NerdDice::DiceSet object after specifying the mandatory number of sides and the optional number of dice (default: 1). The list below provides the options and indicates whether they are cascaded to the Die objects in the collection.

  • bonus (Duck-type Integer, default: 0): Bonus or penalty to apply to the total after all dice are rolled. Not applied to Die objects
  • randomization_technique (Symbol, default: nil): Randomization technique override to use for the DiceSet. If nil it will use the value in NerdDice.configuration. Applied to Die objects by default with ability to modify
  • damage_type (String, default: nil): Optional string indicating the damage type associated with the dice for systems where it is relevant. Applied to Die objects by default with ability to modify
  • foreground_color (String, default: NerdDice.configuration.die_foreground_color): Intended foreground color to apply to the dice in the DiceSet. Should be a valid CSS color but is not validated or enforced and doesn't currently have any real functionality associated with it. Applied to Die objects by default with ability to modify
  • background_color (String, default: NerdDice.configuration.die_background_color): Intended background color to apply to the dice in the DiceSet. Should be a valid CSS color but is not validated or enforced and doesn't currently have any real functionality associated with it. Applied to Die objects by default with ability to modify

Properties of individual Die objects

When initialized from a DiceSet object most of the properties of the Die object are inherited from the DiceSet object. In addition, there is an is_included_in_total public attribute that can be set to indicate whether the value of that particular die should be included in the total for its parent DiceSet. This property always starts out as true when the Die is initialized, but can be set to false.

# six sided die
die = NerdDice::Die.new(6, randomization_technique: :randomized, damage_type: 'psychic',
                        foreground_color: '#FFF', background_color: '#0FF')
die.is_included_in_total # => true
die.included_in_total? # => true
die.is_included_in_total  = false
die.included_in_total? # => false

# value property
die.value # =>  Integer between 1 and number_of_sides

# Rolls/rerolls the Die, sets value to the result of the roll, and returns the new value
die.roll # => Integer.

Iterating through dice in a DiceSet

The DiceSet class mixes in the Enumerable module and the Die object mixes in the Comparable module. This allows you to iterate over the dice in the collection. The sort method on the dice will return the die objects in ascending value from lowest to highest.

dice_set = NerdDice.roll_dice(6, 3) # => NerdDice::DiceSet
dice_set.dice => Array of Die objects
dice_set.length # => 3. (dice_set.dice.length)
dice_set[0] # => NerdDice::Die (first element of dice array)
# take actions on each die
dice_set.each do |die|
  # print the current value
  puts "Die value before reroll is #{die.value}"
  # set the foreground_color of the die
  die.foreground_color = ["gray", "#FF0000#", "#d9d9d9", "green"].shuffle.first
  # reroll the die
  die.roll
  # print the new value
  puts "Die value after reroll is #{die.value}"
  # do other things
end

Methods and method chaining on the DiceSet

Since the DiceSet is an object, you can call methods that operate on the result returned and allow for things like the 5e advantage/disadvantage mechanic, the ability to re-roll all of the dice in the DiceSet, or to mark them all as included in the total.

##############################################
# highest/with_advantage and lowest/with_disadvantage methods
#       assuming 4d6 with values of [1, 3, 4, 6]
##############################################
dice_set = NerdDice.roll_dice(6, 4)

# the 6, 4, and 3 will have is_included_in_total true while the 1 has it false
# Returns the existing DiceSet object with the changes made to dice inclusion
dice_set.highest(3) # => DiceSet
dice_set.with_advantage(3) # => DiceSet (Alias of highest method)

# calling total after highest/with_advantage for this DiceSet
dice_set.total # => 13

# same DiceSet using lowest.
# The 1, 3, and 4 will have is_included_in_total true while the 6 has it false
dice_set.lowest(3) # => DiceSet
dice_set.with_disadvantage(3) # => DiceSet (Alias of lowest method)

# calling total after lowest/with_disadvantage for this DiceSet
dice_set.total # => 8

# you can chain these methods (assumes the same seed as the above examples)
NerdDice.roll_dice(6, 4).with_advantage(3).total # => 13
NerdDice.roll_dice(6, 4).lowest(3).total # => 8

# reroll_all! method
dice_set = NerdDice.roll_dice(6, 4)
# rerolls each of the Die objects in the collection and re-includes them in the total
dice_set.reroll_all!

# include_all_dice! method
dice_set.include_all_dice! # resets is_included_in_total to true for all Die objects

Rolling Ability Scores

You can call roll_ability_scores or total_ability_scores to get back an array of DiceSet objects or Integer objects, respectively. The total_ability_scores method calls total on each DiceSet and returns those numbers with one value per ability score. The Configuration object defaults to 6 ability scores using a methodology of 4d6 drop the lowest by default.

# return an array of DiceSet objects including info about the discarded dice
#
NerdDice.roll_ability_scores
#=> [DiceSet0, DiceSet1, ...]
   # => DiceSet0 hash representation { total: 12, dice: [
   #             {value: 2, is_included_in_total: true},
   #             {value: 6, is_included_in_total: true},
   #             {value: 4, is_included_in_total: true},
   #             {value: 1, is_included_in_total: false}
   #         ]}
# if you want to get back DiceSet objects that you can interact with

# just return an array of totaled ability scores
NerdDice.total_ability_scores
#=> [12, 14, 13, 15, 10, 8]

Both methods can be called without arguments to use the values specified in NerdDice.configuration or passed a set of options.

# total_dice and roll_dice take the same set of options
NerdDice.roll_ability_scores(
       ability_score_array_size: 7,
       ability_score_number_of_sides: 8,
       ability_score_dice_rolled: 5,
       ability_score_dice_kept: 4,
       randomization_technique: :randomized,
       foreground_color: "#FF0000",
       background_color: "#FFFFFF"
     )
# => [DiceSet0, DiceSet1, ...] with 7 ability scores that each roll 5d8 dropping the lowest
# or if called with total_ability_scores
# => [27, 17, 21, 17, 23, 13, 27]

Note: If you try to call this method with ability_score_dice_kept greater than ability_score_dice_rolled an error will be raised.

Manually setting or refreshing the random generator seed

For randomization techniques other than :securerandom you can manually set or refresh the generator's seed by calling the refresh_seed! method. This is automatically called at the interval specified in NerdDice.configuration.refresh_seed_interval if it is not nil.

# no arguments, will refresh the seed for the configured generator(s) only
NerdDice.refresh_seed! # => hash with old seed(s) or nil if :securerandom

# OPTIONS:
    #   randomization_technique (Symbol) => NerdDice::RANDOMIZATION_TECHNIQUES
    #   random_rand_seed (Integer) => Seed to set for Random
    #   random_object_seed (Integer) => Seed to set for new Random object
NerdDice.refresh_seed!(randomization_technique:  :randomized,
                       random_rand_seed:         1337,
                       random_object_seed:       24601)

NOTE: Ability to specify a seed is primarily provided for testing purposes. This makes all random numbers generated transparently deterministic and should not be used if you want behavior approximating randomness.

Utility Methods

Harvesting Totals from DiceSets

The harvest_totals method take any collection of objects where each element responds to total and return an array of the results of the total method.

ability_score_array = NerdDice.roll_ability_scores
# => Array of 6 DiceSet objects

# Arguments:
#   collection (Enumerable) a collection where each element responds to total
#
# Return (Array) => Data type of each element will be whatever is returned by total method
totals_array = NerdDice.harvest_totals(totals_array)
# => [15, 14, 13, 12, 10, 8]
# yes, it just happened to be the standard array by amazing coincidence

Convenience Methods Mixin

NerdDice provides an optional mixin NerdDice::ConvenienceMethods that uses Ruby's method_missing metaprogramming pattern to allow you to roll any number of dice with bonuses and/or the advantage/disadvantage mechanic by dynamically responding to methods that you type that match the roll_ or total_ pattern.

Considerations for ConvenienceMethods

Before mixing in this method with a class, be aware of other method_missing gems that you are also mixing into your project and be sure to write robust tests. We have sought to use method_missing in a responsible manner that delegates back to the default implementation using super if the method does not match the ConvenienceMethods pattern, but there is no guarantee that other gems included in your project are doing the same. If you run into problems with the ConvenienceMethods module interacting with other method_missing gems, everything that the ConvenienceMethods module does can be replicated using the module-level methods described above or by calling the convenience method on NerdDice.

Once a particular method has been called, it will define that method so that the next time it will invoke the method directly instead of traversing up the call stack for method_missing, which improves performance. The method will remain defined for the duration of the Ruby interpreter process.

Calling ConvenienceMethods as NerdDice class methods

NerdDice extends the ConvenienceMethods module into the top-level module as class methods, so you can call the methods on the NerdDice module without needing to worry about the implications of extending it into your own class.

require 'nerd_dice'
# works with all the examples and patterns below
NerdDice.roll_3d6_lowest2_minus1
NerdDice.total_d20_with_advantage_p6

Mixing in the ConvenienceMethods module

To mix the NerdDice DSL into your class, make sure the gem is required if not already and then use include NerdDice::ConvenienceMethods as shown below:

# example of a class that mixes in NerdDice::ConvenienceMethods
require 'nerd_dice'
class Monster
  include NerdDice::ConvenienceMethods

  # hard-coding probably not the best solution
  # but gives you an idea how to mix in to a simple class
  def hits_the_monster
    # using the ConvenienceMethods version
    total_d20_plus5 >= @armor_class ? "hit" : "miss"
  end

  def initialize(armor_class=16)
    @armor_class = armor_class
  end
end

To mix in the module as class methods, you can use extend NerdDice::ConvenienceMethods

# example of a class that mixes in NerdDice::ConvenienceMethods
require 'nerd_dice'
class OtherClass
  extend NerdDice::ConvenienceMethods
end
OtherClass.roll_3d6_lowest2_minus1 # returns NerdDice::DiceSet

ConvenienceMethods usage examples

Any invocation of NerdDice.roll_dice and NerdDice.total_dice can be duplicated using the NerdDice::ConvenienceMethods mixin. Here are some examples of what you can do with the return types and equivalent methods in the comments:

  • roll_dNN and total_dNN roll one die
roll_d20 # => DiceSet: NerdDice.roll_dice(20)
roll_d8 # => DiceSet: NerdDice.roll_dice(8)
roll_d1000 # => DiceSet: NerdDice.roll_dice(1000)
total_d20 # => Integer NerdDice.total_dice(20)
total_d8 # => Integer NerdDice.total_dice(8)
total_d1000 # => Integer NerdDice.total_dice(1000)
  • roll_NNdNN and total_NNdNN roll specified quantity of dice
roll_2d20 # => DiceSet: NerdDice.roll_dice(20, 2)
roll_3d8 # => DiceSet: NerdDice.roll_dice(8, 3)
roll_22d1000 # => DiceSet: NerdDice.roll_dice(1000, 22)
total_2d20 # => Integer NerdDice.total_dice(20, 2)
total_3d8 # => Integer NerdDice.total_dice(8, 3)
total_22d1000 # => Integer NerdDice.total_dice(1000, 22)
  • Keyword arguments are passed on to roll_dice/total_dice method
roll_2d20 foreground_color: "blue" # => DiceSet: NerdDice.roll_dice(20, 2, foreground_color: "blue")

total_d12 randomization_technique: :randomized
# => Integer NerdDice.total_dice(12, randomization_technique: :randomized)
total_22d1000 randomization_technique: :random_rand
# => Integer NerdDice.total_dice(1000, 22, randomization_technique: :random_rand)

roll_4d6_with_advantage3 background_color: 'blue'
# => DiceSet: NerdDice.roll_dice(4, 3, background_color: 'blue').highest(3)
total_4d6_with_advantage3 randomization_technique: :random_rand
# => Integer: NerdDice.roll_dice(4, 3, randomization_technique: :random_rand).highest(3).total
  • Positive and negative bonuses can be used with plus (alias p) or minus (alias m)
roll_d20_plus6 # => DiceSet: NerdDice.roll_dice(20, bonus: 6)
total_3d8_p2 # => Integer: NerdDice.total_dice(8, 3, bonus: 2)
total_d20_minus5 # => Integer: NerdDice.total_dice(20, bonus: -6)
roll_3d8_m3 # => DiceSet: NerdDice.roll_dice(8, 3, bonus: -3)
  • _with_advantageN or highestN roll with advantage
  • _with_disadvantageN or lowestN roll with disadvantage
  • Calling roll_dNN_with_advantage (and variants) rolls 2 dice and keeps one
# equivalent
roll_3d8_with_advantage1
roll_3d8_highest1
# => DiceSet: NerdDice.roll_dice(8, 3).with_advantage(1)

# calls roll_dice and total to return an integer
total_3d8_with_advantage1
total_3d8_highest1
# => Integer: NerdDice.roll_dice(8, 3).with_advantage(1).total

# rolls two dice in this case
# equal to roll_2d20_with_advantage but more natural
roll_d20_with_advantage # => DiceSet: NerdDice.roll_dice(20, 2).with_advantage(1)
# equal to total_2d20_with_advantage but more natural
total_d20_with_advantage # => Integer: NerdDice.roll_dice(20, 2).with_advantage(1).total

ConvenienceMethods error handling

  • If you try to call with a plus and a minus, an Exception is raised
  • If you call with a bonus and a keyword argument and they don't match, an Exception is raised
  • Any combination not expressly allowed or matched will call super on method_missing
roll_3d8_plus3_m2 # will raise NameError using super method_missing
roll_3d8_plus3 bonus: 1 # will raise NerdDice::Error with message about "Bonus integrity failure"
roll_d20_with_advantage_lowest # will raise NameError using super method_missing
total_4d6_lowest3_highest2 # will raise NameError using super method_missing

Code Along!

You can contribute to this project in a variety of ways. See the CONTRIBUTING page for more details. You don't need to be a programmer to contribute. We welcome feature requests, bug reports, code contributions, and documentation contributions as well as art, design and/or creative input.

Conduct

Just be polite and courteous in your interactions. It is possible to disagree passionately about ideas without making it personal. You can make a point without using language that leads to escalation. Nobody working on this project is perfect. In the event that something goes wrong, seek forgiveness and reconciliation with one another. Mercy triumphs over judgment.

We welcome and encourage your participation in this open-source project. We welcome those of all backgrounds and abilities, but we refuse to adopt the Contributor Covenant or any similar code of conduct for reasons outlined in BURN_THE_CONTRIBUTOR_COVENANT_WITH_FIRE. We welcome those who disagree about codes of conduct to contribute to this project, but we want to make it abundantly clear that Code of Conduct Trolls have no power here.

Unlicense, License, and Copyright

The project is dual-licensed under the MIT license and the UNLICENSE (with strong preference toward the UNLICENSE). The content is released under CC0 (no rights reserved). You are free to include it in its original form or modified with or without additional modification in your own project.