Upgrading game versions automatically

Now that I have created the means to upgrade an old game state version to the latest version, I will implement this behavior during process initialization whenever an existing game session is resumed from a stored state. I create a new test case for this desired behavior and update the game session server code until the test passes.

defmodule Minotaur.GameEngine.ResumeActiveSessionsTest do # … describe "a game session is saved with an old version of game state" do setup [:create_game, :save_old_game_version_session] test "game is resumed with upgraded state version", ctx do :ok = GameEngine.resume_active_sessions() assert {:ok, game} = GameEngine.get_game(ctx.game.id) assert ctx.game.id == game.id assert Game.latest_version() == game.version assert EventsLog.new() == game.events_log end end defp save_old_game_version_session(%{game: game}) do game = game |> Map.put(:events_log, nil) |> Map.put(:version, 1) attrs = %{ game_id: game.id, join_code: "OLD_VER", game_status: :active, latest_round: game.round, game_state: game } {:ok, _} = %GameSessionSummary{} |> GameSessionSummary.changeset(attrs) |> Repo.insert() [game: game] end end

Move event visibility

Events are created in the game state events log whenever a player character moves, but nothing is built into the front end UI yet to display this event log. Before showing these events, I want to build a way to filter events by ones that each player should have visibility to. If a player character moves locations, but no one else is around to see it, those other players should not see the event in their log UI. I will need to define the rules for when a player can see specific move events.

Let’s say the game state has the following world grid with player characters P1, P2, P3, P4, and P5 in their starting positions on round 1. For their actions, P1 and P3 move east, P2 moves southeast, and P4 and P5 remain in their original positions.

Round 1 --> Round 2 ● ● ● ● ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ● 0,-1 ● 1,-1 ● ● 0,-1 ● 1,-1 ● + + + --> + + + + + + + + + ● ● ● --> ● ● ● ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ● -1,0 ● 0,0 ● 1,0 ● --> ● -1,0 ● 0,0 ● 1,0 ● + + + + + + + + + [P1,P2] + [P5] + + --> + [P4] + [P1,P3] + + ● [P3,P4] ● ● ● ● ● [P5] ● ● ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ● + + + --> + + + + + + + [P2] + + ● ● ● --> ● ● ● ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ● ● --> ● ●

Here is how I imagine each player’s event log based on what they should see assuming that all movement is resolved simultaneously:

P1

  • P2 moved southeast.
  • P3 moved east.
  • You moved east.

P2

  • P1 moved east.
  • P3 moved east.
  • You moved southeast

P3

  • P1 moved east.
  • P2 moved southeast.
  • You moved east.

P4

  • P1 moved east.
  • P2 moved southeast.
  • P3 moved east.

P5

  • P1 entered the chamber
  • P3 entered the chamber

The basic rules for which players can see move events can be described as:

  1. Players can see their own movement.
  2. Players can see the movement of any player that starts in the same hex as them.
  3. Any player that doesn’t move can see the movement of any player that moves into their hex.

I am thinking to store a list for each player with event ids for the events to which they have visibility. The problem with referencing a PCMovedEvent as visible for a player is that it isn’t clear which of the visibility rules applies to them unless it was their own movement. I could store more information in the event such as which players were in the hex at the time of the event, but that could bloat the event data with duplicate information if several players are clustered together. Another option might be to split the event into more types which gives more context to why each player sees the event.

I’ll explore the idea of splitting PCMovedEvent into PCLeftHexEvent and PCEnteredHexEvent. This might be the wrong path to go down, but I’ll see where it leads me.

Reimaging the move event

I apply these new event types to the previous example and list which players can see each.

Event TypeContextVisibile To
PCLeftHexEventid: 0, player: P1, from: -1,0, to: 0,0P2, P3, P4
PCEnteredHexEventid: 1, player: P1, from: -1,0, to: 0,0P1, P5
PCLeftHexEventid: 2, player: P2, from: -1,0, to: -1,1P1, P3, P4
PCEnteredHexEventid: 3, player: P2, from: -1,0, to: -1,1P2
PCLeftHexEventid: 4, player: P3, from: -1,0, to: 0,0P1, P2, P4
PCEnteredHexEventid: 5, player: P3, from: -1,0, to: 0,0P3, P5

And make sure that I can derive the desired event text using only the context from the event and which players have visibility.

PlayerEvent IdEvent to Text
P11You moved east
P12P2 moved southeast
P14P3 moved east
P20P1 moved east
P23You moved southeast
P24P3 moved east
P30P1 moved east
P32P2 moved southeast
P35You moved east
P40P1 moved east
P42P2 moved southeast
P44P3 moved east
P51P1 entered chamber
P55P3 entered chamber

That actually worked out well. Without needing to know where each player was located on the grid, I can create the event text from the event context.

The inner context for both the left and entered hex events are the same, but this allows setting visibility separately for each half of the full movement event. In the case where no other player sees the player leave a hex, only the PCEnteredHex event would be created which would not duplicate data.

As I imagine an alternative to splitting move events, I can’t think of how I would design filtering on a single PCMovedEvent without the event structure and conditional text conversion being more complex than I’d like. Maybe I’ll come up with a better solution down the way, but for now I’ll follow this course of splitting the move event in two.

Rewriting move events

Now that I know what I’m aiming for, I can replace the existing test for PCMovedEvent to assert the new behavior I want to build. I can take the grid scenario I just used to create test cases since I’ve already mapped out what events I expect to be created given the player movements.

I start with the scenario of a player moving from one occupied hex to another occupied hex.

defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do # … # Round 1 --> Round 2 # # ● ● --> ● ● # ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ # ● -1,0 ● 0,0 ● --> ● -1,0 ● 0,0 ● # + + + + + + # + [P1,P2] + [P4] + --> + [P2,P3] + [P1,P4] + # ● [P3] ● ● ● ● ● # ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ --> ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ # ● -1,1 ● 0,1 ● ● -1,1 ● 0,1 ● # + + + --> + + + # + + + + + + # ● ● ● --> ● ● ● # ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ ˚ ˳ ˳ ˚ # ● ● --> ● ● # 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 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 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

One of the reasons for failure of the new tests is that there is no :events_visible_by_player key in the EventsLog struct. I implement this new field in the embedded schema to get past this specific error.

defmodule Minotaur.GameEngine.EventsLog do # … embedded_schema do field :events, EventsMap field :events_visible_by_player, :map, default: %{} end end

I then jump into the Session module where move events are created and see what it takes to make this test pass. One of the first things I realize is the behavior I want to add won’t work with the existing logic for resolving move actions. I want to simulate all the move events occurring simultaneously, but the current logic is applying each action one at a time without any specific ordering. If I add the new move event types to the existing action resolution logic, my initial test scenario will likely pass, but when I get to the scenario where multiple players are moving, this logic will need to be changed.

Since move actions should be resolved together, I will change the action resolution to group all move actions and resolve them separately from any other action type. I start with refactoring how actions are resolved by splitting out the move actions and explicitly resolving them after everything else. This is just a refactor step so all of the existing green tests are still passing with this change.

defmodule Minotaur.GameEngine.Session do # … defp resolve_end_of_round_world_changes(game) do game |> apply_all_actions() |> increment_action_points() end defp apply_all_actions(game) do {move_actions, other_actions} = game |> get_all_player_actions() |> Enum.split_with(fn %MoveAction{} -> true _ -> false end) other_actions |> Enum.reduce(game, &apply_action/2) |> apply_move_actions(move_actions) end defp apply_move_actions(game, moves) do Enum.reduce(moves, game, &apply_action/2) end end

Now I can work with move actions as a group and handle them differently than the individually resolved other action types. My initial idea for resolving move actions is to update the game world state with all character moves and apply the previously defined rules for event visibility to see which events should be created and which players can see them. I will have the pre-move game state, post-move game state, and the list of all move actions available to work with.

PCEnteredHexEvent is always created for a move action since the moving player always sees this. The only other players that should see this event are ones whose characters are in the moved character’s destination hex in both the pre-move and post-move game state (i.e. they didn’t move).

PCLeftHexEvent will only be created if there were any other player characters in the moved character’s origin hex in the pre-move game state. The post-move state isn’t considered since this event is only concerned with where the players start.

I revert the existing PCMovedEvent logic from the move action resolver function so I can handle the world state updates separately from the events creation. I then apply a separate reduce on the move actions list to create a PCEnteredHexEvent for each player that moved.

defp apply_move_actions(game, moves) do post_moves_game = Enum.reduce(moves, game, &apply_move/2) events_log = Enum.reduce(moves, game.events_log, fn move, log -> update_event_log_with_move(log, move, game.world, post_moves_game.world) end) %{post_moves_game | events_log: events_log} end defp update_event_log_with_move(log, move, pre_move_world, _post_move_world) do %{player_id: player_id, vector: vector} = move %{position: from} = pre_move_world.player_characters[player_id] to = Worlds.apply_vector(from, vector) event = %PCEnteredHexEvent{ player_id: move.player_id, from: from, to: to } add_event(log, event) end defp add_event(%EventsLog{} = log, event) do event_id = map_size(log.events) event = %{event | id: event_id} put_in(log, [:events, event_id], event) end

This puts the event where it is expected to go and one of the assertions in the new test scenario is now passing. I still have to create the other half of the event for the players who see the PCLeftHexEvent and make sure these events are visible to the right players.

This is an exciting start, but I’ll have to leave this entry here and pick up the remaining logic another day.