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 +7 -0
- data/README.md +236 -0
- data/exe/dicey +6 -0
- data/exe/dicey-to-gnuplot +27 -0
- data/lib/dicey/abstract_die.rb +91 -0
- data/lib/dicey/cli/blender.rb +81 -0
- data/lib/dicey/cli/options.rb +99 -0
- data/lib/dicey/die_foundry.rb +57 -0
- data/lib/dicey/numeric_die.rb +17 -0
- data/lib/dicey/output_formatters/gnuplot_formatter.rb +12 -0
- data/lib/dicey/output_formatters/hash_formatter.rb +32 -0
- data/lib/dicey/output_formatters/json_formatter.rb +12 -0
- data/lib/dicey/output_formatters/key_value_formatter.rb +20 -0
- data/lib/dicey/output_formatters/list_formatter.rb +12 -0
- data/lib/dicey/output_formatters/yaml_formatter.rb +12 -0
- data/lib/dicey/regular_die.rb +32 -0
- data/lib/dicey/roller.rb +28 -0
- data/lib/dicey/sum_frequency_calculators/base_calculator.rb +89 -0
- data/lib/dicey/sum_frequency_calculators/brute_force.rb +58 -0
- data/lib/dicey/sum_frequency_calculators/kronecker_substitution.rb +81 -0
- data/lib/dicey/sum_frequency_calculators/multinomial_coefficients.rb +104 -0
- data/lib/dicey/sum_frequency_calculators/runner.rb +38 -0
- data/lib/dicey/sum_frequency_calculators/test_runner.rb +111 -0
- data/lib/dicey/version.rb +5 -0
- data/lib/dicey.rb +10 -2
- data/sig/dicey.rbs +3 -0
- metadata +62 -60
- data/Rakefile +0 -7
- data/Readme.md +0 -21
- data/lib/dicey/dice.rb +0 -14
- data/test/dice_test.rb +0 -17
- data/test/die_test.rb +0 -13
- data/test/test_helper.rb +0 -2
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: [](https://rubygems.org/gems/dicey) -->
|
7
|
+
<!-- [](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
|
+

|
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,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
|