Packages

Add a dice-rolling DSL with sigil support and reusable compiled functions to your application.

Current section

Files

Jump to
ex_dice_roller lib ex_dice_roller.ex
Raw

lib/ex_dice_roller.ex

defmodule ExDiceRoller do
@moduledoc """
Converts strings into dice rolls and returns expected results. Ignores any
spaces, including tabs and newlines, in the provided string. A roll can be
invoked via `ExDiceRoller.roll/3`.
iex> ExDiceRoller.roll("2d6+3")
8
iex> ExDiceRoller.roll("(1d4)d(6*y)-(2/3+1dx)", [x: 2, y: 3])
11
iex> import ExDiceRoller.Sigil
iex> ExDiceRoller.roll(~a/1d2+z/, [z: ~a/1d2/], [:explode])
8
Rolls and invoked compiled functions can be supplied a number of options:
* `:cache`: Performs a cache lookup, with a miss generating a compiled
roll that is both cached and returned.
`ExDiceRoller.Cache.obtain/2` for more information.
* `:explode`: Causes dice to _explode_. This means that if a die roll results
in the highest possible value for a die (such as rolling a 20 on a d20), the
die will be rerolled until the result is no longer the max possible. It then
sums the total of all rolls and returns that value.
* `:keep`: Retains each dice roll.
For more information, see `ExDiceRoller.Compilers.Roll`.
* `:highest`: Selects the highest of all calculated values when using the `,`
operator.
* `:lowest`: Selects the lowest of all calculated values when using the `,`
operator.
## Order of Precedence
The following table shows order of precendence, from highest to lowest,
of the operators available to ExDiceRoller.
Operator | Associativity | Compiler
--------------------- | ------------- | ----------------------------
`d` | left-to-right | `ExDiceRoller.Compilers.Roll`
`+`, `-` | unary | NA (handled by the parser in `dice_parser.yrl`)
`*`, `/`, `%`, `^` | left-to-right | `ExDiceRoller.Compilers.Math`
`+`, `-` | left-to-right | `ExDiceRoller.Compilers.Math`
`,` | left-to-right | `ExDiceRoller.Compilers.Separator`
### Effects of Parentheses
As in math, parentheses can be used to create sub-expressions.
iex> ExDiceRoller.tokenize("1+3d4*1-2/-3") |> elem(1) |> ExDiceRoller.parse()
{:ok,
{{:operator, '-'},
{{:operator, '+'}, {:digit, '1'},
{{:operator, '*'}, {:roll, {:digit, '3'}, {:digit, '4'}}, {:digit, '1'}}},
{{:operator, '/'}, {:digit, '2'}, {:digit, '-3'}}}}
iex> ExDiceRoller.tokenize("(1+3)d4*1-2/-3") |> elem(1) |> ExDiceRoller.parse()
{:ok,
{{:operator, '-'},
{{:operator, '*'},
{:roll, {{:operator, '+'}, {:digit, '1'}, {:digit, '3'}}, {:digit, '4'}},
{:digit, '1'}}, {{:operator, '/'}, {:digit, '2'}, {:digit, '-3'}}}}
iex> ExDiceRoller.tokenize("1+3d(4*1)-2/-3") |> elem(1) |> ExDiceRoller.parse()
{:ok,
{{:operator, '-'},
{{:operator, '+'}, {:digit, '1'},
{:roll, {:digit, '3'}, {{:operator, '*'}, {:digit, '4'}, {:digit, '1'}}}},
{{:operator, '/'}, {:digit, '2'}, {:digit, '-3'}}}}
iex> ExDiceRoller.tokenize("1+3d4*(1-2)/-3") |> elem(1) |> ExDiceRoller.parse()
{:ok,
{{:operator, '+'}, {:digit, '1'},
{{:operator, '/'},
{{:operator, '*'}, {:roll, {:digit, '3'}, {:digit, '4'}},
{{:operator, '-'}, {:digit, '1'}, {:digit, '2'}}}, {:digit, '-3'}}}}
## Compiled Rolls
Some systems utilize complex dice rolling equations. Repeatedly tokenizing,
parsing, and interpreting complicated dice rolls strings can lead to a
performance hit on an application. To ease the burden, developers can
_compile_ a dice roll string into an anonymous function. This anonymous
function can be passed around as any other function and reused repeatedly
without having to re-tokenize the string, nor re-interpret a parsed
expression.
iex> {:ok, roll_fun} = ExDiceRoller.compile("2d6+3")
iex> ExDiceRoller.execute(roll_fun)
8
iex> ExDiceRoller.execute(roll_fun)
13
iex> ExDiceRoller.execute(roll_fun)
10
More information can be found in `ExDiceRoller.Compiler`.
## Variables
Single-letter variables can be used when compiling dice rolls. However, values
for those variables must be supplied upon invocation. Values can be any of the
following:
* numbers
* expressions, such as "1d6+2"
* compiled dice rolls
* results of `~a` sigil, as described in `ExDiceRoller.Sigil`
```elixir
iex> {:ok, fun} = ExDiceRoller.compile("2d4+x")
iex> ExDiceRoller.execute(fun, x: 2)
7
iex> ExDiceRoller.execute(fun, x: "5d100")
245
iex> {:ok, fun_2} = ExDiceRoller.compile("3d8-2")
iex> ExDiceRoller.execute(fun, x: fun_2)
23
iex> import ExDiceRoller.Sigil
iex> ExDiceRoller.execute(fun, x: ~a/3d5+2d4/)
22
```
More information can be found in `ExDiceRoller.Compilers.Variable`.
## Caching
ExDiceRoller can cache and reuse dice rolls.
iex> ExDiceRoller.start_cache()
iex> ExDiceRoller.roll("8d6-(4d5)", [], [:cache])
20
iex> ExDiceRoller.roll("8d6-(4d5)", [], [:cache])
13
iex> ExDiceRoller.roll("1d3+x", [x: 4], [:cache])
6
iex> ExDiceRoller.roll("1d3+x", [x: 1], [:cache, :explode])
6
More details can be found in the documentation for `ExDiceRoller.Cache`.
## Sigil Support
ExDiceRoller comes with its own sigil, `~a`, that can be used to create
compiled dice roll functions or roll them on the spot.
iex> import ExDiceRoller.Sigil
iex> fun = ~a/2d6+2/
iex> ExDiceRoller.roll(fun)
7
iex> ExDiceRoller.roll(~a|1d4+x/5|, [x: 43])
11
iex> ExDiceRoller.roll(~a|xdy|, [x: fun, y: ~a/12d4-15/])
111
More information can be found in `ExDiceRoller.Sigil`.
## ExDiceRoller Examples
The following examples show a variety of types of rolls, and includes examples
of basic and complex rolls, caching, sigil support, variables, and
combinations of thereof.
iex> ExDiceRoller.roll("1")
1
iex> ExDiceRoller.roll("1d8")
1
iex> ExDiceRoller.roll("2d20 + 5")
34
iex> import ExDiceRoller.Sigil
iex> ExDiceRoller.roll(~a/2d8-2/)
3
iex> ExDiceRoller.roll("(1d4)d(6*5) - (2/3+1)")
18
iex> ExDiceRoller.roll("1+2-3*4+5/6*7+8-9")
-4
iex> ExDiceRoller.roll("1+\t2*3d 4")
15
iex> ExDiceRoller.roll("1dx+6-y", x: 10, y: 5)
10
iex> import ExDiceRoller.Sigil
iex> ExDiceRoller.roll(~a/2+5dx/, x: ~a|3d(7/2)|)
19
iex> ExDiceRoller.roll("1d2", [], [:explode])
1
iex> ExDiceRoller.roll("1d2", [], [:explode])
7
iex> ExDiceRoller.start_cache()
iex> ExDiceRoller.roll("1d2+x", [x: 3], [:cache])
4
iex> ExDiceRoller.roll("1d2+x", [x: 3], [:cache, :explode])
10
iex> import ExDiceRoller.Sigil
iex> ~a/1d2+3/r
4
iex> ~a/1d2+2/re
9
"""
alias ExDiceRoller.{Cache, Compiler, Parser, Tokenizer}
@cache_table Application.fetch_env!(:ex_dice_roller, :cache_table)
@doc """
Processes a given string as a dice roll and returns the final result. The
final result is a rounded integer.
iex> ExDiceRoller.roll("1d6+15")
18
Note that using variables with this call will result in errors. If you need
variables, use `roll/3` instead.
"""
@spec roll(String.t()) :: integer | list(integer)
def roll(roll_string), do: roll(roll_string, [], [])
@doc """
Processes a given string as a dice roll and returns the calculated result. The
result is a rounded integer.
Any variable values should be specified in `args`. Options can be passed in
`opts`.
Possible values for `opts` include:
* `:cache`: Performs a cache lookup, with a miss generating a compiled
roll that is both cached and returned.
`ExDiceRoller.Cache.obtain/2` for more information.
* `:explode`: Causes dice to _explode_. This means that if a die roll results
in the highest possible value for a die (such as rolling a 20 on a d20), the
die will be rerolled until the result is no longer the max possible. It then
sums the total of all rolls and returns that value.
* `:keep`: Retains each dice roll.
For more information, see `ExDiceRoller.Compilers.Roll`.
* `:highest`: Selects the highest of all calculated values when using the `,`
operator.
* `:lowest`: Selects the lowest of all calculated values when using the `,`
operator.
### Examples
iex> ExDiceRoller.roll("1d6+15", [])
18
iex> ExDiceRoller.roll("1d8+x", x: 5)
6
iex> ExDiceRoller.roll("1d3", [], [:explode])
5
iex> ExDiceRoller.roll("1d3", [], [:explode])
4
iex> ExDiceRoller.roll("1d3", [], [:explode])
2
iex> ExDiceRoller.start_cache(ExDiceRoller.Cache)
iex> ExDiceRoller.roll("(1d6)d4-3+y", [y: 3], [:cache])
10
iex> ExDiceRoller.roll("1d2+y", [y: 1], [:cache, :explode])
2
iex> ExDiceRoller.roll("1d2+y", [y: 2], [:cache, :explode])
11
iex> ExDiceRoller.roll("1,2", [], [:highest])
2
iex> ExDiceRoller.roll("10,12,45,3,100", [], [:lowest])
3
"""
@spec roll(String.t() | Compiler.compiled_fun(), Compiler.args, Compiler.opts) :: integer
def roll(roll_string, args, opts \\ [])
def roll(roll_string, args, [:cache | rest]) do
@cache_table
|> Cache.obtain(roll_string)
|> execute(args, rest)
end
def roll(roll_string, args, opts) when is_bitstring(roll_string) do
with {:ok, tokens} <- Tokenizer.tokenize(roll_string),
{:ok, parsed_tokens} <- Parser.parse(tokens) do
calculate(parsed_tokens, args, opts)
else
{:error, _} = err -> err
end
end
def roll(compiled, args, opts) when is_function(compiled) do
execute(compiled, args, opts)
end
@doc "Helper function that calls `ExDiceRoller.Tokenizer.tokenize/1`."
@spec tokenize(String.t()) :: {:ok, Tokenizer.tokens()}
def tokenize(roll_string), do: Tokenizer.tokenize(roll_string)
@doc "Helper function that calls `ExDiceRoller.Tokenizer.tokenize/1`."
@spec parse(Tokenizer.tokens()) :: {:ok, Parser.expression()}
def parse(tokens), do: Parser.parse(tokens)
@doc """
Takes a given expression from parse and calculates the result.
"""
@spec calculate(Parser.expression(), Compiler.args(), Compiler.opts()) :: number
def calculate(expression, args \\ [], opts \\ []) do
expression
|> compile()
|> elem(1)
|> execute(args, opts)
end
@doc """
Compiles a string or `t:expression/0` into an anonymous function.
iex> {:ok, roll_fun} = ExDiceRoller.compile("1d8+2d(5d3+4)/3")
iex> ExDiceRoller.execute(roll_fun)
5
If `roll` is not a string or expression compile/1 will return
`{:error, {:cannot_compile_roll, other}}`.
"""
@spec compile(String.t() | Parser.expression()) ::
{:ok, Compiler.compiled_function()} | {:error, any}
def compile(roll)
def compile(roll) when is_bitstring(roll) do
with {:ok, tokens} <- Tokenizer.tokenize(roll),
{:ok, parsed_tokens} <- Parser.parse(tokens) do
compile(parsed_tokens)
else
{:error, _} = err -> err
end
end
def compile(roll) when is_tuple(roll) do
{:ok, Compiler.compile(roll)}
end
def compile(other), do: {:error, {:cannot_compile_roll, other}}
@doc "Executes a function built by `compile/1`."
@spec execute(function, Compiler.args(), Compiler.opts()) :: integer | list(integer)
def execute(compiled, args \\ [], opts \\ []) when is_function(compiled) do
compiled.(args, opts)
end
@doc """
Starts the underlying roll function cache. See `ExDiceRoller.Cache` for more
details.
"""
@spec start_cache(atom | none) :: {:ok, any}
def start_cache(cache \\ @cache_table) do
{:ok, _} = Cache.start_link(cache)
end
end