Game Over
A game is over when there is no more than one player remaining alive.
If one player is alive at the end of the game, they are shown a winner screen which directs them back to the main menu.
Eliminated players are shown a Game Over screen on the round their character starts with 0 health.
Even after the game is over, the game session process is still alive and the game session summary has a :game_status
of “active”.
This means the game still shows in progress on the player’s dashboard and players can rejoin the ended game.
The winner can actually keep playing the game, but eliminated players will be shown the Game Over screen upon joining.
The next update I’ll make is to ensure the end of the game properly stops the session process and marks the session summary record as no longer active.
Starting with a test
I add a new test case for the existing feature test scenario of a player being the last character alive. The new test is failing since the game is still in progress after the player wins.
describe "when player has last character alive" do
setup [:register_user, :create_game_session]
test "should show player has won", ctx do
ctx.session
|> sign_in(as: ctx.user)
|> join_game_and_wait_until_loaded(ctx.join_code, "00:00")
|> assert_text("YOU WON!")
end
test "game should no longer show on user dashboard", ctx do
ctx.session
|> sign_in(as: ctx.user)
|> join_game_and_wait_until_loaded(ctx.join_code, "00:00")
|> assert_text("YOU WON!")
|> visit("/")
|> assert_text("Welcome User")
|> refute_has(Query.text("Games in Progress"))
end
end
I don’t remember exactly where the code determines a win scenario so I work backwards from the existing test cast to see which conditions cause “YOU WON!” to be rendered. At the end of a round, the game state is checked for any eliminated players and player statuses are updated. Each user’s LiveView assigns their player state to the socket and a different template is rendered depending on the status. There is no concept for the game being in play versus having concluded so I’ll need to implement that next.
I update the Game
struct definition to add a :status
attribute which can be either :active
or :concluded
.
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 :world, World
end
I next update the end_round
function in the Session
module which handles all game state transformations.
I pipe the game state to a new private function to update the game status to :concluded
if the game over condition is met.
defmodule Minotaur.GameEngine.Session do
# …
def end_round(game, round_end_time) do
new_round_game =
game
|> Map.update!(:round, &(&1 + 1))
|> Map.put(:round_end_time, round_end_time)
|> resolve_end_of_round_world_changes()
|> resolve_dead_pcs()
|> Map.put(:registered_actions, %{})
|> check_for_winner()
|> check_for_game_over()
{:ok, new_round_game}
end
defp check_for_game_over(game) do
if map_size(game.world.player_characters) < 2 do
%{game | status: :concluded}
else
game
end
end
end
To make the test pass, the associated GameSessionSummary
record in the database will need to update its :game_status
value to something other than :active
otherwise the game session will show up on the user dashboard.
A simple change to the update_summary_record
function in the SessionServer
module is all that is needed to accomplish this.
defp update_summary_record(game, join_code) do
Repo.transaction(fn ->
attrs = %{
game_id: game.id,
join_code: join_code,
game_status: game.status,
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
The test is now passing, but I’m seeing some inconsistent results when I run the full test suite.
Race conditions
There seems to be some issues with the browser assertions not running before the game session timer expires to start the next round especially when running on my laptop. I am simply hardcoding the next round to start with a timestamp 1 second in the future which is going to have some problems like this on less powerful machines. Increasing the hardcoded value could help with this, but is going to slow down the test suite.
Instead, I update the tests to trigger the round end manually from an existing function of the GameEngine
module and extend the next round timer to 30 seconds.
This should cover the case where the timer has already lapsed before the browser looks for the DOM elements and is ready to continue the test.
describe "when player has last character alive" do
setup [:register_user, :create_game_session]
test "game should no longer show on user dashboard", ctx do
session =
ctx.session
|> sign_in(as: ctx.user)
|> join_game_and_wait_until_loaded(ctx.join_code, "00:00")
{:ok, _} = GameEngine.end_round(ctx.game_id)
session
|> assert_text("YOU WON!")
|> visit("/")
|> assert_text("Welcome User")
|> refute_has(Query.text("Games in Progress"))
end
end
Stopping game session processes
Concluded games no longer show on user dashboards, but the game session processes are still running in memory. I need to terminate these sessions in a way that they won’t be restarted by the dynamic supervisor.
I create a new test scenario in the GameEngineTest
module to check the process is stopped after a game has concluded.
defmodule Minotaur.GameEngineTest do
# …
describe "round ends with 1 PC alive" do
setup [:create_game_near_end, :start_game_session, :p1_wins]
test "game status is concluded", %{game: game} do
assert :concluded == game.status
end
test "game session process is stopped", %{game: game} do
assert {:error, :not_alive} = GameEngine.get_game(game.id)
end
end
defp create_game_near_end(_) do
players = [
p1 = player_fixture(%{user_id: user_fixture().id}),
p2 = player_fixture(%{user_id: user_fixture().id})
]
game =
game_fixture(players: players)
|> put_in([:world, :player_characters, p1.id, :health], 1)
|> put_in([:world, :player_characters, p2.id, :health], 1)
[game: game, p1: p1, p2: p2]
end
defp start_game_session(ctx) do
join_code = "AABB"
{:ok, _pid} = GameEngine.continue_game(join_code, ctx.game)
[join_code: join_code]
end
defp p1_wins(%{game: game, p1: p1, p2: p2}) do
{:ok, game} = GameEngine.register_attack(game.id, p1.user_id, p2.id)
{:ok, game} = GameEngine.end_round(game.id)
[game: game]
end
end
The test case checking for the updated game status is already passing since this behavior was previously added. The second test case that checks for the process to not be alive is still failing so I’ll need to update the session server code to make it pass. I’ll leave this here for today, but I have a good point to jump back into next time with this test already in place.