Testing game state encoding/decoding

The latest test is now passing, but some others are failing due to encoding issues with the game state. I need to implement the encoding logic for the new EventsLog struct to resolve these failures. Breaking encoding changes will likely be a common occurrence as I build out more detailed game features so I will create an new test module focused on encoding and decoding game state from the database.

I create a base case where fresh game state is inserted to the database and assert that the decoded values match what was saved.

defmodule Minotaur.GameEngine.GameSessionTest do
  use Minotaur.DataCase

  import Minotaur.GameEngineFixtures

  alias Minotaur.GameEngine.GameSessionSummary
  alias Minotaur.Repo

  describe "a record with new game state is stored in the database" do
    setup [:create_game, :insert_summary, :get_summary]

    test "game state is properly decoded", %{game: game} = ctx do
      decoded_game = ctx.loaded_summary.game_state

      assert game.id == decoded_game.id
      assert :active == decoded_game.status
      assert DateTime.truncate(game.round_end_time, :second) == decoded_game.round_end_time
      assert game.players == decoded_game.players
      assert game.round == decoded_game.round
      assert game.version == decoded_game.version
      assert game.events_log == decoded_game.events_log
      assert game.world == decoded_game.world
    end
  end

  defp create_game(_ctx) do
    [game: game_fixture()]
  end

  defp insert_summary(%{game: game}) do
    attrs = %{
      game_id: game.id,
      join_code: "JOINCODE",
      game_status: game.status,
      latest_round: game.round,
      game_state: game
    }

    {:ok, summary} =
      %GameSessionSummary{}
      |> GameSessionSummary.changeset(attrs)
      |> Repo.insert()

    [summary_id: summary.id]
  end

  defp get_summary(%{summary_id: summary_id}) do
    summary = Repo.get(GameSessionSummary, summary_id)

    [loaded_summary: summary]
  end
end

I add a new test case to validate the two registered action types are properly decoded.

  describe "game state with registered actions" do
    setup [:create_game, :register_actions, :insert_summary, :get_summary]

    test "actions are properly decoded", %{game: game, loaded_summary: summary} do
      assert game.registered_actions == summary.game_state.registered_actions
    end
  end

  defp register_actions(%{game: game, p1: p1, p2: p2}) do
    move_vector = %Vector{q: -1, r: 0}
    game = put_in(game, [:world, :player_characters, p1.id, :action_points], 3)

    {:ok, game} = Session.register_player_attack(game, p1.user_id, p2.id)
    {:ok, game} = Session.register_player_move(game, p1.user_id, move_vector)

    [game: game]
  end

  defp create_game(_ctx) do
    p1 = %Player{id: 3, display_name: "Player1", user_id: 111}
    p2 = %Player{id: 6, display_name: "Player2", user_id: 222}
    coord = %Coord{q: 0, r: 0}

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

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

I add another case for dead player characters.

  describe "world state with dead characters" do
    setup [:create_game, :kill_characters, :insert_summary, :get_summary]

    test "world is properly decoded", %{game: game, loaded_summary: summary} do
      assert game.world == summary.game_state.world
    end
  end

  defp kill_characters(%{game: game, p2: p2}) do
    {:ok, game} =
      game
      |> put_in([:world, :player_characters, p2.id, :health], 0)
      |> Session.end_round(nil)

    assert game.world.dead_characters[p2.id]

    [game: game]
  end

Finally, I add a test case for EventsLog with the newly added PCMovedEvent struct.

  describe "events log state with events" do
    setup [:create_game, :add_events, :insert_summary, :get_summary]

    test "events log is properly decoded", %{game: game, loaded_summary: summary} do
      assert game.events_log == summary.game_state.events_log
    end
  end

  defp add_events(%{game: game, p1: p1}) do
    move_vector = %Vector{q: -1, r: 0}

    {:ok, game} = Session.register_player_move(game, p1.user_id, move_vector)
    {:ok, game} = Session.end_round(game, nil)

    [game: game]
  end

This test fails since the :events map and its nested map values are using string keys by default. I add a custom Ecto type to define the cast callback used to convert decoded database values to the correct structures. Before defining the full cast callback logic, I simply pass through the unmodified events value to make sure the custom Ecto type is wired up correctly.

defmodule Minotaur.GameEngine.EctoTypes.EventsMap do
  @moduledoc false

  use Ecto.Type

  def type, do: :map

  def cast(value) when is_map(value) do
    # TODO

    {:ok, value}
  end

  def cast(_), do: :error

  # load and dump are not used for embeds, but required for Ecto.Type behaviour
  def load(_), do: :error
  def dump(_), do: :error
end
defmodule Minotaur.GameEngine.EventsLog do
  # …

  embedded_schema do
    field :events, EventsMap
  end

  def new(attrs \\ []) do
    attrs = Keyword.merge([events: %{}], attrs)

    struct(__MODULE__, attrs)
  end
end
defmodule Minotaur.GameEngine.Game do
  # …

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

    attrs = Keyword.merge(defaults, attrs)

    struct(__MODULE__, attrs)
  end
end

Everything still compiles and the test has the same failing result as before, so I update the cast callback to make the test pass.

  def cast(value) when is_map(value) do
    events =
      Enum.map(value, fn {k, v} ->
        key = if is_binary(k), do: String.to_integer(k), else: k
        event = convert_event(v)

        {key, event}
      end)

    {:ok, Map.new(events)}
  end

  def cast(_), do: :error

  defp convert_event(map) do
    struct_name = String.to_existing_atom(map["__struct__"])
    attrs = convert_to_atom_keys(map)

    convert_to_struct(struct_name, attrs)
  end

  defp convert_to_struct(PCMovedEvent = name, attrs) do
    attrs =
      attrs
      |> Map.update!(:from, &convert_coord/1)
      |> Map.update!(:to, &convert_coord/1)

    struct(name, attrs)
  end

  defp convert_coord(attrs) do
    struct(Coord, convert_to_atom_keys(attrs))
  end

I’ve repeated myself a few times with the convert_to_atom_keys/1 private method so I move that to a separate helper module and import it into the custom type modules that use it. I decide to move all the boilerplate callbacks needed for the Ecto.Type behaviour into the __using__ macro that can be brought into all of these custom map type modules.

defmodule Minotaur.GameEngine.EctoCustomMapType do
  @moduledoc """
  Provides boilerplate and helper functions for :map type custom Ecto.Type
  modules used in embedded schemas.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Type

      import unquote(__MODULE__)

      @impl true
      def type, do: :map

      # load and dump are not used for embeds, but required for Ecto.Type behaviour
      @impl true
      def load(_), do: :error

      @impl true
      def dump(_), do: :error
    end
  end

  def convert_to_atom_keys(map) do
    Enum.reduce(map, %{}, fn {key, value}, acc ->
      new_key = convert_key(key)
      Map.put(acc, new_key, value)
    end)
  end

  defp convert_key(key) when is_binary(key), do: String.to_existing_atom(key)
  defp convert_key(key), do: key
end

The test is now passing and I should have all existing structs that are nested within game state covered by these encoding tests. As the game state structure changes over time, I can add new test scenarios in the encoding test module to ensure database decoding is handled correctly.

Game state versioning

The Game struct has a :version field to track which historical structure was used at the time it was initialized. I now have a potentially breaking change with this new :events_log field since any game state stored in memory or the database will be have a nil value for :events_log when pulled into a node running the latest code. I should increment the version of any new game state initialized by the latest code and add logic for upgrading older version state to be compatible with the latest.

I update the @current_version module attribute for the Game module which is referenced as the default value for :version in the struct definition.

I also create a new test module for the feature I want to build which will upgrade an older game state version to be compatible with the latest version. I create a base case for when the game state is the same version as the latest one and the original state is returned unchanged. I implement the upgrade_latest_version/1 function with the base case logic in the Game module to make the test pass.

defmodule Minotaur.GameEngine.GameTest do
  # …

  describe "upgrade_latest_version/1 with latest version state" do
    setup [:create_latest_version]

    test "returns state unchanged", %{game: game} do
      upgraded_game = Game.upgrade_latest_version(game)

      assert game == upgraded_game
    end
  end

  defp create_latest_version(_ctx) do
    [game: game_fixture()]
  end
end
defmodule Minotaur.GameEngine.Game do
  # …

  @current_version 2

  def upgrade_latest_version(%{version: @current_version} = game) do
    game
  end
end

I add a new test case for upgrading from version 1 and implement this case in the Game module.

defmodule Minotaur.GameEngine.GameTest do
  # …

  describe "upgrade_latest_version/1 with version 1" do
    setup [:create_v1]

    test "returns latest version state", %{game: game} do
      game = Game.upgrade_latest_version(game)

      assert Game.latest_version() == game.version
      assert %EventsLog{events: %{}} = game.events_log
    end
  end

  # v2 adds EventsLog to Game struct
  defp create_v1(_ctx) do
    game =
      game_fixture()
      |> Map.put(:events_log, nil)
      |> Map.put(:version, 1)

    [game: game]
  end
end
defmodule Minotaur.GameEngine.Game do
  # …

  def latest_version, do: @current_version

  def upgrade_latest_version(%{version: @current_version} = game) do
    game
  end

  def upgrade_latest_version(%{version: 1} = game) do
    %{game | events_log: EventsLog.new(), version: 2}
  end
end

I also add a case for an unexpected version number that has no upgrade path.

defmodule Minotaur.GameEngine.GameTest do
  # …

  describe "upgrade_latest_version/1 with invalid version" do
    test "returns error" do
      game = Game.new(version: -1)

      assert {:error, :invalid_game_version} = Game.upgrade_latest_version(game)
    end
  end
end
defmodule Minotaur.GameEngine.Game do
  # …

  def upgrade_latest_version(_) do
    {:error, :invalid_game_version}
  end
end

I make a final refactor to use recursion with upgrade_latest_version which will allow stepping through each version upgrade as I add new ones over time.

  def upgrade_latest_version(%{version: 1} = game) do
    v2 = %{game | events_log: EventsLog.new(), version: 2}

    upgrade_latest_version(v2)
  end

Now I have a way to migrate game state from older versions which I will put to use whenever game session servers are initialized from existing game state.