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 +4 -4
- data/README.md +119 -49
- data/lib/dicey/abstract_die.rb +5 -3
- data/lib/dicey/cli/blender.rb +1 -8
- data/lib/dicey/cli/options.rb +1 -1
- data/lib/dicey/die_foundry.rb +36 -8
- data/lib/dicey/distribution_properties_calculator.rb +3 -3
- data/lib/dicey/mixins/rational_to_integer.rb +21 -0
- data/lib/dicey/mixins/vectorize_dice.rb +30 -0
- data/lib/dicey/numeric_die.rb +5 -1
- data/lib/dicey/output_formatters/key_value_formatter.rb +2 -2
- data/lib/dicey/output_formatters/null_formatter.rb +15 -0
- data/lib/dicey/regular_die.rb +2 -0
- data/lib/dicey/roller.rb +18 -3
- data/lib/dicey/sum_frequency_calculators/auto_selector.rb +41 -0
- data/lib/dicey/sum_frequency_calculators/base_calculator.rb +21 -3
- data/lib/dicey/sum_frequency_calculators/brute_force.rb +23 -38
- data/lib/dicey/sum_frequency_calculators/empirical.rb +21 -2
- data/lib/dicey/sum_frequency_calculators/kronecker_substitution.rb +15 -8
- data/lib/dicey/sum_frequency_calculators/multinomial_coefficients.rb +8 -1
- data/lib/dicey/sum_frequency_calculators/runner.rb +9 -4
- data/lib/dicey/sum_frequency_calculators/test_runner.rb +35 -7
- data/lib/dicey/version.rb +1 -1
- data/lib/dicey.rb +8 -0
- metadata +24 -7
- data/lib/dicey/rational_to_integer.rb +0 -18
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 86d934c5cf9455e15c42f84abe397c7c32ada670512969decd4e44c9eef1841f
|
4
|
+
data.tar.gz: bcba52a9d0e1bbb0308db1025ba2c723bf963aa02b323b631538bdbc722144e2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
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
|
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)",
|
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",
|
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.
|
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.
|
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
|
340
|
-
- `Dicey::SumFrequencyCalculators::MultinomialCoefficients` is specialized for repeated numeric dice, with performance
|
341
|
-
- `Dicey::SumFrequencyCalculators::BruteForce` is the most generic and slowest one, but can
|
342
|
-
- `Dicey::SumFrequencyCalculators::Empirical
|
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`
|
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
|
-
#
|
362
|
-
#
|
363
|
-
#
|
364
|
-
#
|
365
|
-
#
|
366
|
-
#
|
367
|
-
#
|
368
|
-
#
|
369
|
-
#
|
370
|
-
#
|
371
|
-
#
|
372
|
-
#
|
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
|
-
#
|
385
|
-
#
|
386
|
-
#
|
387
|
-
#
|
388
|
-
#
|
389
|
-
#
|
390
|
-
#
|
391
|
-
#
|
392
|
-
#
|
393
|
-
#
|
394
|
-
#
|
395
|
-
#
|
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
|
-
|
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
|
-
- **
|
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
|
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
|
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 **
|
488
|
+
- Limitations: only **integer** dice are allowed, including **regular** dice.
|
428
489
|
- Example: `dicey 5 3,4,1 0,`
|
429
|
-
- Complexity:
|
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:
|
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
|
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:
|
444
|
-
- Example: `dicey 5 1,0.1,2
|
445
|
-
- Complexity:
|
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
|
|
data/lib/dicey/abstract_die.rb
CHANGED
@@ -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
|
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 =
|
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(
|
102
|
+
@current_side_index = self.class.rand(@sides_num)
|
101
103
|
current
|
102
104
|
end
|
103
105
|
|
data/lib/dicey/cli/blender.rb
CHANGED
@@ -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
|
data/lib/dicey/cli/options.rb
CHANGED
@@ -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
|
data/lib/dicey/die_foundry.rb
CHANGED
@@ -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 = /(
|
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
|
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
|
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
|
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 [
|
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 =>
|
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
|
data/lib/dicey/numeric_die.rb
CHANGED
@@ -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
|
data/lib/dicey/regular_die.rb
CHANGED
@@ -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
|
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 {
|
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
|
16
|
-
|
29
|
+
def calculate_heuristic(dice_count, sides_count)
|
30
|
+
1000 * (sides_count**dice_count)
|
17
31
|
end
|
18
32
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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 {
|
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
|
7
|
+
# Calculator for lists of dice with integer sides (fast).
|
8
8
|
#
|
9
|
-
# Example dice: (1,2,3,4), (0,1
|
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?
|
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 {
|
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
|
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,
|
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
|
-
|
26
|
-
raise DiceyError, "no calculator could handle these dice!" unless
|
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(*,
|
59
|
-
results = TEST_DATA.to_h { |test| run_test(test
|
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
|
93
|
+
def run_test(test)
|
74
94
|
dice = build_dice(test.first)
|
75
95
|
test_result =
|
76
|
-
|
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
|
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
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.
|
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-
|
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.
|
64
|
-
source_code_uri: https://github.com/trinistr/dicey/tree/v0.
|
65
|
-
changelog_uri: https://github.com/trinistr/dicey/blob/v0.
|
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
|