dicey 0.15.2 โ†’ 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c18dbf421c9c19d438194ec9f63a9a22bfd18abb50d1f3b491a6e8067e3a04ae
4
- data.tar.gz: e01eabaa58523f5fb2e1610ec671fc41a31f4aee7a583b64aa52d5bb9f083462
3
+ metadata.gz: 86d934c5cf9455e15c42f84abe397c7c32ada670512969decd4e44c9eef1841f
4
+ data.tar.gz: bcba52a9d0e1bbb0308db1025ba2c723bf963aa02b323b631538bdbc722144e2
5
5
  SHA512:
6
- metadata.gz: 48759bc7919e8dbf699777a92735c586a8365f517c2c12d48cb725f12ada1fcfd6904e2c08e54ec7159267ad79003e7a1725a1e6e97694d7d4cbfb32198b2566
7
- data.tar.gz: fa0cd4f20b6262cf927eb98cb5c296db2473910a27e2500323f80aeb5d2ac3224de143f67e0990fe555890535ff395d8514996934759984316d9fe22c4fb0e17
6
+ metadata.gz: 46d142b6caed4432f4adede2d34214498e1bbfe02aadca4aaa58709c5fa9ce15d4c71e75ce37f599752c10f334da0acb616c7278ba5076d1ed4746b36fb7b35b
7
+ data.tar.gz: 14ad44e95941c5e7d94c71968a9f84105d1f6a64b50ab551c94ddea247dda77a80e5f83e2566bdea76ec912b799f1a05e3bbb160d0cc1b3af5211c795bc2b415
data/README.md CHANGED
@@ -24,6 +24,7 @@ In seriousness, this program is mainly useful for calculating total frequency (p
24
24
  - [Example 2: Complex distribution with different dice](#example-2-complex-distribution-with-different-dice)
25
25
  - [Example 3: Custom dice](#example-3-custom-dice)
26
26
  - [Example 4: Rolling even more custom dice](#example-4-rolling-even-more-custom-dice)
27
+ - [Example 5: Non-numeric dice](#example-5-non-numeric-dice)
27
28
  - [All ways to define dice](#all-ways-to-define-dice)
28
29
  - [Usage: API](#usage-api)
29
30
  - [Dice](#dice)
@@ -63,7 +64,17 @@ gem install dicey
63
64
 
64
65
  Or, if using Bundler, add it to your `Gemfile`:
65
66
  ```rb
66
- gem "dicey", "~> 0.14"
67
+ gem "dicey", "~> 0.16"
68
+ ```
69
+
70
+ (Optional) If intending to work with non-numeric dice, install **vector_number** too:
71
+ ```sh
72
+ gem install vector_number
73
+ ```
74
+
75
+ or add it to your `Gemfile`:
76
+ ```rb
77
+ gem "vector_number"
67
78
  ```
68
79
 
69
80
  > [!TIP]
@@ -76,6 +87,7 @@ gem "dicey", "~> 0.14"
76
87
 
77
88
  **Dicey** is tested to work on CRuby 3.0+, latest JRuby and TruffleRuby. Compatible implementations should work too.
78
89
  - JSON and YAML formatting require `json` and `yaml`.
90
+ - Non-numeric dice require gem `vector_number` to be installed.
79
91
 
80
92
  Otherwise, there are no direct dependencies.
81
93
 
@@ -238,18 +250,35 @@ roll => 7/2 # You probably will get a different value here.
238
250
  > [!NOTE]
239
251
  > ๐Ÿ’ก Roll mode is compatible with `--format` option.
240
252
 
253
+ ### Example 5: Non-numeric dice
254
+
255
+ You are a wizard and you have a spellbook with an elemental vortex spell that deals three instances of random elemental damage. Let's find out what you have for your enemies in store today:
256
+ ```sh
257
+ $ dicey 3dโ„๏ธ,๐Ÿ”ฅ,โšก๏ธ,๐ŸŒช,๐ŸŒฒ -m r
258
+ # (โ„๏ธ,๐Ÿ”ฅ,โšก๏ธ,๐ŸŒช,๐ŸŒฒ)+(โ„๏ธ,๐Ÿ”ฅ,โšก๏ธ,๐ŸŒช,๐ŸŒฒ)+(โ„๏ธ,๐Ÿ”ฅ,โšก๏ธ,๐ŸŒช,๐ŸŒฒ)
259
+ roll => 1โ‹…๐ŸŒช + 1โ‹…๐ŸŒฒ + 1โ‹…โšก๏ธ
260
+ ```
261
+
262
+ Wind, wood and lightning in equal proportion it is! Your enemies will tremble!
263
+
264
+ Regrettably, it is not possible to use elemental dice without installing **vector_number** gem first.
265
+
241
266
  ### All ways to define dice
242
267
 
243
- There are three *main* ways to define dice:
268
+ There are four *main* ways to define dice:
244
269
  - *"5", "25", or "525"*: a single positive integer makes a regular die (like a D20).
245
270
  - *"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
271
  - 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.
272
+ - *"1,2,4", "(-1.5,0,1.5)", or "2,"*: a list of any numbers separated by commas, possibly in round brackets, makes a custom numeric die.
248
273
  - Lists can end in a comma, allowing single-number lists.
274
+ - *"1,1.5,Two", "(๐Ÿ’š,๐Ÿงก,๐Ÿ’™,๐Ÿ’œ)" or "('1','(bracket)')"*: a list of strings and numbers separated by commas, possibly in round brackets, makes an arbitrary die.
275
+ - Lists can end in a comma, allowing single-string lists.
276
+ - Single (') or double (") quotes can be used to use other quotes and round brackets in the string. Otherwise, they are prohibited. Commas are always prohibited.
277
+ - Quotes can also be used to treat numbers as strings.
249
278
 
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 "-".
279
+ *"D6", "d(-1,3)", "d2..4", or "d๐Ÿ’š,๐Ÿงก"*: 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
280
 
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.
281
+ *"2D6", "5d-1,3", "277D(2..4)", or "3d๐Ÿ‘‘,โ™ ๏ธ,โ™ฅ๏ธ,โ™ฃ๏ธ,โ™ฆ๏ธ,โš“๏ธ"*: 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
282
 
254
283
  ## Usage: API
255
284
 
@@ -260,7 +289,7 @@ There are three *main* ways to define dice:
260
289
  ### Dice
261
290
 
262
291
  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.
292
+ - `Dicey::AbstractDie` is the base class for other dice, but can be used on its own. It has no restrictions on values of sides.
264
293
  - `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
294
  - `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
295
 
@@ -282,7 +311,7 @@ Dicey::DieFoundry.new.call("100")
282
311
  Dicey::DieFoundry.new.call("2d6")
283
312
  # same as Dicey::RegularDie.from_count(2, 6)
284
313
  Dicey::DieFoundry.new.call("1d1,2,4")
285
- # same as Dicey::NumericDie.from_list([1,2,4])
314
+ # same as Dicey::NumericDie.from_count(1, [1,2,4])
286
315
  ```
287
316
 
288
317
  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`:
@@ -292,6 +321,26 @@ foundry = Dicey::DieFoundry.new
292
321
  # same as [Dicey::RegularDie.new(8), *Dicey::RegularDie.from_count(2, 4)]
293
322
  ```
294
323
 
324
+ #### Really custom dice
325
+
326
+ It is easy enough to create numeric dice or dice with distinct symbols. However, what if a symbolic die is needed, but one which also has custom counts of symbols?
327
+
328
+ For example, a game may have a die which can roll 1-3 ๐Ÿ’š or a โ™ฅ๏ธ. You could just use completely different strings for the different numbers of hearts, but they would not be summable (what if you need to roll two such dice and add them together?). In this case, you can directly use `VectorNumber` to create summable strings:
329
+ ```rb
330
+ # Using Symbols is not required, but they look nicer in output.
331
+ # `DieFoundry` uses Symbols for this reason.
332
+ heal = VectorNumber[:"๐Ÿ’š"]
333
+ regen = VectorNumber[:"โ™ฅ๏ธ"]
334
+ die = Dicey::AbstractDie.new([heal, heal * 2, heal * 3, regen])
335
+ # => #<Dicey::AbstractDie:0x00007f4a7c95efe8 @current_side_index=0, @sides_list=[(1โ‹…๐Ÿ’š), (2โ‹…๐Ÿ’š), (3โ‹…๐Ÿ’š), (1โ‹…โ™ฅ๏ธ)], @sides_num=4>
336
+ ```
337
+
338
+ Now such dice can easily be rolled together and results summed:
339
+ ```rb
340
+ die.roll + die.roll
341
+ # => (5โ‹…๐Ÿ’š)
342
+ ```
343
+
295
344
  ### Rolling
296
345
 
297
346
  `Dicey::AbstractDie#roll` implements the rolling:
@@ -336,20 +385,22 @@ die.roll
336
385
  ### Distribution calculators
337
386
 
338
387
  Distribution 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 a 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.
388
+ - `Dicey::SumFrequencyCalculators::KroneckerSubstitution` is the recommended calculator, able to handle all `Dicey::RegularDie` and more. It is very fast, though sometimes slower than the next one.
389
+ - `Dicey::SumFrequencyCalculators::MultinomialCoefficients` is specialized for repeated numeric dice, with performance on par with the previous one, depending on exact parameters. However, it is currently limited to dice with arithmetic sequences (this includes regular dice, however).
390
+ - `Dicey::SumFrequencyCalculators::BruteForce` is the most generic and slowest one, but can work with *any* dice. It needs gem "**vector_number**" to be installed and available to work with non-numeric dice.
391
+ - `Dicey::SumFrequencyCalculators::Empirical`... this is more of a tool than a calculator. It "calculates" probabilities by performing a large number of rolls and counting frequencies of outcomes. It can be interesting to play around with and see how practical results compare to theoretical ones. Due to its simplicity, it also works with *any* dice.
343
392
 
344
393
  Calculators inherit from `Dicey::SumFrequencyCalculators::BaseCalculator` and provide the following public interface:
345
394
  - `#call(dice, result_type: {:frequencies | :probabilities}, **options) : Hash`
346
395
  - `#valid_for?(dice) : Boolean`
347
396
 
348
- See [Diving deeper](#diving-deeper) for more details on limitations and complexity considerations.
397
+ See [Diving deeper](#diving-deeper) for more details on limitations and complexity considerations of different algorithms.
398
+
399
+ When in doubt which calculator to use (and if a given one *can* be used), use `Dicey::SumFrequencyCalculators::AutoSelector`. Its `#call(dice)` method will return a valid calculator for the given dice or `nil` if none are acceptable.
349
400
 
350
401
  ### Distribution properties
351
402
 
352
- While distribution itself is already enough in most cases (we are talking just dice here, after all). it may be of interest to calculate properties of it: mode, mean, expected value, standard deviation, etc. `Dicey::DistributionPropertiesCalculator` already provides this functionality:
403
+ While distribution itself is already enough in most cases (we are talking just dice here, after all). it may be of interest to calculate properties of it: mode, mean, expected value, standard deviation, etc. `Dicey::DistributionPropertiesCalculator` provides this functionality:
353
404
  ```rb
354
405
  Dicey::DistributionPropertiesCalculator.new.call(
355
406
  Dicey::SumFrequencyCalculators::KroneckerSubstitution.new.call(
@@ -358,18 +409,19 @@ Dicey::DistributionPropertiesCalculator.new.call(
358
409
  )
359
410
  # =>
360
411
  # {:mode=>[4],
361
- # :min=>2,
362
- # :max=>6,
363
- # :total_range=>4,
364
- # :mid_range=>4,
365
- # :median=>4,
366
- # :arithmetic_mean=>4,
367
- # :expected_value=>4,
368
- # :variance=>(4/3),
369
- # :standard_deviation=>1.1547005383792515,
370
- # :skewness=>0.0,
371
- # :kurtosis=>(9/4),
372
- # :excess_kurtosis=>(-3/4)}
412
+ # :modes=>[[4]]
413
+ # :min=>2,
414
+ # :max=>6,
415
+ # :total_range=>4,
416
+ # :mid_range=>4,
417
+ # :median=>4,
418
+ # :arithmetic_mean=>4,
419
+ # :expected_value=>4,
420
+ # :variance=>(4/3),
421
+ # :standard_deviation=>1.1547005383792515,
422
+ # :skewness=>0.0,
423
+ # :kurtosis=>(9/4),
424
+ # :excess_kurtosis=>(-3/4)}
373
425
  ```
374
426
 
375
427
  Of course, for regular dice most properties are quite simple and predicatable due to symmetricity of distribution. It becomes more interesting with unfair, lopsided dice. Remember [Example 3](#example-3-custom-dice)?
@@ -381,21 +433,30 @@ Dicey::DistributionPropertiesCalculator.new.call(
381
433
  )
382
434
  # =>
383
435
  # {:mode=>[5],
384
- # :min=>2,
385
- # :max=>8,
386
- # :total_range=>6,
387
- # :mid_range=>5,
388
- # :median=>5,
389
- # :arithmetic_mean=>5,
390
- # :expected_value=>(31/6),
391
- # :variance=>(101/36),
392
- # :standard_deviation=>1.674979270186815,
393
- # :skewness=>-0.15762965389465178,
394
- # :kurtosis=>(23145/10201),
395
- # :excess_kurtosis=>(-7458/10201)}
436
+ # :modes=>[[5]],
437
+ # :min=>2,
438
+ # :max=>8,
439
+ # :total_range=>6,
440
+ # :mid_range=>5,
441
+ # :median=>5,
442
+ # :arithmetic_mean=>5,
443
+ # :expected_value=>(31/6),
444
+ # :variance=>(101/36),
445
+ # :standard_deviation=>1.674979270186815,
446
+ # :skewness=>-0.15762965389465178,
447
+ # :kurtosis=>(23145/10201),
448
+ # :excess_kurtosis=>(-7458/10201)}
449
+ ```
450
+
451
+ This disitrubution is obviosuly skewed (as can be immediately seen from non-zero skewness), with expected value no longer equal to mean. This is a mild example. It is easily possible to create a distribution with multiple local maxima or high skewness. For example, let's take two D2 and a weighted die to create a distribution with two peaks:
452
+ ```rb
453
+ [*Dicey::RegularDie.from_count(2, 2), Dicey::NumericDie.new([1,8,9])]
454
+ # =>
455
+ # {:mode=>[11, 12],
456
+ # :modes=>[[4], [11, 12]],
396
457
  ```
397
458
 
398
- This disitrubution is obviosuly skewed (as can be immediately seen from non-zero skewness), with expected value no longer equal to mean. This is a mild example. It is easily possible to create a distribution with multiple local maxima and high skewness.
459
+ You can see that 11 and 12 are the most likely outcomes, coming from a larger peak, but a smaller peak (with lower probability) is placed at 4.
399
460
 
400
461
  ## Diving deeper
401
462
 
@@ -405,10 +466,10 @@ For a further discussion of calculations, it is important to understand which cl
405
466
  > [!TIP]
406
467
  > ๐Ÿ’ก If you only need to roll **regular** dice, this section will not contain anything important.
407
468
 
408
- - **Natural** die has sides with only positive integers or 0. For example, (1,2,3,4,5,6), (5,1,6,5), (1,10000), (1,1,1,1,1,1,1,0).
469
+ - **Integer** die has sides with only integers. For example, (1,2,3,4,5,6), (-5,0,5), (1,10000), (1,1,1,1,1,1,1,0).
409
470
  - **Arithmetic** die's sides form an arithmetic sequence. For example, (1,2,3,4,5,6), (1,0,-1), (2.6,2.1,1.6,1.1).
410
471
  - **Numeric** die is limited by having sides confined to โ„ (or โ„‚ if you are feeling particularly adventurous).
411
- - **Abstract** die is not limited by anything other than not having partial sides (and how would that work anyway?).
472
+ - **Abstract** die is unlimited!
412
473
 
413
474
  > [!NOTE]
414
475
  > ๐Ÿ’ก 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.
@@ -418,31 +479,40 @@ Dicey is in principle able to handle any real numeric dice and some abstract dic
418
479
  Currently, three algorithms for calculating frequencies are implemented, with different possibilities and trade-offs.
419
480
 
420
481
  > [!NOTE]
421
- > ๐Ÿ’ก Complexity is listed for `n` dice with at most `m` sides and has not been rigorously proven.
482
+ > ๐Ÿ’ก Complexity is listed for **n** dice with at most **m** sides and is only an approximation.
422
483
 
423
484
  ### Kronecker substitution
424
485
 
425
486
  An algorithm based on fast polynomial multiplication. This is the default algorithm, used for most reasonable dice.
426
487
 
427
- - Limitations: only **natural** dice are allowed, including **regular** dice.
488
+ - Limitations: only **integer** dice are allowed, including **regular** dice.
428
489
  - Example: `dicey 5 3,4,1 0,`
429
- - Complexity: `O(mโ‹…n)` where `m` is the highest value
490
+ - Complexity: **O(n<sup>3</sup>โ‹…m<sup>2</sup>)**
491
+ - Running time examples:
492
+ - 6d1000 โ€” 0.5 seconds
493
+ - 1000d6 โ€” 18 seconds
430
494
 
431
495
  ### Multinomial coefficients
432
496
 
433
- This algorithm is based on raising a univariate polynomial to a power and using the coefficients of the result, though certain restrictions are lifted as they don't actually matter for the calculation.
497
+ This algorithm is based on raising a univariate polynomial to a power and using the coefficients of the result, though certain restrictions are lifted as they don't actually matter for the calculation. It is usually faster than Kronecker substitution for many dice with few sides.
434
498
 
435
499
  - Limitations: only *equal* **arithmetic** dice are allowed.
436
500
  - Example: `dicey 1.5,3,4.5,6 1.5,3,4.5,6 1.5,3,4.5,6`
437
- - Complexity: `O(mโ‹…nยฒ)`
501
+ - Complexity: **O(n<sup>2</sup>โ‹…m<sup>2</sup>)**
502
+ - Running time examples:
503
+ - 6d1000 โ€” 1.65 seconds
504
+ - 1000d6 โ€” 10.5 seconds
438
505
 
439
506
  ### Brute force
440
507
 
441
- As a last resort, there is a brute force algorithm which goes through every possible dice roll and adds results together. While quickly growing terrible in performace, it has the largest input space, allowing to work with completely nonsensical dice, including aforementioned dice with complex numbers.
508
+ As a last resort, there is a brute force algorithm which goes through every possible dice roll and adds results together. While quickly growing terrible in performace (and memory usage), it has the largest input space, allowing to work with completely nonsensical dice, including complex numbers and altogether non-numeric values.
442
509
 
443
- - Limitations: objects on dice sides must be numbers.
444
- - Example: `dicey 5 1,0.1,2 1,-1,1,-1,0`
445
- - Complexity: `O(mโฟ)`
510
+ - Limitations: without **vector_number** all values must be numbers, otherwise almost any values are viable.
511
+ - Example: `dicey 5 1,0.1,2 A,B,C`
512
+ - Complexity: **O(m<sup>n</sup>)**
513
+ - Running time examples:
514
+ - 6d10 โ€” 0.25 seconds
515
+ - 10d6 โ€” 9.5 seconds
446
516
 
447
517
  ## Development
448
518
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Dicey
4
4
  # Asbtract die which may have an arbitrary list of sides,
5
- # not even neccessarily numbers (but preferably so).
5
+ # not even neccessarily numbers but strings or other objects.
6
6
  class AbstractDie
7
7
  # rubocop:disable Style/ClassVars
8
8
 
@@ -69,9 +69,11 @@ module Dicey
69
69
  # @param sides_list [Enumerable<Any>]
70
70
  # @raise [DiceyError] if +sides_list+ is empty
71
71
  def initialize(sides_list)
72
- @sides_list = (Array === sides_list) ? sides_list.dup.freeze : sides_list.to_a.freeze
72
+ @sides_list = sides_list.to_a
73
+ @sides_list = @sides_list.dup if @sides_list.equal?(sides_list)
73
74
  raise DiceyError, "dice must have at least one side!" if @sides_list.empty?
74
75
 
76
+ @sides_list.freeze
75
77
  @sides_num = @sides_list.size
76
78
  @current_side_index = 0
77
79
  end
@@ -97,7 +99,7 @@ module Dicey
97
99
  #
98
100
  # @return [Any] rolled side
99
101
  def roll
100
- @current_side_index = self.class.rand(0...@sides_num)
102
+ @current_side_index = self.class.rand(@sides_num)
101
103
  current
102
104
  end
103
105
 
@@ -10,13 +10,6 @@ module Dicey
10
10
  # Slice and dice everything in the Dicey module to produce a useful result.
11
11
  # This is the entry point for the CLI.
12
12
  class Blender
13
- # List of calculators to use, ordered by efficiency.
14
- ROLL_FREQUENCY_CALCULATORS = [
15
- SumFrequencyCalculators::KroneckerSubstitution.new,
16
- SumFrequencyCalculators::MultinomialCoefficients.new,
17
- SumFrequencyCalculators::BruteForce.new,
18
- ].freeze
19
-
20
13
  # How to transform option values from command-line arguments
21
14
  # to internally significant objects.
22
15
  OPTION_TRANSFORMATIONS = {
@@ -27,6 +20,7 @@ module Dicey
27
20
  "gnuplot" => OutputFormatters::GnuplotFormatter.new,
28
21
  "yaml" => OutputFormatters::YAMLFormatter.new,
29
22
  "json" => OutputFormatters::JSONFormatter.new,
23
+ "null" => OutputFormatters::NullFormatter.new,
30
24
  }.freeze,
31
25
  }.freeze
32
26
 
@@ -47,7 +41,6 @@ module Dicey
47
41
  def call(argv = ARGV)
48
42
  options, arguments = get_options_and_arguments(argv)
49
43
  require_optional_libraries(options)
50
- options[:roll_calculators] = ROLL_FREQUENCY_CALCULATORS
51
44
  return_value = RUNNERS[options.delete(:mode)].call(arguments, **options)
52
45
  print return_value if return_value.is_a?(String)
53
46
  !!return_value
@@ -11,7 +11,7 @@ module Dicey
11
11
  # Allowed result types (--result).
12
12
  RESULT_TYPES = %w[frequencies probabilities].freeze
13
13
  # Allowed output formats (--format).
14
- FORMATS = %w[list gnuplot json yaml].freeze
14
+ FORMATS = %w[list gnuplot json yaml null].freeze
15
15
 
16
16
  # Default values for initial values of the options.
17
17
  DEFAULT_OPTIONS = { mode: "frequencies", format: "list", result: "frequencies" }.freeze
@@ -3,27 +3,37 @@
3
3
  require_relative "numeric_die"
4
4
  require_relative "regular_die"
5
5
 
6
- require_relative "rational_to_integer"
6
+ require_relative "mixins/rational_to_integer"
7
7
 
8
8
  module Dicey
9
9
  # Helper class to define die definitions and automatically select the best one.
10
10
  class DieFoundry
11
- include RationalToInteger
11
+ include Mixins::RationalToInteger
12
12
 
13
13
  # Regexp for matching a possible count.
14
- PREFIX = /(?>(?<count>[1-9]\d*+)?d)?+/i
14
+ PREFIX = /(?:(?<count>[1-9]\d*+)?+d)?+/i
15
+ # Regexp for an integer number.
16
+ INTEGER = /(?:-?\d++)/
17
+ # Regexp for a (possibly) fractional number.
18
+ DECIMAL = /(?:-?\d++(?:\.\d++)?)/
19
+ # Regexp for an "arbitrary" string.
20
+ STRING = /(?:(?<side>[^"',()]++)|"(?<side>[^",]++)"|'(?<side>[^',]++)')/
15
21
 
16
22
  # Possible molds for the dice. They are matched in the order as written.
17
23
  MOLDS = [
18
24
  # Positive integer goes into the RegularDie mold.
19
25
  [/\A#{PREFIX}(?<sides>[1-9]\d*+)\z/, :regular_mold].freeze,
20
26
  # Integer range goes into the NumericDie mold.
21
- [/\A#{PREFIX}\(?(?<begin>-?\d++)(?>[-โ€“โ€”โ€ฆ]|\.{2,3})(?<end>-?\d++)\)?\z/, :range_mold].freeze,
27
+ [/\A#{PREFIX}\(?(?<begin>#{INTEGER})(?:[-โ€“โ€”โ€ฆ]|\.{2,3})(?<end>#{INTEGER})\)?\z/,
28
+ :range_mold].freeze,
22
29
  # List of numbers goes into the NumericDie mold.
23
- [/\A#{PREFIX}\(?(?<sides>-?\d++(?>,(?>-?\d++)?)+|,)\)?\z/, :weirdly_shaped_mold].freeze,
30
+ [/\A#{PREFIX}\(?(?<sides>#{INTEGER}(?:(?:,#{INTEGER})++,?+|,))\)?\z/,
31
+ :weirdly_shaped_mold].freeze,
24
32
  # Non-integers require special handling for precision.
25
- [/\A#{PREFIX}\(?(?<sides>-?\d++(?>\.\d++)?(?>,(?>-?\d++(?>\.\d++)?)?)+|,)\)?\z/,
33
+ [/\A#{PREFIX}\(?(?<sides>#{DECIMAL}(?:(?:,#{DECIMAL})++,?+|,))\)?\z/,
26
34
  :weirdly_precise_mold].freeze,
35
+ # Lists of stuff are broken into AbstractDie.
36
+ [/\A#{PREFIX}\(?(?<sides>#{STRING}(?:(?:,#{STRING})++,?+|,))\)?\z/, :cursed_mold].freeze,
27
37
  # Anything else is spilled on the floor.
28
38
  ].freeze
29
39
 
@@ -34,13 +44,16 @@ module Dicey
34
44
  # - integer range (like "3-6" or "(-5..5)"), which produces a {NumericDie};
35
45
  # - list of integers (like "3,4,5", "(-1,0,1)", or "2,"), which produces a {NumericDie};
36
46
  # - list of decimal numbers (like "0.5,0.2,0.8" or "(2.0,)"), which produces a {NumericDie},
37
- # but uses +Rational+ for values to maintain precise results.
47
+ # but uses +Rational+ for values to maintain precise results;
48
+ # - list of strings, possibly mixed with numbers (like "0.5,asdf" or "(๐Ÿ‘‘,โ™ ๏ธ,โ™ฅ๏ธ,โ™ฃ๏ธ,โ™ฆ๏ธ,โš“๏ธ)"),
49
+ # which produces an {AbstractDie} with strings converted to Symbols
50
+ # and numbers treated the same as in previous cases.
38
51
  #
39
52
  # Any die definition can be prefixed with a count, like "2D6" or "1d1,3,5" to create an array.
40
53
  # A plain "d" without an explicit count is ignored instead, creating a single die.
41
54
  #
42
55
  # @param definition [String] die shape
43
- # @return [NumericDie, RegularDie, Array<NumericDie>, Array<RegularDie>]
56
+ # @return [AbstractDie, Array<AbstractDie>]
44
57
  # @raise [DiceyError] if no mold fits the definition
45
58
  def call(definition)
46
59
  matched, name =
@@ -77,6 +90,21 @@ module Dicey
77
90
  build_dice(NumericDie, definition[:count], sides)
78
91
  end
79
92
 
93
+ def cursed_mold(definition)
94
+ sides = definition[:sides].split(",")
95
+ sides.map! do |side|
96
+ case side
97
+ when /\A#{INTEGER}\z/o
98
+ side.to_i
99
+ when /\A#{DECIMAL}\z/o
100
+ rational_to_integer(Rational(side))
101
+ else
102
+ side.match(STRING)[:side].to_sym
103
+ end
104
+ end
105
+ build_dice(AbstractDie, definition[:count], sides)
106
+ end
107
+
80
108
  def build_dice(die_class, count, sides)
81
109
  if count
82
110
  die_class.from_count(count.to_i, sides)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "rational_to_integer"
3
+ require_relative "mixins/rational_to_integer"
4
4
 
5
5
  module Dicey
6
6
  # Calculates distribution properties,
@@ -16,7 +16,7 @@ module Dicey
16
16
  # (median, mean, ...) are all equal.
17
17
  # Mode is often not unique, but includes this center.
18
18
  class DistributionPropertiesCalculator
19
- include RationalToInteger
19
+ include Mixins::RationalToInteger
20
20
 
21
21
  # Calculate properties for a given distribution.
22
22
  #
@@ -27,7 +27,7 @@ module Dicey
27
27
  #
28
28
  # @param distribution [Hash{Numeric => Numeric}, Hash{Any => Numeric}]
29
29
  # distribution with pre-sorted keys
30
- # @return [Hash{Symbol => Numeric, Array<Numeric>, Array<Array<Numeric>>}]
30
+ # @return [Hash{Symbol => Any}]
31
31
  def call(distribution)
32
32
  return {} if distribution.empty?
33
33
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ # @api private
5
+ # Various mixins with shared methods.
6
+ module Mixins
7
+ # Mix-in for converting rationals with denominator of 1 to integers.
8
+ module RationalToInteger
9
+ private
10
+
11
+ # Convert +value+ to +Integer+ if it's a +Rational+ with denominator of 1.
12
+ # Otherwise, return +value+ as-is.
13
+ #
14
+ # @param value [Numeric, Any]
15
+ # @return [Numeric, Integer, Any]
16
+ def rational_to_integer(value)
17
+ (Rational === value && value.denominator == 1) ? value.numerator : value
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ # @api private
5
+ module Mixins
6
+ # Mix-in for converting dice with non-numeric sides into dice with +VectorNumber+ sides.
7
+ module VectorizeDice
8
+ private
9
+
10
+ # Vectorize non-numeric sides for AbstractDie instances,
11
+ # leaving NumericDie instances unchanged.
12
+ #
13
+ # Check for VectorNumber availability *before* calling.
14
+ #
15
+ # @param dice [Array<AbstractDie>]
16
+ # @return [Array<AbstractDie>] a new array of dice
17
+ def vectorize_dice(dice)
18
+ dice.map do |die|
19
+ next die if NumericDie === die
20
+
21
+ sides =
22
+ die.sides_list.map do |side|
23
+ (Numeric === side || VectorNumber === side) ? side : VectorNumber.new([side])
24
+ end
25
+ die.class.new(sides)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -4,8 +4,12 @@ require_relative "abstract_die"
4
4
 
5
5
  module Dicey
6
6
  # A die which only has numeric sides, with no shenanigans.
7
+ #
8
+ # The only inherent difference in behavior compared to {AbstractDie} is
9
+ # that this class checks values for sides on initialization.
10
+ # However, other classes may reject {AbstractDie} even with all numeric sides.
7
11
  class NumericDie < AbstractDie
8
- # @param sides_list [Enumerable<Numeric>]
12
+ # @param sides_list [Array<Numeric>, Range<Numeric>, Enumerable<Numeric>]
9
13
  # @raise [DiceyError] if +sides_list+ contains non-numerical values or is empty
10
14
  def initialize(sides_list)
11
15
  if Range === sides_list
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../rational_to_integer"
3
+ require_relative "../mixins/rational_to_integer"
4
4
 
5
5
  module Dicey
6
6
  module OutputFormatters
@@ -8,7 +8,7 @@ module Dicey
8
8
  # Can add an optional description into the result.
9
9
  # @abstract
10
10
  class KeyValueFormatter
11
- include RationalToInteger
11
+ include Mixins::RationalToInteger
12
12
 
13
13
  # @param hash [Hash{Object => Object}]
14
14
  # @param description [String] text to add as a comment.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dicey
4
+ module OutputFormatters
5
+ # Formatter that doesn't format anything and always returns an empty string.
6
+ class NullFormatter
7
+ # @param hash [Hash{Object => Object}]
8
+ # @param description [String] text to add as a comment.
9
+ # @return [String] always an empty string
10
+ def call(hash, description = nil) # rubocop:disable Lint/UnusedMethodArgument
11
+ ""
12
+ end
13
+ end
14
+ end
15
+ end
@@ -4,6 +4,8 @@ require_relative "numeric_die"
4
4
 
5
5
  module Dicey
6
6
  # Regular die, which has N sides with numbers from 1 to N.
7
+ #
8
+ # As a subclass of {NumericDie}, enjoys its treatment.
7
9
  class RegularDie < NumericDie
8
10
  # @param max [Integer] maximum side / number of sides
9
11
  def initialize(max)
data/lib/dicey/roller.rb CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  require_relative "die_foundry"
4
4
 
5
- require_relative "rational_to_integer"
5
+ require_relative "mixins/rational_to_integer"
6
+ require_relative "mixins/vectorize_dice"
6
7
 
7
8
  module Dicey
8
9
  # Let the dice roll!
10
+ #
11
+ # This is the implementation of roll mode for the CLI.
9
12
  class Roller
10
- include RationalToInteger
13
+ include Mixins::RationalToInteger
14
+ include Mixins::VectorizeDice
11
15
 
12
16
  # @param arguments [Array<String>] die definitions
13
17
  # @param format [#call] formatter for output
@@ -17,9 +21,15 @@ module Dicey
17
21
  raise DiceyError, "no dice!" if arguments.empty?
18
22
 
19
23
  dice = arguments.flat_map { |definition| die_foundry.cast(definition) }
20
- result = dice.sum(&:roll)
24
+ result = roll_dice(dice)
21
25
 
22
26
  format.call({ "roll" => rational_to_integer(result) }, AbstractDie.describe(dice))
27
+ rescue TypeError
28
+ warn <<~TEXT
29
+ Dice with non-numeric sides need gem "vector_number" to be present and available.
30
+ If this is intended, please install the gem.
31
+ TEXT
32
+ raise DiceyError, "can not roll dice with non-numeric sides!"
23
33
  end
24
34
 
25
35
  private
@@ -27,5 +37,10 @@ module Dicey
27
37
  def die_foundry
28
38
  @die_foundry ||= DieFoundry.new
29
39
  end
40
+
41
+ def roll_dice(dice)
42
+ dice = vectorize_dice(dice) if defined?(VectorNumber)
43
+ dice.sum(&:roll)
44
+ end
30
45
  end
31
46
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brute_force"
4
+ require_relative "kronecker_substitution"
5
+ require_relative "multinomial_coefficients"
6
+
7
+ module Dicey
8
+ module SumFrequencyCalculators
9
+ # Tool to automatically select a calculator for a given set of dice.
10
+ #
11
+ # Calculator is guaranteed to be compatible, with a strong chance of being the most performant.
12
+ #
13
+ # @see BaseCalculator#heuristic_complexity
14
+ class AutoSelector
15
+ # Calculators to consider when selecting a match.
16
+ AVAILABLE_CALCULATORS = [
17
+ KroneckerSubstitution.new,
18
+ MultinomialCoefficients.new,
19
+ BruteForce.new,
20
+ ].freeze
21
+
22
+ # @param calculators [Array<BaseCalculator>]
23
+ # calculators which this instance will consider
24
+ def initialize(calculators = AVAILABLE_CALCULATORS)
25
+ @calculators = calculators
26
+ end
27
+
28
+ # Determine best (or adequate) calculator for a given set of dice
29
+ # based on heuristics from the list of available calculators.
30
+ #
31
+ # @param dice [Enumerable<NumericDie>]
32
+ # @return [BaseCalculator, nil] +nil+ if no calculator is compatible
33
+ def call(dice)
34
+ compatible = @calculators.select { _1.valid_for?(dice) }
35
+ return if compatible.empty?
36
+
37
+ compatible.min_by { _1.heuristic_complexity(dice) }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -27,7 +27,8 @@ module Dicey
27
27
 
28
28
  # @param dice [Enumerable<AbstractDie>]
29
29
  # @param result_type [Symbol] one of {RESULT_TYPES}
30
- # @param options [Hash{Symbol => Any}] calculator-specific options
30
+ # @param options [Hash{Symbol => Any}] calculator-specific options,
31
+ # refer to the calculator's documentation to see what it accepts
31
32
  # @return [Hash{Numeric => Numeric}] frequencies or probabilities for each outcome
32
33
  # @raise [DiceyError] if +result_type+ is invalid
33
34
  # @raise [DiceyError] if dice list is invalid for the calculator
@@ -54,6 +55,16 @@ module Dicey
54
55
  dice.is_a?(Enumerable) && dice.all?(AbstractDie) && validate(dice)
55
56
  end
56
57
 
58
+ # Heuristic complexity of the calculator, used to determine best calculator.
59
+ #
60
+ # @see AutoSelector
61
+ #
62
+ # @param dice [Enumerable<AbstractDie>]
63
+ # @return [Integer]
64
+ def heuristic_complexity(dice)
65
+ calculate_heuristic(dice.length, dice.map(&:sides_num).max).to_i
66
+ end
67
+
57
68
  private
58
69
 
59
70
  # Do additional validation on the dice list.
@@ -62,12 +73,19 @@ module Dicey
62
73
  true
63
74
  end
64
75
 
76
+ # Calculate heuristic complexity of the calculator.
77
+ #
78
+ # @param dice_count [Integer]
79
+ # @param sides_count [Integer] maximum number of sides
80
+ # @return [Numeric]
81
+ def calculate_heuristic(dice_count, sides_count)
82
+ raise NotImplementedError
83
+ end
84
+
65
85
  # Peform frequencies calculation.
66
86
  # (see #call)
67
87
  def calculate(dice, **nil)
68
- # :nocov:
69
88
  raise NotImplementedError
70
- # :nocov:
71
89
  end
72
90
 
73
91
  # Check that resulting frequencies actually add up to what they are supposed to be.
@@ -2,55 +2,40 @@
2
2
 
3
3
  require_relative "base_calculator"
4
4
 
5
+ require_relative "../mixins/vectorize_dice"
6
+
5
7
  module Dicey
6
8
  module SumFrequencyCalculators
7
- # Calculator for a collection of {NumericDie} using exhaustive search (very slow).
9
+ # Calculator for a collection of {AbstractDie} using exhaustive search (very slow).
10
+ #
11
+ # If dice include non-numeric sides, gem +vector_number+ has to be installed.
8
12
  class BruteForce < BaseCalculator
13
+ include Mixins::VectorizeDice
14
+
9
15
  private
10
16
 
11
17
  def validate(dice)
12
- dice.all?(NumericDie)
18
+ if defined?(VectorNumber) || dice.all?(NumericDie)
19
+ true
20
+ else
21
+ warn <<~TEXT
22
+ Dice with non-numeric sides need gem "vector_number" to be present and available.
23
+ If this is intended, please install the gem.
24
+ TEXT
25
+ false
26
+ end
13
27
  end
14
28
 
15
- def calculate(dice, **nil)
16
- combine_dice_enumerators(dice).map(&:sum).tally
29
+ def calculate_heuristic(dice_count, sides_count)
30
+ 1000 * (sides_count**dice_count)
17
31
  end
18
32
 
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))
35
- end
36
-
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?
44
-
45
- enums
46
- .reverse
47
- .reduce(block) { |inner, enum|
48
- ->(values) { enum.each_entry { inner.call([*values, _1]) } }
49
- }
50
- .call([])
51
- end
33
+ def calculate(dice, **nil)
34
+ dice = vectorize_dice(dice) if defined?(VectorNumber)
35
+ dice.map(&:sides_list).reduce { |result, die|
36
+ result.flat_map { |roll| die.map { |side| roll + side } }
37
+ }.tally
52
38
  end
53
- # :nocov:
54
39
  end
55
40
  end
56
41
  end
@@ -2,9 +2,11 @@
2
2
 
3
3
  require_relative "base_calculator"
4
4
 
5
+ require_relative "../mixins/vectorize_dice"
6
+
5
7
  module Dicey
6
8
  module SumFrequencyCalculators
7
- # "Calculator" for a collection of {NumericDie} using empirically-obtained statistics.
9
+ # "Calculator" for a collection of {AbstractDie} using empirically-obtained statistics.
8
10
  #
9
11
  # @note This calculator is mostly a joke. It can be useful for educational purposes,
10
12
  # or to verify results of {BruteForce} when in doubt. It is not used by default.
@@ -12,19 +14,36 @@ module Dicey
12
14
  # Does a number of rolls and calculates approximate probabilities from that.
13
15
  # Even if frequencies are requested, results are non-integer.
14
16
  #
17
+ # If dice include non-numeric sides, gem +vector_number+ has to be installed.
18
+ #
15
19
  # *Options:*
16
20
  # - *rolls* (Integer) (_defaults_ _to:_ _N_) โ€” number of rolls to perform
17
21
  class Empirical < BaseCalculator
22
+ include Mixins::VectorizeDice
23
+
18
24
  # Default number of rolls to perform.
19
25
  N = 10_000
20
26
 
21
27
  private
22
28
 
23
29
  def validate(dice)
24
- dice.all?(NumericDie)
30
+ if defined?(VectorNumber) || dice.all?(NumericDie)
31
+ true
32
+ else
33
+ warn <<~TEXT
34
+ Dice with non-numeric sides need gem "vector_number" to be present and available.
35
+ If this is intended, please install the gem.
36
+ TEXT
37
+ false
38
+ end
39
+ end
40
+
41
+ def calculate_heuristic(dice_count, sides_count)
42
+ N * dice_count * Math.log2(sides_count)
25
43
  end
26
44
 
27
45
  def calculate(dice, rolls: N)
46
+ dice = vectorize_dice(dice) if defined?(VectorNumber)
28
47
  statistics = rolls.times.with_object(Hash.new(0)) { |_, hash| hash[dice.sum(&:roll)] += 1 }
29
48
  total_results = dice.map(&:sides_num).reduce(:*)
30
49
  statistics.transform_values { Rational(_1 * total_results, rolls) }
@@ -4,9 +4,9 @@ require_relative "base_calculator"
4
4
 
5
5
  module Dicey
6
6
  module SumFrequencyCalculators
7
- # Calculator for lists of dice with non-negative integer sides (fast).
7
+ # Calculator for lists of dice with integer sides (fast).
8
8
  #
9
- # Example dice: (1,2,3,4), (0,1,5,6), (5,4,5,4,5).
9
+ # Example dice: (1,2,3,4), (0,1,-5,6), (5,4,5,4,5).
10
10
  #
11
11
  # Based on Kronecker substitution method for polynomial multiplication.
12
12
  # @see https://en.wikipedia.org/wiki/Kronecker_substitution
@@ -17,15 +17,19 @@ module Dicey
17
17
  private
18
18
 
19
19
  def validate(dice)
20
- dice.all? { |die| die.sides_list.all? { _1.is_a?(Integer) && _1 >= 0 } }
20
+ dice.all? { |die| die.sides_list.all?(Integer) }
21
+ end
22
+
23
+ def calculate_heuristic(dice_count, sides_count)
24
+ (dice_count**3.2) * 100 * (sides_count**1.9)
21
25
  end
22
26
 
23
27
  def calculate(dice, **nil)
24
- polynomials = build_polynomials(dice)
28
+ polynomials, offset = build_polynomials(dice)
25
29
  evaluation_point = find_evaluation_point(polynomials)
26
30
  values = evaluate_polynomials(polynomials, evaluation_point)
27
31
  product = values.reduce(:*)
28
- extract_coefficients(product, evaluation_point)
32
+ extract_coefficients(product, evaluation_point, offset, polynomials.count)
29
33
  end
30
34
 
31
35
  # Turn dice into hashes where keys are side values and values are numbers of those sides,
@@ -35,7 +39,8 @@ module Dicey
35
39
  # @param dice [Enumerable<NumericDie>]
36
40
  # @return [Array<Hash{Integer => Integer}>]
37
41
  def build_polynomials(dice)
38
- dice.map { _1.sides_list.tally }
42
+ minimum = dice.map { |die| die.sides_list.min }.min
43
+ [dice.map { |die| die.sides_list.map { _1 - minimum }.tally }, minimum]
39
44
  end
40
45
 
41
46
  # Find a power of 2 which is larger in magnitude than any resulting polynomial coefficients,
@@ -67,13 +72,15 @@ module Dicey
67
72
  #
68
73
  # @param product [Integer]
69
74
  # @param evaluation_point [Integer]
75
+ # @param offset [Integer]
76
+ # @param number_of_dice [Integer]
70
77
  # @return [Hash{Integer => Integer}]
71
- def extract_coefficients(product, evaluation_point)
78
+ def extract_coefficients(product, evaluation_point, offset, number_of_dice)
72
79
  window = evaluation_point - 1
73
80
  window_shift = window.bit_length
74
81
  (0..).each_with_object({}) do |power, result|
75
82
  coefficient = product & window
76
- result[power] = coefficient unless coefficient.zero?
83
+ result[power + (offset * number_of_dice)] = coefficient unless coefficient.zero?
77
84
  product >>= window_shift
78
85
  break result if product.zero?
79
86
  end
@@ -11,6 +11,7 @@ module Dicey
11
11
  # Based on extension of Pascal's triangle for a higher number of coefficients.
12
12
  # @see https://en.wikipedia.org/wiki/Pascal's_triangle
13
13
  # @see https://en.wikipedia.org/wiki/Trinomial_triangle
14
+ # @see https://en.wikipedia.org/wiki/Multinomial_distribution
14
15
  class MultinomialCoefficients < BaseCalculator
15
16
  private
16
17
 
@@ -33,6 +34,12 @@ module Dicey
33
34
  true
34
35
  end
35
36
 
37
+ def calculate_heuristic(dice_count, sides_count)
38
+ # Fitting shows both coefficients to be around 500,
39
+ # but empirical runtime doesn't agree, so 150 it is.
40
+ 150 * (dice_count**2.2) * 500 * (sides_count**1.9)
41
+ end
42
+
36
43
  def calculate(dice, **nil)
37
44
  first_die = dice.first
38
45
  number_of_sides = first_die.sides_num
@@ -71,7 +78,7 @@ module Dicey
71
78
  # @return [Array<Integer>]
72
79
  def next_row_of_coefficients(row_index, window_size, previous_row)
73
80
  length = (row_index * window_size) + 1
74
- (0..length).map do |col_index|
81
+ (0...length).map do |col_index|
75
82
  # Have to clamp to 0 to prevent accessing array from the end.
76
83
  # BUG: TruffleRuby can't handle endless range in #clamp (see https://github.com/oracle/truffleruby/issues/3945)
77
84
  window_range = ((col_index - window_size).clamp(0..col_index)..col_index)
@@ -13,17 +13,18 @@ module Dicey
13
13
  # Transform die definitions to roll frequencies.
14
14
  #
15
15
  # @param arguments [Array<String>] die definitions
16
- # @param roll_calculators [Array<BaseCalculator>] list of calculators to use
17
16
  # @param format [#call] formatter for output
18
17
  # @param result [Symbol] result type selector
19
18
  # @return [nil]
20
19
  # @raise [DiceyError]
21
- def call(arguments, roll_calculators:, format:, result:, **)
20
+ def call(arguments, format:, result:, **)
22
21
  raise DiceyError, "no dice!" if arguments.empty?
23
22
 
24
23
  dice = arguments.flat_map { |definition| die_foundry.cast(definition) }
25
- frequencies = roll_calculators.find { _1.valid_for?(dice) }&.call(dice, result_type: result)
26
- raise DiceyError, "no calculator could handle these dice!" unless frequencies
24
+ calculator = calculator_selector.call(dice)
25
+ raise DiceyError, "no calculator could handle these dice!" unless calculator
26
+
27
+ frequencies = calculator.call(dice, result_type: result)
27
28
 
28
29
  format.call(frequencies, AbstractDie.describe(dice))
29
30
  end
@@ -33,6 +34,10 @@ module Dicey
33
34
  def die_foundry
34
35
  @die_foundry ||= DieFoundry.new
35
36
  end
37
+
38
+ def calculator_selector
39
+ @calculator_selector ||= AutoSelector.new
40
+ end
36
41
  end
37
42
  end
38
43
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "auto_selector"
3
4
  require_relative "brute_force"
4
5
  require_relative "kronecker_substitution"
5
6
  require_relative "multinomial_coefficients"
@@ -8,6 +9,8 @@ module Dicey
8
9
  module SumFrequencyCalculators
9
10
  # A simple testing facility for roll frequency calculators.
10
11
  class TestRunner
12
+ AVAILABLE_CALCULATORS = AutoSelector::AVAILABLE_CALCULATORS
13
+
11
14
  # These are manually calculated frequencies,
12
15
  # with test cases for pretty much all variations of what this program can handle.
13
16
  TEST_DATA = [
@@ -42,6 +45,25 @@ module Dicey
42
45
  { Complex(1, 1) => 1, Complex(2, 1) => 1, Complex(3, 1) => 1,
43
46
  Complex(1, 2) => 1, Complex(2, 2) => 1, Complex(3, 2) => 1,
44
47
  Complex(1, 3) => 1, Complex(2, 3) => 1, Complex(3, 3) => 1 }],
48
+ *(
49
+ # :nocov:
50
+ if defined?(VectorNumber)
51
+ # :nocov:
52
+ [
53
+ [[["s", "a", "d", 33]],
54
+ { VectorNumber["s"] => 1, VectorNumber["a"] => 1, VectorNumber["d"] => 1, 33 => 1 }],
55
+ [[%w[s a d], [0, 1, 2]],
56
+ { VectorNumber["s"] => 1, VectorNumber["s", 1] => 1, VectorNumber["s", 2] => 1,
57
+ VectorNumber["a"] => 1, VectorNumber["a", 1] => 1, VectorNumber["a", 2] => 1,
58
+ VectorNumber["d"] => 1, VectorNumber["d", 1] => 1, VectorNumber["d", 2] => 1 }],
59
+ [Array.new(2) { ["s", "a", 4] },
60
+ {
61
+ VectorNumber["s"] * 2 => 1, VectorNumber["a"] * 2 => 1, 8 => 1,
62
+ VectorNumber["s", "a"] => 2, VectorNumber["s", 4] => 2, VectorNumber["a", 4] => 2,
63
+ }],
64
+ ]
65
+ end
66
+ ),
45
67
  ].freeze
46
68
 
47
69
  # Strings for displaying test results.
@@ -51,12 +73,11 @@ module Dicey
51
73
 
52
74
  # Check all tests defined in {TEST_DATA} with every passed calculator.
53
75
  #
54
- # @param roll_calculators [Array<BaseCalculator>]
55
76
  # @param report_style [Symbol] one of: +:full+, +:quiet+;
56
77
  # +:quiet+ style does not output any text
57
78
  # @return [Boolean] whether there are no failing tests
58
- def call(*, roll_calculators:, report_style:, **)
59
- results = TEST_DATA.to_h { |test| run_test(test, roll_calculators) }
79
+ def call(*, report_style:, **)
80
+ results = TEST_DATA.to_h { |test| run_test(test) }
60
81
  full_report(results) if report_style == :full
61
82
  results.values.none? do |test_result|
62
83
  test_result.values.any? { FAILURE_RESULTS.include?(_1) }
@@ -67,13 +88,12 @@ module Dicey
67
88
 
68
89
  # @param test [Array(Array<Integer, Array<Numeric>>, Hash{Numeric => Integer})]
69
90
  # pair of a dice list definition and expected results
70
- # @param calculators [Array<BaseCalculator>]
71
91
  # @return [Array(Array<NumericDie>, Hash{BaseCalculator => Symbol})]
72
92
  # result of running the test in a format suitable for +#to_h+
73
- def run_test(test, calculators)
93
+ def run_test(test)
74
94
  dice = build_dice(test.first)
75
95
  test_result =
76
- calculators.each_with_object({}) do |calculator, hash|
96
+ AVAILABLE_CALCULATORS.each_with_object({}) do |calculator, hash|
77
97
  hash[calculator] = run_test_on_calculator(calculator, dice, test.last)
78
98
  end
79
99
  [dice, test_result]
@@ -84,7 +104,15 @@ module Dicey
84
104
  # @param definition [Array<Integer, Array<Integer>>]
85
105
  # @return [Array<NumericDie>]
86
106
  def build_dice(definition)
87
- definition.map { _1.is_a?(Integer) ? RegularDie.new(_1) : NumericDie.new(_1) }
107
+ definition.map do |die_def|
108
+ if die_def.is_a?(Integer)
109
+ RegularDie.new(die_def)
110
+ elsif die_def.all?(Numeric)
111
+ NumericDie.new(die_def)
112
+ else
113
+ AbstractDie.new(die_def)
114
+ end
115
+ end
88
116
  end
89
117
 
90
118
  # Determine test result for the selected calculator.
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.15.2"
4
+ VERSION = "0.16.1"
5
5
  end
data/lib/dicey.rb CHANGED
@@ -1,10 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Try to load "vector_number" pre-emptively.
4
+ begin
5
+ require "vector_number"
6
+ rescue LoadError
7
+ # VectorNumber not available, sad
8
+ end
9
+
3
10
  # A library for rolling dice and calculating roll frequencies.
4
11
  module Dicey
5
12
  # General error for Dicey.
6
13
  class DiceyError < StandardError; end
7
14
 
15
+ Dir["dicey/mixins/*.rb", base: __dir__].each { require_relative _1 }
8
16
  Dir["dicey/*.rb", base: __dir__].each { require_relative _1 }
9
17
  Dir["dicey/output_formatters/*.rb", base: __dir__].each { require_relative _1 }
10
18
  Dir["dicey/sum_frequency_calculators/*.rb", base: __dir__].each { require_relative _1 }
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dicey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.2
4
+ version: 0.16.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandr Bulancov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-08 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2025-10-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: vector_number
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.4.3
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.4.3
13
27
  description: |
14
28
  Dicey provides a CLI executable and a Ruby API for fast calculation of
15
29
  frequency/probability distributions of dice rolls,
@@ -36,16 +50,19 @@ files:
36
50
  - lib/dicey/cli/options.rb
37
51
  - lib/dicey/die_foundry.rb
38
52
  - lib/dicey/distribution_properties_calculator.rb
53
+ - lib/dicey/mixins/rational_to_integer.rb
54
+ - lib/dicey/mixins/vectorize_dice.rb
39
55
  - lib/dicey/numeric_die.rb
40
56
  - lib/dicey/output_formatters/gnuplot_formatter.rb
41
57
  - lib/dicey/output_formatters/hash_formatter.rb
42
58
  - lib/dicey/output_formatters/json_formatter.rb
43
59
  - lib/dicey/output_formatters/key_value_formatter.rb
44
60
  - lib/dicey/output_formatters/list_formatter.rb
61
+ - lib/dicey/output_formatters/null_formatter.rb
45
62
  - lib/dicey/output_formatters/yaml_formatter.rb
46
- - lib/dicey/rational_to_integer.rb
47
63
  - lib/dicey/regular_die.rb
48
64
  - lib/dicey/roller.rb
65
+ - lib/dicey/sum_frequency_calculators/auto_selector.rb
49
66
  - lib/dicey/sum_frequency_calculators/base_calculator.rb
50
67
  - lib/dicey/sum_frequency_calculators/brute_force.rb
51
68
  - lib/dicey/sum_frequency_calculators/empirical.rb
@@ -60,9 +77,9 @@ licenses:
60
77
  metadata:
61
78
  homepage_uri: https://github.com/trinistr/dicey
62
79
  bug_tracker_uri: https://github.com/trinistr/dicey/issues
63
- documentation_uri: https://rubydoc.info/gems/dicey/0.15.2
64
- source_code_uri: https://github.com/trinistr/dicey/tree/v0.15.2
65
- changelog_uri: https://github.com/trinistr/dicey/blob/v0.15.2/CHANGELOG.md
80
+ documentation_uri: https://rubydoc.info/gems/dicey/0.16.1
81
+ source_code_uri: https://github.com/trinistr/dicey/tree/v0.16.1
82
+ changelog_uri: https://github.com/trinistr/dicey/blob/v0.16.1/CHANGELOG.md
66
83
  rubygems_mfa_required: 'true'
67
84
  post_install_message:
68
85
  rdoc_options:
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dicey
4
- # @api private
5
- # Mix-in for converting rationals with denominator of 1 to integers.
6
- module RationalToInteger
7
- private
8
-
9
- # Convert +value+ to +Integer+ if it's a +Rational+ with denominator of 1.
10
- # Otherwise, return +value+ as-is.
11
- #
12
- # @value [Numeric, Any]
13
- # @return [Numeric, Integer, Any]
14
- def rational_to_integer(value)
15
- (Rational === value && value.denominator == 1) ? value.numerator : value
16
- end
17
- end
18
- end