dicey 0.13.1 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13602eca86dfef1610dd824ffcab19809d7416418f077ea7b51f8f2fc713ec17
4
- data.tar.gz: 7a24922897bad67486e72592189a51358bc861e53c332a0780901ef7ddf66e2f
3
+ metadata.gz: a269ac8baf4bf849781175b605d92c23ff95fae3e53baa540519ece9947cec0d
4
+ data.tar.gz: af8f74b92b35a2818d18dc91d9917d8af6e91e16f8674f02eebfb5e56b1759fd
5
5
  SHA512:
6
- metadata.gz: 336cdbe4078db28e015ed393a65b358c9df13873b70da3f215cb75af95c84a67ae7ff6ec0939cd66c9f12b281e792d1acb9a0d00ec4e3f9c107b446e70cad703
7
- data.tar.gz: 964b1502f92086c86b2861bd2eec432effec526b5aecbeb056f82a59cc2be83b1fe7fbe39482350d6764f994f432ebaef3f6e76b70f824adcc2cd630e9decaaf
6
+ metadata.gz: 1b031d4ee67b9c3b1ebe0ba23ac2289f7d14536af4fda1b98b7f481fd9e027ef3b57d8911f7d3c78ca46da0554b63226312e7ae4fa136fc34431bf7f86b7f349
7
+ data.tar.gz: ac52115c59f846e4662ed055e871bf31f2e87b2080acb07042730698d5fa6d107daaf64792645752b47bced701ace913b58464a7a96e56dffe6d3aba3a0af2d8
data/README.md CHANGED
@@ -1,55 +1,92 @@
1
1
  # Dicey
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/dicey.svg?icon=si%3Arubygems)](https://rubygems.org/gems/dicey)
4
+ [![CI](https://github.com/trinistr/dicey/actions/workflows/CI.yaml/badge.svg)](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: [![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
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 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.
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
+ - [Online (no installation)](#online-no-installation)
18
+ - [Recommended](#recommended)
19
+ - [For those who want the full command line experience](#for-those-who-want-the-full-command-line-experience)
20
+ - [Installation](#installation)
21
+ - [Requirements](#requirements)
22
+ - [Usage: CLI (command line)](#usage-cli-command-line)
23
+ - [Example 1: Basic distribution](#example-1-basic-distribution)
24
+ - [Example 2: Complex distribution with different dice](#example-2-complex-distribution-with-different-dice)
25
+ - [Example 3: Custom dice](#example-3-custom-dice)
26
+ - [Example 4: Rolling even more custom dice](#example-4-rolling-even-more-custom-dice)
27
+ - [All ways to define dice](#all-ways-to-define-dice)
28
+ - [Usage: API](#usage-api)
29
+ - [Dice](#dice)
30
+ - [Rolling](#rolling)
31
+ - [Calculators](#calculators)
32
+ - [Diving deeper](#diving-deeper)
33
+ - [Development](#development)
34
+ - [Contributing](#contributing)
35
+ - [License](#license)
36
+
37
+ ## Online (no installation)
38
+
39
+ ### Recommended
40
+
41
+ Use online version of **Dicey** on its own website: [dicey.bulancov.tech](https://dicey.bulancov.tech)!
42
+
43
+ It does not provide quite all features, but it's easy to use and quick to get started.
14
44
 
15
- ## No installation
45
+ ### For those who want the full command line experience
16
46
 
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.
47
+ Thanks to the efforts of Ruby developers, you can run full **Dicey** online!
48
+ 1. Head over to the prepared [RunRuby page](https://runruby.dev/gist/476679a55c24520782613d9ceb89d9a3).
49
+ 2. Make sure that "*-main.rb*" is open.
50
+ 3. Input arguments between "ARGUMENTS" lines, separated by spaces. Refer to [Usage / CLI](#usage--cli-command-line) section.
21
51
  4. Click "**Run code**" button below the editor.
52
+ 5. Results will be printed to the "Logs" tab.
53
+
54
+ If familiar with Ruby, you can also use **RunRuby** to explore the API. Refer to [Usage / API](#usage--api) section for documentation.
22
55
 
23
56
  ## Installation
24
57
 
25
- Install via `gem`:
58
+ Install manually via `gem`:
26
59
  ```sh
27
60
  gem install dicey
28
61
  ```
29
62
 
30
63
  Or, if using Bundler, add it to your `Gemfile`:
31
64
  ```rb
32
- gem "dicey", "~> 0.13"
65
+ gem "dicey", "~> 0.14"
33
66
  ```
34
67
 
35
68
  > [!TIP]
36
69
  > 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
70
 
38
71
  > [!NOTE]
39
- > `dicey` 0.0.1 was a completely separate project by [Adam Rogers](https://github.com/rodreegez). Big thanks for transfering the name!
72
+ > `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
73
 
41
74
  ### Requirements
42
75
 
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.
76
+ **Dicey** is tested to work on CRuby 3.0+, latest JRuby and TruffleRuby. Compatible implementations should work too.
77
+ - JSON and YAML formatting require `json` and `yaml`.
78
+ - Decimal dice require `bigdecimal`.
79
+
80
+ Otherwise, there are no direct dependencies.
44
81
 
45
- ## Usage
82
+ ## Usage: CLI (command line)
46
83
 
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.
84
+ Following examples assume that `dicey` (or `dicey-to-gnuplot`) is executable and is in `$PATH`.
48
85
 
49
86
  > [!NOTE]
50
87
  > 💡 Run `dicey --help` to get a list of all possible options.
51
88
 
52
- ### Example 1
89
+ ### Example 1: Basic distribution
53
90
 
54
91
  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
92
  ```sh
@@ -58,7 +95,7 @@ $ dicey 4 4
58
95
 
59
96
  It should output the following:
60
97
  ```sh
61
- # ⚃;⚃
98
+ # D4+D4
62
99
  2 => 1
63
100
  3 => 2
64
101
  4 => 3
@@ -73,7 +110,7 @@ First line is a comment telling you that calculation ran for two D4s. Every line
73
110
  If probability is preferred, there is an option for that:
74
111
  ```sh
75
112
  $ dicey 4 4 --result probabilities # or -r p for short
76
- # ⚃;⚃
113
+ # D4+D4
77
114
  2 => 0.0625
78
115
  3 => 0.125
79
116
  4 => 0.1875
@@ -85,12 +122,13 @@ $ dicey 4 4 --result probabilities # or -r p for short
85
122
 
86
123
  This shows that 5 will probably be rolled a quarter of the time.
87
124
 
88
- ### Example 2
125
+ ### Example 2: Complex distribution with different dice
89
126
 
90
127
  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
128
  ```sh
92
- $ dicey 8 4 4
93
- # [8];⚃;⚃
129
+ # Note the shorthand notation for two dice!
130
+ $ dicey 8 2d4
131
+ # D8+D4+D4
94
132
  3 => 1
95
133
  4 => 3
96
134
  5 => 6
@@ -109,26 +147,55 @@ $ dicey 8 4 4
109
147
 
110
148
  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
149
 
150
+ #### Example 2.1: Graph
151
+
112
152
  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
153
  ```sh
114
- $ dicey 8 4 4 --format gnuplot | dicey-to-gnuplot
115
- # --format gnuplot can be abbreviated to -f g
154
+ $ dicey 8 2d4 -f g | dicey-to-gnuplot
155
+ # `--format gnuplot` is shortened to `-f g`
116
156
  ```
117
157
 
118
- This will create a PNG image named `[8];⚃;⚃.png`:
119
- ![Graph of damage roll frequencies for Burning Sword]([8];⚃;⚃.png)
158
+ This will create a PNG image named "*D8+D4+D4.png*":
159
+ ![Graph of damage roll frequencies for Burning Sword](D8+D4+D4.png)
120
160
 
121
- > [!NOTE]
122
- > 💡 It is possible to output JSON or YAML with `--format json` and `--format yaml` respectively.
161
+ #### Example 2.2: JSON and YAML
162
+
163
+ 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.
164
+
165
+ JSON via `dicey 8 2d4 --format json`:
166
+ ```json
167
+ {"description":"D8+D4+D4","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}}
168
+ ```
123
169
 
124
- ### Example 3
170
+ YAML via `dicey 8 2d4 --format yaml`:
171
+ ```yaml
172
+ ---
173
+ description: D8+D4+D4
174
+ results:
175
+ 3: 1
176
+ 4: 3
177
+ 5: 6
178
+ 6: 10
179
+ 7: 13
180
+ 8: 15
181
+ 9: 16
182
+ 10: 16
183
+ 11: 15
184
+ 12: 13
185
+ 13: 10
186
+ 14: 6
187
+ 15: 3
188
+ 16: 1
189
+ ```
190
+
191
+ ### Example 3: Custom dice
125
192
 
126
193
  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
194
 
128
195
  Having ran to a computer as fast as you can, you sic **Dicey** on the problem:
129
196
  ```sh
130
197
  $ dicey 1,2,4 4
131
- # (1,2,4);⚃
198
+ # (1,2,4)+D4
132
199
  2 => 1
133
200
  3 => 2
134
201
  4 => 2
@@ -140,30 +207,150 @@ $ dicey 1,2,4 4
140
207
 
141
208
  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
209
 
210
+ But what if you had TWO weird D4s?
211
+ ```sh
212
+ $ dicey 2d1,2,4
213
+ # (1,2,4)+(1,2,4)
214
+ 2 => 1
215
+ 3 => 2
216
+ 4 => 1
217
+ 5 => 2
218
+ 6 => 2
219
+ 8 => 1
220
+ ```
221
+
222
+ Hah, now this is a properly cursed distribution!
223
+
143
224
  > [!TIP]
144
- > 💡 A single integer argument N practically is a shorthand for listing every side from 1 to N.
225
+ > 💡 A single positive integer argument N practically is a shorthand for listing every side from 1 to N.
145
226
 
146
- ### Example 4
227
+ ### Example 4: Rolling even more custom dice
147
228
 
148
229
  You have a sudden urge to roll dice while only having boring integer dice at home. Where to find *the cool* dice though?
149
230
 
150
231
  Look no further than **roll** mode introduced in **Dicey** 0.12:
151
232
  ```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);⚃
233
+ $ dicey 0.5,1.5,2.5 4 --mode roll # As always, can be abbreviated to -m r
234
+ # (0.5e0,0.15e1,0.25e1)+D4
154
235
  roll => 0.35e1 # You probably will get a different value here.
155
236
  ```
156
237
 
157
238
  > [!NOTE]
158
- > 💡 Roll mode is compatible with `--format`, but not `--result`.
239
+ > 💡 Roll mode is compatible with `--format` option.
240
+
241
+ ### All ways to define dice
242
+
243
+ There are three *main* ways to define dice:
244
+ - *"5", "25", or "525"*: a single positive integer makes a regular die (like a D20).
245
+ - *"3-6", "-5..5", "(0-1)"*: a pair of integers with a separator, possibly in round brackets, makes a numeric die with integers in the range.
246
+ - Accepted separators: "-", "..", "...", "–" (en dash), "—" (em dash), "…" (ellipsis).
247
+ - *"1,2,4", "(-1.5,0,1.5)", or "2,"*: a list of any numbers separated by commas, possibly in round brackets, makes an arbitrary numeric die.
248
+ - Lists can end in a comma, allowing single-number lists.
249
+
250
+ *"D6", "d(-1,3)", or "d2..4"*: any definitions can be prefixed with "d" or "D". While this doesn't do anything on its own, it can be useful to not start a definition with "-".
251
+
252
+ *"2D6", "5d-1,3", or "277D(2..4)"*: any definitions can be prefixed with "*N*d" or "*N*D", where *N* is a positive integer. This creates *N* copies of the die.
253
+
254
+ ## Usage: API
255
+
256
+ > [!Note]
257
+ > - Latest API documentation from `main` branch is automatically deployed to [GitHub Pages](https://trinistr.github.io/dicey).
258
+ > - Documentation for published versions is available on [RubyDoc](https://rubydoc.info/gems/dicey).
259
+
260
+ ### Dice
261
+
262
+ There are 3 classes of dice currently:
263
+ - `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.
264
+ - `Dicey::NumericDie` behaves much the same as `Dicey::AbstractDie` (being its subclass), except for checking that all values are instances of `Numeric`. It can be initialized with an Array or Range.
265
+ - `Dicey::RegularDie` is a specialized subclass of `Dicey::NumericDie`. It is defined by a single integer *N* which is expanded to a range (1..*N*).
266
+
267
+ All dice classes have constructor methods aside from `.new`:
268
+ - `.from_list` takes a list of definitions and calls `.new` with each one;
269
+ - `.from_count` takes a count and a definition and calls `.new` with it specified number of times.
270
+
271
+ See [Diving deeper](#diving-deeper) for more theoretical information.
272
+
273
+ > [!NOTE]
274
+ > 💡 Using `Float` values is liable to cause precision issues. Due to in-built result verification, this **will** raise errors. Use `Rational` or `BigDecimal` instead.
275
+
276
+ #### DieFoundry
277
+
278
+ `Dicey::DieFoundry#call` provides the interface for creating dice from Strings as available in CLI:
279
+ ```rb
280
+ Dicey::DieFoundry.new.call("100")
281
+ # same as Dicey::RegularDie.new(100)
282
+ Dicey::DieFoundry.new.call("2d6")
283
+ # same as Dicey::RegularDie.from_count(2, 6)
284
+ Dicey::DieFoundry.new.call("1d1,2,4")
285
+ # same as Dicey::NumericDie.from_list([1,2,4])
286
+ ```
287
+
288
+ 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`:
289
+ ```rb
290
+ foundry = Dicey::DieFoundry.new
291
+ %w[8 2d4].flat_map { foundry.call(_1) }
292
+ # same as [Dicey::RegularDie.new(8), *Dicey::RegularDie.from_count(2, 4)]
293
+ ```
294
+
295
+ ### Rolling
296
+
297
+ `Dicey::AbstractDie#roll` implements the rolling:
298
+ ```rb
299
+ Dicey::AbstractDie.new([0, 1, 5, "10"]).roll
300
+ # almost same as [0, 1, 5, "10"].sample
301
+ Dicey::RegularDie.new(6).roll
302
+ # almost same as rand(1..6)
303
+ ```
304
+
305
+ Dice retain their roll state, with `#current` returning the last roll (or initial side if never rolled):
306
+ ```rb
307
+ die = Dicey::RegularDie.new(6)
308
+ die.current
309
+ # => 1
310
+ die.roll
311
+ # => 3
312
+ die.current
313
+ # => 3
314
+ ```
315
+
316
+ Rolls can be reproducible if a specific seed is set:
317
+ ```rb
318
+ Dicey::AbstractDie.srand(493_525)
319
+ die = Dicey::RegularDie.new(6)
320
+ die.roll
321
+ # => 4
322
+ die.roll
323
+ # => 1
324
+ # Repeat:
325
+ Dicey::AbstractDie.srand(493_525)
326
+ die = Dicey::RegularDie.new(6)
327
+ die.roll
328
+ # => 4
329
+ die.roll
330
+ # => 1
331
+ ```
332
+
333
+ > [!NOTE]
334
+ > 💡 Randomness source is *global*, shared between all dice and probably not thread-safe.
335
+
336
+ ### Calculators
337
+
338
+ Frequency calculators live in `Dicey::SumFrequencyCalculators` module. There are four calculators currently:
339
+ - `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.
340
+ - `Dicey::SumFrequencyCalculators::MultinomialCoefficients` is specialized for repeated numeric dice, with performance only slightly worse. However, it is currently limited to dice with arithmetic sequences.
341
+ - `Dicey::SumFrequencyCalculators::BruteForce` is the most generic and slowest one, but can in principle work with any dice. Currently, it is also limited to `Dicey::NumericDie`, as it's unclear how to handle other values.
342
+ - `Dicey::SumFrequencyCalculators::Empirical`. This is more of a tool than a calculator. It can be interesting to play around with and see how practical results compare to theoretical ones.
343
+
344
+ Calculators inherit from `Dicey::SumFrequencyCalculators::BaseCalculator` and provide the following public interface:
345
+ - `#call(dice, result_type: {:frequencies | :probabilities}, **options) : Hash`
346
+ - `#valid_for?(dice) : Boolean`
347
+
348
+ See [next section](#diving-deeper) for more details on limitations and complexity considerations.
159
349
 
160
350
  ## Diving deeper
161
351
 
162
352
  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.
353
+ - **Regular** die — a die with N sides with sequential integers from 1 to N, like a classic cubic D6, D20, or even a coin if you assume that it rolls 1 and 2. These are dice used for many tabletop games, including role-playing games. Most probably, you will only ever need these and not anything beyond.
167
354
 
168
355
  > [!TIP]
169
356
  > 💡 If you only need to roll **regular** dice, this section will not contain anything important.
@@ -174,11 +361,11 @@ For a further discussion of calculations, it is important to understand which cl
174
361
  - **Abstract** die is not limited by anything other than not having partial sides (and how would that work anyway?).
175
362
 
176
363
  > [!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).
364
+ > 💡 If your die definition starts with a negative number, it can be bracketed, prefixed with "d", or put after "--" pseudo-argument to avoid processing as an option.
178
365
 
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.
366
+ Dicey is in principle able to handle any real 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
367
 
181
- Currently, three algorithms are implemented, with different possibilities and trade-offs.
368
+ Currently, three algorithms for calculating frequencies are implemented, with different possibilities and trade-offs.
182
369
 
183
370
  > [!NOTE]
184
371
  > 💡 Complexity is listed for `n` dice with at most `m` sides and has not been rigorously proven.
@@ -188,7 +375,7 @@ Currently, three algorithms are implemented, with different possibilities and tr
188
375
  An algorithm based on fast polynomial multiplication. This is the default algorithm, used for most reasonable dice.
189
376
 
190
377
  - Limitations: only **natural** dice are allowed, including **regular** dice.
191
- - Example: `dicey 5 3,4,1 '(0)'`
378
+ - Example: `dicey 5 3,4,1 0,`
192
379
  - Complexity: `O(m⋅n)` where `m` is the highest value
193
380
 
194
381
  ### Multinomial coefficients
@@ -209,7 +396,7 @@ As a last resort, there is a brute force algorithm which goes through every poss
209
396
 
210
397
  ## Development
211
398
 
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.
399
+ 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
400
 
214
401
  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
402
 
@@ -223,13 +410,13 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/trinis
223
410
 
224
411
  ### Checklist for a new or updated feature
225
412
 
226
- - Running `rspec` reports 100% coverage (unless it's impossible to achieve in one run).
227
- - Running `rubocop` reports no offenses.
413
+ - Running `rake spec` reports 100% coverage (unless it's impossible to achieve in one run).
414
+ - Running `rake rubocop` reports no offenses.
228
415
  - 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.
416
+ - Tests cover the behavior and its interactions. 100% coverage *is not enough*, as it does not guarantee that all code paths are tested.
230
417
  - 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.
418
+ - "*CHANGELOG.md*" lists the change if it has impact on users.
419
+ - "*README.md*" is updated if the feature should be visible there, including the Kanban board.
233
420
 
234
421
  ## License
235
422
 
@@ -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(...)
@@ -20,72 +21,129 @@ module Dicey
20
21
 
21
22
  # Yes, class variable is actually useful here.
22
23
  # TODO: Allow supplying a custom Random.
24
+
25
+ # Shared randomness source, accessed through {.rand} and {.srand}.
23
26
  @@random = Random.new
24
27
 
25
28
  # rubocop:enable Style/ClassVars
26
29
 
27
30
  # Get a text representation of a list of dice.
28
31
  #
29
- # @param dice [Enumerable<AbstractDie>]
32
+ # @param dice [Enumerable<AbstractDie>, AbstractDie]
30
33
  # @return [String]
31
34
  def self.describe(dice)
32
- dice.join(";")
35
+ return dice.to_s if AbstractDie === dice
36
+
37
+ dice.to_a.join("+")
38
+ end
39
+
40
+ # Create a bunch of different dice at once.
41
+ #
42
+ # @param definitions [Array<Enumerable<Any>>, Array<Any>]
43
+ # list of definitions suitable for the dice class
44
+ # @return [Array<AbstractDie>]
45
+ def self.from_list(*definitions)
46
+ definitions.map { new(_1) }
33
47
  end
34
48
 
35
- attr_reader :sides_list, :sides_num
49
+ # Create a number of equal dice.
50
+ #
51
+ # @param count [Integer] number of dice to create
52
+ # @param definition [Enumerable<Any>, Any]
53
+ # definition suitable for the dice class
54
+ # @return [Array<AbstractDie>]
55
+ def self.from_count(count, definition)
56
+ Array.new(count) { new(definition) }
57
+ end
36
58
 
37
- # @param sides_list [Enumerable<Object>]
59
+ # Die's list of sides.
60
+ #
61
+ # @return [Array<Any>]
62
+ attr_reader :sides_list
63
+
64
+ # Number of sides of the die.
65
+ #
66
+ # @return [Integer]
67
+ attr_reader :sides_num
68
+
69
+ # @param sides_list [Enumerable<Any>]
38
70
  # @raise [DiceyError] if +sides_list+ is empty
39
71
  def initialize(sides_list)
40
- @sides_list = sides_list.is_a?(Array) ? sides_list.dup.freeze : sides_list.to_a.freeze
72
+ @sides_list = (Array === sides_list) ? sides_list.dup.freeze : sides_list.to_a.freeze
41
73
  raise DiceyError, "dice must have at least one side!" if @sides_list.empty?
42
74
 
43
75
  @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
76
+ @current_side_index = 0
53
77
  end
54
78
 
55
79
  # Get current side of the die.
56
- # @return [Object] current side
80
+ #
81
+ # @return [Any] current side
57
82
  def current
58
- @enum.peek
83
+ @sides_list[@current_side_index]
59
84
  end
60
85
 
61
- # Get next side of the die, advancing internal enumerator state.
62
- # Wraps from last to first side.
63
- # @return [Object] next side
86
+ # Get next side of the die, advancing internal state.
87
+ # Starts from first side, wraps from last to first side.
88
+ #
89
+ # @return [Any] next side
64
90
  def next
65
- @enum.next
91
+ ret = current
92
+ @current_side_index = (@current_side_index + 1) % @sides_num
93
+ ret
66
94
  end
67
95
 
68
- # Advance internal enumerator state by a random number using {#next}.
69
- # @return [Object] rolled side
96
+ # Move internal state to a random side.
97
+ #
98
+ # @return [Any] rolled side
70
99
  def roll
71
- self.class.rand(0...sides_num).times { self.next }
100
+ @current_side_index = self.class.rand(0...@sides_num)
72
101
  current
73
102
  end
74
103
 
104
+ # Return a string representing the die.
105
+ #
106
+ # Default representation is a list of sides in round brackets.
107
+ #
75
108
  # @return [String]
76
109
  def to_s
77
- "(#{sides_list.join(",")})"
110
+ (@sides_list.size > 1) ? "(#{@sides_list.join(",")})" : "(#{@sides_list.first},)"
78
111
  end
79
112
 
80
113
  # Determine if this die and the other one have the same list of sides.
81
114
  # Be aware that differently ordered sides are not considered equal.
82
115
  #
83
- # @param other [AbstractDie, Object]
116
+ # @param other [AbstractDie, Any]
84
117
  # @return [Boolean]
85
118
  def ==(other)
86
- return false unless other.is_a?(AbstractDie)
119
+ AbstractDie === other && same_sides?(other)
120
+ end
121
+
122
+ # Determine if this die and the other one are of the same class
123
+ # and have the same list of sides.
124
+ # Be aware that differently ordered sides are not considered equal.
125
+ #
126
+ # +die_1.eql?(die_2)+ implies +die_1.hash == die_2.hash+.
127
+ #
128
+ # @param other [AbstractDie, Any]
129
+ # @return [Boolean]
130
+ def eql?(other)
131
+ self.class === other && same_sides?(other)
132
+ end
87
133
 
88
- sides_list == other.sides_list
134
+ # Generates an Integer hash value for this object.
135
+ #
136
+ # @return [Integer]
137
+ def hash
138
+ [self.class, @sides_list].hash
139
+ end
140
+
141
+ private
142
+
143
+ # @param other [AbstractDie]
144
+ # @return [Boolean]
145
+ def same_sides?(other)
146
+ @sides_list == other.sides_list
89
147
  end
90
148
  end
91
149
  end
@@ -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
@@ -51,10 +54,11 @@ module Dicey
51
54
  def add_banner_and_version
52
55
  @parser.banner = <<~TEXT
53
56
  Usage: #{@parser.program_name} [options] <die> [<die> ...]
57
+ #{@parser.program_name} [options] -- <die> [<die> ...]
54
58
  #{@parser.program_name} --test [full|quiet]
55
59
  All option names and arguments can be abbreviated if abbreviation is unambiguous.
60
+ A lone "--" separates options and die definitions, allowing definitions to start with "-".
56
61
  TEXT
57
- @parser.version = Dicey::VERSION
58
62
  end
59
63
 
60
64
  def add_common_options
@@ -86,13 +90,14 @@ module Dicey
86
90
  end
87
91
  end
88
92
 
89
- def easy_option(short, long, values, description, &)
90
- values = values.keys if values.respond_to?(:keys)
93
+ def easy_option(short, long, values, description, &block)
91
94
  option_name = long[/[a-z_]+/].to_sym
92
95
  argument_name = long[/[A-Z_]+/]
93
96
  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, &)
97
+ default_value = "`#{@options[option_name]}` is default."
98
+ @parser.on(
99
+ *[short, long, values, description, listed_values, default_value].compact, &block
100
+ )
96
101
  end
97
102
  end
98
103
  end
@@ -6,52 +6,81 @@ 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 possible count.
10
+ PREFIX = /(?>(?<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
- ->(d) { /\A[1-9]\d*\z/.match?(d) } => :regular_mold,
15
+ [/\A#{PREFIX}(?<sides>[1-9]\d*+)\z/, :regular_mold].freeze,
16
+ # Integer range goes into the NumericDie mold.
17
+ [/\A#{PREFIX}\(?(?<begin>-?\d++)(?>[-–—…]|\.{2,3})(?<end>-?\d++)\)?\z/, :range_mold].freeze,
13
18
  # 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
+ [/\A#{PREFIX}\(?(?<sides>-?\d++(?>,(?>-?\d++)?)+|,)\)?\z/, :weirdly_shaped_mold].freeze,
20
+ # Non-integers require arbitrary precision arithmetic, which is not enabled by default.
21
+ [/\A#{PREFIX}\(?(?<sides>-?\d++(?>\.\d++)?(?>,(?>-?\d++(?>\.\d++)?)?)+|,)\)?\z/,
22
+ :weirdly_precise_mold].freeze,
19
23
  # 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/
24
+ ].freeze
25
25
 
26
26
  # Cast a die definition into a mold to make a die.
27
27
  #
28
- # @param definition [String] die shape, refer to {MOLDS} for possible variants
29
- # @return [NumericDie, RegularDie]
28
+ # Following definitions are recognized:
29
+ # - positive integer (like "6" or "20"), which produces a {RegularDie};
30
+ # - integer range (like "3-6" or "(-5..5)"), which produces a {NumericDie};
31
+ # - list of integers (like "3,4,5", "(-1,0,1)", or "2,"), which produces a {NumericDie};
32
+ # - list of decimal numbers (like "0.5,0.2,0.8" or "(2.0,)"), which produces a {NumericDie},
33
+ # but uses +BigDecimal+ for values to maintain precise results.
34
+ #
35
+ # Any die definition can be prefixed with a count, like "2D6" or "1d1,3,5" to create an array.
36
+ # A plain "d" without an explicit count is ignored instead, creating a single die.
37
+ #
38
+ # @param definition [String] die shape
39
+ # @return [NumericDie, RegularDie, Array<NumericDie>, Array<RegularDie>]
30
40
  # @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)
41
+ def call(definition)
42
+ matched, name =
43
+ MOLDS.reduce(nil) do |_, (shape, mold)|
44
+ match = shape.match(definition)
45
+ break [match, mold] if match
46
+ end
47
+ raise DiceyError, "can not cast die from `#{definition}`!" unless name
48
+
49
+ __send__(name, matched)
34
50
  end
35
51
 
52
+ alias cast call
53
+
36
54
  private
37
55
 
38
56
  def regular_mold(definition)
39
- RegularDie.new(definition.to_i)
57
+ build_dice(RegularDie, definition[:count], definition[:sides].to_i)
58
+ end
59
+
60
+ def range_mold(definition)
61
+ first = definition[:begin].to_i
62
+ last = definition[:end].to_i
63
+ first, last = last, first if first > last
64
+ build_dice(NumericDie, definition[:count], first..last)
40
65
  end
41
66
 
42
67
  def weirdly_shaped_mold(definition)
43
- definition = definition.match(BRACKET_STRIPPER)[1]
44
- NumericDie.new(definition.split(",").map(&:to_i))
68
+ build_dice(NumericDie, definition[:count], definition[:sides].split(",").map(&:to_i))
45
69
  end
46
70
 
47
71
  def weirdly_precise_mold(definition)
48
- require "bigdecimal"
49
- definition = definition.match(BRACKET_STRIPPER)[1]
50
- NumericDie.new(definition.split(",").map { BigDecimal(_1) })
72
+ require "bigdecimal" unless defined?(BigDecimal)
73
+
74
+ sides = definition[:sides].split(",").map { BigDecimal(_1) }
75
+ build_dice(NumericDie, definition[:count], sides)
51
76
  end
52
77
 
53
- def broken_mold(definition)
54
- raise DiceyError, "can not cast die from `#{definition}`!"
78
+ def build_dice(die_class, count, sides)
79
+ if count
80
+ die_class.from_count(count.to_i, sides)
81
+ else
82
+ die_class.new(sides)
83
+ end
55
84
  end
56
85
  end
57
86
  end
@@ -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
- sides_list.each do |value|
12
- raise DiceyError, "`#{value}` is not a number!" unless value.is_a?(Numeric)
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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
+ # Processors which turn data to text.
4
5
  module OutputFormatters
5
6
  # Base formatter for outputting in formats which can be converted from a Hash directly.
6
7
  # Can add an optional description into the result.
@@ -5,28 +5,22 @@ require_relative "numeric_die"
5
5
  module Dicey
6
6
  # Regular die, which has N sides with numbers from 1 to N.
7
7
  class RegularDie < NumericDie
8
- # Characters to use for small dice.
9
- D6 = "⚀⚁⚂⚃⚄⚅"
8
+ # @param max [Integer] maximum side / number of sides
9
+ def initialize(max)
10
+ unless Integer === max && max.positive?
11
+ raise DiceyError, "regular dice can contain only positive integers, #{max.inspect} is not"
12
+ end
10
13
 
11
- # Create a list of regular dice with the same number of sides.
12
- #
13
- # @param dice [Integer]
14
- # @param sides [Integer]
15
- # @return [Array<RegularDie>]
16
- def self.create_dice(dice, sides)
17
- (1..dice).map { new(sides) }
18
- end
19
-
20
- # @param sides [Integer]
21
- def initialize(sides)
22
- super((1..sides))
14
+ super((1..max))
23
15
  end
24
16
 
25
- # Dice with 1–6 sides are displayed with a single character.
26
- # More than that, and we get into the square bracket territory.
17
+ # Return a string representing the die.
18
+ #
19
+ # Regular dice are represented with a "D" followed by the number of sides.
20
+ #
27
21
  # @return [String]
28
22
  def to_s
29
- (sides_num <= D6.size) ? D6[sides_num - 1] : "[#{sides_num}]"
23
+ "D#{sides_num}"
30
24
  end
31
25
  end
32
26
  end
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.map { |definition| die_foundry.cast(definition) }
16
- dice.each(&:roll)
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
 
@@ -1,8 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
+ # Calculators for probability distributions of dice.
4
5
  module SumFrequencyCalculators
5
6
  # Base frequencies calculator.
7
+ #
8
+ # *Options:*
9
+ #
10
+ # Calculators may have calculator-specific options,
11
+ # passed as extra keyword arguments to {#call}.
12
+ # If present, they will be documented under *Options* heading
13
+ # on the class itself.
14
+ #
6
15
  # @abstract
7
16
  class BaseCalculator
8
17
  # Possible values for +result_type+ argument in {#call}.
@@ -10,17 +19,20 @@ module Dicey
10
19
 
11
20
  # @param dice [Enumerable<AbstractDie>]
12
21
  # @param result_type [Symbol] one of {RESULT_TYPES}
22
+ # @param options [Hash{Symbol => Any}] calculator-specific options
13
23
  # @return [Hash{Numeric => Numeric}] frequencies of each sum
14
24
  # @raise [DiceyError] if +result_type+ is invalid
15
25
  # @raise [DiceyError] if dice list is invalid for the calculator
16
26
  # @raise [DiceyError] if calculator returned obviously wrong results
17
- def call(dice, result_type: :frequencies)
27
+ def call(dice, result_type: :frequencies, **options)
18
28
  unless RESULT_TYPES.include?(result_type)
19
29
  raise DiceyError, "#{result_type} is not a valid result type!"
20
30
  end
31
+ # Short-circuit for a degenerate case.
32
+ return {} if dice.empty?
21
33
  raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
22
34
 
23
- frequencies = calculate(dice)
35
+ frequencies = calculate(dice, **options)
24
36
  verify_result(frequencies, dice)
25
37
  frequencies = sort_result(frequencies)
26
38
  transform_result(frequencies, result_type)
@@ -31,7 +43,7 @@ module Dicey
31
43
  # @param dice [Enumerable<AbstractDie>]
32
44
  # @return [Boolean]
33
45
  def valid_for?(dice)
34
- dice.is_a?(Enumerable) && dice.all? { _1.is_a?(AbstractDie) } && validate(dice)
46
+ dice.is_a?(Enumerable) && dice.all?(AbstractDie) && validate(dice)
35
47
  end
36
48
 
37
49
  private
@@ -44,8 +56,10 @@ module Dicey
44
56
 
45
57
  # Peform frequencies calculation.
46
58
  # (see #call)
47
- def calculate(dice)
59
+ def calculate(dice, **nil)
60
+ # :nocov:
48
61
  raise NotImplementedError
62
+ # :nocov:
49
63
  end
50
64
 
51
65
  # Check that resulting frequencies actually add up to what they are supposed to be.
@@ -55,7 +69,7 @@ module Dicey
55
69
  # @return [void]
56
70
  # @raise [DiceyError] if result is wrong
57
71
  def verify_result(frequencies, dice)
58
- valid = frequencies.values.sum == dice.map(&:sides_num).reduce(:*)
72
+ valid = frequencies.values.sum == (dice.map(&:sides_num).reduce(:*) || 0)
59
73
  raise DiceyError, "calculator #{self.class} returned invalid results!" unless valid
60
74
  end
61
75
 
@@ -74,14 +88,11 @@ module Dicey
74
88
  # @param result_type [Symbol] one of {RESULT_TYPES}
75
89
  # @return [Hash{Numeric => Numeric}]
76
90
  def transform_result(frequencies, result_type)
77
- case result_type
78
- when :frequencies
91
+ if result_type == :frequencies
79
92
  frequencies
80
- when :probabilities
93
+ else
81
94
  total = frequencies.values.sum
82
95
  frequencies.transform_values { _1.fdiv(total) }
83
- else
84
- # Invalid, but was already checked in #call.
85
96
  end
86
97
  end
87
98
  end
@@ -8,51 +8,49 @@ module Dicey
8
8
  class BruteForce < BaseCalculator
9
9
  private
10
10
 
11
- # def validate(dice)
12
- # dice.all? { |die| die.is_a?(NumericDie) }
13
- # end
11
+ def validate(dice)
12
+ dice.all?(NumericDie)
13
+ end
14
14
 
15
- def calculate(dice)
16
- # TODO: Replace `combine_dice_enumerators` with `Enumerator.product`.
15
+ def calculate(dice, **nil)
17
16
  combine_dice_enumerators(dice).map(&:sum).tally
18
17
  end
19
18
 
20
- # Get an enumerator which goes through all possible permutations of dice sides.
21
- #
22
- # @param dice [Enumerable<NumericDie>]
23
- # @return [Enumerator<Array>]
24
- def combine_dice_enumerators(dice)
25
- sides_num_list = dice.map(&:sides_num)
26
- total = sides_num_list.reduce(:*)
27
- Enumerator.new(total) do |yielder|
28
- current_values = dice.map(&:next)
29
- remaining_iterations = sides_num_list
30
- total.times do
31
- yielder << current_values
32
- iterate_dice(dice, remaining_iterations, current_values)
33
- end
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
- # Iterate through dice, getting next side for first die,
38
- # then getting next side for second die, resetting first die, and so on.
39
- # This is analogous to incrementing by 1 in a positional system
40
- # where each position is a die.
41
- #
42
- # @param dice [Enumerable<NumericDie>]
43
- # @param remaining_iterations [Array<Integer>]
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
- remaining_iterations[i] = die.sides_num
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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_calculator"
4
+
5
+ module Dicey
6
+ module SumFrequencyCalculators
7
+ # "Calculator" for a collection of {NumericDie} using empirically-obtained statistics.
8
+ #
9
+ # @note This calculator is mostly a joke. It can be useful for educational purposes,
10
+ # or to verify results of {BruteForce} when in doubt. It is not used by default.
11
+ #
12
+ # Does a number of rolls and calculates approximate probabilities from that.
13
+ # Even if frequencies are requested, results are non-integer.
14
+ #
15
+ # *Options:*
16
+ # - *rolls* (Integer) (_defaults_ _to:_ _N_) — number of rolls to perform
17
+ class Empirical < BaseCalculator
18
+ # Default number of rolls to perform.
19
+ N = 10_000
20
+
21
+ private
22
+
23
+ def validate(dice)
24
+ dice.all?(NumericDie)
25
+ end
26
+
27
+ def calculate(dice, rolls: N)
28
+ statistics = rolls.times.with_object(Hash.new(0)) { |_, hash| hash[dice.sum(&:roll)] += 1 }
29
+ total_results = dice.map(&:sides_num).reduce(:*)
30
+ statistics.transform_values { (_1 * total_results).fdiv(rolls) }
31
+ end
32
+
33
+ def verify_result(*)
34
+ # Ignore verification, as this is inherently imprecise.
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
@@ -10,7 +10,9 @@ module Dicey
10
10
  #
11
11
  # Based on Kronecker substitution method for polynomial multiplication.
12
12
  # @see https://en.wikipedia.org/wiki/Kronecker_substitution
13
- # @see https://arxiv.org/pdf/0712.4046v1.pdf in particular section 3
13
+ # @see https://arxiv.org/pdf/0712.4046v1.pdf
14
+ # David Harvey, Faster polynomial multiplication via multi-point Kronecker substitution
15
+ # (in particular section 3)
14
16
  class KroneckerSubstitution < BaseCalculator
15
17
  private
16
18
 
@@ -18,7 +20,7 @@ module Dicey
18
20
  dice.all? { |die| die.sides_list.all? { _1.is_a?(Integer) && _1 >= 0 } }
19
21
  end
20
22
 
21
- def calculate(dice)
23
+ def calculate(dice, **nil)
22
24
  polynomials = build_polynomials(dice)
23
25
  evaluation_point = find_evaluation_point(polynomials)
24
26
  values = evaluate_polynomials(polynomials, evaluation_point)
@@ -9,7 +9,7 @@ module Dicey
9
9
  # Example dice: (1,2,3,4), (-2,-1,0,1,2), (0,0.2,0.4,0.6), (-1,-2,-3).
10
10
  #
11
11
  # Based on extension of Pascal's triangle for a higher number of coefficients.
12
- # @see https://en.wikipedia.org/wiki/Pascal%27s_triangle
12
+ # @see https://en.wikipedia.org/wiki/Pascal's_triangle
13
13
  # @see https://en.wikipedia.org/wiki/Trinomial_triangle
14
14
  class MultinomialCoefficients < BaseCalculator
15
15
  private
@@ -30,11 +30,10 @@ 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
- # @param dice [Array<NumericDie>]
36
- # @return [Hash{Numeric => Integer}]
37
- def calculate(dice)
36
+ def calculate(dice, **nil)
38
37
  first_die = dice.first
39
38
  number_of_sides = first_die.sides_num
40
39
  number_of_dice = dice.size
@@ -48,27 +47,22 @@ module Dicey
48
47
  #
49
48
  # @param dice [Integer] number of dice, must be positive
50
49
  # @param sides [Integer] number of sides, must be positive
51
- # @param throw_away_garbage [Boolean] whether to discard unused coefficients (debug option)
52
50
  # @return [Array<Integer>]
53
- def multinomial_coefficients(dice, sides, throw_away_garbage: true)
54
- # This builds a triangular matrix where each first element is a 1.
55
- # Each element is a sum of +m+ elements in the previous row
56
- # with indices less or equal to its, with out-of-bounds indices corresponding to 0s.
57
- # Example for m=3:
51
+ def multinomial_coefficients(dice, sides)
52
+ # This builds a triangular matrix where first elements are always 1s
53
+ # and other elements are sums of +sides+ elements in the previous row
54
+ # with indices less or equal, with out-of-bounds indices corresponding to 0s.
55
+ # Example for sides=3:
58
56
  # 1
59
57
  # 1 1 1
60
58
  # 1 2 3 2 1
61
59
  # 1 3 6 7 6 3 1, etc.
62
- coefficients = [[1]]
63
- (1..dice).each do |row_index|
64
- row = next_row_of_coefficients(row_index, sides - 1, coefficients.last)
65
- if throw_away_garbage
66
- coefficients[0] = row
67
- else
68
- coefficients << row
69
- end
60
+ # We start directly from second row, which corresponds to 1 die.
61
+ coefficients = Array.new(sides, 1)
62
+ (2..dice).each do |row_index|
63
+ coefficients = next_row_of_coefficients(row_index, sides - 1, coefficients)
70
64
  end
71
- coefficients.last
65
+ coefficients
72
66
  end
73
67
 
74
68
  # @param row_index [Integer]
@@ -79,7 +73,8 @@ module Dicey
79
73
  length = (row_index * window_size) + 1
80
74
  (0..length).map do |col_index|
81
75
  # Have to clamp to 0 to prevent accessing array from the end.
82
- window_range = ((col_index - window_size).clamp(0..)..col_index)
76
+ # BUG: TruffleRuby can't handle endless range in #clamp (see https://github.com/oracle/truffleruby/issues/3945)
77
+ window_range = ((col_index - window_size).clamp(0..col_index)..col_index)
83
78
  window_range.sum { |i| previous_row.fetch(i, 0) }
84
79
  end
85
80
  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.map { |definition| die_foundry.cast(definition) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
- VERSION = "0.13.1"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/dicey.rb CHANGED
@@ -5,7 +5,7 @@ module Dicey
5
5
  # General error for Dicey.
6
6
  class DiceyError < StandardError; end
7
7
 
8
- Dir["#{__dir__}/dicey/*.rb"].each { require _1 }
9
- Dir["#{__dir__}/dicey/output_formatters/*.rb"].each { require _1 }
10
- Dir["#{__dir__}/dicey/sum_frequency_calculators/*.rb"].each { require _1 }
8
+ Dir["dicey/*.rb", base: __dir__].each { require_relative _1 }
9
+ Dir["dicey/output_formatters/*.rb", base: __dir__].each { require_relative _1 }
10
+ Dir["dicey/sum_frequency_calculators/*.rb", base: __dir__].each { require_relative _1 }
11
11
  end
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.13.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandr Bulancov
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-09-22 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
@@ -35,22 +46,23 @@ files:
35
46
  - lib/dicey/roller.rb
36
47
  - lib/dicey/sum_frequency_calculators/base_calculator.rb
37
48
  - lib/dicey/sum_frequency_calculators/brute_force.rb
49
+ - lib/dicey/sum_frequency_calculators/empirical.rb
38
50
  - lib/dicey/sum_frequency_calculators/kronecker_substitution.rb
39
51
  - lib/dicey/sum_frequency_calculators/multinomial_coefficients.rb
40
52
  - lib/dicey/sum_frequency_calculators/runner.rb
41
53
  - lib/dicey/sum_frequency_calculators/test_runner.rb
42
54
  - lib/dicey/version.rb
43
- - sig/dicey.rbs
44
55
  homepage: https://github.com/trinistr/dicey
45
56
  licenses:
46
57
  - MIT
47
58
  metadata:
48
59
  homepage_uri: https://github.com/trinistr/dicey
49
60
  bug_tracker_uri: https://github.com/trinistr/dicey/issues
50
- documentation_uri: https://rubydoc.info/gems/dicey/0.13.1
51
- source_code_uri: https://github.com/trinistr/dicey/tree/v0.13.1
52
- changelog_uri: https://github.com/trinistr/dicey/blob/v0.13.1/CHANGELOG.md
61
+ documentation_uri: https://rubydoc.info/gems/dicey/0.15.0
62
+ source_code_uri: https://github.com/trinistr/dicey/tree/v0.15.0
63
+ changelog_uri: https://github.com/trinistr/dicey/blob/v0.15.0/CHANGELOG.md
53
64
  rubygems_mfa_required: 'true'
65
+ post_install_message:
54
66
  rdoc_options:
55
67
  - "--main"
56
68
  - README.md
@@ -60,14 +72,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
60
72
  requirements:
61
73
  - - ">="
62
74
  - !ruby/object:Gem::Version
63
- version: 3.1.0
75
+ version: 3.0.0
64
76
  required_rubygems_version: !ruby/object:Gem::Requirement
65
77
  requirements:
66
78
  - - ">="
67
79
  - !ruby/object:Gem::Version
68
80
  version: '0'
69
81
  requirements: []
70
- rubygems_version: 3.7.1
82
+ rubygems_version: 3.5.22
83
+ signing_key:
71
84
  specification_version: 4
72
- summary: Calculator of dice roll frequencies/probabilities. Also rolls dice.
85
+ summary: Calculator for dice roll frequency/probability distributions. Also rolls
86
+ dice.
73
87
  test_files: []
data/sig/dicey.rbs DELETED
@@ -1,3 +0,0 @@
1
- module Dicey
2
- VERSION: String
3
- end