Skip to main content

lib/dic_ex.ex

defmodule DicEx do
  readme = Path.expand("../README.md", __DIR__)
  @external_resource readme
  @moduledoc readme
             |> File.read!()
             |> String.split("<!-- MDOC -->")
             |> 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