Creating PCLeftHexEvent events

I last left the tests failing for the event creation behavior I want to build. Before making these tests pass, I do some refactoring to remove some duplicate logic for calculating the origin and destination hex coordinates from the move actions. This logic is handled in the apply_move/2 function to update the world state and repeated in the function that creates move events. I move the event creation logic into apply_move and have it return a tuple of the updated world and the associated PCEnteredHexEvent event which is created for every PC move action. The reducer function that applies all move actions now returns the post-moves world state and a list of PCEnteredHexEvent events associated with those moves.

The updated events log is created by iterating over this event list and generating an event id by which to store the events in the event map. I can use this reducer function to also apply the player visibility rules logic in a later change.

defmodule Minotaur.GameEngine.Session do
  # …

  defp apply_move_actions(game, moves) do
    {post_moves_world, entered_hex_events} =
      Enum.reduce(moves, {game.world, []}, fn move, {world, events} ->
        {world, event} = apply_move(move, world)

        {world, [event | events]}
      end)

    events_log =
      Enum.reduce(entered_hex_events, game.events_log, fn event, log ->
        update_event_log_with_move(log, event, game.world, post_moves_world)
      end)

    %{game | world: post_moves_world, events_log: events_log}
  end

  defp apply_move(%MoveAction{} = action, world) do
    %{player_id: player_id, vector: vector} = action
    %{position: original_position} = world.player_characters[player_id]
    new_position = Worlds.apply_vector(original_position, vector)
    world = put_in(world, [:player_characters, player_id, :position], new_position)

    event = %PCEnteredHexEvent{
      player_id: player_id,
      from: original_position,
      to: new_position
    }

    {world, event}
  end

  defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
    add_event(log, entered_hex_event)
  end
end

The previously green tests for move actions are still passing with this refactor which I owe to making small, incremental changes as I worked.

I make a simple change to update_event_log_with_move which adds a PCLeftHexEvent to the event log for each PCEnteredHexEvent. This is not the final outcome I want since PCLeftHexEvent should not be created if there are no other players to see the move, but this is a small step toward that behavior.

  defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
    left_hex_event = struct(PCLeftHexEvent, Map.from_struct(entered_hex_event))

    log
    |> add_event(entered_hex_event)
    |> add_event(left_hex_event)
  end

One of the new event tests is now passing with this simple change since both of these event types are expected to exist in the log for the scenario where a player moves from an 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 "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 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
  end
end

The next test I need to make pass is checking that the correct players have visibility to the move events. There currently isn’t any logic implemented for event visibility so the test is obviously failing from the start. The test is failing due to the value of events_visible_by_player being an empty map which means all player event lists are nil. It would probably be a good idea to initialize this map with empty lists for every player in the game to avoid unexpected issues when this value is not expected to be nil.

Initializing the events log

I pivot to create a new test for this initialization behavior.

defmodule Minotaur.GameEngine.GameTest do
  # …

  describe "new/1 with players map" do
    setup [:create_players, :create_game_with_players]

    setup %{players: players} do
      [game: Game.new(players: players)]
    end

    test "initializes empty events list for each player", %{players: players, game: game} do
      player_events = game.events_log.events_visible_by_player

      players
      |> Map.keys()
      |> Enum.each(fn player_id ->
        assert [] == player_events[player_id]
      end)
    end
  end

  defp create_players(_ctx) do
    p1 = player_fixture()
    p2 = player_fixture()
    p3 = player_fixture()

    players = %{
      p1.id => p1,
      p2.id => p2,
      p3.id => p3
    }

    [players: players]
  end
end

I update the new function for the Game and EventsLog modules to make the test pass.

defmodule Minotaur.GameEngine.Game do
  # …

  def new(attrs \\ []) do
    defaults = [
      players: %{},
      registered_actions: %{}
    ]

    attrs =
      defaults
      |> Keyword.merge(attrs)
      |> Map.new()

    attrs = Map.put(attrs, :events_log, initialize_events_log(attrs))

    struct(__MODULE__, attrs)
  end

  defp initialize_events_log(%{events_log: %EventsLog{} = events_log}) do
    events_log
  end

  defp initialize_events_log(%{players: players}) do
    EventsLog.new(players: players)
  end
end
defmodule Minotaur.GameEngine.EventsLog do
  # …

  def new(attrs \\ []) do
    defaults = [
      events: %{},
      players: %{}
    ]

    attrs =
      defaults
      |> Keyword.merge(attrs)
      |> Map.new()
      |> initialize_player_events()

    struct(__MODULE__, attrs)
  end

  defp initialize_player_events(%{players: players} = attrs) do
    player_events =
      players
      |> Map.keys()
      |> Enum.map(fn player_id -> {player_id, []} end)
      |> Map.new()

    Map.put(attrs, :events_visible_by_player, player_events)
  end
end

The events log initialization should only happen when an :events_log key value pair is not passed to Game.new. I add a new test case to verify that this working as expected.

  describe "new/1 with given events log" do
    setup [:create_players]

    setup %{players: players} do
      custom_log =
        EventsLog.new()
        |> Map.put(:events, [%PCEnteredHexEvent{id: 100}])
        |> Map.put(:events_visible_by_player, %{1 => [100]})

      game = Game.new(events_log: custom_log, players: players)

      [events_log: custom_log, game: game]
    end

    test "should use events_log value in game", %{events_log: log, game: game} do
      assert log == game.events_log
    end
  end

New events, new encodings

I notice that my encoding/decoding tests are failing since adding the new event types. I update the custom Ecto.Type definition for the EventsMap module to handle converting the string-key map representations of these events to their proper structs.

Even after those events are being properly cast to structs, there are still some failing tests for decoding the events log. This time it is the new events_visible_by_player data which expects to be a map with integer keys, but the default cast behavior is to use string keys. I create a new custom type which will define how to decode this map to the expected format.

defmodule Minotaur.GameEngine.EctoTypes.EventsVisibleByPlayerMap do
  use Minotaur.GameEngine.EctoCustomMapType

  def cast(value) when is_map(value) do
    player_events =
      value
      |> Enum.map(fn {k, v} ->
        key = if is_binary(k), do: String.to_integer(k), else: k
        {key, v}
      end)
      |> Map.new()

    {:ok, player_events}
  end

  def cast(_), do: :error
end

Back on track

The encoding/decoding tests are all passing so I switch back to the still failing move event tests.

    test "only players in origin hex can see PCLeftHexEvent", 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
          {_, %PCLeftHexEvent{player_id: ^p1_id}} -> true
          _ -> false
        end)

      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

To make the test pass for player visibility to the PCLeftHexEvent event, I need to apply the rule that it is only seen by players that started in the same hex as the moved character. I start by getting a list of player characters that are in the same hex as the moved character in the pre-moved world state. I filter out the moved character since they should only see their own PCEnteredHexEvent.

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_event(entered_hex_event)
    |> add_event(left_hex_event)
  end
end

There is no such function get_pcs_at_coord/2 defined in the Worlds module, but it would be very handy to have that behavior handled in that module. I implement this function and will likely need to use it again soon.

defmodule Minotaur.GameEngine.Worlds do
  # …

  def get_pcs_at_coord(%{player_characters: pcs}, coord) do
    pcs
    |> Map.values()
    |> Enum.filter(fn %{position: position} ->
      position == coord
    end)
  end
end

I add a function to specifically handle adding the PCLeftHexEvent events to the log which will encapsulate the rules for player visibility. I also need to change the return value of add_event since it is responsible for assigning an id to the event and that id is needed for setting visibility to players. I could look up the event id from the most recently added event, but I prefer having it explicitly returned by the 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(log, left_hex_event, player_ids_in_origin_hex)
    {_, log} = add_event(log, entered_hex_event)

    log
  end

  defp add_left_hex_event(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

  defp add_event(%EventsLog{} = log, event) do
    event_id = map_size(log.events)
    event = %{event | id: event_id}
    log = put_in(log, [:events, event_id], event)

    {event_id, log}
  end
end

The test case for PCLeftHexEvent visibility is now passing. I know this logic will need to be changed for the cases where there are no players in the origin hex, but I’ll wait until I get to that test case before adding the logic for it.

There is one more failing test case for this initial move action scenario which is the player visibility for PCEnteredHexEvent. It should be fairly straightforward to code the visibility rules for this event just like with PCLeftHexEvent. I’ll tackle that one in my next development session.