Player Attack Events (Part 1)
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.