Events log UI component

I add a new component for the events log which will take a list of event objects and render text elements in a reverse-ordered flexbox column. This will ensure the most recent event message is at the bottom of the log component. I configure the overflow styles so messages that don’t fit within the height of the log component will be hidden and the user will need to use the scroll feature to see older event messages.

I convert the element to an Alpine component and wire it up with some dummy values using the inline x-data directive and render template event message elements with x-for. Once satisfied with the look of things, I replace the iterator source from the dummy data with an Alpine $store.eventsLog reference which defaults to an empty list at startup. I hard-code a sample message which states “You joined the game.” to be added to the eventsLog store at startup.

Now that things are wired up to take event objects, I’ll next figure out how to get the move events created on the back end to be added to the store as the game progresses.

Sending events from the backend

The game state currently being pushed to the frontend client each round is a snapshot of the backend state to which the player has visibility. The events log is a different kind of state in that it is tracking data from previous rounds. Pushing the full event log each round is going to add a lot of duplicate data in the payload which will grow each round. I should only push the full log content (or at least the most recent events) on the initial mount and push events that were added in the last round with each round update as the game progresses.

The events log for the backend game state is updated from resolved actions when the round timer expires in the game session process. The timer expiration will also trigger a message to be broadcast to subscribers of the game session topic that the next round has started. The GameLive module which manages socket connections with clients handles this next round started event by fetching the new game state from the associated game session process. The changes to the events log from the previous round are not obvious since event records contain no timestamps or reference to the round on which they occurred. I’ll need to make a change to the existing logic to allow selecting only the new events from the log on a new round update.

One option could be to push more data with the next round started message which could contain the new game state and a list of new events from the log. This would avoid the need for the subscribers to go back to the source to pull the new state and try to figure out what changed. However, I prefer to keep these types of messages as small as possible and avoid pushing potentially unnecessary data to subscribers. Not all subscribers to this event will care about the events log data.

Another option is to add a new key to the event data which says on which round the event occurred. This will make it easy to grab only the most recent events from the log. I’m not sure why I didn’t have this information already in the event data since its usefulness seems obvious in hindsight.

I start implementing this change by updating the existing tests for move events to expect a round value which currently doesn’t exist.

Existing test:

describe "player moves from occupied hex to occupied hex" do
    setup [:new_game]

    setup %{game: game, p1: p1} do
      {:ok, game} = Session.register_player_move(game, p1.user_id, %Vector{q: 1, r: 0})
      {:ok, game} = Session.end_round(game, nil)

      assert %Coord{q: 0, r: 0} == game.world.player_characters[p1.id][:position]

      [game: game]
    end

    test "creates expected move events", %{game: game, p1: p1} do
      events = game.events_log.events
      from = %Coord{q: -1, r: 0}
      to = %Coord{q: 0, r: 0}

      assert 2 == map_size(events)
      assert find_event(events, PCLeftHexEvent, player_id: p1.id, to: to, from: from)
      assert find_event(events, PCEnteredHexEvent, player_id: p1.id, to: to, from: from)
    end
end

Updated test:

describe "player moves from occupied hex to occupied hex" do
  # …

    test "creates expected move events", %{game: game, p1: p1} do
      events = game.events_log.events
      from = %Coord{q: -1, r: 0}
      to = %Coord{q: 0, r: 0}
      round = game.round - 1

      assert 2 == map_size(events)

      assert find_event(events, PCLeftHexEvent,
               player_id: p1.id,
               to: to,
               from: from,
               round: round
             )

      assert find_event(events, PCEnteredHexEvent,
               player_id: p1.id,
               to: to,
               from: from,
               round: round
             )
    end
end

The test is failing since no changes have been made to the actual code yet. Running the tests as I work is a quick way to validate the changes I’m making are having the desired effect. Fortunately, the place where these move event structs are created has relatively easy access to the root game state’s :round value and the test is passing with only changes to a few lines of code.

Increment game state version

Before pushing out this updated event schema, I will need to first increment the current game state version and add a new upgrade_latest_version definition to backfill any existing game state versions that have move events without a :round value.

I create a new test scenario which replicates a game state that has the previous version of the events log with no round data in events. I write assertions to check that the game state version is updated to the latest and the move events have round values backfilled to the previous round number.

defmodule Minotaur.GameEngine.GameTest do
  # …

  describe "upgrade_latest_version/1 from version 3" do
    setup [:create_game, :apply_actions, :rollback_v4]

    test "returns latest version state", %{game: game} do
      game = Game.upgrade_latest_version(game)

      assert Game.latest_version() == game.version
    end

    test "round for events are backfilled to previous round number", %{game: game} do
      game = Game.upgrade_latest_version(game)

      Enum.each(game.events_log.events, fn {_, event} ->
        assert game.round - 1 == event.round
      end)
    end
  end

  # v4 adds :round to move events
  defp rollback_v4(%{game: game}) do
    new_log =
      game.events_log
      |> update_in([:events], fn events ->
        Enum.map(events, fn {id, event} ->
          {id, Map.put(event, :round, nil)}
        end)
      end)

    game =
      game
      |> Map.put(:events_log, new_log)
      |> Map.put(:version, 3)

    [game: game]
  end
end

To make the tests pass, I update the Game module to handle upgrading game state from version 3 to 4, which is the new latest version.

defmodule Minotaur.GameEngine.Game do
  # …

  @current_version 4

  def upgrade_latest_version(%{version: 3, round: round} = game) do
    v4 =
      game
      |> update_in([:events_log, :events], fn events ->
        backfill_round = round - 1

        events
        |> Enum.map(fn
          {id, %PCEnteredHexEvent{} = event} -> {id, %{event | round: backfill_round}}
          {id, %PCLeftHexEvent{} = event} -> {id, %{event | round: backfill_round}}
          {id, event} -> {id, event}
        end)
        |> Map.new()
      end)
      |> Map.put(:version, 4)

    upgrade_latest_version(v4)
  end

  # …
end

The tests are passing and so I push out the latest changes to production. Now that the move events track on which round they occurred, I can build the next feature for selecting only events from the previously ended round to send over web sockets to populate the events log on the clients.