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.