Player Attack Events (Part 1)

- 5 mins read

The events log is now showing messages to the user for all player character moves they can see. Attack event messages are the next feature to add which should be much simpler than the move events. These events should be visible to any players with a character starting in the same hex where the attack occurred.

Starting with a test

I start by creating a new feature test case to check the UI element has the expected event message displayed after registered attacks are resolved from the previous round.

defmodule MinotaurWeb.Game.PlayerAttackedTest do
  # …

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

  describe "round ends and player attacks another player" do
    setup [:register_user, :create_game, :register_attacks, :join_session_before_next_round]

    test "should update event log", ctx do
      ctx.session
      |> find(@events_log, fn element ->
        assert_text(element, "You attacked Player2.")
        assert_text(element, "Player2 attacked Player3.")
        assert_text(element, "Player3 attacked you.")
      end)
    end
  end

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

    coord = %Coord{q: 0, r: 0}

    game =
      game_fixture(
        players: players,
        grid_coords: [coord],
        pc_coords: %{
          p1.id => coord,
          p2.id => coord,
          p3.id => coord
        }
      )

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

  defp register_attacks(%{game: game, p1: p1, p2: p2, p3: p3}) do
    {:ok, game} = Session.register_player_attack(game, p1.user_id, p2.id)
    {:ok, game} = Session.register_player_attack(game, p2.user_id, p3.id)
    {:ok, game} = Session.register_player_attack(game, p3.user_id, p1.id)

    [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 = "NOPE"
    {: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

Just to check that the test is wired up correctly, I inject the expected event messages into the front-end initialization process of the events log data store and the test passes. I remove these hard-coded values and make sure the test fails so I know I have a point to validate the feature has the expected behavior as I iterate on the implementation.

This outer test will tell me when I’ve completed the final feature rendering to the user, but I also want to test with a smaller scope and in a way that doesn’t have all the baggage of testing around a web framework. I add a new test case which will test the interface of the game session end_round function which is core to the outer test scenario. Looking up events is very common when writing tests that touch the events log state so I migrate some helper functions from an existing test module for move events into a shared helper module which gives me the find_event function in my tests.

defmodule Minotaur.GameEngine.Session.EndRoundPlayerAttackTest do
  # …

  describe "player attacks a player character" do
    setup [:new_game]

    setup %{game: game, p1: p1, p2: p2, p3: p3} do
      {:ok, game} = Session.register_player_attack(game, p1.user_id, p2.id)
      {:ok, game} = Session.register_player_attack(game, p2.user_id, p3.id)
      {:ok, game} = Session.end_round(game, nil)

      [game: game]
    end

    test "creates expected attack events", %{game: game, p1: p1, p2: p2, p3: p3} do
      events = game.events_log.events
      round = game.round - 1

      assert 2 == map_size(events)

      assert find_event(events, PCAttackedPCEvent,
               round: round,
               player_id: p1.id,
               target_id: p2.id
             )

      assert find_event(events, PCAttackedPCEvent,
               round: round,
               player_id: p2.id,
               target_id: p3.id
             )
    end
  end

  defp new_game(_) do
    players = [
      p1 = %Player{id: 1, display_name: "Player1", user_id: 111},
      p2 = %Player{id: 2, display_name: "Player2", user_id: 222},
      p3 = %Player{id: 3, display_name: "Player3", user_id: 333},
      p4 = %Player{id: 4, display_name: "Player4", user_id: 444}
    ]

    coords = [
      coord_1 = %Coord{q: 0, r: 0},
      coord_2 = %Coord{q: -1, r: 0}
    ]

    game =
      game_fixture(
        round: 77,
        players: players,
        grid_coords: coords,
        pc_coords: %{
          p1.id => coord_1,
          p2.id => coord_1,
          p3.id => coord_1,
          p4.id => coord_2
        }
      )

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

This test has event lookups for PCAttackedPCEvent structs in the events log, but this struct has not yet been defined. I create that struct definition based on the fields I expect to use in the test as well as some basic encoding logic which will be needed to serialize the event record when the events log is stored.

defmodule Minotaur.GameEngine.Events.PCAttackedPCEvent do
  @moduledoc false

  use StructAccess

  @type player_id :: integer()

  @type t :: %__MODULE__{
          id: integer(),
          player_id: player_id(),
          target_id: player_id(),
          round: integer()
        }

  defstruct [:id, :player_id, :target_id, :round]

  defimpl Jason.Encoder do
    def encode(value, opts) do
      Jason.Encode.map(value, opts)
    end
  end
end

I then find the function that processes player attacks at the end of the round and update it to add a new event to the game state’s events log for each attack.

defmodule Minotaur.GameEngine.Session do
  # …

  defp add_attacked_event_to_log(game, action) do
    event = %PCAttackedPCEvent{
      player_id: action.player_id,
      target_id: action.target_id,
      round: game.round
    }

    {_event_id, log} = add_event(game.events_log, event)

    %{game | events_log: log}
  end

  defp apply_action(%AttackAction{} = action, game) do
    target = game.world.player_characters[action.target_id]
    updated_target = %{target | health: target.health - 1}

    game
    |> put_in([:world, :player_characters, action.target_id], updated_target)
    |> add_attacked_event_to_log(action)
  end
end

The test case is now passing, but having an event in the log is only useful if players can see it. I add a new test case to check that players in the same hex where the attack occurred have a reference to the event in their player events log.

    test "only players in same hex can see event", ctx do
      %{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
      %{events: events, events_visible_by_player: player_events} = game.events_log
      event = find_event(events, PCAttackedPCEvent, player_id: p1.id)

      assert Enum.member?(player_events[p1.id], event.id)
      assert Enum.member?(player_events[p2.id], event.id)
      assert Enum.member?(player_events[p3.id], event.id)

      refute Enum.member?(player_events[p4.id], event.id)
    end

And I update the game session code to make the test pass.

  defp add_attacked_event_to_log(game, action) do
    %{player_id: player_id, target_id: target_id} = action
    coord = game.world.player_characters[player_id].position

    event = %PCAttackedPCEvent{
      player_id: player_id,
      target_id: target_id,
      round: game.round
    }

    player_ids =
      game.world
      |> Worlds.get_pcs_at_coord(coord)
      |> Enum.map(& &1.player_id)

    log = add_event_with_visibility(game.events_log, event, player_ids)

    %{game | events_log: log}
  end

  defp apply_action(%AttackAction{} = action, game) do
    target = game.world.player_characters[action.target_id]
    updated_target = %{target | health: target.health - 1}

    game
    |> put_in([:world, :player_characters, action.target_id], updated_target)
    |> add_attacked_event_to_log(action)
  end

The events for players attacking other player characters are now being created on the backend, but they are not yet being pushed to the frontend clients which will be the next area of focus for this feature.