lib/rollex.ex

defmodule Rollex do
  @moduledoc """
  Rollex is an elixir application that lets you evaluate dice rolls with simple arithmetic operators.
  The API attempts to follow common, de facto standards found in other dice rolling applications (e.g. VTTs)
  and libraries as much as possible for familiarity and ease of use.

  * The operators supported are `+, -, /, *`.
  * Grouping is via parentheses
  * Polyhedral dice are designated using the `<quantity>d<sides>` format (Ex. 20d8 or D100).
    The following operations are supported to filter such rolls:
      * `<`: consider rolls less than the target number to be successes (`3d10<4`)
      * `>`: consider rolls greater than the target number to be successes (`3d10>4`)
      * `=`: consider rolls equal to the target number to be successes (`1d6=6`)
      * `=[low..high]`: consider rolls between `low` and `high` to be successes (`1d6=[1..2]`)
      * `=[x,y,..]`: consider rolls in the list of numbers to be successes (`1d6=[1,3,5]` would succeed on odd numbers only)
      * `o`: only odd numbers are considered succcesses
      * `e`: only even numbers are considered succcesses
      * `k` and `kh`: keeps the highest N dice in the pool (`4d6k3` or `4d6kh3`)
      * `kl`: keeps the lowest N dice in the pool (`4d6kl2`)
      * `dh`: drops the highest N dice in the pool (`4d6dh2`)
      * `d` and `dl`: drops the lower N dice in the pool (`4d6d1` or `4d6dl1`)
      * `r<`, `r>`, and `r=` reroll dice that are less than, greater then, or equal to a number (or a range in the case of equality)
  * [Fudge dice](https://en.wikipedia.org/wiki/Fudge_(role-playing_game_system)) are designated using '<quantity>df',
    The starting state may optionally be provided with a one-letter code for: (t)errible, (p)oor, (m)ediocre, (f)air, (g)ood, g(r)eat, (s)superb
    If no starting state is defined, it assumed to start at the neutral "fair" state.


  When a roll is evaluated, Rollex returns a tagged success tuple (`{:ok | :error, term}`). On success, the `term` is a map that contains a `tokens` and a `totals` field. The `totals` field contains information such as the sum total (arithmetic) and the number of successes, while the tokens field contains each token and their respect results.

  Tokens with the `is_dice: true` field represent "rollable" dice. This can be used to, for example, filter the results for dice that were rolled and show the user a representation of those dice being rolled.

  Example:

  ```
  iex> Rollex.roll("1d6+1d4")
  {:ok,
    %{
      tokens: [
        %Rollex.Tokens.RegularDice{
          arithmetic: 3,
          is_dice: true,
          operation: nil,
          quantity: 1,
          regex: ~r/\A(\d*)[dD](\d+)/,
          rejected_rolls: [],
          sides: 6,
          valid_rolls: [3]
        },
        %Rollex.Tokens.Addition{regex: ~r/\A\+/},
        %Rollex.Tokens.RegularDice{
          arithmetic: 1,
          is_dice: true,
          operation: nil,
          quantity: 1,
          regex: ~r/\A(\d*)[dD](\d+)/,
          rejected_rolls: [],
          sides: 4,
          valid_rolls: [1]
        },
        %Rollex.Tokens.End{regex: ~r/\A\z/}
      ],
      totals: %{arithmetic: 4, successes: 2}
    }
  }
  ```

  Dice expressions may also be precompiled using `compile/2`. The resulting struct can then be passed
  to `evaluate/1` as many times as desired. These are therefore all equivalent:

  ```
  Rollex.roll("1d6")
  Rollex.compile("1d6") |> Rollex.evaluate()

  dice = Rollex.compile("1d6")
  Rollex.evaluate(dice)
  Rollex.evaluate(dice)
  ```

  A map containing the odds for all possible results of a given dice roll can be generated by passing a
  compiled roll to `histogram/1`:

  ```
  iex> Rollex.compile("2d6") |> Rollex.histogram()
  {:ok,
    %{
      2 => 2.778,
      3 => 5.556,
      4 => 8.333,
      5 => 11.111,
      6 => 13.889,
      7 => 16.667,
      8 => 13.889,
      9 => 11.111,
      10 => 8.333,
      11 => 5.556,
      12 => 2.778
    }
  }
  ```
  """

  # NOTE: Order Matters! If an item matches in Rollex.Lexer._do_tokenize/0, the lexer will skip the rest of the tokens
  # This is why number is after dice tokens, since number will very frequently match on what should be dice input
  @default_operations [
    %Rollex.Tokens.Number{},
    %Rollex.Tokens.Subtraction{},
    %Rollex.Tokens.Addition{},
    %Rollex.Tokens.Multiplication{},
    %Rollex.Tokens.Division{},
    %Rollex.Tokens.LeftParenthesis{},
    %Rollex.Tokens.RightParenthesis{}
  ]

  @default_dice [
    %Rollex.Tokens.RegularDice{},
    %Rollex.Tokens.FudgeDice{}
  ]

  defstruct expression: "", compiled: [], valid: false, error: nil

  @type t :: %__MODULE__{
          expression: String.t(),
          compiled: [map],
          valid: boolean,
          error: String.t() | nil
        }

  @type token :: %{required(:regex) => term, atom => term}
  @type tokens :: [token]

  @type diceType :: :polyhedral | :fudge | :paranoia
  @type diceTypes :: [diceType]
  @type totals :: %{
          optional(:arithmetic) => number,
          optional(:successes) => number,
          optional(:histogram) => map
        }
  @type evaluatedRoll :: %{tokens: tokens, totals: totals}
  @type rollResult :: {:ok, evaluatedRoll} | {:error, String.t()}

  @doc """
  Takes a string representing a die roll and returns the result
  """
  @spec roll(expression :: String.t(), dice_types :: :all | diceTypes) :: rollResult
  def roll(expression, dice_types \\ :all) do
    expression
    |> compile(dice_types)
    |> evaluate()
  end

  @doc "Compiles a string representing a die roll into a form suitable for passing into evaluate/1"
  @spec compile(expression :: String.t(), dice_types :: :all | diceTypes) :: __MODULE__.t()
  def compile(expression, dice_types \\ :all) do
    dice = dice_to_use(dice_types)

    expression
    |> Rollex.Lexer.tokenize(dice ++ @default_operations)
    |> Rollex.Validator.validate()
    |> create_struct(expression)
  end

  @doc "Runs a compiled expression, producing a result"
  @spec evaluate(__MODULE__.t()) :: rollResult
  def evaluate(%{valid: true, compiled: compiled_expr}) do
    Rollex.Evaluator.evaluate(compiled_expr)
  end

  def evaluate(%{expression: expression}), do: {:error, "Invalid expression #{expression}"}
  def evaluate(_), do: {:error, "Not a Rollex expresson!"}

  @spec min(__MODULE__.t()) :: {:ok, number} | {:error, String.t()}
  defdelegate min(roll), to: Rollex.Distribution

  @spec max(__MODULE__.t()) :: {:ok, number} | {:error, String.t()}
  defdelegate max(roll), to: Rollex.Distribution

  @spec range(__MODULE__.t()) :: {:ok, number, number} | {:error, String.t()}
  defdelegate range(roll), to: Rollex.Distribution

  @spec histogram(__MODULE__.t()) :: {:ok, map} | {:error, String.t()}
  def histogram(%{valid: true, compiled: compiled_expr}) do
    Rollex.Evaluator.histogram(compiled_expr)
  end

  def histogram(%{error: error}), do: {:error, error || "Invalid expression"}

  def default_tokens(), do: @default_dice ++ @default_operations

  defp create_struct({:ok, compiled}, expression) do
    %__MODULE__{
      expression: expression,
      compiled: compiled,
      valid: true
    }
  end

  defp create_struct({:error, error}, expression) do
    %__MODULE__{
      expression: expression,
      compiled: [],
      valid: false,
      error: error
    }
  end

  defp dice_to_use(:all), do: @default_dice

  defp dice_to_use(dice_types) do
    dice_types
    |> Enum.uniq()
    |> Enum.reduce([], &type_to_die/2)
  end

  defp type_to_die(:polyhedral, acc), do: [%Rollex.Tokens.RegularDice{} | acc]
  defp type_to_die(:fudge, acc), do: [%Rollex.Tokens.FudgeDice{} | acc]
  defp type_to_die(_, acc), do: acc
end