Compare commits

...

19 Commits

Author SHA1 Message Date
Arthur POULET 84ef972fec
documentation: Update forge urls 2021-03-27 11:36:09 +01:00
Arthur POULET 050af10ac2
fix: Fix crystal version required 2021-03-23 23:54:23 +01:00
Arthur POULET d85b7f5b6e
fix: update to crystal 1.0.0 2021-03-23 23:32:33 +01:00
Arthur POULET 9859028786
Merge branch 'patch-1' 2017-10-21 21:02:05 +02:00
Arthur POULET 450c67004a
Merge branch 'master' of https://github.com/Lucie-Dispot/crystal_rollable into patch-1 2017-10-21 21:01:51 +02:00
Arthur POULET 766c1cb064
Improve and fix exploding dice 2017-10-21 21:00:19 +02:00
Lucie Dispot b13efe4adb Fix die explosions 2017-10-21 19:57:12 +02:00
Arthur POULET 98fb5f6683
Update README 2017-10-02 18:21:59 +02:00
Arthur POULET 4e61ef0657
Fix sign to add exploding dice to Roll 2017-10-02 16:54:11 +02:00
Arthur POULET 098221f983
Add exploding dice to Roll 2017-10-02 16:46:47 +02:00
Arthur POULET 4b5c3e5ce8
Add exploding dice computations 2017-10-02 16:36:33 +02:00
Arthur POULET 958228ec9a
Add exploding attribute to dice (no stats change yet) 2017-10-02 16:07:04 +02:00
Arthur POULET a99f7eac83
Update README 2017-06-30 01:47:11 +01:00
Arthur POULET e9d7cf9724
Update CHANGELOG and add few documentation lines 2016-08-28 00:07:55 +02:00
Arthur POULET fb1dd360e5
Clear coding style using inline scope declarations 2016-08-28 00:00:31 +02:00
Arthur POULET 379e48f86a
Improve Roll#parse
possibility to catch the errors in a block
2016-08-27 23:57:21 +02:00
Arthur POULET 1e0848480a
fix DOS attack on Die and Dice
note: roll parser is not protected.It should be done in the application.
2016-08-01 00:43:14 +02:00
Arthur POULET 8738322bea
Roll.compact removes empty group of die 2016-07-13 19:46:14 +02:00
Arthur POULET c65cd2c935
add one more spec 2016-07-13 19:28:43 +02:00
18 changed files with 609 additions and 459 deletions

0
.gitignore vendored Normal file → Executable file
View File

0
.travis.yml Normal file → Executable file
View File

View File

@ -1,7 +1,14 @@
# v0.1
## v0.1.3
- Minor improvements
## v0.1.2
- Block DOS attack in the parser
## v0.1.1
- Dice can be ordered (<, <=, >, >=, <=>), Roll.compact, Roll.order
## v0.1.0
- Roll.parse("...") to create a rollable set of dices
- Die (one die), Dice (count of Die), Roll (list of Dice)

16
Makefile Normal file
View File

@ -0,0 +1,16 @@
NAME=`ls -d src/*/ | cut -f2 -d'/'`
all: deps_opt test
test:
crystal spec
deps:
shards install
deps_update:
shards update
deps_opt:
@[ -d lib/ ] || make deps
doc:
crystal docs
.PHONY: all run build release test deps deps_update doc

View File

@ -1,16 +1,20 @@
# rollable
TODO: Write a description here
Roll and parse dices
## Installation [![travis](https://travis-ci.org/Nephos/crystal_rollable.svg)](https://travis-ci.org/Nephos/crystal_rollable)
Works with crystal v1.0.0
## Installation
[![travis](https://travis-ci.org/Nephos/crystal_rollable.svg)](https://travis-ci.org/Nephos/crystal_rollable)
Add this to your application's `shard.yml`:
```yaml
dependencies:
rollable:
github: Nephos/crystal_rollable
git: https://git.sceptique.eu/Sceptique/rollable
branch: master
```
@ -19,7 +23,8 @@ dependencies:
```crystal
require "rollable"
Rollable::Roll.parse("2d6+4").test # => roll 2 dices and add 4 to the sum
Rollable::Roll.parse("2d6+4").test # => Roll 2 dices and add 4 to the sum
Rollable::Roll.parse("!1d20 + !1d8").test # => Exploding dices
```
@ -29,7 +34,7 @@ TODO: Write development instructions here
## Contributing
1. Fork it ( https://github.com/Nephos/crystal_rollable/fork )
1. Fork it ( https://git.sceptique.eu/Sceptique/rollable/fork )
2. Create your feature branch (git checkout -b my-new-feature)
3. Commit your changes (git commit -am 'Add some feature')
4. Push to the branch (git push origin my-new-feature)
@ -37,4 +42,4 @@ TODO: Write development instructions here
## Contributors
- [Nephos](https://github.com/Nephos) Arthur Poulet - creator, maintainer
- [Sceptique](https://git.sceptique.eu/Sceptique) Arthur Poulet - creator, maintainer

View File

@ -1,7 +1,9 @@
name: rollable
version: 0.1.1
version: 1.0.0
crystal: 1.0.0
authors:
- Arthur Poulet <arthur.poulet@mailoo.org>
- Arthur Poulet <arthur.poulet@sceptique.eu>
license: MIT

View File

@ -5,6 +5,7 @@ describe Rollable::Dice do
d.min.should eq 2
d.max.should eq 40
d.average.should eq 21
expect_raises(Exception) { Rollable::Dice.new 1001, 20 }
end
it "details" do
@ -40,12 +41,15 @@ describe Rollable::Dice do
Rollable::Dice.parse("-1d6").min.should eq -6
Rollable::Dice.parse("-1d6").max.should eq -1
Rollable::Dice.parse("-1d6").count.should eq 1
Rollable::Dice.parse("!1d6").count.should eq 1
Rollable::Dice.parse("!1d6").min.should eq 1
Rollable::Dice.parse("!1d6").max.should eq Rollable::Die.new(1..6, true).max
end
it "parse (error)" do
expect_raises { Rollable::Dice.parse("yolo") }
expect_raises { Rollable::Dice.parse("1d6+1", true) }
expect_raises { Rollable::Dice.parse("--1d4") }
expect_raises(Exception) { Rollable::Dice.parse("yolo") }
expect_raises(Exception) { Rollable::Dice.parse("1d6+1", true) }
expect_raises(Exception) { Rollable::Dice.parse("--1d4") }
end
it "consume" do

View File

@ -3,6 +3,8 @@ describe Rollable::Die do
Rollable::Die.new(1..20).should be_a(Rollable::Die)
Rollable::Die.new(10..20).should be_a(Rollable::Die)
Rollable::Die.new(20).should be_a(Rollable::Die)
Rollable::Die.new(20, true).should be_a(Rollable::Die)
expect_raises(Exception) { Rollable::Die.new(1001) }
end
it "min, max, average" do
@ -12,6 +14,9 @@ describe Rollable::Die do
Rollable::Die.new(1..20).min.should eq 1
Rollable::Die.new(1..20).max.should eq 20
Rollable::Die.new(1..20).average.should eq 10.5
Rollable::Die.new(1..20, true).min.should eq 1
Rollable::Die.new(1..20, true).max.should eq 80
Rollable::Die.new(1..20, true).average.should eq (10.5*0.05**0 + 10.5*0.05**1 + 10.5*0.05**2 + 10.5*0.05**3).round(3)
end
it "test" do
@ -19,6 +24,7 @@ describe Rollable::Die do
min = rand 1..10
max = rand min..20
((min..max).includes? Rollable::Die.new(min..max).test).should eq(true)
((min..max*Rollable::Die::EXPLODING_ITERATIONS).includes? Rollable::Die.new(min..max, true).test).should eq(true)
end
end
@ -26,18 +32,25 @@ describe Rollable::Die do
Rollable::Die.new(1..20).like?(Rollable::Die.new(1..20)).should eq true
Rollable::Die.new(1..20).like?(Rollable::Die.new(-20..-1)).should eq true
Rollable::Die.new(1..20).like?(Rollable::Die.new(1..10)).should eq false
Rollable::Die.new(1..20, true).like?(Rollable::Die.new(1..20, false)).should eq true
end
it "negative?" do
Rollable::Die.new(1..20).negative?.should eq false
Rollable::Die.new(-1..20).negative?.should eq false
Rollable::Die.new(1..-20).negative?.should eq false
Rollable::Die.new(-1..-20).negative?.should eq true
Rollable::Die.new(1..20, true).negative?.should eq false
Rollable::Die.new(-1..-20, true).negative?.should eq true
end
it "fixed?" do
Rollable::Die.new(1..20).fixed?.should eq false
Rollable::Die.new(1..1).fixed?.should eq true
Rollable::Die.new(20..20).fixed?.should eq true
Rollable::Die.new(1).fixed?.should eq true
Rollable::Die.new(1..20, true).fixed?.should eq false
Rollable::Die.new(1, true).fixed?.should eq true
end
it "reverse" do
@ -45,12 +58,16 @@ describe Rollable::Die do
Rollable::Die.new(1..20).reverse.max.should eq -1
Rollable::Die.new(1..1).reverse.min.should eq -1
Rollable::Die.new(1..1).reverse.max.should eq -1
Rollable::Die.new(1..20, true).reverse.min.should eq -20
Rollable::Die.new(1..20, true).reverse.max.should eq -4
end
it "to_s" do
Rollable::Die.new(1..20).to_s.should eq "D20"
Rollable::Die.new(2..2).to_s.should eq "2"
Rollable::Die.new(2..4).to_s.should eq "D(2,4)"
Rollable::Die.new(1..20, true).to_s.should eq "!D20"
Rollable::Die.new(2..4, true).to_s.should eq "!D(2,4)"
end
it "cmp" do
@ -83,5 +100,8 @@ describe Rollable::Die do
((Rollable::Die.new(1..4) <=> Rollable::Die.new(1..4)) == 0).should eq true
((Rollable::Die.new(2..3) <=> Rollable::Die.new(1..4)) < 0).should eq true
(Rollable::Die.new(2..6) == Rollable::Die.new(4..4)).should eq false
# TODO: (Rollable::Die.new(1..20, true) == Rollable::Die.new(1..20, false)).should eq false
# TODO: (Rollable::Die.new(1..20, true) > Rollable::Die.new(1..20, false)).should eq true
end
end

View File

@ -8,17 +8,31 @@ describe Rollable::Roll do
r.min.should eq 5
r.max.should eq 24
r.average.should eq 14.5
10.times do
100.times do
(5..24).includes?(r.test).should eq true
end
end
it "initialize exploding" do
r = Rollable::Roll.new [
Rollable::Dice.new(1, 6, true),
]
r.should be_a Rollable::Roll
min = 1
max = 6*Rollable::Die::EXPLODING_ITERATIONS
r.min.should eq min
r.max.should eq max
100.times do
(min..max).includes?(r.test).should eq true
end
end
it "test (details)" do
r = Rollable::Roll.new [Rollable::Dice.new(2, 6), Rollable::Dice.new(1, 4)]
r.min_details.should eq([1, 1, 1])
r.max_details.should eq([6, 6, 4])
r.average_details.should eq([3.5, 3.5, 2.5])
10.times do
100.times do
t = r.test_details
(1..6).includes?(t[0]).should eq true
(1..6).includes?(t[1]).should eq true
@ -40,10 +54,12 @@ describe Rollable::Roll do
r1.should be_a(Rollable::Roll)
r1.min.should eq 6
r1.max.should eq 16
(Rollable::Roll.parse("!2d6+4").average > Rollable::Roll.parse("2d6+4").average).should be_true
end
it "parse (error)" do
expect_raises { Rollable::Roll.parse("yolo") }
expect_raises(Exception) { Rollable::Roll.parse("yolo") }
Rollable::Roll.parse("yolo") { |_| true } rescue fail("must be catch in block")
end
it "parse (more)" do
@ -56,6 +72,9 @@ describe Rollable::Roll do
it "to_s" do
Rollable::Roll.parse("1d6").to_s.should eq("1D6")
Rollable::Roll.parse("-1d6").to_s.should eq("-1D6")
Rollable::Roll.parse("4").to_s.should eq("4")
Rollable::Roll.parse("-4").to_s.should eq("-4")
Rollable::Roll.parse("-4 + 2D6").to_s.should eq("-4 + 2D6")
Rollable::Roll.parse(" 1d6 - 1 + 2 - 1d6 ").to_s.should eq("1D6 - 1 + 2 - 1D6")
end
@ -86,6 +105,7 @@ describe Rollable::Roll do
Rollable::Roll.parse("1 + 2d6 + 3").compact!.to_s.should eq("2D6 + 4")
Rollable::Roll.parse("2d8 + 1d6 + 1d20 + 5 + 2d8 + 1 + 2 + 1d6 + 1 + 1d6").compact!.to_s.should eq "4D8 + 3D6 + 1D20 + 9"
Rollable::Roll.parse("2d8 + 1d6 + 1d20 + 5 + 2d8 + 1 + 2 - 1d6 - 1 + 1d6").compact!.to_s.should eq "4D8 + 1D6 + 1D20 + 7"
Rollable::Roll.parse("1D6 + 1D6 - 2D6").compact!.size.should eq 0
end
it "order" do

View File

@ -1,6 +1,6 @@
require "./rollable/*"
module Rollable
class ParsingError < Exception
end
end
require "./rollable/*"

View File

@ -3,117 +3,125 @@ require "./is_rollable"
require "./die"
require "./fixed_value"
module Rollable
# A `Dice` is a amount of `Die`.
# It is rollable exactly like a classic `Die`
#
# It is also possible to get the details of a roll, using the methods
# `.min_details`, `.max_details`, `.average_details`, `.test_details`
# Not a front class. It is used to represent a tuple of die type and die amount
#
# A `Dice` is a amount of `Die`.
# It is rollable exactly like a classic `Die`
#
# It is also possible to get the details of a roll, using the methods
# `.min_details`, `.max_details`, `.average_details`, `.test_details`
#
# Example:
# ```
# d = Dice.parse "2d6"
# d.min # => 2
# d.max # => 12
# d.average # => 7
# d.min_details # => [1, 1]
# d.test # => the sum of 2 random values between 1..6
# ```
class Rollable::Dice < Rollable::IsRollable
MAX = 1000
@count : Int32
@die : Die
getter count, die
def initialize(@count, @die)
check_count!
end
# Create a `Dice` with "die_type" faces.
def initialize(@count, die_type : Int32, exploding : Bool = false)
@die = Die.new(1..die_type, exploding)
check_count!
end
private def check_count!
raise ParsingError.new "Cannot more than #{MAX} dice (#{@count})" if @count > MAX
if @count < 0
@count = -@count
@die.reverse!
end
self
end
def count=(count : Int32)
@count = count
check_count!
end
def clone
Dice.new(@count, @die.clone)
end
delegate "fixed?", to: die
delegate "negative?", to: die
# Reverse the `Die` of the `Dice`.
#
# Example:
# ```
# d = Dice.parse "2d6"
# d.min # => 2
# d.max # => 12
# d.average # => 7
# d.min_details # => [1, 1]
# d.test # => the sum of 2 random values between 1..6
# Dice.parse("1d6").reverse # => -1d6
# ```
class Dice < IsRollable
@count : Int32
@die : Die
def reverse : Dice
Dice.new -@count, @die
end
getter count, die
def reverse!
@die.reverse!
self
end
def initialize(@count, @die)
check_count!
end
{% for ft in ["min", "max"] %}
def {{ ft.id }} : Int32
@die.{{ ft.id }} * @count
end
# Create a `Dice` with "die_type" faces.
def initialize(@count, die_type : Int32)
@die = Die.new(1..die_type)
check_count!
end
def {{ (ft + "_details").id }} : Array(Int32)
@count.times.to_a.map{ @die.{{ ft.id }} }
end
{% end %}
private def check_count!
if @count < 0
@count = -@count
@die.reverse!
end
self
end
# Roll an amount of `Dice` as specified, and return the sum
def test : Int32
@count.times.reduce(0) { |r, l| r + @die.test }
end
def count=(count : Int32)
@count = count
check_count!
end
# Roll an amount of `Dice` as specified, and return the values
def test_details : Array(Int32)
@count.times.to_a.map { @die.test }
end
def clone
Dice.new(@count, @die.clone)
end
def average : Float64
@die.average * @count
end
delegate "fixed?", to: die
delegate "negative?", to: die
def average_details : Array(Float64)
@count.times.to_a.map { @die.average }
end
# Reverse the `Die` of the `Dice`.
#
# Example:
# ```
# Dice.parse("1d6").reverse # => -1d6
# ```
def reverse : Dice
Dice.new -@count, @die
end
def ==(right : Dice)
@count == right.count && @die == right.die
end
def reverse!
@die.reverse!
self
end
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Dice)
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
{% for ft in ["min", "max"] %}
def {{ ft.id }} : Int32
@die.{{ ft.id }} * @count
end
def {{ (ft + "_details").id }} : Array(Int32)
@count.times.to_a.map{ @die.{{ ft.id }} }
end
{% end %}
# Roll an amount of `Dice` as specified, and return the sum
def test : Int32
@count.times.reduce(0) { |r, l| r + @die.test }
end
# Roll an amount of `Dice` as specified, and return the values
def test_details : Array(Int32)
@count.times.to_a.map { @die.test }
end
def average : Float64
@die.average * @count
end
def average_details : Array(Float64)
@count.times.to_a.map { @die.average }
end
def ==(right : Dice)
@count == right.count && @die == right.die
end
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Dice)
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
def <=>(right : Dice)
average != right.average ? average - right.average <=> 0 : max != right.max ? max - right.max <=> 0 : min - right.min <=> 0
def <=>(right : Dice) : Int32
if average != right.average
average - right.average > 0 ? 1 : -1
elsif max != right.max
max - right.max <=> 0
else
min - right.min <=> 0
end
end
end

View File

@ -1,75 +1,76 @@
# coding: utf-8
module Rollable
class Dice
# Returns the `Dice` and the string parsed from `str`, in a `NamedTuple`
# with "str" and "dice" keys.
#
# - If "strict" is true, then the string must end following the regex
# `\A\d+(d\d+)?\Z/i`
#
# - If "strict" is false, then the string doesn't have to finish following
# the regexp.
private def self.parse_string(str : String, strict = true) : NamedTuple(str: String, dice: Dice)
match = str.match(/\A(?<sign>-|\+)? *(?<count>\d+)(?:(?:d)(?<die>\d+))?#{strict ? "\\Z" : ""}/i)
raise ParsingError.new("Parsing Error: dice, near to '#{str}'") if match.nil?
sign = (match["sign"]? || "+") == "+" ? 1 : -1
count = match["count"]
die = match["die"]?
if die.nil?
return {str: match[0], dice: FixedValue.new_dice(sign * count.to_i)}
else
return {str: match[0], dice: Dice.new(sign * count.to_i, die.to_i)}
end
class Rollable::Dice
# Returns the `Dice` and the string parsed from `str`, in a `NamedTuple`
# with "str" and "dice" keys.
#
# - If "strict" is true, then the string must end following the regex
# `\A(!)?\d+(d\d+)?\Z/i`
#
# - If "strict" is false, then the string doesn't have to finish following
# the regexp.
private def self.parse_string(str : String, strict = true) : NamedTuple(str: String, dice: Dice)
match = str.match(/\A(?<sign>-|\+)? *(?<exploding>!)?(?<count>\d+)(?:(?:d)(?<die>\d+))?#{strict ? "\\Z" : ""}/i)
raise ParsingError.new("Parsing Error: dice, near to '#{str}'") if match.nil?
sign = (match["sign"]? || "+") == "+" ? 1 : -1
count = match["count"]
die = match["die"]?
exploding = match["exploding"]? ? true : false
if die.nil?
{str: match[0], dice: FixedValue.new_dice(sign * count.to_i)}
else
{str: match[0], dice: Dice.new(sign * count.to_i, die.to_i, exploding)}
end
end
# Return a valid string parsed from `str`. (see `#parse_string`)
#
# Yields the `Dice` parsed from `str`.
#
# Then, it returns the string read.
# If strict is false, only the valid string is returned.
def self.parse(str : String, strict = true) : String
data = parse_string(str, strict)
yield data[:dice]
return data[:str]
# Return a valid string parsed from `str`. (see `#parse_string`)
#
# Yields the `Dice` parsed from `str`.
# Then, it returns the string read.
# If strict is false, only the valid string is returned.
# ```
# Dice.parse("1d6") {|dice| dice.roll } => "1d6"
# ```
def self.parse(str : String, strict = true) : String
data = parse_string(str, strict)
yield data[:dice]
return data[:str]
end
# Returns the `Dice` parsed. (see `#parse_string`)
def self.parse(str : String, strict = true) : Dice
data = parse_string(str, strict)
return data[:dice]
end
# Returns the unconsumed string.
#
# Parse `str`, and yield a `Dice` parsed.
# It does not requires to be a full valid string
# (see #parse when strict is false).
# ```
# rest = Dice.consume("1d6+2") do |dice|
# # dice = Dice.new(1, Die.new(1..6))
# end
# # rest = "+2"
# ```
def self.consume(str : String) : String?
str = str.strip
consumed = parse(str, false) do |dice|
yield dice
end
return consumed.size >= str.size ? nil : str[consumed.size..-1]
end
# Returns the `Dice` parsed. (see `#parse_string`)
def self.parse(str : String, strict = true) : Dice
data = parse_string(str, strict)
return data[:dice]
end
# Returns the unconsumed string.
#
# Parse `str`, and yield a `Dice` parsed.
# It does not requires to be a full valid string
# (see #parse when strict is false).
# ```
# rest = Dice.consume("1d6+2") do |dice|
# # dice = Dice.new(1, Die.new(1..6))
# end
# # rest = "+2"
# ```
def self.consume(str : String) : String?
str = str.strip
consumed = parse(str, false) do |dice|
yield dice
end
return consumed.size >= str.size ? nil : str[consumed.size..-1]
end
# Return a string which represents the `Dice`
#
# - If the value is fixed ```(n..n)```, then it return the @count * value
# - Else, it just add the count before the `Dice` like "{count}{dice.to_s}"
def to_s : String
if fixed?
(negative? ? "-" : "") + (@count * @die.min).abs.to_s
else
(negative? ? "-" : "") + "#{@count}" + (negative? ? @die.reverse : @die).to_s
end
# Return a string which represents the `Dice`
#
# - If the value is fixed ```(n..n)```, then it return the @count * value
# - Else, it just add the count before the `Dice` like "{count}{dice.to_s}"
def to_s : String
if fixed?
(negative? ? "-" : "") + (@count * @die.min).abs.to_s
else
(negative? ? "-" : "") + "#{@count}" + (negative? ? @die.reverse : @die).to_s
end
end
end

View File

@ -1,116 +1,162 @@
require "./is_rollable"
module Rollable
# A `Die` is a range of Integer values.
# It is rollable.
# Not a front class. It is used to represent a type of dice with faces
#
# A `Die` is a range of Integer values.
# It is rollable.
#
# Example:
# ```
# d = Die.new(1..6)
# d.min # => 1
# d.max # => 6
# d.average # => 3.5
# d.test # => a random value included in 1..6
# ```
# TODO: make it a Struct ?
class Rollable::Die < Rollable::IsRollable
MAX = 1000
EXPLODING_ITERATIONS = 4
@faces : Range(Int32, Int32)
getter exploding : Bool
getter faces
def initialize(@faces, @exploding = false)
raise ParsingError.new "Cannot die with more than #{MAX} faces (#{@faces})" if @faces.size > MAX
if @faces.end < @faces.begin
@faces = @faces.end..@faces.begin
end
end
def clone
Die.new(@faces, @exploding)
end
def initialize(nb_faces : Int32, @exploding = false)
raise ParsingError.new "Cannot die with more than #{MAX} faces (#{nb_faces})" if nb_faces > MAX
@faces = 1..nb_faces
if @faces.end < @faces.begin
@faces = @faces.end..@faces.begin
end
end
# Number of faces of the `Die`
def size
@faces.size
end
def fixed?
size == 1
end
def negative?
min < 0 && max < 0
end
def like?(other : Die)
@faces == other.faces || @faces == other.reverse.faces
end
# Reverse the values
#
# Example:
# ```
# d = Die.new(1..6)
# d.min # => 1
# d.max # => 6
# d.average # => 3.5
# d.test # => a random value included in 1..6
# Die.new(1..6).reverse # => Die.new -6..-1
# ```
# TODO: make it a Struct ?
class Die < IsRollable
@faces : Range(Int32, Int32)
def reverse : Die
Die.new -@faces.end..-@faces.begin, @exploding
end
getter faces
def reverse!
@faces = -@faces.end..-@faces.begin
self
end
def initialize(@faces)
end
def clone
Die.new(@faces)
end
def initialize(nb_faces : Int32)
@faces = 1..nb_faces
end
# Number of faces of the `Die`
def size
@faces.size
end
def fixed?
size == 1
end
def negative?
min < 0 && max < 0
end
def like?(other : Die)
@faces == other.faces || @faces == other.reverse.faces
end
# Reverse the values
#
# Example:
# ```
# Die.new(1..6).reverse # => Die.new -6..-1
# ```
def reverse : Die
Die.new -max..-min
end
def reverse!
@faces = -max..-min
self
end
def max : Int32
def max : Int32
if @exploding
@faces.end * EXPLODING_ITERATIONS
else
@faces.end
end
end
def min : Int32
@faces.begin
def min : Int32
@faces.begin
end
private def explode(&block)
EXPLODING_ITERATIONS.times do |_|
value = @faces.to_a.sample
yield value
break if value != @faces.end
end
end
# Return a random value in the range of the dice
def test : Int32
# Return a random value in the range of the dice
def test : Int32
if @exploding
sum = 0
explode { |value| sum += value }
sum
else
@faces.to_a.sample
end
end
# Mathematical expectation.
#
# A d6 will have a expected value of 3.5
def average : Float64
@faces.reduce { |r, l| r + l }.to_f64 / @faces.size
# Mathematical expectation.
#
# A d6 will have a expected value of 3.5
def average : Float64
proba = @faces.size.to_f64
non_exploding_average = @faces.reduce { |r, l| r + l }.to_f64 / proba
if @exploding
EXPLODING_ITERATIONS.times.reduce(0.0) {|base, i| base + non_exploding_average / proba ** i }.round(3)
else
non_exploding_average
end
end
# Return a string.
# - It may be a fixed value ```(n..n) => "#{n}"```
# - It may be a dice ```(1..n) => "D#{n}"```
# - Else, ```(a..b) => "D(#{a},#{b})"```
def to_s : String
if self.size == 1
min.to_s
elsif self.min == 1
"D#{self.max}"
else
"D(#{self.min},#{self.max})"
end
end
# Return a string.
# - It may be a fixed value ```(n..n) => "#{n}"```
# - It may be a dice ```(1..n) => "D#{n}"```
# - Else, ```(a..b) => "D(#{a},#{b})"```
def to_s : String
string = if self.size == 1
min.to_s
elsif self.min == 1
"D#{@faces.end}"
else
"D(#{@faces.begin},#{@faces.end})"
end
string = "!#{string}" if @exploding
return string
end
def ==(right : Die)
@faces == right.faces
end
def ==(right : Die)
@faces == right.faces && @exploding == right.exploding
end
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Die)
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Die)
return false if @exploding != right.exploding
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
def <=>(right : Die)
average != right.average ? average - right.average <=> 0 : max != right.max ? max - right.max <=> 0 : min - right.min <=> 0
def <=>(right : Die) : Int32
if average != right.average
average - right.average > 0 ? 1 : -1
elsif max != right.max
max - right.max <=> 0
else
min - right.min <=> 0
end
end
end
#

View File

@ -1,24 +1,22 @@
require "./die"
module Rollable
# Allow to create a die with a fixed value.
# The die will only gives this value everytime.
# (`.min`, `.max`, `.test`, `.average`)
#
# This is equivalent to
# ```
# Die.new(n..n) # => FixedValue.new_die n
# Dice.new(1, Die.new(n..n)) # => FixedValue.new_dice n
# ```
module FixedValue
# Return a `Die` with only one face.
def self.new_die(value : Int32)
Die.new value..value
end
# Allows to create a die with a fixed value.
# The die will only gives this value everytime.
# (`.min`, `.max`, `.test`, `.average`)
#
# This is equivalent to
# ```
# Die.new(n..n) # => FixedValue.new_die n
# Dice.new(1, Die.new(n..n)) # => FixedValue.new_dice n
# ```
module Rollable::FixedValue
# Return a `Die` with only one face.
def self.new_die(value : Int32)
Die.new value..value
end
# Return a `Dice` with only one face.
def self.new_dice(fixed : Int32)
Dice.new 1, FixedValue.new_die(fixed)
end
# Return a `Dice` with only one face.
def self.new_dice(fixed : Int32)
Dice.new 1, FixedValue.new_die(fixed)
end
end

View File

@ -1,8 +1,6 @@
module Rollable
abstract class IsRollable
abstract def min : Int32
abstract def max : Int32
abstract def average : Int32
abstract def test : Int32
end
abstract class Rollable::IsRollable
abstract def min : Int32
abstract def max : Int32
abstract def average : Float64
abstract def test : Int32
end

View File

@ -3,139 +3,154 @@ require "./die"
require "./fixed_value"
require "./dice"
module Rollable
# `Roll` is a list of `Dice`.
#
# It is rollable, making the sum of each `Dice` values.
# It is also possible to get the details of a roll, using the methods
# `.min_details`, `.max_details`, `.average_details`, `.test_details`
# `Roll` is a list of `Dice`.
#
# It is rollable, making the sum of each `Dice` values.
# It is also possible to get the details of a roll, using the methods
# `.min_details`, `.max_details`, `.average_details`, `.test_details`
#
# Example:
# ```
# r = Rollable.parse "1d6+2" # note: it also support "1d6 + 2"
# r.min # => 3
# r.max # => 9
# r.average # => 5.5
# r.test # => the sum of a random value in 1..6 and 2
# ```
class Rollable::Roll < Rollable::IsRollable
@dice : Array(Dice)
getter dice
def initialize(@dice)
end
def clone
Roll.new(@dice.clone)
end
delegate size, to: @dice
# Reverse the values of the `Roll`.
def reverse! : Roll
@dice.each { |die| die.reverse! }
self
end
# Return a reversed copy of the `Roll`'s values.
#
# Example:
# ```
# r = Rollable.parse "1d6+2" # note: it also support "1d6 + 2"
# r.min # => 3
# r.max # => 9
# r.average # => 5.5
# r.test # => the sum of a random value in 1..6 and 2
# Roll.parse("1d6").reverse # => -1d6
# ```
class Roll < IsRollable
@dice : Array(Dice)
def reverse : Roll
Roll.new @dice.map { |die| die.reverse }
end
getter dice
{% for ft in ["min", "max", "test"] %}
def {{ ft.id }} : Int32
@dice.reduce(0) { |r, l| r + l.{{ ft.id }} }
end
def initialize(@dice)
def {{ (ft + "_details").id }} : Array(Int32)
@dice.map {|dice| dice.{{ (ft + "_details").id }} }.flatten
end
{% end %}
def average : Float64
@dice.reduce(0.0) { |r, l| r + l.average }
end
def average_details : Array(Float64)
@dice.map { |dice| dice.average_details }.flatten
end
def ==(right : Roll)
@dice.size == right.dice.size && @dice.map_with_index { |e, i| right.dice[i] == e }.all? { |e| e == true }
end
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Roll)
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
def <=>(right : Roll) : Int32
if average != right.average
average - right.average > 0 ? 1 : -1
elsif max != right.max
max - right.max <=> 0
else
min - right.min <=> 0
end
end
def clone
Roll.new(@dice.clone)
end
def order!
@dice.sort! { |a, b| b <=> a }
self
end
# Reverse the values of the `Roll`.
def reverse! : Roll
@dice.each { |die| die.reverse! }
self
end
def order
clone.order!
end
# Return a reversed copy of the `Roll`'s values.
#
# Example:
# ```
# Roll.parse("1d6").reverse # => -1d6
# ```
def reverse : Roll
Roll.new @dice.map { |die| die.reverse }
end
{% for ft in ["min", "max", "test"] %}
def {{ ft.id }} : Int32
@dice.reduce(0) { |r, l| r + l.{{ ft.id }} }
end
def {{ (ft + "_details").id }} : Array(Int32)
@dice.map {|dice| dice.{{ (ft + "_details").id }} }.flatten
end
{% end %}
def average : Float64
@dice.reduce(0.0) { |r, l| r + l.average }
end
def average_details : Array(Float64)
@dice.map { |dice| dice.average_details }.flatten
end
def ==(right : Roll)
@dice.size == right.dice.size && @dice.map_with_index { |e, i| right.dice[i] == e }.all? { |e| e == true }
end
{% for op in [">", "<", ">=", "<="] %}
def {{ op.id }}(right : Roll)
average != right.average ?
average {{ op.id }} right.average :
max != right.max ?
max {{ op.id }} right.max :
min {{ op.id }} right.min
end
{% end %}
def <=>(right : Roll) : Int32
average != right.average ? average - right.average <=> 0 : max != right.max ? max - right.max <=> 0 : min - right.min <=> 0
end
def order!
@dice.sort! { |a, b| b <=> a }
self
end
def order
clone.order!
end
# let a [1d6, 1d4, 1d6, 2, 2d6]
# first, we copy it
# for 1d6 we check evey d6 in the copy, fetch and delete them
def compact!
i = 0
until i >= @dice.size
# fetch the current dice
dice_current = @dice[i]
dice_type = dice_current.die
dice_count = dice_current.count
# fetch all dice with the same type
j = @dice.size
until i >= j - 1
j = j - 1
if @dice[j].die == dice_type
@dice[i].count += @dice[j].count
elsif @dice[j].die == dice_type.reverse
@dice[i].count -= @dice[j].count
else
next
end
@dice.delete_at j
# let a [1d6, 1d4, 1d6, 2, 2d6]
# first, we copy it
# for 1d6 we check evey d6 in the copy, fetch and delete them
def compact!
i = 0
until i >= @dice.size
# fetch the current dice
dice_current = @dice[i]
dice_type = dice_current.die
dice_count = dice_current.count
# fetch all dice with the same type
j = @dice.size
until i >= j - 1
j = j - 1
if @dice[j].die == dice_type
@dice[i].count += @dice[j].count
elsif @dice[j].die == dice_type.reverse
@dice[i].count -= @dice[j].count
else
next
end
i = i + 1
@dice.delete_at j
end
compact_fixed!
self
i = i + 1
end
compact_fixed!
compact_empty!
self
end
private def compact_fixed!
fixed = @dice.map_with_index { |d, idx| {d, idx} }
fixed.select! { |t| t[0].die.fixed? }
idx = 0
fixed_dice = fixed.map do |t|
@dice.delete_at(t[1] - idx)
idx = idx + 1
t[0].max
end.sum
@dice << FixedValue.new_dice(fixed_dice) if fixed_dice != 0
end
private def compact_fixed!
fixed = @dice.map_with_index { |d, idx| {d, idx} }
fixed.select! { |t| t[0].die.fixed? }
idx = 0
fixed_dice = fixed.map do |t|
@dice.delete_at(t[1] - idx)
idx = idx + 1
t[0].max
end.sum
@dice << FixedValue.new_dice(fixed_dice) if fixed_dice != 0
end
def compact
clone.compact!
private def compact_empty!
i = @dice.size - 1
until i < 0
@dice.delete_at(i) if @dice[i].count == 0
i = i - 1
end
end
def compact
clone.compact!
end
end
require "./roll/*"

View File

@ -1,51 +1,61 @@
module Rollable
class Roll
# Parse the string and return an array of `Dice`
#
# see `Dice.consume`
#
# The string passed as parameter is consumed, part by part, to create an
# Array of `Dice`. The string must follow grammar below (case insensitive):
# ```text
# - dice = [\d+][d][\d+]
# - sign = ['+', '-']
# - sdice = [sign]?[dice]
# - roll = [sign][dice][sdice]*
# ```
def self.parse_str(str : String?, list : Array(Dice) = Array(Dice).new) : Array(Dice)
return list if str.nil?
str = str.strip
sign = str[0]
if sign != '+' && sign != '-' && !list.empty?
raise ParsingError.new("Parsing Error: roll, near to '#{str}'")
end
str = str[1..-1] if sign == '-' || sign == '+'
rest = Dice.consume(str) do |dice|
list << (sign == '-' ? dice.reverse : dice)
end
parse_str(rest, list)
return list
class Rollable::Roll
# Parse the string and return an array of `Dice`
#
# see `Dice.consume`
#
# The string passed as parameter is consumed, part by part, to create an
# Array of `Dice`. The string must follow grammar below (case insensitive):
# ```text
# - dice = [\d+][d][\d+]
# - sign = ['+', '-']
# - sdice = [sign]?[dice]
# - roll = [sign][dice][sdice]*
# ```
private def self.parse_str(str : String?, list : Array(Dice) = Array(Dice).new) : Array(Dice)
return list if str.nil?
str = str.strip
sign = str[0]
if sign != '+' && sign != '-' && sign != '!' && !list.empty?
raise ParsingError.new("Parsing Error: roll, near to '#{str}'")
end
# Parse the string "str" and returns a new `Roll` object
# see `#parse_str`
def self.parse(str : String) : Roll
return Roll.new(parse_str(str))
str = str[1..-1] if sign == '-' || sign == '+'
rest = Dice.consume(str) do |dice|
list << (sign == '-' ? dice.reverse : dice)
end
parse_str(rest, list)
return list
end
def to_s : String
@dice.reduce(nil) do |l, r|
# puts "l:#{l.to_s}, r:#{r.to_s}"
if l
if r.negative?
l + " - " + r.reverse.to_s
else
l + " + " + r.to_s
end
else
r.to_s
end
end.to_s
# Parse the string "str" and returns a new `Roll` object
#
# see `#parse_str`
def self.parse(str : String) : Roll
return Roll.new(parse_str(str))
end
# Parse the string "str" and returns a new `Roll` object,
# and execute the "block" if an error occured
def self.parse(str : String) : Roll?
begin
return self.parse(str)
rescue err
yield err
return nil
end
end
def to_s : String
@dice.reduce(nil) do |l, r|
# puts "l:#{l.to_s}, r:#{r.to_s}"
if l
if r.negative?
l + " - " + r.reverse.to_s
else
l + " + " + r.to_s
end
else
r.to_s
end
end.to_s
end
end

View File

@ -1,3 +1,3 @@
module Rollable
VERSION = "0.1.1"
VERSION = "0.1.4"
end