Starting with a test

My next goal is to implement move events in the frontend UI component. I start by writing a new feature test with Wallaby to validate the behavior I want to see. I write just enough of the outer test structure to get something to run, then I focus on the assertions for the expected behavior. Before constructing the scenario context for the test, I focus on the assertions for the expected behavior.

defmodule MinotaurWeb.Game.PlayerMovedTest do
  use Minotaur.FeatureCase

  @events_log Query.css("#events-log")

  describe "round ends and player moves to a new hex" do
    [:create_game, :register_player_moves, :join_session_before_next_round]

    test "should update event log", ctx do
      ctx.session
      |> find(@events_log, fn element ->
        assert_text(element, Query.css("p"), "PlayerB moved to the east.")
        assert_text(element, Query.css("p"), "PlayerC arrived from the southwest.")
      end)
    end
  end

  defp create_game(_ctx) do
    :ok
  end

  defp register_player_moves(_ctx) do
    :ok
  end

  defp join_session_before_next_round(_ctx) do
    :ok
  end
end

I then go back to create the context that will setup the scenario for the test to run.

defmodule MinotaurWeb.Game.PlayerMovedTest do
  # …

  defp create_game(%{user: user}) do
    players = [
      p1 = %Player{id: 1, display_name: "PlayerA", user_id: user.id},
      p2 = %Player{id: 2, display_name: "PlayerB", user_id: user_fixture().id},
      p3 = %Player{id: 3, display_name: "PlayerC", user_id: user_fixture().id}
    ]

    game =
      game_fixture(
        players: players,
        grid_coords: [
          %Coord{q: 0, r: 0},
          %Coord{q: -1, r: 1},
          %Coord{q: 1, r: 0}
        ],
        pc_coords: %{
          p1.id => %Coord{q: 0, r: 0},
          p2.id => %Coord{q: 0, r: 0},
          p3.id => %Coord{q: -1, r: 1}
        }
      )

    [game: game, p1: p1, p2: p2, p3: p3]
  end

  defp register_player_moves(%{game: game, p2: p2, p3: p3}) do
    {:ok, game} = Session.register_player_move(game, p2.user_id, %Vector{q: 1, r: 0})
    {:ok, game} = Session.register_player_move(game, p3.user_id, %Vector{q: 1, r: -1})

    [game: game]
  end

  defp join_session_before_next_round(ctx) do
    next_round_start = DateTime.add(DateTime.utc_now(), 1, :second)
    {:ok, game} = Session.end_round(ctx.game, next_round_start)

    join_code = "LMNO"
    {:ok, _pid} = GameEngine.continue_game(join_code, game)

    ctx.session
    |> sign_in(as: ctx.user)
    |> join_game_and_wait_until_loaded(join_code, "00:")

    :ok
  end
end

To test that the test is wired up correctly, I change the assertion to check for the default event which is already implemented when joining the game. I also drop in a take_screenshot call in the test pipeline to make a visual check that the game session is joined and players have moved. The test passes, so I am confident the scenario is configured correctly and I rollback the last assertion change. I now have my feedback loop to check if my code changes create the desired behavior.

Updating LiveView event payloads

The frontend UI component is configured to render event messages from a list in a local data store. I can populate this data store from within the LiveView JavaScript event hook handlers. There is already an event handler for the game-joined event which populates initial game values. I can update the payload sent from the backend for this event to include the move event list for the joined player.

I update the existing push_game_joined_event/3 function to extract recent events for the current user and include them with the LiveVew event payload. The newest events are at the front of the events list so the first 30 events are taken.

defmodule MinotaurWeb.GameLive do
  # …

  defp get_events(%{events_log: events_log}, player_id) do
    events_log.events_visible_by_player[player_id]
    |> Enum.take(30)
    |> Enum.map(fn event_id -> GameFormatter.format(events_log.events[event_id]) end)
  end

  defp push_game_joined_event(socket, game, player_state) do
    state = %{
      player_state: GameFormatter.format(player_state),
      round: get_round_data(game),
      events: get_events(game, player_state.player_id)
    }

    push_event(socket, "game-joined", state)
  end
end

I add format function definitions to convert the event structs to maps with relevant :event_type fields.

defmodule MinotaurWeb.GameFormatter do
  # …

  def format(%PCEnteredHexEvent{} = event) do
    event
    |> Map.from_struct()
    |> Map.put(:event_type, "PC_ENTERED_HEX")
  end

  def format(%PCLeftHexEvent{} = event) do
    event
    |> Map.from_struct()
    |> Map.put(:event_type, "PC_LEFT_HEX")
  end
end

These changes will ensure the frontend will receive a list of recent events when they connect to the LiveView page, but I’ll need to update the next-round-started event to continually push new events while the session is connected. I add events to the payload for this event, but only send the events which have been created from the previous round.

defmodule MinotaurWeb.GameLive do
  # …

  defp get_new_round_events(%{events_log: events_log, round: round}, player_id) do
    %{events: events} = events_log

    events_log.events_visible_by_player[player_id]
    |> Enum.reduce_while([], fn event_id, acc ->
      event = events[event_id]

      # Return only events from previous round.
      # Newest events are at front of list.
      # Stop reducing once an older event is reached.
      if event && event.round >= round - 1 do
        {:cont, [event | acc]}
      else
        {:halt, acc}
      end
    end)
    |> Enum.map(&GameFormatter.format/1)
  end

  defp push_next_round_started_event(socket, game, player_state) do
    state = %{
      player_state: GameFormatter.format(player_state),
      round: get_round_data(game),
      events: get_new_round_events(game, player_state.player_id)
    }

    push_event(socket, "next-round-started", state)
  end
end

This should be all I need on the backend. To close out this feature, I’ll need to make the final integration between the frontend event handler and the data store from which event messages are rendered.