From ad3d4722246e00acb33c79d538e4453246504e5f Mon Sep 17 00:00:00 2001 From: Askaholic Date: Thu, 6 May 2021 22:55:21 -0800 Subject: [PATCH] Extract game launching sequence --- server/ladder_service.py | 124 ++++++++++++++++-------- tests/unit_tests/test_ladder_service.py | 77 ++++++++++++++- 2 files changed, 158 insertions(+), 43 deletions(-) diff --git a/server/ladder_service.py b/server/ladder_service.py index 27929f885..56032e4aa 100644 --- a/server/ladder_service.py +++ b/server/ladder_service.py @@ -30,12 +30,20 @@ ) from .decorators import with_logger from .game_service import GameService -from .games import InitMode, LadderGame +from .games import Game, InitMode, LadderGame from .matchmaker import MapPool, MatchmakerQueue, OnMatchedCallback, Search from .players import Player, PlayerState +from .protocol import DisconnectedError from .types import GameLaunchOptions, Map, NeroxisGeneratedMap +class GameLaunchError(Exception): + """When a game failed to launch because some players did not connect""" + + def __init__(self, players: List[Player]): + self.players = players + + @with_logger class LadderService(Service): """ @@ -446,47 +454,10 @@ def get_player_mean(player): game.set_player_option(player.id, "Color", slot) game_options = queue.get_game_options() - if game_options: - game.gameOptions.update(game_options) - - self._logger.debug("Starting ladder game: %s", game) - # Options shared by all players - options = GameLaunchOptions( - mapname=game.map_name, - expected_players=len(all_players), - game_options=game_options - ) - def game_options(player: Player) -> GameLaunchOptions: - return options._replace( - team=game.get_player_option(player.id, "Team"), - faction=player.faction, - map_position=game.get_player_option(player.id, "StartSpot") - ) - - await host.lobby_connection.launch_game( - game, is_host=True, options=game_options(host) - ) - try: - await game.wait_hosted(60) - finally: - # TODO: Once the client supports `match_cancelled`, don't - # send `launch_game` to the client if the host timed out. Until - # then, failing to send `launch_game` will cause the client to - # think it is searching for ladder, even though the server has - # already removed it from the queue. - - await asyncio.gather(*[ - guest.lobby_connection.launch_game( - game, is_host=False, options=game_options(guest) - ) - for guest in all_guests - if guest.lobby_connection is not None - ]) - await game.wait_launched(60 + 10 * len(all_guests)) - self._logger.debug("Ladder game launched successfully %s", game) + await self.launch_game(game, host, all_guests, game_options) except Exception as e: - if isinstance(e, asyncio.TimeoutError): + if isinstance(e.__cause__, asyncio.TimeoutError): self._logger.info( "Ladder game failed to start! %s setup timed out", game @@ -503,6 +474,79 @@ def game_options(player: Player) -> GameLaunchOptions: player.state = PlayerState.IDLE player.write_message(msg) + async def launch_game( + self, + game: Game, + host: Player, + guests: List[Player], + game_options: Optional[dict] = {} + ) -> None: + self._logger.debug("Starting ladder game: %s", game) + all_players = (host, *guests) + + if game_options: + game.gameOptions.update(game_options) + + # Options shared by all players + options = GameLaunchOptions( + mapname=game.map_name, + expected_players=len(all_players), + game_options=game_options + ) + + def game_options(player: Player) -> GameLaunchOptions: + return options._replace( + team=game.get_player_option(player.id, "Team"), + faction=player.faction, + map_position=game.get_player_option(player.id, "StartSpot") + ) + + # Check if anyone DC'd from the server entirely + disconnected_players = [ + player for player in all_players + if player.lobby_connection is None + ] + if disconnected_players: + raise GameLaunchError(disconnected_players) + + try: + await host.lobby_connection.launch_game( + game, + is_host=True, + options=game_options(host) + ) + await game.wait_hosted(60) + except (asyncio.TimeoutError, DisconnectedError) as e: + raise GameLaunchError([host]) from e + finally: + # TODO: Once the client supports `match_cancelled`, don't + # send `launch_game` to the client if the host timed out. Until + # then, failing to send `launch_game` will cause the client to + # think it is searching for ladder, even though the server has + # already removed it from the queue. + + await asyncio.gather(*[ + guest.lobby_connection.launch_game( + game, + is_host=False, + options=game_options(guest) + ) + for guest in guests + if guest.lobby_connection is not None + ], return_exceptions=True) + + try: + await game.wait_launched(60 + 10 * len(guests)) + except asyncio.TimeoutError as e: + connected_players = game.players + disconnected_players = [ + player for player in all_players + if player not in connected_players + ] + raise GameLaunchError(disconnected_players) from e + + self._logger.debug("Ladder game launched successfully %s", game) + async def get_game_history( self, players: List[Player], diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 9ed034961..410b2f430 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -6,15 +6,17 @@ from hypothesis import given, settings from hypothesis import strategies as st -from server import LadderService, LobbyConnection +from server import LobbyConnection from server.db.models import matchmaker_queue, matchmaker_queue_map_pool -from server.games import LadderGame -from server.ladder_service import game_name +from server.games import GameState, LadderGame +from server.ladder_service import GameLaunchError, LadderService, game_name from server.matchmaker import MapPool, MatchmakerQueue from server.players import PlayerState +from server.protocol import DisconnectedError from server.rating import RatingType from server.types import Map, NeroxisGeneratedMap from tests.conftest import make_player +from tests.unit_tests.conftest import add_connected_player from tests.utils import autocontext, fast_forward from .strategies import st_players @@ -22,6 +24,17 @@ pytestmark = pytest.mark.asyncio +@pytest.fixture +def game(database, game_service, game_stats_service): + return LadderGame( + 42, + database, + game_service, + game_stats_service, + rating_type=RatingType.GLOBAL + ) + + async def test_queue_initialization(database, game_service): ladder_service = LadderService(database, game_service) @@ -217,6 +230,64 @@ async def test_start_game_timeout( # assert p2.state is PlayerState.IDLE +async def test_launch_game_error_host( + ladder_service: LadderService, + game, + player_factory +): + # Host disconnected before game launch + host = player_factory("Dostya", player_id=1, lobby_connection_spec=None) + guest = player_factory("Rhiza", player_id=2, lobby_connection_spec="auto") + + with pytest.raises(GameLaunchError) as ex_info: + await ladder_service.launch_game(game, host, [guest]) + assert ex_info.value.players == [host] + + # Host disconnected during game launch + host = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") + disconnected_error = DisconnectedError("Test") + host.lobby_connection.launch_game.side_effect = disconnected_error + + with pytest.raises(GameLaunchError) as ex_info: + await ladder_service.launch_game(game, host, [guest]) + assert ex_info.value.players == [host] + assert ex_info.value.__cause__ == disconnected_error + + +async def test_launch_game_error_many_disconnected( + ladder_service: LadderService, + game, + player_factory +): + # Multiple players disconnect before game launch + host = player_factory("Dostya", player_id=1, lobby_connection_spec=None) + p2 = player_factory("Rhiza", player_id=2, lobby_connection_spec="auto") + p3 = player_factory("QAI", player_id=3, lobby_connection_spec=None) + p4 = player_factory("Brackman", player_id=4, lobby_connection_spec="auto") + + with pytest.raises(GameLaunchError) as ex_info: + await ladder_service.launch_game(game, host, [p2, p3, p4]) + assert ex_info.value.players == [host, p3] + + # Players 3 and 4 disconnect during game launch + host = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") + p3 = player_factory("QAI", player_id=3, lobby_connection_spec="auto") + disconnected_error = DisconnectedError("Test") + timeout_error = asyncio.TimeoutError() + p3.lobby_connection.launch_game.side_effect = disconnected_error + p4.lobby_connection.launch_game.side_effect = disconnected_error + game.wait_hosted = CoroutineMock() + game.wait_launched = CoroutineMock(side_effect=timeout_error) + game.state = GameState.LOBBY + add_connected_player(game, host) + add_connected_player(game, p2) + + with pytest.raises(GameLaunchError) as ex_info: + await ladder_service.launch_game(game, host, [p2, p3, p4]) + assert ex_info.value.players == [p3, p4] + assert ex_info.value.__cause__ == timeout_error + + @given( player1=st_players("p1", player_id=1, lobby_connection_spec="mock"), player2=st_players("p2", player_id=2, lobby_connection_spec="mock"),