dicey 0.0.1 → 0.13.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 13602eca86dfef1610dd824ffcab19809d7416418f077ea7b51f8f2fc713ec17
4
+ data.tar.gz: 7a24922897bad67486e72592189a51358bc861e53c332a0780901ef7ddf66e2f
5
+ SHA512:
6
+ metadata.gz: 336cdbe4078db28e015ed393a65b358c9df13873b70da3f215cb75af95c84a67ae7ff6ec0939cd66c9f12b281e792d1acb9a0d00ec4e3f9c107b446e70cad703
7
+ data.tar.gz: 964b1502f92086c86b2861bd2eec432effec526b5aecbeb056f82a59cc2be83b1fe7fbe39482350d6764f994f432ebaef3f6e76b70f824adcc2cd630e9decaaf
data/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # Dicey
2
+
3
+ > [!TIP]
4
+ > You may be viewing documentation for an older (or newer) version of the gem than intended. Look at [Changelog](https://github.com/trinistr/dicey/blob/main/CHANGELOG.md) to see all versions, including unreleased changes.
5
+
6
+ <!-- Latest: [![Gem Version](https://badge.fury.io/rb/dicey.svg?icon=si%3Arubygems)](https://rubygems.org/gems/dicey) -->
7
+ <!-- [![CI](https://github.com/trinistr/dicey/actions/workflows/CI.yaml/badge.svg)](https://github.com/trinistr/dicey/actions/workflows/CI.yaml) -->
8
+
9
+ ***
10
+
11
+ The premier solution in total paradigm shift for resolving dicey problems of tomorrow, today, used by industry-leading professionals around the world!
12
+
13
+ In seriousness, this program produces total frequency (probability) distributions of all possible dice rolls for a given set of dice. Dice in such a set can be different or even have arbitrary numbers on the sides.
14
+
15
+ ## No installation
16
+
17
+ Thanks to the efforts of Ruby developers, you can try **Dicey** online!
18
+ 1. Head over to https://runruby.dev/gist/476679a55c24520782613d9ceb89d9a3
19
+ 2. Make sure that "*-main.rb*" is open
20
+ 3. Input arguments between "ARGUMENTS" lines, separated by spaces.
21
+ 4. Click "**Run code**" button below the editor.
22
+
23
+ ## Installation
24
+
25
+ Install via `gem`:
26
+ ```sh
27
+ gem install dicey
28
+ ```
29
+
30
+ Or, if using Bundler, add it to your `Gemfile`:
31
+ ```rb
32
+ gem "dicey", "~> 0.13"
33
+ ```
34
+
35
+ > [!TIP]
36
+ > Versions upto 0.12.1 were packaged as a single executable file. You can still download it from the [release](https://github.com/trinistr/dicey/releases/tag/v0.12.1).
37
+
38
+ > [!NOTE]
39
+ > `dicey` 0.0.1 was a completely separate project by [Adam Rogers](https://github.com/rodreegez). Big thanks for transfering the name!
40
+
41
+ ### Requirements
42
+
43
+ **Dicey** is developed on Ruby 3.2, but should work fine on 3.1 and later versions. There are no dependencies aside from standard library (`json`, `yaml`, `bigdecimal`) and common usage will not even load them.
44
+
45
+ ## Usage
46
+
47
+ Following examples assume that `dicey` (or `dicey-to-gnuplot`) is executable and is in `$PATH`. You can also run it with `ruby dicey` instead.
48
+
49
+ > [!NOTE]
50
+ > 💡 Run `dicey --help` to get a list of all possible options.
51
+
52
+ ### Example 1
53
+
54
+ Let's start with something simple. Imagine that your Bard character has Vicious Mockery cantrip with 2d4 damage, and you would like to know the distribution of possible damage rolls. Run **Dicey** with two 4s as arguments:
55
+ ```sh
56
+ $ dicey 4 4
57
+ ```
58
+
59
+ It should output the following:
60
+ ```sh
61
+ # ⚃;⚃
62
+ 2 => 1
63
+ 3 => 2
64
+ 4 => 3
65
+ 5 => 4
66
+ 6 => 3
67
+ 7 => 2
68
+ 8 => 1
69
+ ```
70
+
71
+ First line is a comment telling you that calculation ran for two D4s. Every line after that has the form `roll sum => frequency`, where frequency is the number of different rolls which result in this sum. As can be seen, 5 is the most common result with 4 possible different rolls.
72
+
73
+ If probability is preferred, there is an option for that:
74
+ ```sh
75
+ $ dicey 4 4 --result probabilities # or -r p for short
76
+ # ⚃;⚃
77
+ 2 => 0.0625
78
+ 3 => 0.125
79
+ 4 => 0.1875
80
+ 5 => 0.25
81
+ 6 => 0.1875
82
+ 7 => 0.125
83
+ 8 => 0.0625
84
+ ```
85
+
86
+ This shows that 5 will probably be rolled a quarter of the time.
87
+
88
+ ### Example 2
89
+
90
+ During your quest to end all ends you find a cool Burning Sword which deals 1d8 slashing damage and 2d4 fire damage on attack. You run **Dicey** with these dice:
91
+ ```sh
92
+ $ dicey 8 4 4
93
+ # [8];⚃;⚃
94
+ 3 => 1
95
+ 4 => 3
96
+ 5 => 6
97
+ 6 => 10
98
+ 7 => 13
99
+ 8 => 15
100
+ 9 => 16
101
+ 10 => 16
102
+ 11 => 15
103
+ 12 => 13
104
+ 13 => 10
105
+ 14 => 6
106
+ 15 => 3
107
+ 16 => 1
108
+ ```
109
+
110
+ Results show that while the total range is 3–16, it is much more likely to roll numbers in the 6–13 range. That's pretty fire, huh?
111
+
112
+ If you downloaded `dicey-to-gnuplot` and have [gnuplot](http://gnuplot.info) installed, it is possible to turn these results into a graph with a somewhat clunky command:
113
+ ```sh
114
+ $ dicey 8 4 4 --format gnuplot | dicey-to-gnuplot
115
+ # --format gnuplot can be abbreviated to -f g
116
+ ```
117
+
118
+ This will create a PNG image named `[8];⚃;⚃.png`:
119
+ ![Graph of damage roll frequencies for Burning Sword]([8];⚃;⚃.png)
120
+
121
+ > [!NOTE]
122
+ > 💡 It is possible to output JSON or YAML with `--format json` and `--format yaml` respectively.
123
+
124
+ ### Example 3
125
+
126
+ While walking home from work you decide to take a shortcut through a dark alleyway. Suddenly, you notice a die lying on the ground. Looking closer, it turns out to be a D4, but its 3 side was erased from reality. You just have to learn what impact this has on a roll together with a normal D4. Thankfully, you know just the program for the job.
127
+
128
+ Having ran to a computer as fast as you can, you sic **Dicey** on the problem:
129
+ ```sh
130
+ $ dicey 1,2,4 4
131
+ # (1,2,4);⚃
132
+ 2 => 1
133
+ 3 => 2
134
+ 4 => 2
135
+ 5 => 3
136
+ 6 => 2
137
+ 7 => 1
138
+ 8 => 1
139
+ ```
140
+
141
+ Hmm, this looks normal, doesn't it? But wait, why are there two 2s in a row? Turns out that not having one of the sides just causes the roll frequencies to slightly dip in the middle. Good to know.
142
+
143
+ > [!TIP]
144
+ > 💡 A single integer argument N practically is a shorthand for listing every side from 1 to N.
145
+
146
+ ### Example 4
147
+
148
+ You have a sudden urge to roll dice while only having boring integer dice at home. Where to find *the cool* dice though?
149
+
150
+ Look no further than **roll** mode introduced in **Dicey** 0.12:
151
+ ```sh
152
+ dicey 0.5,1.5,2.5 4 --mode roll # As always, can be abbreviated to -m r
153
+ # (0.5e0,0.15e1,0.25e1);⚃
154
+ roll => 0.35e1 # You probably will get a different value here.
155
+ ```
156
+
157
+ > [!NOTE]
158
+ > 💡 Roll mode is compatible with `--format`, but not `--result`.
159
+
160
+ ## Diving deeper
161
+
162
+ For a further discussion of calculations, it is important to understand which classes of dice exist.
163
+ - **Regular** die — a die with N sides with sequential integers from 1 to N,
164
+ like a classic cubic D6, D20, or even a coin if you assume that it rolls 1 and 2.
165
+ These are dice used for many tabletop games, including role-playing games.
166
+ Most probably, you will only ever need these and not anything beyond.
167
+
168
+ > [!TIP]
169
+ > 💡 If you only need to roll **regular** dice, this section will not contain anything important.
170
+
171
+ - **Natural** die has sides with only positive integers or 0. For example, (1,2,3,4,5,6), (5,1,6,5), (1,10000), (1,1,1,1,1,1,1,0).
172
+ - **Arithmetic** die's sides form an arithmetic sequence. For example, (1,2,3,4,5,6), (1,0,-1), (2.6,2.1,1.6,1.1).
173
+ - **Numeric** die is limited by having sides confined to ℝ (or ℂ if you are feeling particularly adventurous).
174
+ - **Abstract** die is not limited by anything other than not having partial sides (and how would that work anyway?).
175
+
176
+ > [!NOTE]
177
+ > 💡 If your die starts with a negative number or only has a single natural side, brackets can be employed to force treating it as a sides list, e.g. `dicey '(-1)'` (quotation is required due to shell processing).
178
+
179
+ Dicey is in principle able to handle any numeric dice and some abstract dice with well-defined summation (tested on complex numbers), though not every possibility is exposed through command-line interface: that is limited to floating-point values.
180
+
181
+ Currently, three algorithms are implemented, with different possibilities and trade-offs.
182
+
183
+ > [!NOTE]
184
+ > 💡 Complexity is listed for `n` dice with at most `m` sides and has not been rigorously proven.
185
+
186
+ ### Kronecker substitution
187
+
188
+ An algorithm based on fast polynomial multiplication. This is the default algorithm, used for most reasonable dice.
189
+
190
+ - Limitations: only **natural** dice are allowed, including **regular** dice.
191
+ - Example: `dicey 5 3,4,1 '(0)'`
192
+ - Complexity: `O(m⋅n)` where `m` is the highest value
193
+
194
+ ### Multinomial coefficients
195
+
196
+ This algorithm is based on raising a univariate polynomial to a power and using the coefficients of the result, though certain restrictions are lifted as they don't actually matter for the calculation.
197
+
198
+ - Limitations: only *equal* **arithmetic** dice are allowed.
199
+ - Example: `dicey 1.5,3,4.5,6 1.5,3,4.5,6 1.5,3,4.5,6`
200
+ - Complexity: `O(m⋅n²)`
201
+
202
+ ### Brute force
203
+
204
+ As a last resort, there is a brute force algorithm which goes through every possible dice roll and adds results together. While quickly growing terrible in performace, it has the largest input space, allowing to work with completely nonsensical dice, including aforementioned dice with complex numbers.
205
+
206
+ - Limitations: objects on dice sides must be numbers.
207
+ - Example: `dicey 5 1,0.1,2 1,-1,1,-1,0`
208
+ - Complexity: `O(mⁿ)`
209
+
210
+ ## Development
211
+
212
+ After checking out the repo, run `bundle` (or `bundle install`) to install dependencies. Then, run `rake spec` to run the tests, `rake rubocop` to lint code and check style compliance, `rake rbs` to validate signatures or just `rake` to do everything above. There is also `rake steep` to check typing, and `rake docs` to generate YARD documentation.
213
+
214
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment, or `bin/benchmark` to run a benchmark script and generate a StackProf flamegraph.
215
+
216
+ To install this gem onto your local machine, run `rake install`.
217
+
218
+ To release a new version, run `rake version:{major|minor|patch}`, and then run `rake release`, which will build the package and push the `.gem` file to [rubygems.org](https://rubygems.org). After that, push the release commit and tags to the repository with `git push --follow-tags`.
219
+
220
+ ## Contributing
221
+
222
+ Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/dicey.
223
+
224
+ ### Checklist for a new or updated feature
225
+
226
+ - Running `rspec` reports 100% coverage (unless it's impossible to achieve in one run).
227
+ - Running `rubocop` reports no offenses.
228
+ - Running `rake steep` reports no new warnings or errors.
229
+ - Tests cover the behavior and its interactions. 100% coverage *is not enough*, as it does not guarantee that all code paths are covered.
230
+ - Documentation is up-to-date: generate it with `rake docs` and read it.
231
+ - `CHANGELOG.md` lists the change if it has impact on users.
232
+ - `README.md` is updated if the feature should be visible there.
233
+
234
+ ## License
235
+
236
+ This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT), see [LICENSE.txt](https://github.com/trinistr/dicey/blob/main/LICENSE.txt).
data/exe/dicey ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dicey/cli/blender"
5
+
6
+ exit Dicey::CLI::Blender.new.call
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Generate an image from dicey results.
5
+ # Requires gnuplot to be installed and in PATH.
6
+ # Usage: dicey 6 6 6 -f gnuplot | gnuplot-for-dicey
7
+
8
+ require "tempfile"
9
+
10
+ data = ARGF.read
11
+ description = data[/(?<=\A# ).+$/] || "dice"
12
+ Tempfile.create do |file|
13
+ file << data
14
+ file.flush
15
+ Process.wait(
16
+ Process.spawn(
17
+ "gnuplot",
18
+ "-e", "set term pngcairo size 1000,600",
19
+ "-e", %(set output "#{description}.png"),
20
+ "-e", "set boxwidth 0.9 relative",
21
+ "-e", "set style fill solid 0.5",
22
+ "-e", "plot [][0:] \"#{file.path}\" " \
23
+ "using 1:2:xticlabels(1) with boxes title \"#{description}\", " \
24
+ "'' using 1:2:2 with labels notitle"
25
+ )
26
+ )
27
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ # Asbtract die which may have an arbitrary list of sides,
5
+ # not even neccessarily numbers (but preferably so).
6
+ class AbstractDie
7
+ # rubocop:disable Style/ClassVars
8
+
9
+ # Get a random value using a private instance of Random.
10
+ # @see Random#rand
11
+ def self.rand(...)
12
+ @@random.rand(...)
13
+ end
14
+
15
+ # Reset internal randomizer using a new seed.
16
+ # @see Random.new
17
+ def self.srand(...)
18
+ @@random = Random.new(...)
19
+ end
20
+
21
+ # Yes, class variable is actually useful here.
22
+ # TODO: Allow supplying a custom Random.
23
+ @@random = Random.new
24
+
25
+ # rubocop:enable Style/ClassVars
26
+
27
+ # Get a text representation of a list of dice.
28
+ #
29
+ # @param dice [Enumerable<AbstractDie>]
30
+ # @return [String]
31
+ def self.describe(dice)
32
+ dice.join(";")
33
+ end
34
+
35
+ attr_reader :sides_list, :sides_num
36
+
37
+ # @param sides_list [Enumerable<Object>]
38
+ # @raise [DiceyError] if +sides_list+ is empty
39
+ def initialize(sides_list)
40
+ @sides_list = sides_list.is_a?(Array) ? sides_list.dup.freeze : sides_list.to_a.freeze
41
+ raise DiceyError, "dice must have at least one side!" if @sides_list.empty?
42
+
43
+ @sides_num = @sides_list.size
44
+
45
+ sides_enum = @sides_list.to_enum
46
+ @enum =
47
+ Enumerator.produce do
48
+ sides_enum.next
49
+ rescue StopIteration
50
+ sides_enum.rewind
51
+ retry
52
+ end
53
+ end
54
+
55
+ # Get current side of the die.
56
+ # @return [Object] current side
57
+ def current
58
+ @enum.peek
59
+ end
60
+
61
+ # Get next side of the die, advancing internal enumerator state.
62
+ # Wraps from last to first side.
63
+ # @return [Object] next side
64
+ def next
65
+ @enum.next
66
+ end
67
+
68
+ # Advance internal enumerator state by a random number using {#next}.
69
+ # @return [Object] rolled side
70
+ def roll
71
+ self.class.rand(0...sides_num).times { self.next }
72
+ current
73
+ end
74
+
75
+ # @return [String]
76
+ def to_s
77
+ "(#{sides_list.join(",")})"
78
+ end
79
+
80
+ # Determine if this die and the other one have the same list of sides.
81
+ # Be aware that differently ordered sides are not considered equal.
82
+ #
83
+ # @param other [AbstractDie, Object]
84
+ # @return [Boolean]
85
+ def ==(other)
86
+ return false unless other.is_a?(AbstractDie)
87
+
88
+ sides_list == other.sides_list
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ # Classes pertaining to CLI.
5
+ # NOT loaded by default, use +require "dicey/cli/blender"+ as needed.
6
+ module CLI
7
+ require_relative "../../dicey"
8
+ require_relative "options"
9
+
10
+ # Slice and dice everything in the Dicey module to produce a useful result.
11
+ # This is the entry point for the CLI.
12
+ class Blender
13
+ # List of calculators to use, ordered by efficiency.
14
+ ROLL_FREQUENCY_CALCULATORS = [
15
+ SumFrequencyCalculators::KroneckerSubstitution.new,
16
+ SumFrequencyCalculators::MultinomialCoefficients.new,
17
+ SumFrequencyCalculators::BruteForce.new,
18
+ ].freeze
19
+
20
+ # How to transform option values from command-line arguments
21
+ # to internally significant objects.
22
+ OPTION_TRANSFORMATIONS = {
23
+ mode: lambda(&:to_sym),
24
+ result: lambda(&:to_sym),
25
+ format: {
26
+ "list" => OutputFormatters::ListFormatter.new,
27
+ "gnuplot" => OutputFormatters::GnuplotFormatter.new,
28
+ "yaml" => OutputFormatters::YAMLFormatter.new,
29
+ "json" => OutputFormatters::JSONFormatter.new,
30
+ }.freeze,
31
+ }.freeze
32
+
33
+ # What to run for every mode.
34
+ # Every runner must respond to `call(arguments, **options)`
35
+ # and return +true+, +false+ or a String.
36
+ RUNNERS = {
37
+ roll: Roller.new,
38
+ frequencies: SumFrequencyCalculators::Runner.new,
39
+ test: SumFrequencyCalculators::TestRunner.new,
40
+ }.freeze
41
+
42
+ # Run the program, blending everything together.
43
+ #
44
+ # @param argv [Array<String>] arguments for the program
45
+ # @return [Boolean]
46
+ # @raise [DiceyError] anything can happen
47
+ def call(argv = ARGV)
48
+ options, arguments = get_options_and_arguments(argv)
49
+ require_optional_libraries(options)
50
+ options[:roll_calculators] = ROLL_FREQUENCY_CALCULATORS
51
+ return_value = RUNNERS[options.delete(:mode)].call(arguments, **options)
52
+ print return_value if return_value.is_a?(String)
53
+ !!return_value
54
+ end
55
+
56
+ private
57
+
58
+ def get_options_and_arguments(argv)
59
+ options = Options.new
60
+ arguments = options.read(argv)
61
+ options = options.to_h
62
+ options.each_pair do |k, v|
63
+ options[k] = OPTION_TRANSFORMATIONS[k][v] || v if OPTION_TRANSFORMATIONS[k]
64
+ end
65
+ [options, arguments]
66
+ end
67
+
68
+ # Require libraries only when needed, to cut on run time.
69
+ def require_optional_libraries(options)
70
+ case options[:format]
71
+ when OutputFormatters::YAMLFormatter
72
+ require "yaml"
73
+ when OutputFormatters::JSONFormatter
74
+ require "json"
75
+ else
76
+ # No additional libraries needed
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Dicey
6
+ module CLI
7
+ # Helper class for parsing command-line options and generating help.
8
+ class Options
9
+ # Allowed modes (--mode) (only directly selectable).
10
+ MODES = %w[frequencies roll].freeze
11
+ # Allowed result types (--result).
12
+ RESULT_TYPES = %w[frequencies probabilities].freeze
13
+ # Allowed output formats (--format).
14
+ FORMATS = %w[list gnuplot json yaml].freeze
15
+
16
+ # Default values for initial values of the options.
17
+ DEFAULT_OPTIONS = { mode: "frequencies", format: "list", result: "frequencies" }.freeze
18
+
19
+ def initialize(initial_options = DEFAULT_OPTIONS.dup)
20
+ @options = initial_options
21
+ @parser = ::OptionParser.new
22
+ add_banner_and_version
23
+ add_common_options
24
+ add_test_options
25
+ add_other_options
26
+ end
27
+
28
+ # Parse command-line arguments as options and return non-option arguments.
29
+ #
30
+ # @param argv [Array<String>]
31
+ # @return [Array<String>] non-option arguments
32
+ def read(argv)
33
+ @parser.parse!(argv, into: @options)
34
+ end
35
+
36
+ # Get an option value by key.
37
+ #
38
+ # @param key [Symbol]
39
+ # @return [Object]
40
+ def [](key)
41
+ @options[key]
42
+ end
43
+
44
+ # @return [Hash{Symbol => Object}]
45
+ def to_h
46
+ @options.dup
47
+ end
48
+
49
+ private
50
+
51
+ def add_banner_and_version
52
+ @parser.banner = <<~TEXT
53
+ Usage: #{@parser.program_name} [options] <die> [<die> ...]
54
+ #{@parser.program_name} --test [full|quiet]
55
+ All option names and arguments can be abbreviated if abbreviation is unambiguous.
56
+ TEXT
57
+ @parser.version = Dicey::VERSION
58
+ end
59
+
60
+ def add_common_options
61
+ easy_option("-m", "--mode MODE", MODES, "What kind of action or calculation to perform.")
62
+ easy_option("-r", "--result RESULT_TYPE", RESULT_TYPES,
63
+ "Select type of result to calculate (only for frequencies).")
64
+ easy_option("-f", "--format FORMAT", FORMATS, "Select output format for results.")
65
+ end
66
+
67
+ def add_test_options
68
+ @parser.on_tail(
69
+ "--test [REPORT_STYLE]", %w[full quiet],
70
+ "Check predefined calculation cases and exit.",
71
+ "REPORT_STYLE can be: `full`, `quiet`.", "`full` is default."
72
+ ) do |report_style|
73
+ @options[:mode] = :test
74
+ @options[:report_style] = report_style&.to_sym || :full
75
+ end
76
+ end
77
+
78
+ def add_other_options
79
+ @parser.on_tail("-h", "--help", "Show this help and exit.") do
80
+ puts @parser.help
81
+ exit
82
+ end
83
+ @parser.on_tail("-v", "--version", "Show program version and exit.") do
84
+ puts @parser.ver
85
+ exit
86
+ end
87
+ end
88
+
89
+ def easy_option(short, long, values, description, &)
90
+ values = values.keys if values.respond_to?(:keys)
91
+ option_name = long[/[a-z_]+/].to_sym
92
+ argument_name = long[/[A-Z_]+/]
93
+ listed_values = "#{argument_name} can be: #{values.map { "`#{_1}`" }.join(", ")}."
94
+ default_value = "`#{@options[option_name]}` is default." if @options[option_name]
95
+ @parser.on(*[short, long, values, description, listed_values, default_value].compact, &)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "numeric_die"
4
+ require_relative "regular_die"
5
+
6
+ module Dicey
7
+ # Helper class to define die definitions and automatically select the best one.
8
+ class DieFoundry
9
+ # Possible molds for the dice. They are matched in the order as written.
10
+ MOLDS = {
11
+ # Positive integer goes into the RegularDie mold.
12
+ ->(d) { /\A[1-9]\d*\z/.match?(d) } => :regular_mold,
13
+ # List of numbers goes into the NumericDie mold.
14
+ ->(d) { /\A\(?-?\d++(?:,-?\d++)*\)?\z/.match?(d) } => :weirdly_shaped_mold,
15
+ # Real numbers require arbitrary precision arithmetic, which is not enabled by default.
16
+ ->(d) {
17
+ /\A\(?-?\d++(?:\.\d++)?(?:,-?\d++(?:\.\d++)?)*+\)?\z/.match?(d)
18
+ } => :weirdly_precise_mold,
19
+ # Anything else is spilled on the floor.
20
+ ->(*) { true } => :broken_mold,
21
+ }.freeze
22
+
23
+ # Regexp for removing brackets from lists.
24
+ BRACKET_STRIPPER = /\A\(?(.+)\)?\z/
25
+
26
+ # Cast a die definition into a mold to make a die.
27
+ #
28
+ # @param definition [String] die shape, refer to {MOLDS} for possible variants
29
+ # @return [NumericDie, RegularDie]
30
+ # @raise [DiceyError] if no mold fits the definition
31
+ def cast(definition)
32
+ _shape, mold = MOLDS.find { |shape, _mold| shape.call(definition) }
33
+ __send__(mold, definition)
34
+ end
35
+
36
+ private
37
+
38
+ def regular_mold(definition)
39
+ RegularDie.new(definition.to_i)
40
+ end
41
+
42
+ def weirdly_shaped_mold(definition)
43
+ definition = definition.match(BRACKET_STRIPPER)[1]
44
+ NumericDie.new(definition.split(",").map(&:to_i))
45
+ end
46
+
47
+ def weirdly_precise_mold(definition)
48
+ require "bigdecimal"
49
+ definition = definition.match(BRACKET_STRIPPER)[1]
50
+ NumericDie.new(definition.split(",").map { BigDecimal(_1) })
51
+ end
52
+
53
+ def broken_mold(definition)
54
+ raise DiceyError, "can not cast die from `#{definition}`!"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_die"
4
+
5
+ module Dicey
6
+ # A die which only has numeric sides, with no shenanigans.
7
+ class NumericDie < AbstractDie
8
+ # @param sides_list [Enumerable<Numeric>]
9
+ # @raise [DiceyError] if +sides_list+ contains non-numerical values or is empty
10
+ def initialize(sides_list)
11
+ sides_list.each do |value|
12
+ raise DiceyError, "`#{value}` is not a number!" unless value.is_a?(Numeric)
13
+ end
14
+ super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "key_value_formatter"
4
+
5
+ module Dicey
6
+ module OutputFormatters
7
+ # Formats a hash as a text file suitable for consumption by Gnuplot.
8
+ class GnuplotFormatter < KeyValueFormatter
9
+ SEPARATOR = " "
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ module OutputFormatters
5
+ # Base formatter for outputting in formats which can be converted from a Hash directly.
6
+ # Can add an optional description into the result.
7
+ # @abstract
8
+ class HashFormatter
9
+ # @param hash [Hash{Object => Object}]
10
+ # @param description [String] text to add to result as an extra key
11
+ # @return [String]
12
+ def call(hash, description = nil)
13
+ hash = hash.transform_keys { to_primitive(_1) }
14
+ hash.transform_values! { to_primitive(_1) }
15
+ output = {}
16
+ output["description"] = description if description
17
+ output["results"] = hash
18
+ output.public_send(self.class::METHOD)
19
+ end
20
+
21
+ private
22
+
23
+ def to_primitive(value)
24
+ primitive?(value) ? value : value.to_s
25
+ end
26
+
27
+ def primitive?(value)
28
+ value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(String)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hash_formatter"
4
+
5
+ module Dicey
6
+ module OutputFormatters
7
+ # Formats a hash as a JSON document under +results+ key, with optional +description+ key.
8
+ class JSONFormatter < HashFormatter
9
+ METHOD = :to_json
10
+ end
11
+ end
12
+ end