dicey 0.18.0 โ†’ 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c11673348152c65cc6b6bb0b50a3bdf6b515479b6df9e1885c26d1e89e826dd4
4
- data.tar.gz: 722bcec16c95bd5fbd9774aa382b3d074c2c2898fbef1308ecce9f988f2e2f55
3
+ metadata.gz: 6c3e35746de9367cc5f314d235013036e23347addba832d108612e96a5d0b556
4
+ data.tar.gz: 7c269e9c93c22370780a7dec18e8439a433c07c6f67dad5d2574453196f3b82e
5
5
  SHA512:
6
- metadata.gz: 536a1274842408a7daeb543382ffd799fbf500020a9bde67f050ce2c3ce0e95e3dd9b0b94034f115af78f0c4d95ad7eef88910271b0db131b6433ad8303b2500
7
- data.tar.gz: 5a67e9950f0fb8b82b156dcd001623a9029a1fd666e08f6570095648ef4e57e582699ca1ffcb85d1ec9e746adb8dd1f27ad1b36630f5682629a2d5ff57272ac4
6
+ metadata.gz: 1ac83738ab8b68e73ce66003c524d38020e1cd6157d833de26567c9c0fb89e84ae036833cdce81747e4ef672432a36c62c12802b27f6646f1f860d3565d28ead
7
+ data.tar.gz: 6131b2bd5b4d1fadb7a9a9fbb458cbea0c7e63839ac6a0819c0ff29c58449a873626c2acc3d54e04ddd9ae48fd6084d461ac4f0518c723cc5288ded82aa416c1
data/README.md CHANGED
@@ -70,7 +70,7 @@ gem install dicey
70
70
 
71
71
  Or, if using Bundler, add it to your `Gemfile`:
72
72
  ```rb
73
- gem "dicey", "~> 0.17"
73
+ gem "dicey", "~> 0.19"
74
74
  ```
75
75
 
76
76
  > [!TIP]
@@ -87,7 +87,7 @@ gem "dicey", "~> 0.17"
87
87
 
88
88
  ## Usage: CLI (command line)
89
89
 
90
- Following examples assume that `dicey` (or `dicey-to-gnuplot`) is executable and is in `$PATH`.
90
+ Following examples assume that `dicey` (or `dicey-to-gnuplot`) is executable and is in `$PATH`. This should be the case if installed through RubyGems or Bundler.
91
91
 
92
92
  > [!NOTE]
93
93
  > ๐Ÿ’ก Run `dicey --help` to get a list of all possible options.
@@ -259,19 +259,28 @@ Wind, wood and lightning in equal proportion it is! Your enemies will tremble!
259
259
 
260
260
  There are four *main* ways to define dice:
261
261
  - *"5", "25", or "525"*: a single positive integer makes a regular die (like a D20).
262
- - *"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.
263
- - Accepted separators: "-", "..", "...", "โ€“" (en dash), "โ€”" (em dash), "โ€ฆ" (ellipsis).
262
+ - *"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.
263
+ - Accepted separators: "..", "...", "โ€“" (en dash), "โ€”" (em dash), "โ€ฆ" (ellipsis).
264
264
  - *"1,2,4", "(-1.5,0,3/2)", or "2,"*: a list of any numbers separated by commas, possibly in round brackets, makes a custom numeric die.
265
265
  - Lists can end in a comma, allowing single-number lists.
266
266
  - There is no difference between equal decimal and fractional representations of numbers.
267
- - *"1,1.5,Two", "(๐Ÿ’š,๐Ÿงก,๐Ÿ’™,๐Ÿ’œ)" or "('1','(bracket)')"*: a list of strings and numbers separated by commas, possibly in round brackets, makes an arbitrary die.
267
+ - *"1,1.5,Two", "(๐Ÿ’š,๐Ÿงก,๐Ÿ’™,๐Ÿ’œ)", or "('1','(bracket)')"*: a list of strings and numbers separated by commas, possibly in round brackets, makes an arbitrary die.
268
268
  - Lists can end in a comma, allowing single-string lists.
269
- - Single (') or double (") quotes can be used to include other quotes and round brackets in the string. Otherwise, they are prohibited. Commas are always prohibited.
269
+ - Single (') or double (") quotes can be used to include other quotes, round brackets and ambigious characters in a string. Otherwise, they are prohibited. Commas are always prohibited.
270
270
  - Quotes can also be used to treat numbers as strings.
271
+ - *"+5", "+WIZ", or "-2.5"*: a single value prefixed with a sign makes a die with exactly that value.
272
+ - Accepted signs: "+", "-", "โˆ’" (minus).
273
+ - Single (') or double (") quotes can be used to include other quotes, round brackets and ambigious characters in a string. Otherwise, they are prohibited. Commas are always prohibited.
274
+ - Non-numeric values are only accepted with a "+".
271
275
 
272
- *"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 "-".
276
+ *"D6", "d(-1,3)", "d2..4", "d+5", 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 "-".
273
277
 
274
- *"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.
278
+ *"2D6", "5d-1,3", "277D(2..4)", "3d+5", 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.
279
+
280
+ *"5+3", "2d6+4", "2D3โ€”5-1", or "d(๐Ÿ’š,๐Ÿงก)+โ™ฅ๏ธ"*: any definitions can be suffixed with a single signed value (see above), except for a single signed value itself. This adds the constant factor to result once.
281
+
282
+ > [!NOTE]
283
+ > ๐Ÿ’ก If your die definition starts with a negative number, it can be bracketed, prefixed with "d", or put after "--" pseudo-argument to avoid being processed as an option.
275
284
 
276
285
  ## Usage: API
277
286
 
@@ -281,10 +290,11 @@ There are four *main* ways to define dice:
281
290
 
282
291
  ### Dice
283
292
 
284
- There are 3 classes of dice currently:
293
+ There are 4 classes of dice currently:
285
294
  - `Dicey::AbstractDie` is the base class for other dice, but can be used on its own. It has no restrictions on values of sides.
286
295
  - `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.
287
296
  - `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*).
297
+ - `Dicey::StaticDie` is a specialized subclass of `Dicey::AbstractDie`. It accepts any single value and always produces that value.
288
298
 
289
299
  All dice classes have constructor methods aside from `.new`:
290
300
  - `.from_list` takes a list of definitions and calls `.new` with each one;
@@ -293,7 +303,7 @@ All dice classes have constructor methods aside from `.new`:
293
303
  See [Diving deeper](#diving-deeper) for more theoretical information.
294
304
 
295
305
  > [!NOTE]
296
- > ๐Ÿ’ก Using `Float` values is liable to cause precision issues. Due to in-built result verification, this **will** raise errors. Use `Rational` or `BigDecimal` instead.
306
+ > ๐Ÿ’ก Using `Float` values is liable to cause precision issues. Due to built-in result verification, this **will** raise errors. Use `Rational` or `BigDecimal` instead.
297
307
 
298
308
  #### DieFoundry
299
309
 
@@ -373,6 +383,9 @@ die.roll
373
383
  > [!NOTE]
374
384
  > ๐Ÿ’ก Randomness source is *global*, shared between all dice and probably not thread-safe.
375
385
 
386
+ > [!NOTE]
387
+ > ๐Ÿ’ก `Dicey::StaticDie` has no impact on roll randomness, unlike other single-sided dice.
388
+
376
389
  ### Distribution calculators
377
390
 
378
391
  Distribution calculators live in `Dicey::DistributionCalculators` module. There are three main calculators currently.
@@ -467,9 +480,6 @@ For a further discussion of calculations, it is important to understand which cl
467
480
  - **Numeric** die is limited by having sides confined to โ„ (or โ„‚ if you are feeling particularly adventurous).
468
481
  - **Abstract** die is unlimited!
469
482
 
470
- > [!NOTE]
471
- > ๐Ÿ’ก 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.
472
-
473
483
  Currently, three algorithms for calculating distributions are implemented, with different possibilities and trade-offs.
474
484
 
475
485
  > [!NOTE]
@@ -16,6 +16,10 @@ module Dicey
16
16
  # {.srand} can be used to (re)set the internal randomizer's state for all dice,
17
17
  # allowing to reproduce the same sequence of rolls (if it was done with a known state).
18
18
  class AbstractDie
19
+ # Matcher to check whether string needs quoting.
20
+ # @see DieFoundry::SPECIAL
21
+ STRING_TO_QUOTE = /["',()+โˆ’-]/
22
+
19
23
  # Yes, class variable is actually useful here.
20
24
  # TODO: Allow supplying a custom Random.
21
25
  # rubocop:disable Style/ClassVars
@@ -49,7 +53,12 @@ module Dicey
49
53
  def self.describe(dice)
50
54
  return dice.to_s if AbstractDie === dice
51
55
 
52
- dice.to_a.join("+")
56
+ dice.map(&:to_s).reduce { |string, die|
57
+ die_string = die.to_s
58
+ string << "+" unless die_string.match?(/\A[+-]/)
59
+ string << die_string
60
+ string
61
+ }.to_s
53
62
  end
54
63
 
55
64
  # Create a bunch of different dice at once from a list of definitions.
@@ -85,7 +94,7 @@ module Dicey
85
94
  # @raise [DiceyError] if +sides_list+ is empty
86
95
  def initialize(sides_list)
87
96
  @sides_list = sides_list.to_a
88
- @sides_list = @sides_list.dup if @sides_list.equal?(sides_list)
97
+ @sides_list = @sides_list.dup if @sides_list.equal?(sides_list) && !@sides_list.frozen?
89
98
  raise DiceyError, "dice must have at least one side!" if @sides_list.empty?
90
99
 
91
100
  @sides_list.freeze
@@ -121,10 +130,12 @@ module Dicey
121
130
  # Return a string representing the die.
122
131
  #
123
132
  # Default representation is a list of sides in round brackets.
133
+ # Strings are quoted.
124
134
  #
125
135
  # @return [String]
126
136
  def to_s
127
- (@sides_list.size > 1) ? "(#{@sides_list.join(",")})" : "(#{@sides_list.first},)"
137
+ sides = @sides_list.map { |side| side_to_s(side) }
138
+ (@sides_list.size > 1) ? "(#{sides.join(",")})" : "(#{sides.first},)"
128
139
  end
129
140
 
130
141
  # Determine if this die and the other one have the same list of sides.
@@ -159,8 +170,35 @@ module Dicey
159
170
  [self.class, @sides_list].hash
160
171
  end
161
172
 
173
+ # Whether all sides of this die are +Numeric+.
174
+ #
175
+ # @return [Boolean]
176
+ def numeric?
177
+ return @numeric if defined?(@numeric)
178
+
179
+ @numeric = @sides_list.all?(Numeric)
180
+ end
181
+
182
+ # Freezes +self+ (if not already frozen); returns +self+.
183
+ #
184
+ # Performs computations that memoize results before freezing.
185
+ def freeze
186
+ numeric? unless frozen?
187
+ super
188
+ end
189
+
162
190
  private
163
191
 
192
+ # @param side [#to_s]
193
+ # @return [String]
194
+ def side_to_s(side)
195
+ if String === side && side.match?(STRING_TO_QUOTE)
196
+ side.include?('"') ? "'#{side}'" : "\"#{side}\""
197
+ else
198
+ side.to_s
199
+ end
200
+ end
201
+
164
202
  # @param other [AbstractDie]
165
203
  # @return [Boolean]
166
204
  def same_sides?(other)
@@ -24,7 +24,10 @@ module Dicey
24
24
  [[2, 2, 2, 2], { 4 => 1, 5 => 4, 6 => 6, 7 => 4, 8 => 1 }],
25
25
  [[1, 2, 3], { 3 => 1, 4 => 2, 5 => 2, 6 => 1 }],
26
26
  [[3, 2, 1], { 3 => 1, 4 => 2, 5 => 2, 6 => 1 }],
27
+ [["+5"], { 5 => 1 }],
28
+ [["+5", "-12.2"], { -7.2r => 1 }],
27
29
  [[[0], 1], { 1 => 1 }],
30
+ [[[2, 3, 4], "-3"], { -1 => 1, 0 => 1, 1 => 1 }],
28
31
  [[4, 6], { 2 => 1, 3 => 2, 4 => 3, 5 => 4, 6 => 4, 7 => 4, 8 => 3, 9 => 2, 10 => 1 }],
29
32
  [[[3, 17, 21]], { 3 => 1, 17 => 1, 21 => 1 }],
30
33
  [[[3, 3, 3, 3, 3, 5, 5, 5]], { 3 => 5, 5 => 3 }],
@@ -42,10 +45,9 @@ 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
+ [[2, 2, "+2"], { 4 => 1, 5 => 2, 6 => 1 }],
45
49
  *(
46
- # :nocov:
47
- if defined?(VectorNumber)
48
- # :nocov:
50
+ if defined?(VectorNumber) # simplecov:disable
49
51
  [
50
52
  [[["s", "a", "d", 33]],
51
53
  { VectorNumber["s"] => 1, VectorNumber["a"] => 1, VectorNumber["d"] => 1, 33 => 1 }],
@@ -58,6 +60,10 @@ module Dicey
58
60
  VectorNumber["s"] * 2 => 1, VectorNumber["a"] * 2 => 1, 8 => 1,
59
61
  VectorNumber["s", "a"] => 2, VectorNumber["s", 4] => 2, VectorNumber["a", 4] => 2,
60
62
  }],
63
+ [[[1, 3], "+d"], { VectorNumber[1, "d"] => 1, VectorNumber[3, "d"] => 1 }],
64
+ [[%w[A B], %w[A B], "+A"],
65
+ { VectorNumber["A"] * 3 => 1, VectorNumber["A", "B", "A"] => 2,
66
+ VectorNumber["B", "B", "A"] => 1 }],
61
67
  ]
62
68
  end
63
69
  ),
@@ -93,8 +99,8 @@ module Dicey
93
99
  def run_test(test)
94
100
  dice = build_dice(test.first)
95
101
  test_result =
96
- AVAILABLE_CALCULATORS.each_with_object({}) do |calculator, hash|
97
- hash[calculator] = run_test_on_calculator(calculator, dice, test.last)
102
+ AVAILABLE_CALCULATORS.to_h do |calculator|
103
+ [calculator, run_test_on_calculator(calculator, dice, test.last)]
98
104
  end
99
105
  [dice, test_result]
100
106
  end
@@ -104,9 +110,11 @@ module Dicey
104
110
  # @param definition [Array<Integer, Array<Integer>>]
105
111
  # @return [Array<AbstractDie>]
106
112
  def build_dice(definition)
107
- definition.map do |die_def|
113
+ definition.flat_map do |die_def|
108
114
  if die_def.is_a?(Integer)
109
115
  RegularDie.new(die_def)
116
+ elsif die_def.is_a?(String)
117
+ DieFoundry.new.cast(die_def)
110
118
  elsif die_def.all?(Numeric)
111
119
  NumericDie.new(die_def)
112
120
  else
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "abstract_die"
3
4
  require_relative "numeric_die"
4
5
  require_relative "regular_die"
6
+ require_relative "static_die"
5
7
 
6
8
  require_relative "mixins/rational_to_integer"
7
9
 
@@ -11,60 +13,81 @@ module Dicey
11
13
  class DieFoundry
12
14
  include Mixins::RationalToInteger
13
15
 
14
- # Regexp for matching a possible count.
15
- PREFIX = /(?:(?<count>[1-9]\d*+)?+d)?+/i
16
- # Regexp for an integer number.
17
- INTEGER = /(?:-?\d++)/
18
- # Regexp for a (possibly) fractional number.
19
- FRACTION = %r{(?:-?\d++(?:/\d++|\.\d++)?)}
20
- # Regexp for an "arbitrary" string.
21
- STRING = /(?:(?<side>[^"',()]++)|"(?<side>[^",]++)"|'(?<side>[^',]++)')/
16
+ # Special characters disallowed in unquoted strings.
17
+ # @see AbstractDie::STRING_TO_QUOTE
18
+ SPECIAL = %{"',()+โˆ’-}
19
+
20
+ # Pattern for an integer number.
21
+ INTEGER = "(?:-?\\d++)"
22
+ # Pattern for a possibly fractional number.
23
+ NUMBER = "(?:-?\\d++(?:/\\d++|\\.\\d++)?)"
24
+ # Pattern for an "arbitrary" string or number.
25
+ STRING = %{(?:(?<string>[^#{SPECIAL}]++)|"(?<string>[^",]++)"|'(?<string>[^',]++)')}.freeze
26
+ # Pattern for a number or string (allowing negative numbers).
27
+ VALUE = "(?:#{NUMBER}(?=[,)+โˆ’-]|\\z)|#{STRING})".freeze
28
+
29
+ # Pattern for matching a possible count.
30
+ COUNT = "(?:(?<count>[1-9]\\d*+)?+[Dd])?+"
31
+ # Pattern for matching an optional constant factor.
32
+ CONSTANT = "(?<constant>(?<sign>[+โˆ’-])(?<constant_value>#{NUMBER})|" \
33
+ "(?<sign>\\+)(?<constant_value>#{STRING}))".freeze
34
+
35
+ molder = ->(pattern) { /\A#{COUNT}(?:#{pattern}|\(#{pattern}\))#{CONSTANT}?\z/ }
22
36
 
23
37
  # Possible molds for the dice. They are matched in the order as written.
24
38
  MOLDS = [
25
39
  # Positive integer goes into the RegularDie mold.
26
- [/\A#{PREFIX}(?<sides>[1-9]\d*+)\z/, :regular_mold].freeze,
40
+ [/\A#{COUNT}(?<sides>[1-9]\d*+)#{CONSTANT}?\z/, :regular_mold],
27
41
  # Integer range goes into the NumericDie mold.
28
- [/\A#{PREFIX}\(?(?<begin>#{INTEGER})(?:[-โ€“โ€”โ€ฆ]|\.{2,3})(?<end>#{INTEGER})\)?\z/,
29
- :range_mold].freeze,
42
+ [molder.("(?<begin>#{INTEGER})(?:[โ€“โ€”โ€ฆ]|\\.{2,3})(?<end>#{INTEGER})"), :range_mold],
30
43
  # List of numbers goes into the NumericDie mold.
31
- [/\A#{PREFIX}\(?(?<sides>#{INTEGER}(?:(?:,#{INTEGER})++,?+|,))\)?\z/,
32
- :weirdly_shaped_mold].freeze,
44
+ [molder.("(?<sides>#{INTEGER}(?:(?:,#{INTEGER})++,?+|,))"), :weirdly_shaped_mold],
33
45
  # Non-integers require special handling for precision.
34
- [/\A#{PREFIX}\(?(?<sides>#{FRACTION}(?:(?:,#{FRACTION})++,?+|,))\)?\z/,
35
- :weirdly_precise_mold].freeze,
46
+ [molder.("(?<sides>#{NUMBER}(?:(?:,#{NUMBER})++,?+|,))"), :weirdly_precise_mold],
36
47
  # Lists of stuff are broken into AbstractDie.
37
- [/\A#{PREFIX}\(?(?<sides>#{STRING}(?:(?:,#{STRING})++,?+|,))\)?\z/, :cursed_mold].freeze,
48
+ [molder.("(?<sides>#{VALUE}(?:(?:,#{VALUE})++,?+|,))"), :cursed_mold],
49
+ # Sign-prefixed value goes into the StaticDie mold.
50
+ [/\A#{COUNT}(?:#{CONSTANT}|\(#{CONSTANT}\))\z/, :static_mold],
38
51
  # Anything else is spilled on the floor.
39
- ].freeze
52
+ ].each(&:freeze).freeze
40
53
 
41
54
  # Cast a die definition into a mold to make a die.
42
55
  #
43
56
  # Following definitions are recognized:
44
57
  # - positive integer (like "6" or "20"), which produces a {RegularDie};
45
- # - integer range (like "3-6" or "(-5..5)"), which produces a {NumericDie};
58
+ # - integer range (like "3โ€”6" or "(-5..5)"), which produces a {NumericDie};
46
59
  # - list of integers (like "(3,4,5)", "-1,0,1", or "2,"), which produces a {NumericDie};
47
60
  # - list of decimal numbers (like "0.5,0.2,0.8" or "(2.0,)"), which produces a {NumericDie},
48
61
  # but uses +Rational+ for values to maintain precise results;
49
62
  # - list of strings, possibly mixed with numbers (like "0.5,asdf" or "(๐Ÿ‘‘,โ™ ๏ธ,โ™ฅ๏ธ,โ™ฃ๏ธ,โ™ฆ๏ธ,โš“๏ธ)"),
50
63
  # which produces an {AbstractDie} with numbers treated the same as in previous cases,
51
64
  # and other or quoted values treated as Strings.
65
+ # - signed value (like "+3", "-3.6" or "(+ABC)"), which produces a {StaticDie},
66
+ # non-numeric values are only allowed as positive values;
52
67
  #
53
68
  # Any die definition can be prefixed with a count, like "2D6" or "1d1,3,5" to create an array.
54
- # A plain "d" without an explicit count is ignored instead, creating a single die.
69
+ # A plain "d"/"D" without an explicit count is ignored instead, creating a single die.
70
+ #
71
+ # All die definitions (aside from plain signed value) can be suffixed with a signed value
72
+ # to add or subtract from the result, like "2D6+3" or "5dA,B,C+C".
73
+ # Only numbers can be subtracted.
55
74
  #
56
75
  # @param definition [String] die shape
57
76
  # @return [AbstractDie, Array<AbstractDie>]
58
77
  # @raise [DiceyError] if no mold fits the definition
59
78
  def call(definition)
60
79
  matched, name =
61
- MOLDS.reduce(nil) do |_, (shape, mold)|
80
+ MOLDS.find do |(shape, mold)|
62
81
  match = shape.match(definition)
63
82
  break [match, mold] if match
64
83
  end
65
84
  raise DiceyError, "can not cast die from `#{definition}`!" unless name
66
85
 
67
- __send__(name, matched)
86
+ if matched[:constant] && name != :static_mold
87
+ [__send__(name, matched), static_mold(matched, ignore_count: true)].flatten
88
+ else
89
+ __send__(name, matched)
90
+ end
68
91
  end
69
92
 
70
93
  alias cast call
@@ -93,19 +116,28 @@ module Dicey
93
116
 
94
117
  def cursed_mold(definition)
95
118
  sides = definition[:sides].split(",")
96
- sides.map! do |side|
97
- case side
98
- when /\A#{INTEGER}\z/o
99
- side.to_i
100
- when /\A#{FRACTION}\z/o
101
- rational_to_integer(Rational(side))
102
- else
103
- side.match(STRING)[:side]
104
- end
105
- end
119
+ sides.map! { |side| parse_value(side) }
106
120
  build_dice(AbstractDie, definition[:count], sides)
107
121
  end
108
122
 
123
+ def static_mold(definition, ignore_count: false)
124
+ value = parse_value(definition[:constant_value])
125
+ value = -value if definition[:sign] != "+"
126
+
127
+ build_dice(StaticDie, ignore_count ? nil : definition[:count], value)
128
+ end
129
+
130
+ def parse_value(side)
131
+ case side
132
+ when /\A#{INTEGER}\z/o
133
+ side.to_i
134
+ when /\A#{NUMBER}\z/o
135
+ rational_to_integer(Rational(side))
136
+ else
137
+ side.match(STRING)[:string]
138
+ end
139
+ end
140
+
109
141
  def build_dice(die_class, count, sides)
110
142
  if count
111
143
  die_class.from_count(count.to_i, sides)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../mixins/vectorize_dice"
4
+
3
5
  module Dicey
4
6
  module DistributionCalculators
5
7
  # Base class for implementing distribution calculators.
@@ -26,6 +28,8 @@ module Dicey
26
28
  #
27
29
  # @abstract
28
30
  class BaseCalculator
31
+ include Mixins::VectorizeDice
32
+
29
33
  # Possible values for +result_type+ argument in {#call}.
30
34
  RESULT_TYPES = %i[weights probabilities].freeze
31
35
 
@@ -33,6 +37,10 @@ module Dicey
33
37
  #
34
38
  # Returns empty hash for an empty list of dice.
35
39
  #
40
+ # @note Calculation is supposed to return exact results.
41
+ # Using dice with +Float+ values can break this promise and raise errors.
42
+ # Please use +Integer+, +Rational+ or +BigDecimal+ instead.
43
+ #
36
44
  # @param dice [Enumerable<AbstractDie>]
37
45
  # @param result_type [Symbol] one of {RESULT_TYPES}
38
46
  # @param options [Hash{Symbol => Any}] calculator-specific options,
@@ -47,23 +55,31 @@ module Dicey
47
55
  unless RESULT_TYPES.include?(result_type)
48
56
  raise DiceyError, "#{result_type} is not a valid result type!"
49
57
  end
50
- raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(dice)
51
-
58
+ raise DiceyError, "dice must be an Enumerable!" unless Enumerable === dice
52
59
  # Short-circuit for a degenerate case.
53
60
  return {} if dice.empty?
54
61
 
55
- distribution = calculate(dice, **options)
62
+ static_dice, normal_dice = dice.partition { StaticDie === _1 }
63
+ raise DiceyError, "#{self.class} can not handle these dice!" unless valid_for?(normal_dice)
64
+
65
+ distribution = prepare_distribution(normal_dice, static_dice, options)
56
66
  verify_result(distribution, dice)
57
- distribution = sort_result(distribution)
58
67
  transform_result(distribution, result_type)
59
68
  end
60
69
 
61
70
  # Whether this calculator can be used for the list of dice.
62
71
  #
72
+ # {StaticDie} instances are always ignored.
73
+ #
63
74
  # @param dice [Enumerable<AbstractDie>]
64
75
  # @return [Boolean]
65
76
  def valid_for?(dice)
66
- dice.is_a?(Enumerable) && (dice.empty? || (dice.all?(AbstractDie) && validate(dice)))
77
+ return false if !(Enumerable === dice) || !dice.all?(AbstractDie)
78
+
79
+ normal_dice = dice.grep_v(StaticDie)
80
+ return true if normal_dice.none?
81
+
82
+ validate(normal_dice)
67
83
  end
68
84
 
69
85
  # Heuristic complexity of the calculator, used to determine best calculator.
@@ -77,7 +93,7 @@ module Dicey
77
93
  def heuristic_complexity(dice)
78
94
  return 0 if dice.empty?
79
95
 
80
- calculate_heuristic(dice.length, dice.map(&:sides_num).max).to_i
96
+ calculate_heuristic(dice.grep_v(StaticDie).length, dice.map(&:sides_num).max).to_i
81
97
  end
82
98
 
83
99
  private
@@ -103,9 +119,35 @@ module Dicey
103
119
  raise NotImplementedError
104
120
  end
105
121
 
122
+ # Prepare distribution by calculating it for normal dice and adding static dice to it.
123
+ #
124
+ # @param normal_dice [Enumerable<AbstractDie>]
125
+ # @param static_dice [Enumerable<StaticDie>]
126
+ # @param options [Hash]
127
+ # @return [Hash{Any => Integer}]
128
+ def prepare_distribution(normal_dice, static_dice, options)
129
+ if normal_dice.any?
130
+ distribution = calculate(normal_dice, **options)
131
+ distribution = sort_result(distribution)
132
+ else
133
+ # We can't get to this point if there are no dice at all,
134
+ # so prepare a "nothing" distribution to add static dice to it.
135
+ distribution = { 0 => 1 }
136
+ end
137
+
138
+ if static_dice.any?
139
+ c = vectorize_dice(static_dice).sum(&:value)
140
+ # This is done via `+=` because different `k + c` can produce the same key.
141
+ distribution = distribution.each_with_object(Hash.new(0)) { |(k, v), h| h[k + c] += v }
142
+ distribution.default = nil
143
+ end
144
+
145
+ distribution
146
+ end
147
+
106
148
  # Check that resulting weights actually add up to what they are supposed to be.
107
149
  #
108
- # @param distribution [Hash{Numeric => Integer}]
150
+ # @param distribution [Hash{Any => Integer}]
109
151
  # @param dice [Enumerable<AbstractDie>]
110
152
  # @return [void]
111
153
  # @raise [DiceyError] if result is wrong
@@ -125,9 +167,9 @@ module Dicey
125
167
 
126
168
  # Transform calculated weights to requested result type, if needed.
127
169
  #
128
- # @param distribution [Hash{Numeric => Integer}]
170
+ # @param distribution [Hash{Any => Integer}]
129
171
  # @param result_type [Symbol] one of {RESULT_TYPES}
130
- # @return [Hash{Numeric => Numeric}]
172
+ # @return [Hash{Any => Numeric}]
131
173
  def transform_result(distribution, result_type)
132
174
  if result_type == :weights
133
175
  distribution
@@ -3,7 +3,6 @@
3
3
  require_relative "base_calculator"
4
4
 
5
5
  require_relative "../mixins/missing_math"
6
- require_relative "../mixins/vectorize_dice"
7
6
 
8
7
  module Dicey
9
8
  module DistributionCalculators
@@ -13,12 +12,12 @@ module Dicey
13
12
  # If dice include non-numeric sides, gem +vector_number+ has to be installed.
14
13
  class Binomial < BaseCalculator
15
14
  include Mixins::MissingMath
16
- include Mixins::VectorizeDice
17
15
 
18
16
  private
19
17
 
20
18
  def validate(dice)
21
- dice.first.sides_num == 2 && dice.all? { _1 == dice.first }
19
+ die = dice.first
20
+ die.sides_num == 2 && dice.all? { _1 == die }
22
21
  end
23
22
 
24
23
  def calculate_heuristic(dice_count, _sides_count)
@@ -2,8 +2,6 @@
2
2
 
3
3
  require_relative "base_calculator"
4
4
 
5
- require_relative "../mixins/vectorize_dice"
6
-
7
5
  module Dicey
8
6
  module DistributionCalculators
9
7
  # "Calculator" for a collection of {AbstractDie} using empirically-obtained statistics.
@@ -19,15 +17,13 @@ module Dicey
19
17
  # *Options:*
20
18
  # - *rolls* (Integer) (_defaults_ _to:_ _N_) โ€” number of rolls to perform
21
19
  class Empirical < BaseCalculator
22
- include Mixins::VectorizeDice
23
-
24
20
  # Default number of rolls to perform.
25
21
  N = 10_000
26
22
 
27
23
  private
28
24
 
29
25
  def validate(dice)
30
- !!defined?(VectorNumber) || dice.all?(NumericDie)
26
+ !!defined?(VectorNumber) || dice.all?(&:numeric?)
31
27
  end
32
28
 
33
29
  def calculate_heuristic(dice_count, sides_count)
@@ -2,8 +2,6 @@
2
2
 
3
3
  require_relative "base_calculator"
4
4
 
5
- require_relative "../mixins/vectorize_dice"
6
-
7
5
  module Dicey
8
6
  module DistributionCalculators
9
7
  # Calculator for a collection of {AbstractDie} which goes through
@@ -11,12 +9,10 @@ module Dicey
11
9
  #
12
10
  # If dice include non-numeric sides, gem +vector_number+ has to be available.
13
11
  class Iterative < BaseCalculator
14
- include Mixins::VectorizeDice
15
-
16
12
  private
17
13
 
18
14
  def validate(dice)
19
- !!defined?(VectorNumber) || dice.all?(NumericDie)
15
+ !!defined?(VectorNumber) || dice.all?(&:numeric?)
20
16
  end
21
17
 
22
18
  def calculate_heuristic(dice_count, sides_count)
@@ -24,7 +20,7 @@ module Dicey
24
20
  end
25
21
 
26
22
  def calculate(dice, **nil)
27
- dice = vectorize_dice(dice)
23
+ dice = vectorize_dice(dice).sort_by! { _1.numeric? ? 0 : _1.sides_num }
28
24
 
29
25
  dice[1..].reduce(dice.first.sides_list.tally) do |previous_distribution, die|
30
26
  convolve_with_die(previous_distribution, die.sides_list.tally)
@@ -28,7 +28,7 @@ module Dicey
28
28
 
29
29
  def validate(dice)
30
30
  first_die = dice.first
31
- return false unless first_die.is_a?(NumericDie)
31
+ return false unless first_die.numeric?
32
32
  return false unless dice.all? { _1 == first_die }
33
33
  return true if first_die.sides_num == 1
34
34
 
@@ -36,7 +36,7 @@ module Dicey
36
36
  end
37
37
 
38
38
  # @param sides_list [Array<Numeric>]
39
- # @return [false, Array<Numeric>]
39
+ # @return [Boolean]
40
40
  def arithmetic_sequence?(sides_list)
41
41
  increment = sides_list[1] - sides_list[0]
42
42
  return false if increment.zero?
@@ -47,9 +47,7 @@ module Dicey
47
47
  # Simplest multinomial distribution: two regular dice.
48
48
  def bimultinomial(die)
49
49
  middle = die.sides_num
50
- (1...(die.sides_num * 2)).each_with_object({}) do |i, hash|
51
- hash[i + 1] = middle - (middle - i).abs
52
- end
50
+ (1...(die.sides_num * 2)).to_h { |i| [i + 1, middle - (middle - i).abs] }
53
51
  end
54
52
  end
55
53
  end
@@ -5,6 +5,11 @@ require_relative "mixins/rational_to_integer"
5
5
  module Dicey
6
6
  # @note This class is considered experimental. It may be changed at any point.
7
7
  #
8
+ # @note Almost all distribution properties only really make sense
9
+ # for ordered values in a single dimension.
10
+ # This calculator assumes that only distributions of real numbers
11
+ # satisfy these conditions.
12
+ #
8
13
  # Calculates distribution properties,
9
14
  # also known as descriptive statistics when applied to a population sample.
10
15
  #
@@ -36,7 +41,7 @@ module Dicey
36
41
  def call(distribution)
37
42
  return {} if distribution.empty?
38
43
 
39
- calculate_properties(distribution)
44
+ calculate_properties(distribution).transform_values! { rational_to_integer(_1) }
40
45
  end
41
46
 
42
47
  private
@@ -50,7 +55,7 @@ module Dicey
50
55
  modes: modes(distribution),
51
56
  **range_characteristics(outcomes),
52
57
  **median(outcomes),
53
- **means(outcomes, weights),
58
+ **means(outcomes),
54
59
  **moments(distribution),
55
60
  }
56
61
  end
@@ -89,16 +94,16 @@ module Dicey
89
94
  min: min,
90
95
  max: max,
91
96
  range_length: max - min,
92
- mid_range: rational_to_integer(Rational(min + max, 2)),
97
+ mid_range: Rational(min + max, 2),
93
98
  }
94
99
  rescue ArgumentError, TypeError, NoMethodError
95
100
  # Outcomes are not comparable with each other, so a range can not be determined.
96
101
  {}
97
102
  end
98
103
 
99
- def means(outcomes, _weights)
104
+ def means(outcomes)
100
105
  {
101
- arithmetic_mean: rational_to_integer(Rational(outcomes.sum, outcomes.size)),
106
+ arithmetic_mean: Rational(outcomes.sum, outcomes.size),
102
107
  }
103
108
  rescue ArgumentError, TypeError
104
109
  # Outcomes are not summable with each other, means are meaningless.
@@ -121,12 +126,10 @@ module Dicey
121
126
 
122
127
  def moments(distribution)
123
128
  total_weight = distribution.values.sum
124
- expected_value = rational_to_integer(moment(distribution, total_weight, 1))
125
- variance = rational_to_integer(moment(distribution, total_weight, 2) - (expected_value**2))
126
- skewness =
127
- rational_to_integer(moment(distribution, total_weight, 3, expected_value, variance))
128
- kurtosis =
129
- rational_to_integer(moment(distribution, total_weight, 4, expected_value, variance))
129
+ expected_value = moment(distribution, total_weight, 1)
130
+ variance = moment(distribution, total_weight, 2) - (expected_value**2)
131
+ skewness = moment(distribution, total_weight, 3, expected_value, variance)
132
+ kurtosis = moment(distribution, total_weight, 4, expected_value, variance)
130
133
 
131
134
  {
132
135
  expected_value: expected_value,
@@ -137,7 +140,7 @@ module Dicey
137
140
  excess_kurtosis: kurtosis ? kurtosis - 3 : nil,
138
141
  }
139
142
  rescue ArgumentError, TypeError, NoMethodError
140
- # Outcomes are not compatible with each other, moments are fleeing.
143
+ # Outcomes are not compatible with each other, moments are fleeting.
141
144
  {}
142
145
  end
143
146
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
+ # @api private
5
+ # Various mixins with shared methods.
4
6
  module Mixins
5
7
  # @api private
6
8
  # Some math functions missing from Math, though without argument range checks.
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dicey
4
- # @api private
5
- # Various mixins with shared methods.
6
4
  module Mixins
7
5
  # @api private
8
6
  # Mix-in for converting rationals with denominator of 1 to integers.
@@ -13,7 +11,7 @@ module Dicey
13
11
  # Otherwise, return +value+ as-is.
14
12
  #
15
13
  # @param value [Numeric, Any]
16
- # @return [Numeric, Integer, Any]
14
+ # @return [Rational, Integer, Any]
17
15
  def rational_to_integer(value)
18
16
  (Rational === value && value.denominator == 1) ? value.numerator : value
19
17
  end
@@ -22,13 +22,18 @@ module Dicey
22
22
  end
23
23
 
24
24
  def vectorize_die_sides(die)
25
- return die if NumericDie === die
25
+ case die
26
+ when NumericDie
27
+ die
28
+ when StaticDie
29
+ die.class.new(vectorize_one_die_side(die.value))
30
+ else
31
+ die.class.new(die.sides_list.map { vectorize_one_die_side(_1) })
32
+ end
33
+ end
26
34
 
27
- die.class.new(
28
- die.sides_list.map do |side|
29
- (Numeric === side || VectorNumber === side) ? side : VectorNumber.new([side])
30
- end
31
- )
35
+ def vectorize_one_die_side(side)
36
+ (Numeric === side || VectorNumber === side) ? side : VectorNumber.new([side])
32
37
  end
33
38
  end
34
39
  end
@@ -16,6 +16,8 @@ module Dicey
16
16
  unless Integer === sides_list.begin && Integer === sides_list.end
17
17
  raise DiceyError, "`#{sides_list.inspect}` is not a valid range!"
18
18
  end
19
+
20
+ @range = sides_list
19
21
  else
20
22
  sides_list.each do |value|
21
23
  raise DiceyError, "`#{value.inspect}` is not a number!" unless Numeric === value
@@ -24,5 +26,24 @@ module Dicey
24
26
 
25
27
  super
26
28
  end
29
+
30
+ # Whether all sides of this die are +Numeric+.
31
+ #
32
+ # @return [true]
33
+ def numeric?
34
+ true
35
+ end
36
+
37
+ # Return a string representing the die.
38
+ #
39
+ # Default representation is a list of sides in round brackets.
40
+ # If the die was initialized with a +Range+, string will be in the form of +begin..end+.
41
+ #
42
+ # @return [String]
43
+ def to_s
44
+ return "#{@range.begin}..#{@range.end}" if @range
45
+
46
+ super
47
+ end
27
48
  end
28
49
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_die"
4
+
5
+ module Dicey
6
+ # Static die has only one side and always returns the same value.
7
+ # Useful to model constants in dice expressions.
8
+ #
9
+ # @note Unlike other dice, rolling a static die does not advance
10
+ # shared randomness generator, thus it does not have any impact on
11
+ # rolling other dice.
12
+ class StaticDie < AbstractDie
13
+ # Die's only value.
14
+ #
15
+ # @return [Any]
16
+ attr_reader :value
17
+
18
+ # @param value [Any]
19
+ def initialize(value)
20
+ @value = value
21
+ super([@value].freeze)
22
+ end
23
+
24
+ alias current value
25
+ alias next value
26
+ alias roll value
27
+
28
+ # Return a string representing the die.
29
+ #
30
+ # Static dice are represented with a "+" or "-" followed by the absolute value
31
+ # (0 is represented as +0). Strings are quoted.
32
+ #
33
+ # @return [String]
34
+ def to_s
35
+ if @value.respond_to?(:negative?) && @value.negative?
36
+ side_to_s(@value)
37
+ else
38
+ "+#{side_to_s(@value)}"
39
+ end
40
+ end
41
+ end
42
+ end
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.18.0"
4
+ VERSION = "0.19.0"
5
5
  end
data/lib/dicey.rb CHANGED
@@ -12,7 +12,8 @@ end
12
12
  # Includes several classes of dice:
13
13
  # - {AbstractDie}, the base and most generic class;
14
14
  # - {NumericDie}, a subclass for strictly numeric dice;
15
- # - {RegularDie}, for the most common dice.
15
+ # - {RegularDie}, for the most common dice;
16
+ # - {StaticDie}, a constant offset pseudo-die.
16
17
  #
17
18
  # See {AbstractDie} for API and more information.
18
19
  #
@@ -30,6 +31,7 @@ module Dicey
30
31
  require_relative "dicey/abstract_die"
31
32
  require_relative "dicey/numeric_die"
32
33
  require_relative "dicey/regular_die"
34
+ require_relative "dicey/static_die"
33
35
 
34
36
  require_relative "dicey/die_foundry"
35
37
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dicey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Bulancov
@@ -79,6 +79,7 @@ files:
79
79
  - lib/dicey/mixins/vectorize_dice.rb
80
80
  - lib/dicey/numeric_die.rb
81
81
  - lib/dicey/regular_die.rb
82
+ - lib/dicey/static_die.rb
82
83
  - lib/dicey/version.rb
83
84
  homepage: https://github.com/trinistr/dicey
84
85
  licenses:
@@ -86,9 +87,9 @@ licenses:
86
87
  metadata:
87
88
  homepage_uri: https://github.com/trinistr/dicey
88
89
  bug_tracker_uri: https://github.com/trinistr/dicey/issues
89
- documentation_uri: https://rubydoc.info/gems/dicey/0.18.0
90
- source_code_uri: https://github.com/trinistr/dicey/tree/v0.18.0
91
- changelog_uri: https://github.com/trinistr/dicey/blob/v0.18.0/CHANGELOG.md
90
+ documentation_uri: https://rubydoc.info/gems/dicey/0.19.0
91
+ source_code_uri: https://github.com/trinistr/dicey/tree/v0.19.0
92
+ changelog_uri: https://github.com/trinistr/dicey/blob/v0.19.0/CHANGELOG.md
92
93
  rubygems_mfa_required: 'true'
93
94
  rdoc_options:
94
95
  - "--main"