dicey 0.13.1 → 0.14.0
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 +4 -4
- data/README.md +186 -25
- data/lib/dicey/abstract_die.rb +71 -26
- data/lib/dicey/cli/options.rb +8 -5
- data/lib/dicey/die_foundry.rb +44 -25
- data/lib/dicey/numeric_die.rb +9 -2
- data/lib/dicey/regular_die.rb +8 -12
- data/lib/dicey/roller.rb +2 -3
- data/lib/dicey/sum_frequency_calculators/base_calculator.rb +8 -7
- data/lib/dicey/sum_frequency_calculators/brute_force.rb +33 -35
- data/lib/dicey/sum_frequency_calculators/multinomial_coefficients.rb +13 -16
- data/lib/dicey/sum_frequency_calculators/runner.rb +1 -1
- data/lib/dicey/version.rb +1 -1
- metadata +22 -9
- data/sig/dicey.rbs +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b23d3fe347080f349190417819aba5f2778a6196569ab39e38d79049f7557f02
|
4
|
+
data.tar.gz: 5bd9295a6bb1a15fe33fe3b967fd1d2c0784a19e713977f57da7910589687ebe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 778d6f36d896b5db19411bedf0bf272082310eb2c9a1403b57962932979ec0da863b6d408c9245b49bbfb914c99cc2bfa9e8443e0a02d732d2723f93f8b1f6bb
|
7
|
+
data.tar.gz: 694960b274505249fc705e0a61bb1452b6efed062d8b183c154734a9677cc9c28309cf859917443fd997a60de70e00c7f45abc9f9e36e96007949c1bdbee7e0f
|
data/README.md
CHANGED
@@ -1,16 +1,35 @@
|
|
1
1
|
# Dicey
|
2
2
|
|
3
|
+
[](https://rubygems.org/gems/dicey)
|
4
|
+
[](https://github.com/trinistr/dicey/actions/workflows/CI.yaml)
|
5
|
+
|
3
6
|
> [!TIP]
|
4
7
|
> 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
8
|
|
6
|
-
<!-- Latest: [](https://rubygems.org/gems/dicey) -->
|
7
|
-
<!-- [](https://github.com/trinistr/dicey/actions/workflows/CI.yaml) -->
|
8
|
-
|
9
9
|
***
|
10
10
|
|
11
11
|
The premier solution in total paradigm shift for resolving dicey problems of tomorrow, today, used by industry-leading professionals around the world!
|
12
12
|
|
13
|
-
In seriousness, this program
|
13
|
+
In seriousness, this program is mainly useful for calculating 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. It can also be used to roll any dice that it supports.
|
14
|
+
|
15
|
+
## Table of contents
|
16
|
+
|
17
|
+
- [No installation](#no-installation)
|
18
|
+
- [Installation](#installation)
|
19
|
+
- [Requirements](#requirements)
|
20
|
+
- [Usage / CLI (command line interface)](#usage--cli-command-line-interface)
|
21
|
+
- [Example 1 — Basic distribution](#example-1--basic-distribution)
|
22
|
+
- [Example 2 — Complex distribution with different dice](#example-2--complex-distribution-with-different-dice)
|
23
|
+
- [Example 3 — Custom dice](#example-3--custom-dice)
|
24
|
+
- [Example 4 — Rolling even more custom dice](#example-4--rolling-even-more-custom-dice)
|
25
|
+
- [Usage / API](#usage--api)
|
26
|
+
- [Dice](#dice)
|
27
|
+
- [Rolling](#rolling)
|
28
|
+
- [Calculators](#calculators)
|
29
|
+
- [Diving deeper](#diving-deeper)
|
30
|
+
- [Development](#development)
|
31
|
+
- [Contributing](#contributing)
|
32
|
+
- [License](#license)
|
14
33
|
|
15
34
|
## No installation
|
16
35
|
|
@@ -22,7 +41,7 @@ Thanks to the efforts of Ruby developers, you can try **Dicey** online!
|
|
22
41
|
|
23
42
|
## Installation
|
24
43
|
|
25
|
-
Install via `gem`:
|
44
|
+
Install manually via `gem`:
|
26
45
|
```sh
|
27
46
|
gem install dicey
|
28
47
|
```
|
@@ -36,20 +55,24 @@ gem "dicey", "~> 0.13"
|
|
36
55
|
> 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
56
|
|
38
57
|
> [!NOTE]
|
39
|
-
> `dicey` 0.0.1 was a completely separate project by [Adam Rogers](https://github.com/rodreegez). Big thanks for transfering the name!
|
58
|
+
> `dicey` 0.0.1 was a completely separate [project](https://github.com/rodreegez/dicey) by [Adam Rogers](https://github.com/rodreegez). Big thanks for transfering the name!
|
40
59
|
|
41
60
|
### Requirements
|
42
61
|
|
43
|
-
**Dicey** is
|
62
|
+
**Dicey** is tested to work on CRuby 3.0+, latest JRuby and TruffleRuby. Compatible implementations should work too.
|
63
|
+
- JSON and YAML formatting require `json` and `yaml`.
|
64
|
+
- Decimal dice require `bigdecimal`.
|
65
|
+
|
66
|
+
Otherwise, there are no direct dependencies.
|
44
67
|
|
45
|
-
## Usage
|
68
|
+
## Usage / CLI (command line interface)
|
46
69
|
|
47
70
|
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
71
|
|
49
72
|
> [!NOTE]
|
50
73
|
> 💡 Run `dicey --help` to get a list of all possible options.
|
51
74
|
|
52
|
-
### Example 1
|
75
|
+
### Example 1 — Basic distribution
|
53
76
|
|
54
77
|
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
78
|
```sh
|
@@ -85,11 +108,12 @@ $ dicey 4 4 --result probabilities # or -r p for short
|
|
85
108
|
|
86
109
|
This shows that 5 will probably be rolled a quarter of the time.
|
87
110
|
|
88
|
-
### Example 2
|
111
|
+
### Example 2 — Complex distribution with different dice
|
89
112
|
|
90
113
|
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
114
|
```sh
|
92
|
-
|
115
|
+
# Note the shorthand notation for two dice!
|
116
|
+
$ dicey 8 2d4
|
93
117
|
# [8];⚃;⚃
|
94
118
|
3 => 1
|
95
119
|
4 => 3
|
@@ -109,19 +133,48 @@ $ dicey 8 4 4
|
|
109
133
|
|
110
134
|
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
135
|
|
136
|
+
#### Example 2.1 — Graph
|
137
|
+
|
112
138
|
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
139
|
```sh
|
114
|
-
$ dicey 8
|
115
|
-
#
|
140
|
+
$ dicey 8 2d4 --format gnuplot | dicey-to-gnuplot
|
141
|
+
# `--format gnuplot` can be abbreviated as `-f g`
|
116
142
|
```
|
117
143
|
|
118
144
|
This will create a PNG image named `[8];⚃;⚃.png`:
|
119
145
|

|
120
146
|
|
121
|
-
|
122
|
-
|
147
|
+
#### Example 2.2 — JSON and YAML
|
148
|
+
|
149
|
+
If you find that you need to export results for further processing, it would be great if a common data interchange format was used. **Dicey** supports output as JSON and YAML with `--format json` (or `-f j`) and `--format yaml` (or `-f y`) respectively.
|
123
150
|
|
124
|
-
|
151
|
+
JSON via `dicey 8 2d4 --format json`:
|
152
|
+
```json
|
153
|
+
{"description":"[8];⚃;⚃","results":{"3":1,"4":3,"5":6,"6":10,"7":13,"8":15,"9":16,"10":16,"11":15,"12":13,"13":10,"14":6,"15":3,"16":1}}
|
154
|
+
```
|
155
|
+
|
156
|
+
YAML via `dicey 8 2d4 --format yaml`:
|
157
|
+
```yaml
|
158
|
+
---
|
159
|
+
description: "[8];⚃;⚃"
|
160
|
+
results:
|
161
|
+
3: 1
|
162
|
+
4: 3
|
163
|
+
5: 6
|
164
|
+
6: 10
|
165
|
+
7: 13
|
166
|
+
8: 15
|
167
|
+
9: 16
|
168
|
+
10: 16
|
169
|
+
11: 15
|
170
|
+
12: 13
|
171
|
+
13: 10
|
172
|
+
14: 6
|
173
|
+
15: 3
|
174
|
+
16: 1
|
175
|
+
```
|
176
|
+
|
177
|
+
### Example 3 — Custom dice
|
125
178
|
|
126
179
|
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
180
|
|
@@ -141,15 +194,29 @@ $ dicey 1,2,4 4
|
|
141
194
|
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
195
|
|
143
196
|
> [!TIP]
|
144
|
-
> 💡 A single integer argument N practically is a shorthand for listing every side from 1 to N.
|
197
|
+
> 💡 A single positive integer argument N practically is a shorthand for listing every side from 1 to N.
|
198
|
+
|
199
|
+
But what if you had TWO weird D4s?
|
200
|
+
```sh
|
201
|
+
$ dicey 2d1,2,4
|
202
|
+
# (1,2,4);(1,2,4)
|
203
|
+
2 => 1
|
204
|
+
3 => 2
|
205
|
+
4 => 1
|
206
|
+
5 => 2
|
207
|
+
6 => 2
|
208
|
+
8 => 1
|
209
|
+
```
|
210
|
+
|
211
|
+
Hah, now this is a properly cursed distribution!
|
145
212
|
|
146
|
-
### Example 4
|
213
|
+
### Example 4 — Rolling even more custom dice
|
147
214
|
|
148
215
|
You have a sudden urge to roll dice while only having boring integer dice at home. Where to find *the cool* dice though?
|
149
216
|
|
150
217
|
Look no further than **roll** mode introduced in **Dicey** 0.12:
|
151
218
|
```sh
|
152
|
-
dicey 0.5,1.5,2.5 4 --mode roll # As always, can be abbreviated to -m r
|
219
|
+
$ dicey 0.5,1.5,2.5 4 --mode roll # As always, can be abbreviated to -m r
|
153
220
|
# (0.5e0,0.15e1,0.25e1);⚃
|
154
221
|
roll => 0.35e1 # You probably will get a different value here.
|
155
222
|
```
|
@@ -157,6 +224,100 @@ roll => 0.35e1 # You probably will get a different value here.
|
|
157
224
|
> [!NOTE]
|
158
225
|
> 💡 Roll mode is compatible with `--format`, but not `--result`.
|
159
226
|
|
227
|
+
## Usage / API
|
228
|
+
|
229
|
+
> [!Note]
|
230
|
+
> - Latest documentation from `main` branch is automatically deployed to [GitHub Pages](https://trinistr.github.io/dicey).
|
231
|
+
> - Documentation for published versions is available on [RubyDoc](https://rubydoc.info/gems/dicey).
|
232
|
+
|
233
|
+
### Dice
|
234
|
+
|
235
|
+
There are 3 classes of dice currently:
|
236
|
+
- `Dicey::AbstractDie` is the base class for other dice, but can be used on its own. It has no restrictions on values of sides. For now, it is *only* useful for rolling and can't be used for distribution calculations.
|
237
|
+
- `Dicey::NumericDie` behaves much the same as `Dicey::AbstractDie`, except for checking that all values are instances of `Numeric`. It can be initialized with an Array or Range.
|
238
|
+
- `Dicey::RegularDie` is a subclass of `Dicey::NumericDie`. It is defined by a single integer which is expanded to range (1..N).
|
239
|
+
|
240
|
+
All dice classes have constructor methods aside from `.new`:
|
241
|
+
- `.from_list` takes a list of definitions and calls `.new` with each one;
|
242
|
+
- `.from_count` takes a count and a definition and calls `.new` with it specified number of times.
|
243
|
+
|
244
|
+
See [Diving deeper](#diving-deeper) for more information.
|
245
|
+
|
246
|
+
> [!NOTE]
|
247
|
+
> 💡 Using `Float` values is liable to cause precision issues. Due to in-built result verification, this **will** raise errors. Use `Rational` or `BigDecimal` instead.
|
248
|
+
|
249
|
+
#### DieFoundry
|
250
|
+
|
251
|
+
`Dicey::DieFoundry#call` provides the string interface for creating dice as available in CLI:
|
252
|
+
```rb
|
253
|
+
Dicey::DieFoundry.new.call("100")
|
254
|
+
# same as Dicey::RegularDie.new(100)
|
255
|
+
Dicey::DieFoundry.new.call("2d6")
|
256
|
+
# same as Dicey::RegularDie.from_count(2, 6)
|
257
|
+
Dicey::DieFoundry.new.call("1d1,2,4")
|
258
|
+
# same as Dicey::NumericDie.from_list([1,2,4])
|
259
|
+
```
|
260
|
+
|
261
|
+
It only takes a single argument and may return both an array of dice and a single die. You will probably want to use `Enumerable#flat_map`:
|
262
|
+
```rb
|
263
|
+
foundry = Dicey::DieFoundry.new
|
264
|
+
%w[8 2d4].flat_map { foundry.call(_1) }
|
265
|
+
# same as [Dicey::RegularDie.new(8), Dicey::RegularDie.new(4), Dicey::RegularDie.new(4)]
|
266
|
+
```
|
267
|
+
|
268
|
+
### Rolling
|
269
|
+
|
270
|
+
`Dicey::AbstractDie#roll` implements the rolling:
|
271
|
+
```rb
|
272
|
+
Dicey::AbstractDie.new([0, 1, 5, "10"]).roll
|
273
|
+
# almost same as [0, 1, 5, "10"].sample
|
274
|
+
Dicey::RegularDie.new(6).roll
|
275
|
+
# almost same as rand(1..6)
|
276
|
+
```
|
277
|
+
|
278
|
+
Dice retain their roll state, with `#current` returning the last roll (or initial side if never rolled):
|
279
|
+
```rb
|
280
|
+
die = Dicey::RegularDie.new(6)
|
281
|
+
die.current
|
282
|
+
# => 1
|
283
|
+
die.roll
|
284
|
+
# => 3
|
285
|
+
die.current
|
286
|
+
# => 3
|
287
|
+
```
|
288
|
+
|
289
|
+
Rolls can be reproducible if a specific seed is set:
|
290
|
+
```rb
|
291
|
+
Dicey::AbstractDie.srand(493_525)
|
292
|
+
die = Dicey::RegularDie.new(6)
|
293
|
+
die.roll
|
294
|
+
# => 4
|
295
|
+
die.roll
|
296
|
+
# => 1
|
297
|
+
# Repeat:
|
298
|
+
Dicey::AbstractDie.srand(493_525)
|
299
|
+
die = Dicey::RegularDie.new(6)
|
300
|
+
die.roll
|
301
|
+
# => 4
|
302
|
+
die.roll
|
303
|
+
# => 1
|
304
|
+
```
|
305
|
+
|
306
|
+
Randomness source is *global*, shared between all dice and probably not thread-safe.
|
307
|
+
|
308
|
+
### Calculators
|
309
|
+
|
310
|
+
Frequency calculators live in `Dicey::SumFrequencyCalculators` module. There are three implemented calculators:
|
311
|
+
- `Dicey::SumFrequencyCalculators::KroneckerSubstitution` is the recommended calculator, able to handle all `Dicey::RegularDie`. It is very fast, calculating distribution for *100d6* in about 0.1 seconds on my laptop.
|
312
|
+
- `Dicey::SumFrequencyCalculators::MultinomialCoefficients` is specialized for repeated numeric dice, with performance only slightly worse. However, it is currently limited to dice with arithmetic sequences.
|
313
|
+
- `Dicey::SumFrequencyCalculators::BruteForce` is the most generic and slowest one, but can handle any dice. Currently, it is also limited to `Dicey::NumericDie`, as it's unclear how to handle other values.
|
314
|
+
|
315
|
+
Calculators inherit from `Dicey::SumFrequencyCalculators::BaseCalculator` and provide the following public interface:
|
316
|
+
- `#call(dice, result_type: {:frequencies | :probabilities}) : Hash`
|
317
|
+
- `#valid_for?(dice) : Boolean`
|
318
|
+
|
319
|
+
See [next section](#diving-deeper) for more details on limitations and complexity considerations.
|
320
|
+
|
160
321
|
## Diving deeper
|
161
322
|
|
162
323
|
For a further discussion of calculations, it is important to understand which classes of dice exist.
|
@@ -209,7 +370,7 @@ As a last resort, there is a brute force algorithm which goes through every poss
|
|
209
370
|
|
210
371
|
## Development
|
211
372
|
|
212
|
-
After checking out the repo, run `bundle
|
373
|
+
After checking out the repo, run `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
374
|
|
214
375
|
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
376
|
|
@@ -223,13 +384,13 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/trinis
|
|
223
384
|
|
224
385
|
### Checklist for a new or updated feature
|
225
386
|
|
226
|
-
- Running `
|
227
|
-
- Running `rubocop` reports no offenses.
|
387
|
+
- Running `rake spec` reports 100% coverage (unless it's impossible to achieve in one run).
|
388
|
+
- Running `rake rubocop` reports no offenses.
|
228
389
|
- 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
|
390
|
+
- Tests cover the behavior and its interactions. 100% coverage *is not enough*, as it does not guarantee that all code paths are tested.
|
230
391
|
- Documentation is up-to-date: generate it with `rake docs` and read it.
|
231
|
-
-
|
232
|
-
-
|
392
|
+
- "*CHANGELOG.md*" lists the change if it has impact on users.
|
393
|
+
- "*README.md*" is updated if the feature should be visible there, including the Kanban board.
|
233
394
|
|
234
395
|
## License
|
235
396
|
|
data/lib/dicey/abstract_die.rb
CHANGED
@@ -6,6 +6,7 @@ module Dicey
|
|
6
6
|
class AbstractDie
|
7
7
|
# rubocop:disable Style/ClassVars
|
8
8
|
|
9
|
+
# @api private
|
9
10
|
# Get a random value using a private instance of Random.
|
10
11
|
# @see Random#rand
|
11
12
|
def self.rand(...)
|
@@ -26,66 +27,110 @@ module Dicey
|
|
26
27
|
|
27
28
|
# Get a text representation of a list of dice.
|
28
29
|
#
|
29
|
-
# @param dice [Enumerable<AbstractDie
|
30
|
+
# @param dice [Enumerable<AbstractDie>, AbstractDie]
|
30
31
|
# @return [String]
|
31
32
|
def self.describe(dice)
|
32
|
-
dice.
|
33
|
+
return dice.to_s if AbstractDie === dice
|
34
|
+
return dice.join(";") if Array === dice
|
35
|
+
|
36
|
+
dice.to_a.join(";")
|
37
|
+
end
|
38
|
+
|
39
|
+
# Create a bunch of different dice at once.
|
40
|
+
#
|
41
|
+
# @param definitions [Array<Enumerable<Any>>, Array<Any>]
|
42
|
+
# list of definitions suitable for the dice class
|
43
|
+
# @return [Array<AbstractDie>]
|
44
|
+
def self.from_list(*definitions)
|
45
|
+
definitions.map { new(_1) }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Create a number of equal dice.
|
49
|
+
#
|
50
|
+
# @param count [Integer] number of dice to create
|
51
|
+
# @param definition [Enumerable<Any>, Any]
|
52
|
+
# definition suitable for the dice class
|
53
|
+
# @return [Array<AbstractDie>]
|
54
|
+
def self.from_count(count, definition)
|
55
|
+
Array.new(count) { new(definition) }
|
33
56
|
end
|
34
57
|
|
35
58
|
attr_reader :sides_list, :sides_num
|
36
59
|
|
37
|
-
# @param sides_list [Enumerable<
|
60
|
+
# @param sides_list [Enumerable<Any>]
|
38
61
|
# @raise [DiceyError] if +sides_list+ is empty
|
39
62
|
def initialize(sides_list)
|
40
|
-
@sides_list =
|
63
|
+
@sides_list = (Array === sides_list) ? sides_list.dup.freeze : sides_list.to_a.freeze
|
41
64
|
raise DiceyError, "dice must have at least one side!" if @sides_list.empty?
|
42
65
|
|
43
66
|
@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
|
67
|
+
@current_side_index = 0
|
53
68
|
end
|
54
69
|
|
55
70
|
# Get current side of the die.
|
56
|
-
#
|
71
|
+
#
|
72
|
+
# @return [Any] current side
|
57
73
|
def current
|
58
|
-
@
|
74
|
+
@sides_list[@current_side_index]
|
59
75
|
end
|
60
76
|
|
61
|
-
# Get next side of the die, advancing internal
|
62
|
-
#
|
63
|
-
#
|
77
|
+
# Get next side of the die, advancing internal state.
|
78
|
+
# Starts from first side, wraps from last to first side.
|
79
|
+
#
|
80
|
+
# @return [Any] next side
|
64
81
|
def next
|
65
|
-
|
82
|
+
ret = current
|
83
|
+
@current_side_index = (@current_side_index + 1) % @sides_num
|
84
|
+
ret
|
66
85
|
end
|
67
86
|
|
68
|
-
#
|
69
|
-
#
|
87
|
+
# Move internal state to a random side.
|
88
|
+
#
|
89
|
+
# @return [Any] rolled side
|
70
90
|
def roll
|
71
|
-
self.class.rand(0
|
91
|
+
@current_side_index = self.class.rand(0...@sides_num)
|
72
92
|
current
|
73
93
|
end
|
74
94
|
|
75
95
|
# @return [String]
|
76
96
|
def to_s
|
77
|
-
"(#{sides_list.join(",")})"
|
97
|
+
"(#{@sides_list.join(",")})"
|
78
98
|
end
|
79
99
|
|
80
100
|
# Determine if this die and the other one have the same list of sides.
|
81
101
|
# Be aware that differently ordered sides are not considered equal.
|
82
102
|
#
|
83
|
-
# @param other [AbstractDie,
|
103
|
+
# @param other [AbstractDie, Any]
|
84
104
|
# @return [Boolean]
|
85
105
|
def ==(other)
|
86
|
-
|
106
|
+
AbstractDie === other && same_sides?(other)
|
107
|
+
end
|
87
108
|
|
88
|
-
|
109
|
+
# Determine if this die and the other one are of the same class
|
110
|
+
# and have the same list of sides.
|
111
|
+
# Be aware that differently ordered sides are not considered equal.
|
112
|
+
#
|
113
|
+
# +die_1.eql?(die_2)+ implies +die_1.hash == die_2.hash+.
|
114
|
+
#
|
115
|
+
# @param other [AbstractDie, Any]
|
116
|
+
# @return [Boolean]
|
117
|
+
def eql?(other)
|
118
|
+
self.class === other && same_sides?(other)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Generates an Integer hash value for this object.
|
122
|
+
#
|
123
|
+
# @return [Integer]
|
124
|
+
def hash
|
125
|
+
[self.class, @sides_list].hash
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
# @param other [AbstractDie]
|
131
|
+
# @return [Boolean]
|
132
|
+
def same_sides?(other)
|
133
|
+
@sides_list == other.sides_list
|
89
134
|
end
|
90
135
|
end
|
91
136
|
end
|
data/lib/dicey/cli/options.rb
CHANGED
@@ -19,6 +19,9 @@ module Dicey
|
|
19
19
|
def initialize(initial_options = DEFAULT_OPTIONS.dup)
|
20
20
|
@options = initial_options
|
21
21
|
@parser = ::OptionParser.new
|
22
|
+
@parser.program_name = "dicey"
|
23
|
+
@parser.version = Dicey::VERSION
|
24
|
+
|
22
25
|
add_banner_and_version
|
23
26
|
add_common_options
|
24
27
|
add_test_options
|
@@ -54,7 +57,6 @@ module Dicey
|
|
54
57
|
#{@parser.program_name} --test [full|quiet]
|
55
58
|
All option names and arguments can be abbreviated if abbreviation is unambiguous.
|
56
59
|
TEXT
|
57
|
-
@parser.version = Dicey::VERSION
|
58
60
|
end
|
59
61
|
|
60
62
|
def add_common_options
|
@@ -86,13 +88,14 @@ module Dicey
|
|
86
88
|
end
|
87
89
|
end
|
88
90
|
|
89
|
-
def easy_option(short, long, values, description, &)
|
90
|
-
values = values.keys if values.respond_to?(:keys)
|
91
|
+
def easy_option(short, long, values, description, &block)
|
91
92
|
option_name = long[/[a-z_]+/].to_sym
|
92
93
|
argument_name = long[/[A-Z_]+/]
|
93
94
|
listed_values = "#{argument_name} can be: #{values.map { "`#{_1}`" }.join(", ")}."
|
94
|
-
default_value = "`#{@options[option_name]}` is default."
|
95
|
-
@parser.on(
|
95
|
+
default_value = "`#{@options[option_name]}` is default."
|
96
|
+
@parser.on(
|
97
|
+
*[short, long, values, description, listed_values, default_value].compact, &block
|
98
|
+
)
|
96
99
|
end
|
97
100
|
end
|
98
101
|
end
|
data/lib/dicey/die_foundry.rb
CHANGED
@@ -6,52 +6,71 @@ require_relative "regular_die"
|
|
6
6
|
module Dicey
|
7
7
|
# Helper class to define die definitions and automatically select the best one.
|
8
8
|
class DieFoundry
|
9
|
+
# Regexp for matching a count.
|
10
|
+
COUNT = /(?:(?<count>[1-9]\d*+)?d)?+/i
|
11
|
+
|
9
12
|
# Possible molds for the dice. They are matched in the order as written.
|
10
|
-
MOLDS =
|
13
|
+
MOLDS = [
|
11
14
|
# Positive integer goes into the RegularDie mold.
|
12
|
-
|
15
|
+
[/\A#{COUNT}(?<sides>[1-9]\d*+)\z/, :regular_mold],
|
13
16
|
# List of numbers goes into the NumericDie mold.
|
14
|
-
|
15
|
-
#
|
16
|
-
|
17
|
-
|
18
|
-
} => :weirdly_precise_mold,
|
17
|
+
[/\A#{COUNT}\(?(?<sides>-?\d++(?>,(?>-?\d++)?)*)\)?\z/, :weirdly_shaped_mold],
|
18
|
+
# Non-integers require arbitrary precision arithmetic, which is not enabled by default.
|
19
|
+
[/\A#{COUNT}\(?(?<sides>-?\d++(?>\.\d++)?(?>,(?>-?\d++(?>\.\d++)?)?)*)\)?\z/,
|
20
|
+
:weirdly_precise_mold],
|
19
21
|
# Anything else is spilled on the floor.
|
20
|
-
|
21
|
-
}.freeze
|
22
|
-
|
23
|
-
# Regexp for removing brackets from lists.
|
24
|
-
BRACKET_STRIPPER = /\A\(?(.+)\)?\z/
|
22
|
+
].freeze
|
25
23
|
|
26
24
|
# Cast a die definition into a mold to make a die.
|
27
25
|
#
|
28
|
-
#
|
29
|
-
#
|
26
|
+
# Following definitions are recognized:
|
27
|
+
# - positive integer (like "6" or "20"), which produces a {RegularDie};
|
28
|
+
# - list of integers (like "3,4,5", "(-1,0,1)", or "2,"), which produces a {NumericDie};
|
29
|
+
# - list of decimal numbers (like "0.5,0.2,0.8" or "2.0"), which produces a {NumericDie},
|
30
|
+
# but uses +BigDecimal+ for values to maintain precise results.
|
31
|
+
#
|
32
|
+
# Any die definition can be prefixed with a count, like "2D6" or "1d1,3,5" to create an array.
|
33
|
+
# A plain "d" without an explicit count is ignored instead, creating a single die.
|
34
|
+
#
|
35
|
+
# @param definition [String] die shape
|
36
|
+
# @return [NumericDie, RegularDie, Array<NumericDie, RegularDie>]
|
30
37
|
# @raise [DiceyError] if no mold fits the definition
|
31
|
-
def
|
32
|
-
|
33
|
-
|
38
|
+
def call(definition)
|
39
|
+
matched, name =
|
40
|
+
MOLDS.reduce(nil) do |_, (shape, mold)|
|
41
|
+
match = shape.match(definition)
|
42
|
+
break [match, mold] if match
|
43
|
+
end
|
44
|
+
raise DiceyError, "can not cast die from `#{definition}`!" unless name
|
45
|
+
|
46
|
+
__send__(name, matched)
|
34
47
|
end
|
35
48
|
|
49
|
+
alias cast call
|
50
|
+
|
36
51
|
private
|
37
52
|
|
38
53
|
def regular_mold(definition)
|
39
|
-
RegularDie
|
54
|
+
build_dice(RegularDie, definition[:count], definition[:sides].to_i)
|
40
55
|
end
|
41
56
|
|
42
57
|
def weirdly_shaped_mold(definition)
|
43
|
-
definition
|
44
|
-
NumericDie.new(definition.split(",").map(&:to_i))
|
58
|
+
build_dice(NumericDie, definition[:count], definition[:sides].split(",").map(&:to_i))
|
45
59
|
end
|
46
60
|
|
47
61
|
def weirdly_precise_mold(definition)
|
48
|
-
require "bigdecimal"
|
49
|
-
|
50
|
-
|
62
|
+
require "bigdecimal" unless defined?(BigDecimal)
|
63
|
+
|
64
|
+
sides = definition[:sides].split(",").map { BigDecimal(_1) }
|
65
|
+
build_dice(NumericDie, definition[:count], sides)
|
51
66
|
end
|
52
67
|
|
53
|
-
def
|
54
|
-
|
68
|
+
def build_dice(die_class, count, sides)
|
69
|
+
if count
|
70
|
+
die_class.from_count(count.to_i, sides)
|
71
|
+
else
|
72
|
+
die_class.new(sides)
|
73
|
+
end
|
55
74
|
end
|
56
75
|
end
|
57
76
|
end
|
data/lib/dicey/numeric_die.rb
CHANGED
@@ -8,9 +8,16 @@ module Dicey
|
|
8
8
|
# @param sides_list [Enumerable<Numeric>]
|
9
9
|
# @raise [DiceyError] if +sides_list+ contains non-numerical values or is empty
|
10
10
|
def initialize(sides_list)
|
11
|
-
|
12
|
-
|
11
|
+
if Range === sides_list
|
12
|
+
unless Numeric === sides_list.begin && Numeric === sides_list.end
|
13
|
+
raise DiceyError, "`#{sides_list.inspect}` is not a numerical range!"
|
14
|
+
end
|
15
|
+
else
|
16
|
+
sides_list.each do |value|
|
17
|
+
raise DiceyError, "`#{value.inspect}` is not a number!" unless Numeric === value
|
18
|
+
end
|
13
19
|
end
|
20
|
+
|
14
21
|
super
|
15
22
|
end
|
16
23
|
end
|
data/lib/dicey/regular_die.rb
CHANGED
@@ -8,22 +8,18 @@ module Dicey
|
|
8
8
|
# Characters to use for small dice.
|
9
9
|
D6 = "⚀⚁⚂⚃⚄⚅"
|
10
10
|
|
11
|
-
#
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
def self.create_dice(dice, sides)
|
17
|
-
(1..dice).map { new(sides) }
|
18
|
-
end
|
11
|
+
# @param max [Integer] maximum side / number of sides
|
12
|
+
def initialize(max)
|
13
|
+
unless Integer === max && max.positive?
|
14
|
+
raise DiceyError, "regular dice can contain only positive integers, #{max.inspect} is not"
|
15
|
+
end
|
19
16
|
|
20
|
-
|
21
|
-
def initialize(sides)
|
22
|
-
super((1..sides))
|
17
|
+
super((1..max))
|
23
18
|
end
|
24
19
|
|
25
|
-
# Dice with 1–6 sides are displayed with a single character.
|
20
|
+
# Dice with 1–6 sides are displayed with a single character from {D6}.
|
26
21
|
# More than that, and we get into the square bracket territory.
|
22
|
+
#
|
27
23
|
# @return [String]
|
28
24
|
def to_s
|
29
25
|
(sides_num <= D6.size) ? D6[sides_num - 1] : "[#{sides_num}]"
|
data/lib/dicey/roller.rb
CHANGED
@@ -12,10 +12,9 @@ module Dicey
|
|
12
12
|
def call(arguments, format:, **)
|
13
13
|
raise DiceyError, "no dice!" if arguments.empty?
|
14
14
|
|
15
|
-
dice = arguments.
|
16
|
-
dice.
|
15
|
+
dice = arguments.flat_map { |definition| die_foundry.cast(definition) }
|
16
|
+
result = dice.sum(&:roll)
|
17
17
|
|
18
|
-
result = dice.sum(&:current)
|
19
18
|
format.call({ "roll" => result }, AbstractDie.describe(dice))
|
20
19
|
end
|
21
20
|
|
@@ -18,6 +18,8 @@ module Dicey
|
|
18
18
|
unless RESULT_TYPES.include?(result_type)
|
19
19
|
raise DiceyError, "#{result_type} is not a valid result type!"
|
20
20
|
end
|
21
|
+
# Short-circuit for a degenerate case.
|
22
|
+
return {} if dice.empty?
|
21
23
|
raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
|
22
24
|
|
23
25
|
frequencies = calculate(dice)
|
@@ -31,7 +33,7 @@ module Dicey
|
|
31
33
|
# @param dice [Enumerable<AbstractDie>]
|
32
34
|
# @return [Boolean]
|
33
35
|
def valid_for?(dice)
|
34
|
-
dice.is_a?(Enumerable) && dice.all?
|
36
|
+
dice.is_a?(Enumerable) && dice.all?(AbstractDie) && validate(dice)
|
35
37
|
end
|
36
38
|
|
37
39
|
private
|
@@ -45,7 +47,9 @@ module Dicey
|
|
45
47
|
# Peform frequencies calculation.
|
46
48
|
# (see #call)
|
47
49
|
def calculate(dice)
|
50
|
+
# :nocov:
|
48
51
|
raise NotImplementedError
|
52
|
+
# :nocov:
|
49
53
|
end
|
50
54
|
|
51
55
|
# Check that resulting frequencies actually add up to what they are supposed to be.
|
@@ -55,7 +59,7 @@ module Dicey
|
|
55
59
|
# @return [void]
|
56
60
|
# @raise [DiceyError] if result is wrong
|
57
61
|
def verify_result(frequencies, dice)
|
58
|
-
valid = frequencies.values.sum == dice.map(&:sides_num).reduce(:*)
|
62
|
+
valid = frequencies.values.sum == (dice.map(&:sides_num).reduce(:*) || 0)
|
59
63
|
raise DiceyError, "calculator #{self.class} returned invalid results!" unless valid
|
60
64
|
end
|
61
65
|
|
@@ -74,14 +78,11 @@ module Dicey
|
|
74
78
|
# @param result_type [Symbol] one of {RESULT_TYPES}
|
75
79
|
# @return [Hash{Numeric => Numeric}]
|
76
80
|
def transform_result(frequencies, result_type)
|
77
|
-
|
78
|
-
when :frequencies
|
81
|
+
if result_type == :frequencies
|
79
82
|
frequencies
|
80
|
-
|
83
|
+
else
|
81
84
|
total = frequencies.values.sum
|
82
85
|
frequencies.transform_values { _1.fdiv(total) }
|
83
|
-
else
|
84
|
-
# Invalid, but was already checked in #call.
|
85
86
|
end
|
86
87
|
end
|
87
88
|
end
|
@@ -8,51 +8,49 @@ module Dicey
|
|
8
8
|
class BruteForce < BaseCalculator
|
9
9
|
private
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
def validate(dice)
|
12
|
+
dice.all?(NumericDie)
|
13
|
+
end
|
14
14
|
|
15
15
|
def calculate(dice)
|
16
|
-
# TODO: Replace `combine_dice_enumerators` with `Enumerator.product`.
|
17
16
|
combine_dice_enumerators(dice).map(&:sum).tally
|
18
17
|
end
|
19
18
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
19
|
+
if defined?(Enumerator::Product)
|
20
|
+
# Get an enumerator which goes through all possible permutations of dice sides.
|
21
|
+
#
|
22
|
+
# @param dice [Enumerable<NumericDie>]
|
23
|
+
# @return [Enumerator<Array<Numeric>>]
|
24
|
+
def combine_dice_enumerators(dice)
|
25
|
+
Enumerator::Product.new(*dice.map(&:sides_list))
|
26
|
+
end
|
27
|
+
# :nocov:
|
28
|
+
else
|
29
|
+
# Get an enumerator which goes through all possible permutations of dice sides.
|
30
|
+
#
|
31
|
+
# @param dice [Enumerable<NumericDie>]
|
32
|
+
# @return [Enumerator<Array<Numeric>>]
|
33
|
+
def combine_dice_enumerators(dice)
|
34
|
+
product(dice.map(&:sides_list))
|
34
35
|
end
|
35
|
-
end
|
36
36
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
# @param current_values [Array<Numeric>]
|
45
|
-
# @return [void]
|
46
|
-
def iterate_dice(dice, remaining_iterations, current_values)
|
47
|
-
dice.each_with_index do |die, i|
|
48
|
-
value = die.next
|
49
|
-
current_values[i] = value
|
50
|
-
remaining_iterations[i] -= 1
|
51
|
-
break if remaining_iterations[i].nonzero?
|
37
|
+
# Simplified implementation of {Enumerator::Product}.
|
38
|
+
# Adapted from {https://bugs.ruby-lang.org/issues/18685#note-10}.
|
39
|
+
#
|
40
|
+
# @param enums [Enumerable<Enumerable<Numeric>>]
|
41
|
+
# @return [Enumerator<Array<Numeric>>]
|
42
|
+
def product(enums, &block)
|
43
|
+
return to_enum(__method__, enums) unless block_given?
|
52
44
|
|
53
|
-
|
45
|
+
enums
|
46
|
+
.reverse
|
47
|
+
.reduce(block) { |inner, enum|
|
48
|
+
->(values) { enum.each_entry { inner.call([*values, _1]) } }
|
49
|
+
}
|
50
|
+
.call([])
|
54
51
|
end
|
55
52
|
end
|
53
|
+
# :nocov:
|
56
54
|
end
|
57
55
|
end
|
58
56
|
end
|
@@ -30,6 +30,7 @@ module Dicey
|
|
30
30
|
return false if increment.zero?
|
31
31
|
|
32
32
|
sides_list.each_cons(2) { return false if _1 + increment != _2 }
|
33
|
+
true
|
33
34
|
end
|
34
35
|
|
35
36
|
# @param dice [Array<NumericDie>]
|
@@ -48,27 +49,22 @@ module Dicey
|
|
48
49
|
#
|
49
50
|
# @param dice [Integer] number of dice, must be positive
|
50
51
|
# @param sides [Integer] number of sides, must be positive
|
51
|
-
# @param throw_away_garbage [Boolean] whether to discard unused coefficients (debug option)
|
52
52
|
# @return [Array<Integer>]
|
53
|
-
def multinomial_coefficients(dice, sides
|
54
|
-
# This builds a triangular matrix where
|
55
|
-
#
|
56
|
-
# with indices less or equal
|
57
|
-
# Example for
|
53
|
+
def multinomial_coefficients(dice, sides)
|
54
|
+
# This builds a triangular matrix where first elements are always 1s
|
55
|
+
# and other elements are sums of +sides+ elements in the previous row
|
56
|
+
# with indices less or equal, with out-of-bounds indices corresponding to 0s.
|
57
|
+
# Example for sides=3:
|
58
58
|
# 1
|
59
59
|
# 1 1 1
|
60
60
|
# 1 2 3 2 1
|
61
61
|
# 1 3 6 7 6 3 1, etc.
|
62
|
-
|
63
|
-
(1
|
64
|
-
|
65
|
-
|
66
|
-
coefficients[0] = row
|
67
|
-
else
|
68
|
-
coefficients << row
|
69
|
-
end
|
62
|
+
# We start directly from second row, which corresponds to 1 die.
|
63
|
+
coefficients = Array.new(sides, 1)
|
64
|
+
(2..dice).each do |row_index|
|
65
|
+
coefficients = next_row_of_coefficients(row_index, sides - 1, coefficients)
|
70
66
|
end
|
71
|
-
coefficients
|
67
|
+
coefficients
|
72
68
|
end
|
73
69
|
|
74
70
|
# @param row_index [Integer]
|
@@ -79,7 +75,8 @@ module Dicey
|
|
79
75
|
length = (row_index * window_size) + 1
|
80
76
|
(0..length).map do |col_index|
|
81
77
|
# Have to clamp to 0 to prevent accessing array from the end.
|
82
|
-
|
78
|
+
# BUG: TruffleRuby can't handle endless range in #clamp (see https://github.com/oracle/truffleruby/issues/3945)
|
79
|
+
window_range = ((col_index - window_size).clamp(0..col_index)..col_index)
|
83
80
|
window_range.sum { |i| previous_row.fetch(i, 0) }
|
84
81
|
end
|
85
82
|
end
|
@@ -21,7 +21,7 @@ module Dicey
|
|
21
21
|
def call(arguments, roll_calculators:, format:, result:, **)
|
22
22
|
raise DiceyError, "no dice!" if arguments.empty?
|
23
23
|
|
24
|
-
dice = arguments.
|
24
|
+
dice = arguments.flat_map { |definition| die_foundry.cast(definition) }
|
25
25
|
frequencies = roll_calculators.find { _1.valid_for?(dice) }&.call(dice, result_type: result)
|
26
26
|
raise DiceyError, "no calculator could handle these dice!" unless frequencies
|
27
27
|
|
data/lib/dicey/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,25 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dicey
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.14.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alexandr Bulancov
|
8
|
+
autorequire:
|
8
9
|
bindir: exe
|
9
10
|
cert_chain: []
|
10
|
-
date:
|
11
|
+
date: 2025-09-11 00:00:00.000000000 Z
|
11
12
|
dependencies: []
|
13
|
+
description: |
|
14
|
+
Dicey provides a CLI executable and a Ruby API for fast calculation of
|
15
|
+
frequency/probability distributions of dice rolls,
|
16
|
+
with support for all kinds of numeric dice, even Complex ones!
|
17
|
+
Results can be exported as JSON, YAML or a gnuplot data file.
|
18
|
+
|
19
|
+
It can also be used to roll dice. While not the primary focus,
|
20
|
+
rolling is well supported, including ability to seed random source
|
21
|
+
for reproducible results.
|
22
|
+
email:
|
12
23
|
executables:
|
13
24
|
- dicey
|
14
25
|
- dicey-to-gnuplot
|
@@ -40,17 +51,17 @@ files:
|
|
40
51
|
- lib/dicey/sum_frequency_calculators/runner.rb
|
41
52
|
- lib/dicey/sum_frequency_calculators/test_runner.rb
|
42
53
|
- lib/dicey/version.rb
|
43
|
-
- sig/dicey.rbs
|
44
54
|
homepage: https://github.com/trinistr/dicey
|
45
55
|
licenses:
|
46
56
|
- MIT
|
47
57
|
metadata:
|
48
58
|
homepage_uri: https://github.com/trinistr/dicey
|
49
59
|
bug_tracker_uri: https://github.com/trinistr/dicey/issues
|
50
|
-
documentation_uri: https://rubydoc.info/gems/dicey/0.
|
51
|
-
source_code_uri: https://github.com/trinistr/dicey/tree/v0.
|
52
|
-
changelog_uri: https://github.com/trinistr/dicey/blob/v0.
|
60
|
+
documentation_uri: https://rubydoc.info/gems/dicey/0.14.0
|
61
|
+
source_code_uri: https://github.com/trinistr/dicey/tree/v0.14.0
|
62
|
+
changelog_uri: https://github.com/trinistr/dicey/blob/v0.14.0/CHANGELOG.md
|
53
63
|
rubygems_mfa_required: 'true'
|
64
|
+
post_install_message:
|
54
65
|
rdoc_options:
|
55
66
|
- "--main"
|
56
67
|
- README.md
|
@@ -60,14 +71,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
71
|
requirements:
|
61
72
|
- - ">="
|
62
73
|
- !ruby/object:Gem::Version
|
63
|
-
version: 3.
|
74
|
+
version: 3.0.0
|
64
75
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
65
76
|
requirements:
|
66
77
|
- - ">="
|
67
78
|
- !ruby/object:Gem::Version
|
68
79
|
version: '0'
|
69
80
|
requirements: []
|
70
|
-
rubygems_version: 3.
|
81
|
+
rubygems_version: 3.5.22
|
82
|
+
signing_key:
|
71
83
|
specification_version: 4
|
72
|
-
summary: Calculator
|
84
|
+
summary: Calculator for dice roll frequency/probability distributions. Also rolls
|
85
|
+
dice.
|
73
86
|
test_files: []
|
data/sig/dicey.rbs
DELETED