Game State Encoding and Versioning
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.