diff --git a/server/ladder_service.py b/server/ladder_service.py index 477ef462c..b64e70431 100644 --- a/server/ladder_service.py +++ b/server/ladder_service.py @@ -1,7 +1,7 @@ import asyncio import contextlib from collections import defaultdict -from typing import Dict, List, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple import aiocron from sqlalchemy import and_, func, select, text @@ -158,11 +158,16 @@ async def fetch_matchmaker_queues(self, conn) -> Dict[str, Tuple[int, int, int]] )) return matchmaker_queues - async def start_search(self, initiator: Player, search: Search, queue_name: str): + async def start_search( + self, + initiator: Player, + search: Search, + queue_name: str + ): # TODO: Consider what happens if players disconnect while starting # search. Will need a message to inform other players in the search # that it has been cancelled. - self._cancel_existing_searches(initiator) + self._cancel_existing_searches(initiator, queue_name) tasks = [] for player in search.players: @@ -172,6 +177,12 @@ async def start_search(self, initiator: Player, search: Search, queue_name: str) if queue_name == "ladder1v1": tasks.append(self.inform_player(player)) + tasks.append(player.send_message({ + "command": "search_info", + "queue": queue_name, + "state": "start" + })) + try: await asyncio.gather(*tasks) except DisconnectedError: @@ -189,33 +200,52 @@ async def start_search(self, initiator: Player, search: Search, queue_name: str) asyncio.create_task(self.queues[queue_name].search(search)) - async def cancel_search(self, initiator: Player): - searches = self._cancel_existing_searches(initiator) + async def cancel_search( + self, + initiator: Player, + queue_name: Optional[str] = None + ): + searches = self._cancel_existing_searches(initiator, queue_name) tasks = [] - for search in searches: + for queue_name, search in searches: for player in search.players: + # FIXME: This is wrong for multiqueueing if player.state == PlayerState.SEARCHING_LADDER: player.state = PlayerState.IDLE if player.lobby_connection is not None: tasks.append(player.send_message({ - "command": "game_matchmaking", + "command": "search_info", + "queue": queue_name, "state": "stop" })) self._logger.info( - "%s stopped searching for ladder: %s", player, search + "%s stopped searching for %s: %s", initiator, queue_name, search ) await gather_without_exceptions(tasks, DisconnectedError) - def _cancel_existing_searches(self, initiator: Player) -> List[Search]: + def _cancel_existing_searches( + self, + initiator: Player, + queue_name: Optional[str] = None + ) -> List[Tuple[str, Search]]: + """ + Cancel search for a specific queue, or all searches if `queue_name` is + None. + """ + if queue_name: + queue_names = [queue_name] + else: + queue_names = list(self.queues) + searches = [] - for queue_name in self.queues: + for queue_name in queue_names: search = self.searches[queue_name].get(initiator) if search: search.cancel() - searches.append(search) + searches.append((queue_name, search)) del self.searches[queue_name][initiator] return searches @@ -338,7 +368,7 @@ async def start_game(self, host: Player, guest: Player): if not hosted: raise TimeoutError("Host left lobby") finally: - # TODO: Once the client supports `game_launch_cancelled`, don't + # 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 @@ -356,7 +386,7 @@ async def start_game(self, host: Player, guest: Player): self._logger.debug("Ladder game launched successfully") except Exception: self._logger.exception("Failed to start ladder game!") - msg = {"command": "game_launch_cancelled"} + msg = {"command": "match_cancelled"} with contextlib.suppress(DisconnectedError): await asyncio.gather( host.send_message(msg), diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 9b38c1d8a..90ad005bb 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -834,7 +834,7 @@ async def command_game_matchmaking(self, message): raise ClientError("Cannot host game. Please update your client to the newest version.") if state == "stop": - await self.ladder_service.cancel_search(self.player) + await self.ladder_service.cancel_search(self.player, mod) return if state == "start": diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 54afc3583..0948ab964 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -18,6 +18,7 @@ async def queue_player_for_matchmaking(user, lobby_server): 'state': 'start', 'faction': 'uef' }) + await read_until_command(proto, 'search_info') return proto @@ -39,6 +40,7 @@ async def queue_players_for_matchmaking(lobby_server): 'state': 'start', 'faction': 1 # Python client sends factions as numbers }) + await read_until_command(proto2, 'search_info') # If the players did not match, this will fail due to a timeout error await read_until_command(proto1, 'match_found') @@ -145,11 +147,11 @@ async def test_game_matchmaking_timeout(lobby_server): msg2 = await read_until_command(proto2, 'game_launch') # LEGACY BEHAVIOUR: The host does not respond with the appropriate GameState # so the match is cancelled. However, the client does not know how to - # handle `game_launch_cancelled` messages so we still send `game_launch` to + # handle `match_cancelled` messages so we still send `game_launch` to # prevent the client from showing that it is searching when it really isn't. msg1 = await read_until_command(proto1, 'game_launch') - await read_until_command(proto2, 'game_launch_cancelled') - await read_until_command(proto1, 'game_launch_cancelled') + await read_until_command(proto2, 'match_cancelled') + await read_until_command(proto1, 'match_cancelled') assert msg1['uid'] == msg2['uid'] assert msg1['mod'] == 'ladder1v1' @@ -168,10 +170,11 @@ async def test_game_matchmaking_cancel(lobby_server): }) # The server should respond with a matchmaking stop message - msg = await read_until_command(proto, 'game_matchmaking') + msg = await read_until_command(proto, 'search_info') assert msg == { - 'command': 'game_matchmaking', + 'command': 'search_info', + 'queue': 'ladder1v1', 'state': 'stop', } @@ -182,9 +185,9 @@ async def test_game_matchmaking_disconnect(lobby_server): # One player disconnects before the game has launched await proto1.close() - msg = await read_until_command(proto2, 'game_launch_cancelled') + msg = await read_until_command(proto2, 'match_cancelled') - assert msg == {'command': 'game_launch_cancelled'} + assert msg == {'command': 'match_cancelled'} @fast_forward(100) @@ -282,3 +285,41 @@ async def read_update_msg(): assert msg["queues"][0]["queue_name"] == "ladder1v1" assert len(msg["queues"][0]["boundary_80s"]) == 0 + + +@fast_forward(10) +async def test_search_info_messages(lobby_server): + _, _, proto = await connect_and_sign_in( + ("ladder1", "ladder1"), + lobby_server + ) + await read_until_command(proto, "game_info") + + # Start searching + await proto.send_message({ + "command": "game_matchmaking", + "state": "start", + "faction": "uef" + }) + msg = await read_until_command(proto, "search_info") + assert msg == { + "command": "search_info", + "queue": "ladder1v1", + "state": "start" + } + # TODO: Join a second queue here + + # Stop searching + await proto.send_message({ + "command": "game_matchmaking", + "state": "stop", + }) + msg = await read_until_command(proto, "search_info") + assert msg == { + "command": "search_info", + "queue": "ladder1v1", + "state": "stop" + } + + with pytest.raises(asyncio.TimeoutError): + await read_until_command(proto, "search_info", timeout=5) diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 93525b1d1..92a993774 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -4,7 +4,7 @@ import pytest from asynctest import CoroutineMock, exhaust_callbacks from server import LadderService -from server.matchmaker import MapPool, Search +from server.matchmaker import Search from server.players import PlayerState from server.types import Map from tests.utils import fast_forward @@ -49,10 +49,10 @@ async def test_start_game_timeout(ladder_service: LadderService, player_factory) await ladder_service.start_game(p1, p2) - p1.lobby_connection.send.assert_called_once_with({"command": "game_launch_cancelled"}) - p2.lobby_connection.send.assert_called_once_with({"command": "game_launch_cancelled"}) + p1.lobby_connection.send.assert_called_once_with({"command": "match_cancelled"}) + p2.lobby_connection.send.assert_called_once_with({"command": "match_cancelled"}) assert p1.lobby_connection.launch_game.called - # TODO: Once client supports `game_launch_cancelled` change this to `assert not ...` + # TODO: Once client supports `match_cancelled` change this to `assert not ...` assert p2.lobby_connection.launch_game.called @@ -79,8 +79,129 @@ async def test_inform_player(ladder_service: LadderService, player_factory): p1.lobby_connection.send.assert_called_once() -async def test_start_and_cancel_search(ladder_service: LadderService, - player_factory, event_loop): +async def test_search_info_message( + ladder_service: LadderService, + player_factory, + queue_factory, + event_loop +): + ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") + + p1 = player_factory( + "Dostya", + ladder_rating=(1000, 10), + with_lobby_connection=True + ) + p1.send_message = CoroutineMock() + p2 = player_factory( + "Rhiza", + ladder_rating=(1000, 10), + with_lobby_connection=True + ) + p2.send_message = CoroutineMock() + search1 = Search([p1, p2]) + + await ladder_service.start_search(p1, search1, "ladder1v1") + await exhaust_callbacks(event_loop) + + msg = { + "command": "search_info", + "queue": "ladder1v1", + "state": "start" + } + p1.send_message.assert_called_once_with(msg) + p2.send_message.assert_called_once_with(msg) + + p1.send_message.reset_mock() + p2.send_message.reset_mock() + search2 = Search([p1, p2]) + await ladder_service.start_search(p1, search2, "tmm2v2") + await exhaust_callbacks(event_loop) + + msg = { + "command": "search_info", + "queue": "tmm2v2", + "state": "start" + } + p1.send_message.assert_called_once_with(msg) + p2.send_message.assert_called_once_with(msg) + + p1.send_message.reset_mock() + p2.send_message.reset_mock() + await ladder_service.cancel_search(p1) + await exhaust_callbacks(event_loop) + + call_args = [ + mock.call({ + "command": "search_info", + "queue": "ladder1v1", + "state": "stop" + }), + mock.call({ + "command": "search_info", + "queue": "tmm2v2", + "state": "stop" + }), + ] + + assert p1.send_message.call_args_list == call_args + assert p2.send_message.call_args_list == call_args + + +async def test_start_search_multiqueue( + ladder_service: LadderService, + player_factory, + queue_factory, + event_loop +): + ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") + + p1 = player_factory( + "Dostya", ladder_rating=(1000, 10), with_lobby_connection=True + ) + p2 = player_factory( + "Rhiza", ladder_rating=(1000, 10), with_lobby_connection=True + ) + search1 = Search([p1, p2]) + + await ladder_service.start_search(p1, search1, "ladder1v1") + await exhaust_callbacks(event_loop) + + assert ladder_service.searches == { + "ladder1v1": { + p1: search1 + } + } + + search2 = Search([p1, p2]) + await ladder_service.start_search(p1, search2, "tmm2v2") + await exhaust_callbacks(event_loop) + + assert ladder_service.searches == { + "ladder1v1": { + p1: search1 + }, + "tmm2v2": { + p1: search2 + } + } + + await ladder_service.cancel_search(p1, "tmm2v2") + await exhaust_callbacks(event_loop) + + assert ladder_service.searches == { + "ladder1v1": { + p1: search1 + }, + "tmm2v2": {} + } + + +async def test_start_and_cancel_search( + ladder_service: LadderService, + player_factory, + event_loop +): p1 = player_factory('Dostya', player_id=1, ladder_rating=(1500, 500), ladder_games=0) search = Search([p1]) @@ -99,8 +220,17 @@ async def test_start_and_cancel_search(ladder_service: LadderService, async def test_start_search_cancels_previous_search( - ladder_service: LadderService, player_factory, event_loop): - p1 = player_factory('Dostya', player_id=1, ladder_rating=(1500, 500), ladder_games=0) + ladder_service: LadderService, + player_factory, + event_loop +): + p1 = player_factory( + 'Dostya', + player_id=1, + ladder_rating=(1500, 500), + ladder_games=0, + with_lobby_connection=True + ) search1 = Search([p1]) @@ -153,7 +283,7 @@ async def test_cancel_twice(ladder_service: LadderService, player_factory): searches = ladder_service._cancel_existing_searches(p1) assert search.is_cancelled - assert searches == [search] + assert searches == [("ladder1v1", search)] assert not search2.is_cancelled searches = ladder_service._cancel_existing_searches(p1) @@ -161,7 +291,7 @@ async def test_cancel_twice(ladder_service: LadderService, player_factory): searches = ladder_service._cancel_existing_searches(p2) assert search2.is_cancelled - assert searches == [search2] + assert searches == [("ladder1v1", search2)] @fast_forward(5) diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index a6a86edae..7d7fa719c 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -824,7 +824,10 @@ async def test_command_game_matchmaking(lobbyconnection): 'state': 'stop' }) - lobbyconnection.ladder_service.cancel_search.assert_called_with(lobbyconnection.player) + lobbyconnection.ladder_service.cancel_search.assert_called_with( + lobbyconnection.player, + "ladder1v1" + ) async def test_command_matchmaker_info(lobbyconnection, ladder_service, queue_factory):