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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13602eca86dfef1610dd824ffcab19809d7416418f077ea7b51f8f2fc713ec17
4
- data.tar.gz: 7a24922897bad67486e72592189a51358bc861e53c332a0780901ef7ddf66e2f
3
+ metadata.gz: b23d3fe347080f349190417819aba5f2778a6196569ab39e38d79049f7557f02
4
+ data.tar.gz: 5bd9295a6bb1a15fe33fe3b967fd1d2c0784a19e713977f57da7910589687ebe
5
5
  SHA512:
6
- metadata.gz: 336cdbe4078db28e015ed393a65b358c9df13873b70da3f215cb75af95c84a67ae7ff6ec0939cd66c9f12b281e792d1acb9a0d00ec4e3f9c107b446e70cad703
7
- data.tar.gz: 964b1502f92086c86b2861bd2eec432effec526b5aecbeb056f82a59cc2be83b1fe7fbe39482350d6764f994f432ebaef3f6e76b70f824adcc2cd630e9decaaf
6
+ metadata.gz: 778d6f36d896b5db19411bedf0bf272082310eb2c9a1403b57962932979ec0da863b6d408c9245b49bbfb914c99cc2bfa9e8443e0a02d732d2723f93f8b1f6bb
7
+ data.tar.gz: 694960b274505249fc705e0a61bb1452b6efed062d8b183c154734a9677cc9c28309cf859917443fd997a60de70e00c7f45abc9f9e36e96007949c1bdbee7e0f
data/README.md CHANGED
@@ -1,16 +1,35 @@
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
+ - [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 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.
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
- $ dicey 8 4 4
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 4 4 --format gnuplot | dicey-to-gnuplot
115
- # --format gnuplot can be abbreviated to -f g
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
  ![Graph of damage roll frequencies for Burning Sword]([8];⚃;⚃.png)
120
146
 
121
- > [!NOTE]
122
- > 💡 It is possible to output JSON or YAML with `--format json` and `--format yaml` respectively.
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
- ### Example 3
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` (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.
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 `rspec` reports 100% coverage (unless it's impossible to achieve in one run).
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 covered.
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
- - `CHANGELOG.md` lists the change if it has impact on users.
232
- - `README.md` is updated if the feature should be visible there.
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
 
@@ -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.join(";")
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<Object>]
60
+ # @param sides_list [Enumerable<Any>]
38
61
  # @raise [DiceyError] if +sides_list+ is empty
39
62
  def initialize(sides_list)
40
- @sides_list = sides_list.is_a?(Array) ? sides_list.dup.freeze : sides_list.to_a.freeze
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
- # @return [Object] current side
71
+ #
72
+ # @return [Any] current side
57
73
  def current
58
- @enum.peek
74
+ @sides_list[@current_side_index]
59
75
  end
60
76
 
61
- # Get next side of the die, advancing internal enumerator state.
62
- # Wraps from last to first side.
63
- # @return [Object] next side
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
- @enum.next
82
+ ret = current
83
+ @current_side_index = (@current_side_index + 1) % @sides_num
84
+ ret
66
85
  end
67
86
 
68
- # Advance internal enumerator state by a random number using {#next}.
69
- # @return [Object] rolled side
87
+ # Move internal state to a random side.
88
+ #
89
+ # @return [Any] rolled side
70
90
  def roll
71
- self.class.rand(0...sides_num).times { self.next }
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, Object]
103
+ # @param other [AbstractDie, Any]
84
104
  # @return [Boolean]
85
105
  def ==(other)
86
- return false unless other.is_a?(AbstractDie)
106
+ AbstractDie === other && same_sides?(other)
107
+ end
87
108
 
88
- sides_list == other.sides_list
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
@@ -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." if @options[option_name]
95
- @parser.on(*[short, long, values, description, listed_values, default_value].compact, &)
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
@@ -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
- ->(d) { /\A[1-9]\d*\z/.match?(d) } => :regular_mold,
15
+ [/\A#{COUNT}(?<sides>[1-9]\d*+)\z/, :regular_mold],
13
16
  # 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,
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
- ->(*) { true } => :broken_mold,
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
- # @param definition [String] die shape, refer to {MOLDS} for possible variants
29
- # @return [NumericDie, RegularDie]
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 cast(definition)
32
- _shape, mold = MOLDS.find { |shape, _mold| shape.call(definition) }
33
- __send__(mold, definition)
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.new(definition.to_i)
54
+ build_dice(RegularDie, definition[:count], definition[:sides].to_i)
40
55
  end
41
56
 
42
57
  def weirdly_shaped_mold(definition)
43
- definition = definition.match(BRACKET_STRIPPER)[1]
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
- definition = definition.match(BRACKET_STRIPPER)[1]
50
- NumericDie.new(definition.split(",").map { BigDecimal(_1) })
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 broken_mold(definition)
54
- raise DiceyError, "can not cast die from `#{definition}`!"
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
@@ -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
@@ -8,22 +8,18 @@ module Dicey
8
8
  # Characters to use for small dice.
9
9
  D6 = "⚀⚁⚂⚃⚄⚅"
10
10
 
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
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
- # @param sides [Integer]
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.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
 
@@ -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? { _1.is_a?(AbstractDie) } && validate(dice)
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
- case result_type
78
- when :frequencies
81
+ if result_type == :frequencies
79
82
  frequencies
80
- when :probabilities
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
- # 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
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
- # 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
@@ -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, 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:
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
- 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
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.last
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
- window_range = ((col_index - window_size).clamp(0..)..col_index)
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.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.14.0"
5
5
  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.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: 1980-01-02 00:00:00.000000000 Z
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.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
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.1.0
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.7.1
81
+ rubygems_version: 3.5.22
82
+ signing_key:
71
83
  specification_version: 4
72
- summary: Calculator of dice roll frequencies/probabilities. Also rolls dice.
84
+ summary: Calculator for dice roll frequency/probability distributions. Also rolls
85
+ dice.
73
86
  test_files: []
data/sig/dicey.rbs DELETED
@@ -1,3 +0,0 @@
1
- module Dicey
2
- VERSION: String
3
- end