Testing Move Scenarios (Part 1)
Creating PCLeftHexEvent
events
I last left the tests failing for the event creation behavior I want to build.
Before making these tests pass, I do some refactoring to remove some duplicate logic for calculating the origin and destination hex coordinates from the move actions.
This logic is handled in the apply_move/2
function to update the world state and repeated in the function that creates move events.
I move the event creation logic into apply_move
and have it return a tuple of the updated world and the associated PCEnteredHexEvent
event which is created for every PC move action.
The reducer function that applies all move actions now returns the post-moves world state and a list of PCEnteredHexEvent
events associated with those moves.
The updated events log is created by iterating over this event list and generating an event id by which to store the events in the event map. I can use this reducer function to also apply the player visibility rules logic in a later change.
defmodule Minotaur.GameEngine.Session do
# …
defp apply_move_actions(game, moves) do
{post_moves_world, entered_hex_events} =
Enum.reduce(moves, {game.world, []}, fn move, {world, events} ->
{world, event} = apply_move(move, world)
{world, [event | events]}
end)
events_log =
Enum.reduce(entered_hex_events, game.events_log, fn event, log ->
update_event_log_with_move(log, event, game.world, post_moves_world)
end)
%{game | world: post_moves_world, events_log: events_log}
end
defp apply_move(%MoveAction{} = action, world) do
%{player_id: player_id, vector: vector} = action
%{position: original_position} = world.player_characters[player_id]
new_position = Worlds.apply_vector(original_position, vector)
world = put_in(world, [:player_characters, player_id, :position], new_position)
event = %PCEnteredHexEvent{
player_id: player_id,
from: original_position,
to: new_position
}
{world, event}
end
defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
add_event(log, entered_hex_event)
end
end
The previously green tests for move actions are still passing with this refactor which I owe to making small, incremental changes as I worked.
I make a simple change to update_event_log_with_move
which adds a PCLeftHexEvent
to the event log for each PCEnteredHexEvent
.
This is not the final outcome I want since PCLeftHexEvent
should not be created if there are no other players to see the move, but this is a small step toward that behavior.
defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
left_hex_event = struct(PCLeftHexEvent, Map.from_struct(entered_hex_event))
log
|> add_event(entered_hex_event)
|> add_event(left_hex_event)
end
One of the new event tests is now passing with this simple change since both of these event types are expected to exist in the log for the scenario where a player moves from an occupied hex.
defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
# …
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
end
end
The next test I need to make pass is checking that the correct players have visibility to the move events.
There currently isn’t any logic implemented for event visibility so the test is obviously failing from the start.
The test is failing due to the value of events_visible_by_player
being an empty map which means all player event lists are nil
.
It would probably be a good idea to initialize this map with empty lists for every player in the game to avoid unexpected issues when this value is not expected to be nil
.
Initializing the events log
I pivot to create a new test for this initialization behavior.
defmodule Minotaur.GameEngine.GameTest do
# …
describe "new/1 with players map" do
setup [:create_players, :create_game_with_players]
setup %{players: players} do
[game: Game.new(players: players)]
end
test "initializes empty events list for each player", %{players: players, game: game} do
player_events = game.events_log.events_visible_by_player
players
|> Map.keys()
|> Enum.each(fn player_id ->
assert [] == player_events[player_id]
end)
end
end
defp create_players(_ctx) do
p1 = player_fixture()
p2 = player_fixture()
p3 = player_fixture()
players = %{
p1.id => p1,
p2.id => p2,
p3.id => p3
}
[players: players]
end
end
I update the new
function for the Game
and EventsLog
modules to make the test pass.
defmodule Minotaur.GameEngine.Game do
# …
def new(attrs \\ []) do
defaults = [
players: %{},
registered_actions: %{}
]
attrs =
defaults
|> Keyword.merge(attrs)
|> Map.new()
attrs = Map.put(attrs, :events_log, initialize_events_log(attrs))
struct(__MODULE__, attrs)
end
defp initialize_events_log(%{events_log: %EventsLog{} = events_log}) do
events_log
end
defp initialize_events_log(%{players: players}) do
EventsLog.new(players: players)
end
end
defmodule Minotaur.GameEngine.EventsLog do
# …
def new(attrs \\ []) do
defaults = [
events: %{},
players: %{}
]
attrs =
defaults
|> Keyword.merge(attrs)
|> Map.new()
|> initialize_player_events()
struct(__MODULE__, attrs)
end
defp initialize_player_events(%{players: players} = attrs) do
player_events =
players
|> Map.keys()
|> Enum.map(fn player_id -> {player_id, []} end)
|> Map.new()
Map.put(attrs, :events_visible_by_player, player_events)
end
end
The events log initialization should only happen when an :events_log
key value pair is not passed to Game.new
.
I add a new test case to verify that this working as expected.
describe "new/1 with given events log" do
setup [:create_players]
setup %{players: players} do
custom_log =
EventsLog.new()
|> Map.put(:events, [%PCEnteredHexEvent{id: 100}])
|> Map.put(:events_visible_by_player, %{1 => [100]})
game = Game.new(events_log: custom_log, players: players)
[events_log: custom_log, game: game]
end
test "should use events_log value in game", %{events_log: log, game: game} do
assert log == game.events_log
end
end
New events, new encodings
I notice that my encoding/decoding tests are failing since adding the new event types.
I update the custom Ecto.Type
definition for the EventsMap
module to handle converting the string-key map representations of these events to their proper structs.
Even after those events are being properly cast to structs, there are still some failing tests for decoding the events log.
This time it is the new events_visible_by_player
data which expects to be a map with integer keys, but the default cast behavior is to use string keys.
I create a new custom type which will define how to decode this map to the expected format.
defmodule Minotaur.GameEngine.EctoTypes.EventsVisibleByPlayerMap do
use Minotaur.GameEngine.EctoCustomMapType
def cast(value) when is_map(value) do
player_events =
value
|> Enum.map(fn {k, v} ->
key = if is_binary(k), do: String.to_integer(k), else: k
{key, v}
end)
|> Map.new()
{:ok, player_events}
end
def cast(_), do: :error
end
Back on track
The encoding/decoding tests are all passing so I switch back to the still failing move event tests.
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
To make the test pass for player visibility to the PCLeftHexEvent
event, I need to apply the rule that it is only seen by players that started in the same hex as the moved character.
I start by getting a list of player characters that are in the same hex as the moved character in the pre-moved world state.
I filter out the moved character since they should only see their own PCEnteredHexEvent
.
defmodule Minotaur.GameEngine.Session do
# …
defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
%{player_id: moved_player_id} = entered_hex_event
left_hex_event = struct(PCLeftHexEvent, Map.from_struct(entered_hex_event))
player_ids_in_origin_hex =
pre_move_world
|> Worlds.get_pcs_at_coord(entered_hex_event.from)
|> Enum.map(& &1.player_id)
|> Enum.filter(fn player_id -> player_id != moved_player_id end)
log
|> add_event(entered_hex_event)
|> add_event(left_hex_event)
end
end
There is no such function get_pcs_at_coord/2
defined in the Worlds
module, but it would be very handy to have that behavior handled in that module.
I implement this function and will likely need to use it again soon.
defmodule Minotaur.GameEngine.Worlds do
# …
def get_pcs_at_coord(%{player_characters: pcs}, coord) do
pcs
|> Map.values()
|> Enum.filter(fn %{position: position} ->
position == coord
end)
end
end
I add a function to specifically handle adding the PCLeftHexEvent
events to the log which will encapsulate the rules for player visibility.
I also need to change the return value of add_event
since it is responsible for assigning an id to the event and that id is needed for setting visibility to players.
I could look up the event id from the most recently added event, but I prefer having it explicitly returned by the function.
defmodule Minotaur.GameEngine.Session do
# …
defp update_event_log_with_move(log, entered_hex_event, pre_move_world, _post_move_world) do
%{player_id: moved_player_id} = entered_hex_event
left_hex_event = struct(PCLeftHexEvent, Map.from_struct(entered_hex_event))
player_ids_in_origin_hex =
pre_move_world
|> Worlds.get_pcs_at_coord(entered_hex_event.from)
|> Enum.map(& &1.player_id)
|> Enum.filter(fn player_id -> player_id != moved_player_id end)
log = add_left_hex_event(log, left_hex_event, player_ids_in_origin_hex)
{_, log} = add_event(log, entered_hex_event)
log
end
defp add_left_hex_event(events_log, event, player_ids) do
{event_id, events_log} = add_event(events_log, event)
Enum.reduce(player_ids, events_log, fn player_id, log ->
update_in(log, [:events_visible_by_player, player_id], &[event_id | &1])
end)
end
defp add_event(%EventsLog{} = log, event) do
event_id = map_size(log.events)
event = %{event | id: event_id}
log = put_in(log, [:events, event_id], event)
{event_id, log}
end
end
The test case for PCLeftHexEvent
visibility is now passing.
I know this logic will need to be changed for the cases where there are no players in the origin hex, but I’ll wait until I get to that test case before adding the logic for it.
There is one more failing test case for this initial move action scenario which is the player visibility for PCEnteredHexEvent
.
It should be fairly straightforward to code the visibility rules for this event just like with PCLeftHexEvent
.
I’ll tackle that one in my next development session.