lib/kalevala/brain.ex

defprotocol Kalevala.Brain.Node do
  @moduledoc """
  Process a node in the behavior tree
  """

  def run(node, conn, event)
end

defmodule Kalevala.Brain.NullNode do
  @moduledoc """
  A no-op node
  """

  defstruct []

  defimpl Kalevala.Brain.Node do
    def run(_node, conn, _event), do: conn
  end
end

defmodule Kalevala.Brain.FirstSelector do
  @moduledoc """
  Processes each node one at a time and stops processing when the first one succeeds
  """

  defstruct [:nodes]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain.Node

    def run(node, conn, event) do
      result =
        Enum.find_value(node.nodes, fn node ->
          case Node.run(node, conn, event) do
            :error ->
              false

            result ->
              result
          end
        end)

      case is_nil(result) do
        true ->
          conn

        false ->
          result
      end
    end
  end
end

defmodule Kalevala.Brain.ConditionalSelector do
  @moduledoc """
  Processes each node one at a time and stops processing when the first one fails
  """

  defstruct [:nodes]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain.Node

    def run(node, conn, event) do
      Enum.reduce_while(node.nodes, conn, fn node, conn ->
        case Node.run(node, conn, event) do
          :error ->
            {:halt, :error}

          result ->
            {:cont, result}
        end
      end)
    end
  end
end

defmodule Kalevala.Brain.RandomSelector do
  @moduledoc """
  Processes a random node
  """

  defstruct [:nodes]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain.Node

    def run(node, conn, event) do
      node =
        node.nodes
        |> Enum.shuffle()
        |> List.first()

      Node.run(node, conn, event)
    end
  end
end

defmodule Kalevala.Brain.Sequence do
  @moduledoc """
  Process each node one at a time
  """

  defstruct [:nodes]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain.Node

    def run(node, conn, event) do
      Enum.reduce(node.nodes, conn, fn node, conn ->
        case Node.run(node, conn, event) do
          :error ->
            conn

          conn ->
            conn
        end
      end)
    end
  end
end

defmodule Kalevala.Brain.Condition do
  @moduledoc """
  Check if a condition is valid

  Returns error if it does not match
  """

  defstruct [:data, :type]

  @callback match?(Event.t(), Conn.t(), map()) :: boolean()

  defimpl Kalevala.Brain.Node do
    def run(node, conn, event) do
      case node.type.match?(event, conn, node.data) do
        true ->
          conn

        false ->
          :error
      end
    end
  end
end

defmodule Kalevala.Brain.Variable do
  @moduledoc """
  Handle variable data in brain nodes

  Replaces variables in the format of `${variable_name}`. Works with
  a dot notation for nested variables.

  Example:

  The starting data

       %{
         channel_name: "rooms:${room_id}",
         delay: 500,
         text: "Welcome, ${character.name}!"
       }

  With the event data

      %{
        room_id: "room-id",
        character: %{
          name: "Elias"
        }
      }

  Will replace to the following

       %{
         channel_name: "rooms:room-id",
         delay: 500,
         text: "Welcome, Elias!"
       }
  """

  defstruct [:path, :original, :reference, :value]

  @doc """
  Replace action data variables with event data
  """
  def replace(data, event_data) do
    data
    |> detect_variables()
    |> dereference_variables(event_data)
    |> replace_variables(data)
  end

  @doc """
  Detect variables inside of the data
  """
  def detect_variables(data, path \\ []) do
    data
    |> Enum.map(fn {key, value} ->
      find_variables({key, value}, path)
    end)
    |> Enum.reject(&is_nil/1)
    |> List.flatten()
  end

  defp find_variables({key, value}, path) when is_binary(value) do
    variables(value, path ++ [key])
  end

  defp find_variables({key, value}, path) when is_map(value) do
    detect_variables(value, path ++ [key])
  end

  defp find_variables(_, _), do: nil

  @doc """
  Scan the value for a variable, returning `Variable` structs
  """
  def variables(value, path) do
    Enum.map(Regex.scan(~r/\$\{(?<variable>[\w\.]+)\}/, value), fn [string, variable] ->
      %Kalevala.Brain.Variable{
        path: path,
        original: string,
        reference: variable
      }
    end)
  end

  def dereference_variables(variables, event_data) do
    Enum.map(variables, fn variable ->
      dereference_variable(variable, event_data)
    end)
  end

  defp dereference_variable(variables, event_data) when is_list(variables) do
    Enum.map(variables, fn variable ->
      dereference_variable(variable, event_data)
    end)
  end

  defp dereference_variable(variable, event_data) do
    variable_reference = String.split(variable.reference, ".")
    variable_value = dereference(event_data, variable_reference)
    %{variable | value: variable_value}
  end

  @doc """
  Replace detected and dereferenced variables in the data

  Fails if any variables still contain an `:error` value, they were
  not able to be dereferenced.
  """
  def replace_variables(variables, data) do
    failed_replace? =
      Enum.any?(variables, fn variable ->
        variable.value == :error
      end)

    case failed_replace? do
      true ->
        :error

      false ->
        {:ok, Enum.reduce(variables, data, &replace_variable/2)}
    end
  end

  defp replace_variable(variables, data) when is_list(variables) do
    Enum.reduce(variables, data, &replace_variable/2)
  end

  defp replace_variable(variable, data) do
    string = get_in(data, variable.path)
    string = String.replace(string, variable.original, variable.value)
    put_in(data, variable.path, string)
  end

  @doc """
  Dereference a variable path from a map of data
  """
  def dereference(data, variable_path) do
    Enum.reduce(variable_path, data, fn
      _path, nil ->
        :error

      _path, :error ->
        :error

      path, data ->
        data =
          Enum.into(maybe_destruct(data), %{}, fn {key, value} ->
            {to_string(key), value}
          end)

        Map.get(data, path)
    end)
  end

  defp maybe_destruct(data = %{__struct__: struct}) when not is_nil(struct) do
    Map.from_struct(data)
  end

  defp maybe_destruct(data), do: data

  @doc """
  Walk the resulting data map to convert keys from atoms to strings

  This is useful when sending the resulting data struct to action params
  """
  def stringify_keys(nil), do: nil

  def stringify_keys(map) when is_map(map) do
    Enum.into(map, %{}, fn {k, v} ->
      {to_string(k), stringify_keys(v)}
    end)
  end

  def stringify_keys([head | rest]) do
    [stringify_keys(head) | stringify_keys(rest)]
  end

  def stringify_keys(value), do: value
end

defmodule Kalevala.Brain.Action do
  @moduledoc """
  Node to trigger an action
  """

  defstruct [:data, :type, delay: 0]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain.Variable
    alias Kalevala.Character.Conn

    def run(node, conn, event) do
      character = Conn.character(conn, trim: true)
      event_data = Map.merge(Map.from_struct(character), event.data)

      case Variable.replace(node.data, event_data) do
        {:ok, data} ->
          data = Variable.stringify_keys(data)

          Conn.put_action(conn, %Kalevala.Character.Action{
            type: node.type,
            params: data,
            delay: node.delay
          })

        :error ->
          conn
      end
    end
  end
end

defmodule Kalevala.Brain.StateSet do
  @moduledoc """
  Node to set meta values on a character
  """

  defstruct [:data]

  defimpl Kalevala.Brain.Node do
    alias Kalevala.Brain
    alias Kalevala.Brain.Variable
    alias Kalevala.Character.Conn

    def run(node, conn, event) do
      character = Conn.character(conn)

      event_data = Map.merge(Map.from_struct(character), event.data)

      case Variable.replace(node.data, event_data) do
        {:ok, data} ->
          expires_at = expiration(data)

          brain = Brain.put(character.brain, data.key, data.value, expires_at)
          character = %{character | brain: brain}
          Conn.put_character(conn, character)

        :error ->
          conn
      end
    end

    defp expiration(%{ttl: ttl}) when is_integer(ttl) do
      Time.add(Time.utc_now(), ttl, :second)
    end

    defp expiration(_), do: nil
  end
end

defmodule Kalevala.Brain.Conditions.EventMatch do
  @moduledoc """
  Condition check for the event being a message and the regex matches
  """

  alias Kalevala.Character.Conn

  @behaviour Kalevala.Brain.Condition

  @impl true
  def match?(event, conn, data) do
    self_check(event, conn, data) && data.topic == event.topic &&
      Enum.all?(data.data, fn {key, value} ->
        Map.get(event.data, key) == value
      end)
  end

  def self_check(event, conn, %{self_trigger: self_trigger}) do
    acting_character = Map.get(event, :acting_character) || %{}
    character = Conn.character(conn)

    case Map.get(acting_character, :id) == character.id do
      true ->
        self_trigger

      false ->
        true
    end
  end
end

defmodule Kalevala.Brain.Conditions.MessageMatch do
  @moduledoc """
  Condition check for the event being a message and the regex matches
  """

  @behaviour Kalevala.Brain.Condition

  alias Kalevala.Event.Message

  @impl true
  def match?(event = %{topic: Message}, conn, data) do
    data.interested?.(event) &&
      self_check(event, conn, data) &&
      Regex.match?(data.text, event.data.text)
  end

  def match?(_event, _conn, _data), do: false

  def self_check(event, conn, %{self_trigger: self_trigger}) do
    case event.acting_character.id == conn.character.id do
      true ->
        self_trigger

      false ->
        true
    end
  end
end

defmodule Kalevala.Brain.Conditions.StateMatch do
  @moduledoc """
  Match values in the meta map
  """

  alias Kalevala.Brain
  alias Kalevala.Brain.Variable
  alias Kalevala.Character.Conn

  @behaviour Kalevala.Brain.Condition

  @impl true
  def match?(event, conn, data = %{match: match}) do
    character = Conn.character(conn)
    event_data = Map.merge(Map.from_struct(character), event.data)

    case Variable.replace(data, event_data) do
      {:ok, data} ->
        case match do
          "equality" ->
            Brain.get(character.brain, data.key) == data.value

          "inequality" ->
            Brain.get(character.brain, data.key) != data.value

          "nil" ->
            is_nil(Brain.get(character.brain, data.key))
        end

      :error ->
        false
    end
  end
end

defmodule Kalevala.Brain.StateValue do
  @moduledoc false

  defstruct [:key, :expires_at, :value]
end

defmodule Kalevala.Brain.State do
  @moduledoc """
  Keep state around a character's brain

  A key/value store that allows for expiring keys
  """

  alias Kalevala.Brain.StateValue

  defstruct values: []

  @doc """
  Get a key from the store
  """
  def get(state, key, compare_time) do
    value =
      Enum.find(state.values, fn value ->
        value.key == key
      end)

    case expired?(value, compare_time) do
      true ->
        nil

      false ->
        value.value
    end
  end

  defp expired?(nil, _compare_time), do: true

  defp expired?(%{expires_at: expires_at}, compare_time) when expires_at != nil do
    case Time.compare(expires_at, compare_time) do
      :gt ->
        false

      _ ->
        true
    end
  end

  defp expired?(_value, _compare_time), do: false

  @doc """
  Put a key in the store, with an optional expiration time
  """
  def put(state, key, value, expires_at) do
    values =
      Enum.reject(state.values, fn value ->
        value.key == key
      end)

    value = %StateValue{
      expires_at: expires_at,
      key: key,
      value: value
    }

    %{state | values: [value | values]}
  end

  def clean(state, compare_time \\ Time.utc_now()) do
    values =
      Enum.reject(state.values, fn value ->
        expired?(value, compare_time)
      end)

    %{state | values: values}
  end
end

defmodule Kalevala.Brain do
  @moduledoc """
  A struct for holding a character's brain and state
  """

  alias Kalevala.Brain.Node
  alias Kalevala.Brain.State
  alias Kalevala.Character.Conn

  defstruct [:root, state: %Kalevala.Brain.State{}]

  @doc """
  Get a value from the brain's state
  """
  def get(brain, key, compare_time \\ Time.utc_now()) do
    State.get(brain.state, key, compare_time)
  end

  @doc """
  Put a value in the brain's state
  """
  def put(brain, key, value, expires_at \\ nil) do
    state = State.put(brain.state, key, value, expires_at)
    %{brain | state: state}
  end

  @doc """
  Process a new event based on the character's brain data
  """
  def run(brain, conn, event) do
    brain.root
    |> Node.run(conn, event)
    |> clean_state()
  end

  defp clean_state(conn) do
    character = Conn.character(conn)
    state = State.clean(character.brain.state)
    brain = %{character.brain | state: state}
    character = %{character | brain: brain}
    Conn.put_character(conn, character)
  end
end