Game Event Log
The next in-game feature I want to build is a text log that shows a player all resolved events to which they have visibility. This will involve keeping a list of events on the backend and adding a new UI element to the frontend to display events as text.
I’ll need to think about how events will be tracked for each game in a way that can be filtered by each player that has visibility to the event. Some events will have a global visibility such as a new round has started or a player character was killed. Other events are specific to a location and only players whose characters are in the location should have visibility to the event. I can keep a list of global events with a separate hash table of player specific event lists and build the full event log for a player from both lists.
Another use case for the event log is for an admin to be able to list out all events sequentially across all players for a game session. To more easily support this case, I might store all events in a hash table with a unique identifier and have the player events hash table store a list of event IDs to which the player has visibility.
Let the tests drive
I will start by writing a test that asserts an event is created when a player character moved after a registered move is process at the end of the round.
All of the events will occur at the end of the round and the event creation will occur when Session.end_round
is called to update the game state.
The tests for this function will continue to grow as more features are added to the game, so I will first split out the movement related tests to a separate module.
Splitting tests into multiple modules can help organize the test code, but also potentially improves test performance as each module that is tagged with :async
can run tests in parallel with asynchronous tests in other modules.
The existing movement tests for the end_round/2
function that I split into a separate module are simply asserting the player character positions are updated to the correct coordinate on the hex grid.
defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
use ExUnit.Case, async: true
import Minotaur.GameEngineFixtures
alias Minotaur.GameEngine.{Coord, Player, Session, Vector}
describe "player registered a move action" do
setup [:new_game, :move_players]
test "should reset registered_actions to empty map", %{game: game} do
{:ok, game_round2} = Session.end_round(game, nil)
assert %{} == game_round2.registered_actions
end
test "should update position of each moved character", context do
%{game: game, player1: p1, player2: p2} = context
{:ok, round2_game} = Session.end_round(game, nil)
%{player_characters: characters} = round2_game.world
assert %Coord{q: 0, r: 0} == characters[p1.id][:position]
assert %Coord{q: -1, r: 1} == characters[p2.id][:position]
end
end
defp move_players(%{game: game, player1: p1, player2: p2}) do
{:ok, game} = Session.register_player_move(game, p1.user_id, %Vector{q: 1, r: 0})
{:ok, game} = Session.register_player_move(game, p2.user_id, %Vector{q: 0, r: 1})
[game: game]
end
defp new_game(_) do
player1 = %Player{id: 3, display_name: "Player1", user_id: 111}
player2 = %Player{id: 6, display_name: "Player2", user_id: 222}
coord = %Coord{q: -1, r: 0}
game =
game_fixture(
players: [player1, player2],
pc_coords: %{player1.id => coord, player2.id => coord}
)
[game: game, player1: player1, player2: player2]
end
end
I add a new test assertion for the existing scenario of a player registering a move for the round. The test checks that an event is created for each player character movement. The test does not compile because there is not yet any concept of events in the game state or session logic.
defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
# ...
describe "player registered a move action" do
setup [:new_game, :move_players]
# ...
test "creates event for each moved character", ctx do
%{player1: %{id: p1_id}, player2: %{id: p2_id}} = ctx
{:ok, game} = Session.end_round(ctx.game, nil)
events = game.events_log.events
{_, p1_event} = Enum.find(events, fn {_, event} -> event[:player_id] == ctx.player1.id end)
{_, p2_event} = Enum.find(events, fn {_, event} -> event[:player_id] == ctx.player2.id end)
assert %PCMovedEvent{
player_id: ^p1_id,
from: %Coord{q: -1, r: 0},
to: %Coord{q: 0, r: 0}
} = p1_event
assert %PCMovedEvent{
player_id: ^p2_id,
from: %Coord{q: -1, r: 0},
to: %Coord{q: -1, r: 1}
} = p2_event
end
end
end
I create a new PCMovedEvent
struct which allows the test to compile.
defmodule Minotaur.GameEngine.Events.PCMovedEvent do
defstruct [:id, :player_id, :to, :from]
end
I then add an :events_log
field to the Game
struct definition.
defmodule Minotaur.GameEngine.Game do
# …
embedded_schema do
field :id, :binary_id
field :status, Ecto.Enum, values: [:active, :concluded], default: :active
field :round_end_time, :utc_datetime
field :players, PlayersMap
field :registered_actions, RegisteredActionsMap
field :round, :integer, default: 1
field :version, :integer, default: @current_version
embeds_one :events_log, EventsLog
embeds_one :world, World
end
def new(attrs \\ []) do
defaults = [
players: %{},
registered_actions: %{},
events_log: %EventsLog{}
]
attrs = Keyword.merge(defaults, attrs)
struct(__MODULE__, attrs)
end
end
defmodule Minotaur.GameEngine.EventsLog do
use Ecto.Schema
embedded_schema do
field :events, :map, default: %{}
end
end
Next, I update the logic for end_round/2
so the events are created for each moved player character.
Registered actions are applied within the resolve_end_of_round_world_changes
private function which is where event creation can be added.
I need to refactor a few nested functions as there is partial game state data being piped to multiple private functions which will now need the full game state to update events log.
Once the full game state is available in all the inner functions, I update the apply_action
for the matching MoveAction
argument so it creates events for each applied action.
defp apply_action(%MoveAction{} = action, game) do
%{player_id: player_id, vector: vector} = action
%{position: original_position} = game.world.player_characters[player_id]
new_position = Worlds.apply_vector(original_position, vector)
event = %PCMovedEvent{player_id: player_id, from: original_position, to: new_position}
game
|> put_in([:world, :player_characters, player_id, :position], new_position)
|> save_event(event)
end
defp save_event(game, event) do
event_id = map_size(game.events_log.events)
event = %{event | id: event_id}
put_in(game, [:events_log, :events, event_id], event)
end
save_event
is a separate function responsible for generating the event id and updating the game struct with the new event.
I will likely have to change this logic as I add more event types and need to reference the ids in the player visible event map, but this is a good starting point.