defmodule DicEx do readme = Path.expand("../README.md", __DIR__) @external_resource readme @moduledoc readme |> File.read!() |> String.split("") |> Enum.fetch!(1) alias DicEx.{Parser, Result, RNG, Roller} @default_rng RNG.Default @type roll_opt :: {:rng, module() | {module(), term()}} | {:seed, integer()} @doc """ Rolls the given expression and returns a `DicEx.Result`. ## Options * `:seed` — integer seed for the default RNG (reproducible sequence). * `:rng` — an explicit RNG module (stateless) or `{module, state}` tuple. """ @spec roll(binary(), [roll_opt()]) :: Result.t() def roll(expression, opts \\ []) def roll(expression, opts) when is_binary(expression) and is_list(opts) do rng = resolve_rng(opts) case Parser.parse(expression) do {:ok, ast} -> {result, _final_rng} = Roller.evaluate(ast, rng) %{result | expression: expression} {:error, reason} -> raise ArgumentError, "invalid dice expression #{inspect(expression)}: #{reason}" end end @doc """ Like `roll/2` but returns `{:ok, result}` or `{:error, reason}` without raising. Handy when the expression comes from untrusted input (e.g. a chat command parsed by an LLM in dragonEx). """ @spec roll_e(binary(), [roll_opt()]) :: {:ok, Result.t()} | {:error, String.t()} def roll_e(expression, opts \\ []) def roll_e(expression, opts) when is_binary(expression) and is_list(opts) do rng = resolve_rng(opts) with {:ok, ast} <- Parser.parse(expression) do {result, _} = Roller.evaluate(ast, rng) {:ok, %{result | expression: expression}} end end @doc """ Convenience for a single typed roll — the shape the dragonEx UI emits. DicEx.roll_dice(2, 20) # 2d20 DicEx.roll_dice(1, 20, mod: 5) # 1d20 + 5 ## Options * `:mod` — integer modifier added to the total (`+`/`-`). * `:advantage` — boolean, keeps highest of 2 (implies 2 dice if `count==1`). * `:disadvantage` — boolean, keeps lowest of 2. * Plus any option accepted by `roll/2` (`:seed`, `:rng`). """ @spec roll_dice(pos_integer(), pos_integer(), keyword()) :: Result.t() def roll_dice(count, sides, opts \\ []) do base = "#{count}d#{sides}" expr = case {Keyword.get(opts, :advantage), Keyword.get(opts, :disadvantage)} do {true, _} when count == 1 -> "2d#{sides}kh1" {_, true} when count == 1 -> "2d#{sides}kl1" _ -> base end expr = case Keyword.get(opts, :mod) do nil -> expr n when n >= 0 -> "#{expr}+#{n}" n -> "#{expr}#{n}" end roll(expr, Keyword.take(opts, [:seed, :rng])) end @doc """ Pretty-prints a result as a human readable string, e.g. `"2d20kh1+5 = 23"`. """ @spec format(Result.t()) :: String.t() def format(%Result{} = result) do "#{result.expression} = #{result.total}" end # --------------------------------------------------------------------------- defp resolve_rng(opts) do cond do rng = Keyword.get(opts, :rng) -> rng seed = Keyword.get(opts, :seed) -> RNG.Seeded.seed(seed) @default_rng true -> @default_rng end end end