Automatic game session recovery

To finish out the feature of automatically resuming games in progress during a cold start, I call the newly created resume_active_sessions function within the Application module’s start callback just after the root supervision tree is started.

  def start(_type, _args) do
    children = [
      # …
    ]

    opts = [strategy: :one_for_one, name: Minotaur.Supervisor]
    application_start_result = Supervisor.start_link(children, opts)

    GameEngine.resume_active_sessions()

    application_start_result
  end

Update game state every round

Game state can be recovered after a full cluster crash, but the stored game state is never updated after the game session process is started. The next feature to build is updating state at the end of each game round so minimal game progress is lost after being recovered.

I add a new test case for the behavior I want to achieve.

  describe "a game session is stopped after applying end of round changes" do
    setup [:start_game, :play_rounds, :stop_game]

    test "game is resumed with latest updated state", ctx do
      :ok = GameEngine.resume_active_sessions()

      assert {:ok, game} = GameEngine.get_game(ctx.game.id)
      assert game.id == ctx.updated_game.id
      assert game.round == ctx.updated_game.round
      assert game.players == ctx.updated_game.players
      assert game.world == ctx.updated_game.world
    end
  end

  defp play_rounds(%{game: game, user: user}) do
    GameEngine.end_round(game.id)
    GameEngine.register_move(game.id, user.id, %Vector{q: -1, r: 0})

    {:ok, updated_game} = GameEngine.end_round(game.id)

    [updated_game: updated_game]
  end

I expect this test to fail, but it actually passes. This is exactly why it is good to start with a failing test so you can catch the unexpected false positives. I recognize the same gotcha that I ran into with the previous tests in this module where the in-memory state recovery is taking precedence over the state being pulled from the database. I update the test setup functions to clear the stashed state after the game process is shut down and the test is now failing as expected.

I update the game session GenServer module to save the latest game state version when the game round ends. I can reuse the update_summary_record function I defined previously to store the updated game state.

  defp end_round(game, join_code) do
    timer_end_time = get_timer_end_time(@round_time_ms)
    {:ok, game} = Session.end_round(game, timer_end_time)
    notify_next_round_started(game)
    set_round_timer(game, @round_time_ms)
    update_summary_record(game, join_code)

    {:ok, game}
  end

The test is not passing which isn’t too unexpected since update_summary_record is doing an insert where it should now be performing a an “upsert” for existing summary records. GameSessionSummary records are also not unique by the game id field so the current logic is creating a new summary record every time the round ends for each game. I add a migration to create this unique constraint and also add an index for query filters on game_status such as when running the new resume_active_sessions function.

defmodule Minotaur.Repo.Migrations.AddUniqueIndexGameSessionSummary do
  use Ecto.Migration

  def change do
    create unique_index(:game_session_summaries, [:game_id])
    create index(:game_session_summaries, [:game_status])
  end
end

After running the migration, the test raises an error from Ecto trying to insert summary records with an existing game id. I pass options for resolving conflicts to the Repo.insert function call.

  defp update_summary_record(game, join_code) do
    attrs = %{
      game_id: game.id,
      join_code: join_code,
      game_status: :active,
      latest_round: game.round,
      game_state: game,
      player_summaries:
        game.players
        |> Enum.map(fn {_, player} ->
          %PlayerGameSessionSummary{
            user_id: player.user_id,
            player_status: player.status
          }
        end)
    }

    %GameSessionSummary{}
    |> GameSessionSummary.changeset(attrs)
    |> Repo.insert(
      on_conflict: {:replace_all_except, [:id, :inserted_at]},
      conflict_target: [:game_id]
    )
  end

This should update all fields for the GameSessionSummary record in the database except for :id and inserted_at. However, a new problem came from this change. The associated PlayerGameSessionSummary records are being inserted as new records for their respective table which has a unique index on the game session summary foreign key id and the user foreign key id. I don’t believe there is any way to resolve the conflicting associated records with a single insert statement so I break the nested records into a separate statement wrapped in a transaction.

  defp update_summary_record(game, join_code) do
    Repo.transaction(fn ->
      attrs = %{
        game_id: game.id,
        join_code: join_code,
        game_status: :active,
        latest_round: game.round,
        game_state: game
      }

      {:ok, game_session_summary} =
        %GameSessionSummary{}
        |> GameSessionSummary.changeset(attrs)
        |> Repo.insert(
          on_conflict: {:replace_all_except, [:id, :inserted_at]},
          conflict_target: [:game_id]
        )

      insert_player_summary_records(game, game_session_summary)
    end)
  end

  defp insert_player_summary_records(game, game_session_summary) do
    placeholders = %{
      game_session_id: game_session_summary.id,
      timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
    }

    player_summaries_attrs =
      game.players
      |> Enum.map(fn {_, player} ->
        %{
          game_session_id: {:placeholder, :game_session_id},
          user_id: player.user_id,
          player_status: player.status,
          inserted_at: {:placeholder, :timestamp},
          updated_at: {:placeholder, :timestamp}
        }
      end)

    Repo.insert_all(
      PlayerGameSessionSummary,
      player_summaries_attrs,
      placeholders: placeholders,
      on_conflict: {:replace_all_except, [:id, :inserted_at]},
      conflict_target: [:game_session_id, :user_id]
    )
  end

The test is passing which means the updated game state is being pulled out of the database and used to initialize a new game session process.

Default values for custom schema types

It appears I can no longer set a default empty map for the embedded schemas that use a custom Ecto.Type which makes the World struct a little more brittle to work with when every place that creates a new struct needs to provide a default value. I create a simple function to initialize a World struct and fill in the default values, then update all the references in code that create worlds with this new function call.

defmodule Minotaur.GameEngine.World do
  use Ecto.Schema
  use StructAccess

  alias Minotaur.GameEngine.EctoTypes.{DeadPlayerCharactersMap, GridMap, PlayerCharactersMap}

  @primary_key false

  embedded_schema do
    field :grid, GridMap
    field :player_characters, PlayerCharactersMap
    field :dead_characters, DeadPlayerCharactersMap
  end

  def new(attrs \\ []) do
    attrs =
      Keyword.validate!(attrs,
        grid: %{},
        player_characters: %{},
        dead_characters: %{}
      )

    struct(__MODULE__, attrs)
  end
end