Testing Move Scenarios (Part 2)
Here is the last test I need to make pass for the scenario of a player moving from an occupied hex to another occupied hex.
defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
# …
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)
[game: game]
end
# …
test "only player in destination hex and moved player can see PCEnteredHexEvent", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
%{id: p1_id} = p1
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
{event_id, _} =
Enum.find(events, fn
{_, %PCEnteredHexEvent{player_id: ^p1_id}} -> true
_ -> false
end)
assert Enum.member?(player_events[p1.id], event_id)
assert Enum.member?(player_events[p4.id], event_id)
refute Enum.member?(player_events[p2.id], event_id)
refute Enum.member?(player_events[p3.id], event_id)
end
end
end
Before changing any behavior, I refactor the code where PCEnteredHexEvent
events are added to the events log.
I move this logic to its own function where I will later define the logic for setting player visibility to these events.
I also make the function return the updated events log which will make the function pipeable from the add_left_hex_event
function.
defmodule Minotaur.GameEngine.Session do
# …
defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
%{player_id: moved_player_id} = entered_hex_event
left_hex_event = struct(PCLeftHexEvent, Map.from_struct(entered_hex_event))
player_ids_in_origin_hex =
pre_move_world
|> Worlds.get_pcs_at_coord(entered_hex_event.from)
|> Enum.map(& &1.player_id)
|> Enum.filter(fn player_id -> player_id != moved_player_id end)
log
|> add_left_hex_event(left_hex_event, player_ids_in_origin_hex)
|> add_entered_hex_event(entered_hex_event)
end
defp add_entered_hex_event(events_log, event) do
{_, events_log} = add_event(events_log, event)
events_log
end
end
Coding visibility rules
The two rules for PCEnteredHexEvent
player visibility are:
- Always seen by moved player
- Seen by other players with characters in the destination hex that didn’t move.
I make a quick change to add_entered_hex_event
to account for the first rule while considering the second rule.
I hard code a list with only the moved player’s id, knowing I’ll ultimately want to use reduce on the full list of players that can see this event.
defp add_entered_hex_event(events_log, event) do
{event_id, events_log} = add_event(events_log, event)
player_ids = [event.player_id]
Enum.reduce(player_ids, events_log, fn player_id, log ->
update_in(log, [:events_visible_by_player, player_id], &[event_id | &1])
end)
end
To apply the second rule, I need to check which players are in the destination hex in both the pre-move and post-move world states.
I pass both of these world states as arguments to add_entered_hex_event
and compare the players at the destination coord.
I join this filtered list of unmoved players with the moved player which is passed to the reducer for setting event visibility.
This change is all that is needed to make the final test pass for this scenario.
defp add_entered_hex_event(events_log, event, pre_move_world, post_move_world) do
pre_move_player_ids =
pre_move_world
|> Worlds.get_pcs_at_coord(event.to)
|> Enum.map(& &1.player_id)
post_move_player_ids =
post_move_world
|> Worlds.get_pcs_at_coord(event.to)
|> Enum.map(& &1.player_id)
unmoved_player_ids =
Enum.filter(pre_move_player_ids, fn id -> Enum.member?(post_move_player_ids, id) end)
player_ids = [event.player_id | unmoved_player_ids]
{event_id, events_log} = add_event(events_log, event)
Enum.reduce(player_ids, events_log, fn player_id, log ->
update_in(log, [:events_visible_by_player, player_id], &[event_id | &1])
end)
end
Refactoring
I notice a few refactoring opportunities such as passing the world state to add_left_hex_event
instead of the list of filtered players and moving the logic for filtering the player list inside that function.
I also see the same statements duplicated in add_left_hex_event
and add_entered_hex_event
for updating the events log with a given list of player ids with visibility.
This logic can be pulled out to its own function.
defp add_left_hex_event(events_log, event, world) do
player_ids =
world
|> Worlds.get_pcs_at_coord(event.from)
|> Enum.map(& &1.player_id)
|> Enum.filter(fn player_id -> player_id != event.player_id end)
add_event_with_visibility(events_log, event, player_ids)
end
defp add_entered_hex_event(events_log, event, pre_move_world, post_move_world) do
pre_move_player_ids =
pre_move_world
|> Worlds.get_pcs_at_coord(event.to)
|> Enum.map(& &1.player_id)
post_move_player_ids =
post_move_world
|> Worlds.get_pcs_at_coord(event.to)
|> Enum.map(& &1.player_id)
unmoved_player_ids =
Enum.filter(pre_move_player_ids, fn id -> Enum.member?(post_move_player_ids, id) end)
add_event_with_visibility(events_log, event, [event.player_id | unmoved_player_ids])
end
defp add_event_with_visibility(events_log, event, player_ids) do
{event_id, events_log} = add_event(events_log, event)
Enum.reduce(player_ids, events_log, fn player_id, log ->
update_in(log, [:events_visible_by_player, player_id], &[event_id | &1])
end)
end
The tests are still passing and the functions look a little better.
Scenario 2: occupied hex to unoccupied hex
I’ve completed the test scenario for a player moving from an occupied hex to another occupied hex. The next scenario to test is when a player moves from an occupied hex to an unoccupied hex.
Round 1 --> Round 2
● ● --> ● ●
˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,0 ● 0,0 ● --> ● -1,0 ● 0,0 ●
+ + + + + +
+ [P1,P2] + [P4] + --> + [P1,P3] + [P4] +
● [P3] ● ● ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ●
+ + + --> + + +
+ + + + [P2] + +
● ● ● --> ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚
● ● --> ● ●
I create test assertions for this scenario which are similar to the previous scenario. The main difference is there are no players in the destination hex. These tests pass without any code changes.
describe "player moves from occupied hex to unoccupied hex" do
setup [:new_game]
setup %{game: game, p2: p2} do
{:ok, game} = Session.register_player_move(game, p2.user_id, %Vector{q: 0, r: 1})
{:ok, game} = Session.end_round(game, nil)
[game: game]
end
test "creates expected move events", %{game: game, p2: p2} do
events = game.events_log.events
%{id: p2_id} = p2
from = %Coord{q: -1, r: 0}
to = %Coord{q: -1, r: 1}
assert Enum.find(events, fn
{_, %PCLeftHexEvent{player_id: ^p2_id, to: ^to, from: ^from}} -> true
_ -> false
end)
assert Enum.find(events, fn
{_, %PCEnteredHexEvent{player_id: ^p2_id, to: ^to, from: ^from}} -> true
_ -> false
end)
end
test "only players in origin hex can see PCLeftHexEvent", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
%{id: p2_id} = p2
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
{event_id, _} =
Enum.find(events, fn
{_, %PCLeftHexEvent{player_id: ^p2_id}} -> true
_ -> false
end)
assert Enum.member?(player_events[p1.id], event_id)
assert Enum.member?(player_events[p3.id], event_id)
refute Enum.member?(player_events[p2.id], event_id)
refute Enum.member?(player_events[p4.id], event_id)
end
test "only moved player can see PCEnteredHexEvent", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
%{id: p2_id} = p2
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
{event_id, _} =
Enum.find(events, fn
{_, %PCEnteredHexEvent{player_id: ^p2_id}} -> true
_ -> false
end)
assert Enum.member?(player_events[p2.id], event_id)
refute Enum.member?(player_events[p1.id], event_id)
refute Enum.member?(player_events[p3.id], event_id)
refute Enum.member?(player_events[p4.id], event_id)
end
Scenario 3: Multiple player moves from same hex
The next scenario to test is when two players who start in the same hex move to different destination hexes.
Round 1 --> Round 2
● ● --> ● ●
˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,0 ● 0,0 ● --> ● -1,0 ● 0,0 ●
+ + + + + +
+ [P1,P2] + [P4] + --> + [P3] + [P1,P3] +
● [P3] ● ● ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ●
+ + + --> + + +
+ + + + [P2] + +
● ● ● --> ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚
● ● --> ● ●
I create the test scenario setup with Player1 moving east and Player2 moving southeast.
describe "multiple players move from same hex to different hexes" do
setup [:new_game]
setup %{game: game, p1: p1, p2: p2} do
{:ok, game} = Session.register_player_move(game, p1.user_id, %Vector{q: 1, r: 0})
{:ok, game} = Session.register_player_move(game, p2.user_id, %Vector{q: 0, r: 1})
{:ok, game} = Session.end_round(game, nil)
[game: game]
end
end
I expect 4 events to be created with this scenario and the way I’ve been checking if an event exists in the game events log so far takes up a bit of space for each check. Here is what the existing test looks like just to check that 2 events exist:
test "creates expected move events", %{game: game, p1: p1} do
events = game.events_log.events
%{id: p1_id} = p1
from = %Coord{q: -1, r: 0}
to = %Coord{q: 0, r: 0}
assert 2 == map_size(events)
assert Enum.find(events, fn
{_, %PCLeftHexEvent{player_id: ^p1_id, to: ^to, from: ^from}} -> true
_ -> false
end)
assert Enum.find(events, fn
{_, %PCEnteredHexEvent{player_id: ^p1_id, to: ^to, from: ^from}} -> true
_ -> false
end)
end
I’ll refactor these tests to use a new helper function which will return an event from the events log if it matches the event criteria I’m looking for. This makes the test blocks much cleaner and easier to read.
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}
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
def find_event(events, struct_type, match_fields) do
{_, event} = Enum.find(events, {nil, nil}, fn
{_, %^struct_type{} = event} ->
match_event_fields?(event, match_fields)
_ -> false
end)
event
end
defp match_event_fields?(event, match_fields) do
match_fields
|> Enum.all?(fn {key, value} -> Map.get(event, key) == value end)
end
With this new helper function, my next test can be written in a fairly compact style.
describe "multiple players move from same hex to different hexes" do
# …
test "creates expected move events", %{game: game, p1: p1, p2: p2} do
events = game.events_log.events
from = %Coord{q: -1, r: 0}
p1_to = %Coord{q: 0, r: 0}
p2_to = %Coord{q: -1, r: 1}
assert 4 == map_size(events)
assert find_event(events, PCLeftHexEvent, player_id: p1.id, to: p1_to, from: from)
assert find_event(events, PCEnteredHexEvent, player_id: p1.id, to: p1_to, from: from)
assert find_event(events, PCLeftHexEvent, player_id: p2.id, to: p2_to, from: from)
assert find_event(events, PCEnteredHexEvent, player_id: p2.id, to: p2_to, from: from)
end
end
The test is already passing.
I add two more tests for asserting that other players can see the PCLeftHexEvent
if they started in the origin hex.
test "other players starting in origin hex can see PCLeftHexEvent of Player1", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCLeftHexEvent, player_id: p1.id)
assert Enum.member?(player_events[p2.id], event.id)
assert Enum.member?(player_events[p3.id], event.id)
refute Enum.member?(player_events[p1.id], event.id)
refute Enum.member?(player_events[p4.id], event.id)
end
test "other players starting in origin hex can see PCLeftHexEvent of Player2", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCLeftHexEvent, player_id: p2.id)
assert Enum.member?(player_events[p1.id], event.id)
assert Enum.member?(player_events[p3.id], event.id)
refute Enum.member?(player_events[p2.id], event.id)
refute Enum.member?(player_events[p4.id], event.id)
end
The behavior for these tests is already implemented so no code change is needed to make them pass.
I add a few more cases to assert on the visibility of PCEnteredHexEvent
for the two players.
test "only player in destination hex and Player1 can see PCEnteredHexEvent of Player1", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCEnteredHexEvent, player_id: p1.id)
assert Enum.member?(player_events[p1.id], event.id)
assert Enum.member?(player_events[p4.id], event.id)
refute Enum.member?(player_events[p2.id], event.id)
refute Enum.member?(player_events[p3.id], event.id)
end
test "only Player2 can see PCEnteredHexEvent of Player2", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCEnteredHexEvent, player_id: p2.id)
assert Enum.member?(player_events[p2.id], event.id)
refute Enum.member?(player_events[p1.id], event.id)
refute Enum.member?(player_events[p3.id], event.id)
refute Enum.member?(player_events[p4.id], event.id)
end
These tests also pass without any changes needed. Just to make sure I don’t have false positives with these tests, I fiddle with a few of the arguments in the setup function and assertion statements and run the tests again. The tests fail as I would expect with these changes so I’m confident things are wired up correctly. It doesn’t hurt to be skeptical of tests that pass on the first try!
Scenario 4: Multiple players move to the same hex
I add another test scenario where two players start in the same hex and move to the same destination hex.
Even though the previous tests might seem to cover the same general logic as this scenario, there is a new behavior that has not yet been tested.
The untested behavior in this scenario is where a player shares a hex of the moved player’s destination, but they did not start in that hex before the move.
Players should only have visibility to the PCEntertedHexEvent
if they were also in that same hex before the movement or they are the moved player for that event.
Round 1 --> Round 2
● ● --> ● ●
˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,0 ● 0,0 ● --> ● -1,0 ● 0,0 ●
+ + + + + +
+ [P1,P2] + [P4] + --> + [P2] + [P1,P3] +
● [P3] ● ● ● ● [P4] ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ●
+ + + --> + + +
+ + + + + +
● ● ● --> ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚
● ● --> ● ●
I add the new scenario in a describe block and add the first test case for asserting the events are created.
describe "multiple players move from the one hex to the same destination" do
setup [:new_game]
setup %{game: game, p1: p1, p3: p3} do
{:ok, game} = Session.register_player_move(game, p1.user_id, %Vector{q: 1, r: 0})
{:ok, game} = Session.register_player_move(game, p3.user_id, %Vector{q: 1, r: 0})
{:ok, game} = Session.end_round(game, nil)
[game: game]
end
test "creates expected move events", %{game: game, p1: p1, p3: p3} do
events = game.events_log.events
from = %Coord{q: -1, r: 0}
to = %Coord{q: 0, r: 0}
assert 4 == 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)
assert find_event(events, PCLeftHexEvent, player_id: p3.id, to: to, from: from)
assert find_event(events, PCEnteredHexEvent, player_id: p3.id, to: to, from: from)
end
end
This tests passes without any changes.
I don’t bother adding tests for PCLeftHexEvent
visibility with this scenario since the same behavior is well covered in the other scenario tests.
I add tests for the new behavior for a player that has moved in the destination hex not being able to see the PCEnteredHexEvent
for other players.
test "Player3 cannot see PCEnteredHexEvent of Player1", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCEnteredHexEvent, player_id: p1.id)
assert Enum.member?(player_events[p1.id], event.id)
assert Enum.member?(player_events[p4.id], event.id)
refute Enum.member?(player_events[p2.id], event.id)
refute Enum.member?(player_events[p3.id], event.id)
end
test "Player1 cannot see PCEnteredHexEvent of Player3", ctx do
%{game: game, p1: p1, p2: p2, p3: p3, p4: p4} = ctx
events = game.events_log.events
player_events = game.events_log.events_visible_by_player
event = find_event(events, PCEnteredHexEvent, player_id: p3.id)
assert Enum.member?(player_events[p3.id], event.id)
assert Enum.member?(player_events[p4.id], event.id)
refute Enum.member?(player_events[p1.id], event.id)
refute Enum.member?(player_events[p2.id], event.id)
end
These tests are also passing without any code changes. When I built the player visibility logic for this event type, I already included the filtering for unmoved characters before I had a test to cover it.
Scenario 5: Player moves from unoccupied hex
The last scenario I want to test is when a player moves from an unoccupied hex.
The destination hex doesn’t matter in this scenario since all of the behavior cases for destination hexes is already covered by previous scenarios.
The behavior for a player moving from an unoccupied hex should differ from movement from an occupied hex in that the PCLeftHexEvent
should not be visible by any player.
This means this event should not be created at all which I know is not how the current code works.
Round 1 --> Round 2
● ● --> ● ●
˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,0 ● 0,0 ● --> ● -1,0 ● 0,0 ●
+ + + + + +
+ [P1,P2] + [P4] + --> + [P1,P2] + +
● [P3] ● ● ● [P3] ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳
● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ●
+ + + --> + + +
+ + + + + [P4] +
● ● ● --> ● ● ●
˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚
● ● --> ● ●
I add only a single test block for this scenario since I only need to check that the event doesn’t exist.
I also update the find_event
helper function to find a the first match on the struct without passing any fields.
describe "player moves from unoccupied hex" do
setup [:new_game]
setup %{game: game, p4: p4} do
{:ok, game} = Session.register_player_move(game, p4.user_id, %Vector{q: 0, r: 1})
{:ok, game} = Session.end_round(game, nil)
assert %Coord{q: 0, r: 1} == game.world.player_characters[p4.id][:position]
[game: game]
end
test "Should not create a PCLeftHexEvent", %{game: game} do
events = game.events_log.events
refute find_event(events, PCLeftHexEvent)
end
end
def find_event(events, struct_type, match_fields \\ []) do
{_, event} =
Enum.find(events, {nil, nil}, fn
{_, %^struct_type{} = event} ->
match_event_fields?(event, match_fields)
_ ->
false
end)
event
end
defp match_event_fields?(_event, []) do
true
end
defp match_event_fields?(event, match_fields) do
match_fields
|> Enum.all?(fn {key, value} -> Map.get(event, key) == value end)
end
This test is failing so I will need to jump into the Session
module code to prevent this event from being created.
The logic that creates the event is removing the moved player from the list of player ids that are passed to the add_event_with_visibility
function.
In this test scenario, this list of player ids is empty which means the event is created, but no player can see it.
defmodule Minotaur.GameEngine.Session do
# …
defp add_left_hex_event(events_log, event, world) do
player_ids =
world
|> Worlds.get_pcs_at_coord(event.from)
|> Enum.map(& &1.player_id)
add_event_with_visibility(events_log, event, player_ids)
end
end
The simple change for the desired behavior is to add a new function definition that matches on an empty list of player ids and simply return the events log unchanged.
defp add_event_with_visibility(events_log, _event, []) do
events_log
end
defp add_event_with_visibility(events_log, event, player_ids) do
{event_id, events_log} = add_event(events_log, event)
Enum.reduce(player_ids, events_log, fn player_id, log ->
update_in(log, [:events_visible_by_player, player_id], &[event_id | &1])
end)
end
That change did the trick and now all move event scenario tests are passing.