Functional Web Development with Elixir, OTP - Is OTP part of the entity layer?

Now while it’s nice to not have the logic conflated with the GenServer ceremony it can get a bit boilerplate-y when you are dealing with numerous GenServers.

One compromise:

  • be more organized inside the GenServer callback module so that it is very clear what is what
  • in the test suite define some helper functions that make it easier to test the callback functions.

That way the “conflation” can be a bit less distracting.

# alias IslandsEngine.{Demo, Rules}
# state = Demo.init("Miles")
# {:reply, :error, state} = Demo.guess_coordinate(state, :player1, 1, 1)
# {:reply, :ok, state} = Demo.add_player(state, "Trane")
# {:reply, :ok, state} = Demo.position_island(state, :player1, :dot, 1, 1)
# {:reply, :ok, state} = Demo.position_island(state, :player2, :square, 1, 1)
# state = %{state | rules: %Rules{state: :player1_turn}}
# {:reply, {:miss, :none, :no_win}, state} = Demo.guess_coordinate(state, :player1, 5, 5)
# {:reply, :error, state} = Demo.guess_coordinate(state, :player1, 3, 1)
# {:reply, {:hit, :dot, :win}, state} = Demo.guess_coordinate(state, :player2, 1, 1)

defmodule IslandsEngine.Demo do
  alias IslandsEngine.Game

  ### helper functions for testing - i.e. should be under "test"" ###

  def init(name) do
    {:ok, state} = Game.init(name)
    state
  end

  def add_player(state, name),
    do: Game.handle_call({:add_player, name}, self(), state)

  def position_island(state, player, key, row, col),
    do: Game.handle_call({:position_island, player, key, row, col}, self(), state)

  def set_islands(state, player),
    do: Game.handle_call({:set_islands, player}, self(), state)

  def guess_coordinate(state, player, row, col),
    do: Game.handle_call({:guess_coordinate, player, row, col}, self(), state)
end

defmodule IslandsEngine.Game do
  use GenServer

  alias IslandsEngine.{Board, Coordinate, Guesses, Island, Rules}

  # --- GenServer Client API ---

  @players [:player1, :player2]

  def add_player(game, name) when is_binary(name),
    do: GenServer.call(game, {:add_player, name})

  def position_island(game, player, key, row, col) when player in @players,
    do: GenServer.call(game, {:position_island, player, key, row, col})

  def set_islands(game, player) when player in @players,
    do: GenServer.call(game, {:set_islands, player})

  def guess_coordinate(game, player, row, col) when player in @players,
    do: GenServer.call(game, {:guess_coordinate, player, row, col})

  # --- GenServer Ceremony ---

  def via_tuple(name),
    do: {:via, Registry, {Registry.Game, name}}

  def start_link(name) when is_binary(name),
    do: GenServer.start_link(__MODULE__, name, name: via_tuple(name))

  def init(name),
    do: {:ok, game_init(name)}

  def handle_call({:add_player, name}, _from, state),
    do: handle_add_player(state, name)

  def handle_call({:position_island, player, key, row, col}, _from, state),
    do: handle_position_island(state, player, key, row, col)

  def handle_call({:set_islands, player}, _from, state),
    do: handle_set_islands(state, player)

  def handle_call({:guess_coordinate, player, row, col}, _from, state),
    do: handle_guess_coordinate(state, player, row, col)

  def handle_info(:first, state) do
    IO.puts("This message has been handled by handle_info/2, matching on :first.")
    {:noreply, state}
  end

  defp reply_success(state_data, reply), do: {:reply, reply, state_data}

  # --- Game Module Logic

  defp game_init(name) do
    player1 = %{name: name, board: Board.new(), guesses: Guesses.new()}
    player2 = %{name: nil, board: Board.new(), guesses: Guesses.new()}
    %{player1: player1, player2: player2, rules: %Rules{}}
  end

  defp handle_add_player(state, name) do
    with {:ok, rules} <- Rules.check(state.rules, :add_player) do
      state
      |> update_player2_name(name)
      |> update_rules(rules)
      |> reply_success(:ok)
    else
      :error -> {:reply, :error, state}
    end
  end

  defp handle_position_island(state, player, key, row, col) do
    board = player_board(state, player)

    with {:ok, rules} <-
           Rules.check(state.rules, {:position_islands, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {:ok, island} <-
           Island.new(key, coordinate),
         %{} = board <-
           Board.position_island(board, key, island) do
      state
      |> update_board(player, board)
      |> update_rules(rules)
      |> reply_success(:ok)
    else
      :error ->
        {:reply, :error, state}

      {:error, :invalid_coordinate} ->
        {:reply, {:error, :invalid_coordinate}, state}

      {:error, :invalid_island_type} ->
        {:reply, {:error, :invalid_island_type}, state}
    end
  end

  defp handle_set_islands(state, player) do
    board = player_board(state, player)

    with {:ok, rules} <- Rules.check(state.rules, {:set_islands, player}),
         true <- Board.all_islands_positioned?(board) do
      state
      |> update_rules(rules)
      |> reply_success({:ok, board})
    else
      :error -> {:reply, :error, state}
      false -> {:reply, {:error, :not_all_islands_positioned}, state}
    end
  end

  defp handle_guess_coordinate(state, player, row, col) do
    opponent_key = opponent(player)
    opponent_board = player_board(state, opponent_key)

    with {:ok, rules} <-
           Rules.check(state.rules, {:guess_coordinate, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {hit_or_miss, forested_island, win_status, opponent_board} <-
           Board.guess(opponent_board, coordinate),
         {:ok, rules} <-
           Rules.check(rules, {:win_check, win_status}) do
      state
      |> update_board(opponent_key, opponent_board)
      |> update_guesses(player, hit_or_miss, coordinate)
      |> update_rules(rules)
      |> reply_success({hit_or_miss, forested_island, win_status})
    else
      :error ->
        {:reply, :error, state}

      {:error, :invalid_coordinate} ->
        {:reply, {:error, :invalid_coordinate}, state}
    end
  end

  defp player_board(state_data, player), do: Map.get(state_data, player).board

  defp opponent(:player1), do: :player2
  defp opponent(:player2), do: :player1

  defp update_player2_name(state_data, name), do: put_in(state_data.player2.name, name)

  defp update_board(state_data, player, board),
    do: Map.update!(state_data, player, fn player -> %{player | board: board} end)

  defp update_rules(state_data, rules), do: %{state_data | rules: rules}

  defp update_guesses(state_data, player_key, hit_or_miss, coordinate) do
    update_in(state_data[player_key].guesses, fn guesses ->
      Guesses.add(guesses, hit_or_miss, coordinate)
    end)
  end
end