From da5ec4c42605b388c6133f8d0306b53f767c6230 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Fri, 31 Dec 2021 18:17:23 -0900 Subject: [PATCH 1/9] v2 - Remove Deprecations (#816) * Remove modvault * Remove create_account * Remove id and login from welcome message * Remove mod from game_matchmaking command * Remove QDataStreamProtocol * Remove 'faction' parameter from game_matchmaking command * Stop sending game_launch when match is cancelled * Remove InitMode * Remove search boundaries from matchmaker_info --- Dockerfile | 4 +- README.md | 18 --- e2e_tests/fafclient.py | 8 +- e2e_tests/test_login.py | 3 +- server/__init__.py | 4 +- server/games/__init__.py | 2 - server/games/coop.py | 3 +- server/games/custom_game.py | 3 +- server/games/game.py | 2 - server/games/ladder_game.py | 4 +- server/games/typedefs.py | 7 -- server/ladder_service/ladder_service.py | 12 +- server/lobbyconnection.py | 110 +----------------- server/matchmaker/matchmaker_queue.py | 2 - server/matchmaker/search.py | 20 ---- server/servercontext.py | 4 +- tests/conftest.py | 4 +- tests/integration_tests/conftest.py | 2 +- tests/integration_tests/test_game.py | 31 ++--- tests/integration_tests/test_login.py | 12 +- tests/integration_tests/test_matchmaker.py | 43 +++---- tests/integration_tests/test_modvault.py | 55 --------- .../integration_tests/test_server_instance.py | 23 +--- tests/integration_tests/test_servercontext.py | 4 +- .../integration_tests/test_teammatchmaker.py | 57 +++++---- tests/unit_tests/conftest.py | 4 +- tests/unit_tests/test_ladder_service.py | 7 +- tests/unit_tests/test_lobbyconnection.py | 31 +---- tests/unit_tests/test_matchmaker_queue.py | 9 -- tests/unit_tests/test_protocol.py | 58 +++++---- 30 files changed, 140 insertions(+), 406 deletions(-) delete mode 100644 tests/integration_tests/test_modvault.py diff --git a/Dockerfile b/Dockerfile index ce44f206c..a53b07b9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ USER faf # Main entrypoint and the default command that will be run CMD ["/usr/local/bin/python3", "main.py"] -# lobby server runs on 8001/tcp (QDataStream) and 8002/tcp (JSON) -EXPOSE 8001 8002 +# lobby server runs on 8002/tcp (JSON) +EXPOSE 8002 RUN python3 -V diff --git a/README.md b/README.md index 3ab5f9a05..dafcfd18b 100644 --- a/README.md +++ b/README.md @@ -82,26 +82,8 @@ list see [https://faforever.github.io/server/](https://faforever.github.io/serve The protocol is mainly JSON-encoded maps, containing at minimum a `command` key, representing the command to dispatch. -The wire format uses [QDataStream](http://doc.qt.io/qt-5/qdatastream.html) (UTF-16, BigEndian). - -For the lobbyconnection, each message is of the form: -``` -ACTION: QString -``` -With most carrying a footer containing: -``` -LOGIN: QString -SESSION: QString -``` - ## Incoming Packages -##### Mod Vault - -- (deprecated) `{command: modvault, type: start}`: show the last 100 mods -- (deprecated) `{command: modvault, type: like, uid: }`: check if user liked the mod, otherwise increase the like counter -- (deprecated) `{command: modvault, type: download, uid: }`: notify server about a download (for download counter), does not start the download - ##### Social - `{command: social_add, friend|foe: }`: Add a friend or foe - `{command: social_remove, friend|foe: }`: Remove a friend or foe diff --git a/e2e_tests/fafclient.py b/e2e_tests/fafclient.py index 967d06b3a..ae2c253f8 100644 --- a/e2e_tests/fafclient.py +++ b/e2e_tests/fafclient.py @@ -4,7 +4,7 @@ from websockets.client import connect as ws_connect -from server.protocol import QDataStreamProtocol, SimpleJsonProtocol +from server.protocol import SimpleJsonProtocol from tests.integration_tests.conftest import read_until, read_until_command from .websocket_protocol import WebsocketProtocol @@ -32,7 +32,7 @@ async def close(self): await self.proto.close() - async def connect(self, host, port, protocol_class=QDataStreamProtocol): + async def connect(self, host, port, protocol_class=SimpleJsonProtocol): self.proto = protocol_class( *(await asyncio.open_connection(host, port)) ) @@ -104,8 +104,8 @@ async def login(self, username, password): "unique_id": unique_id }) msg = await self.read_until_command("welcome") - self.player_id = msg["id"] - self.player_name = msg["login"] + self.player_id = msg["me"]["id"] + self.player_name = msg["me"]["login"] return msg def get_unique_id(self, session): diff --git a/e2e_tests/test_login.py b/e2e_tests/test_login.py index dc187fd83..a9db7fa4b 100644 --- a/e2e_tests/test_login.py +++ b/e2e_tests/test_login.py @@ -9,6 +9,5 @@ async def test_user_existence(client_factory, username): """Verify that these users exist on the test server""" client, welcome_message = await client_factory.login(username, "foo") - assert welcome_message["login"] == welcome_message["me"]["login"] == username - assert welcome_message["id"] == welcome_message["me"]["id"] + assert welcome_message["me"]["login"] == username assert client.is_connected() diff --git a/server/__init__.py b/server/__init__.py index 777cc64c2..a5d63c734 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -108,7 +108,7 @@ from .oauth_service import OAuthService from .party_service import PartyService from .player_service import PlayerService -from .protocol import Protocol, QDataStreamProtocol +from .protocol import Protocol, SimpleJsonProtocol from .rating_service.rating_service import RatingService from .servercontext import ServerContext from .stats.game_stats_service import GameStatsService @@ -231,7 +231,7 @@ async def listen( self, address: tuple[str, int], name: Optional[str] = None, - protocol_class: type[Protocol] = QDataStreamProtocol, + protocol_class: type[Protocol] = SimpleJsonProtocol, proxy: bool = False, ) -> ServerContext: """ diff --git a/server/games/__init__.py b/server/games/__init__.py index 631171a9f..beda09a3d 100644 --- a/server/games/__init__.py +++ b/server/games/__init__.py @@ -13,7 +13,6 @@ GameConnectionState, GameState, GameType, - InitMode, ValidityState, Victory, VisibilityState @@ -40,7 +39,6 @@ class FeaturedMod(NamedTuple): "GameOptions", "GameState", "GameType", - "InitMode", "LadderGame", "ValidityState", "Victory", diff --git a/server/games/coop.py b/server/games/coop.py index b6a4b27e9..0cc41c1e9 100644 --- a/server/games/coop.py +++ b/server/games/coop.py @@ -1,10 +1,9 @@ from .game import Game -from .typedefs import FA, GameType, InitMode, ValidityState, Victory +from .typedefs import FA, GameType, ValidityState, Victory class CoopGame(Game): """Class for coop game""" - init_mode = InitMode.NORMAL_LOBBY game_type = GameType.COOP def __init__(self, *args, **kwargs): diff --git a/server/games/custom_game.py b/server/games/custom_game.py index be9c97d34..88e39f1ea 100644 --- a/server/games/custom_game.py +++ b/server/games/custom_game.py @@ -4,12 +4,11 @@ from server.rating import RatingType from .game import Game -from .typedefs import GameType, InitMode, ValidityState +from .typedefs import GameType, ValidityState @with_logger class CustomGame(Game): - init_mode = InitMode.NORMAL_LOBBY game_type = GameType.CUSTOM def __init__(self, id, *args, **kwargs): diff --git a/server/games/game.py b/server/games/game.py index fe47226ec..de543e87a 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -40,7 +40,6 @@ GameConnectionState, GameState, GameType, - InitMode, ValidityState, Victory, VisibilityState @@ -55,7 +54,6 @@ class Game: """ Object that lasts for the lifetime of a game on FAF. """ - init_mode = InitMode.NORMAL_LOBBY game_type = GameType.CUSTOM def __init__( diff --git a/server/games/ladder_game.py b/server/games/ladder_game.py index 475196bcb..a1d444a3d 100644 --- a/server/games/ladder_game.py +++ b/server/games/ladder_game.py @@ -7,7 +7,7 @@ from .game import Game from .game_results import ArmyOutcome, GameOutcome -from .typedefs import GameState, GameType, InitMode +from .typedefs import GameState, GameType logger = logging.getLogger(__name__) @@ -23,8 +23,6 @@ def __init__(self, player: Player): class LadderGame(Game): """Class for 1v1 ladder games""" - - init_mode = InitMode.AUTO_LOBBY game_type = GameType.MATCHMAKER def __init__(self, id, *args, **kwargs): diff --git a/server/games/typedefs.py b/server/games/typedefs.py index 8d8142f7d..8605004ff 100644 --- a/server/games/typedefs.py +++ b/server/games/typedefs.py @@ -22,12 +22,6 @@ class GameConnectionState(Enum): ENDED = 3 -@unique -class InitMode(Enum): - NORMAL_LOBBY = 0 - AUTO_LOBBY = 1 - - @unique class GameType(Enum): COOP = "coop" @@ -224,7 +218,6 @@ class FA(object): "GameConnectionState", "GameState", "GameType", - "InitMode", "TeamRatingSummary", "ValidityState", "Victory", diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index ee73d3061..294e3ea33 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -33,7 +33,7 @@ from server.decorators import with_logger from server.exceptions import DisabledError from server.game_service import GameService -from server.games import InitMode, LadderGame +from server.games import LadderGame from server.games.ladder_game import GameClosedError from server.ladder_service.game_name import game_name from server.ladder_service.violation_service import ViolationService @@ -535,7 +535,6 @@ def get_displayed_rating(player: Player) -> float: rating_type=queue.rating_type, max_players=len(all_players), ) - game.init_mode = InitMode.AUTO_LOBBY game.set_name_unchecked(game_name(team1, team2)) team1 = sorted(team1, key=get_displayed_rating) @@ -643,13 +642,8 @@ async def launch_match( await game.wait_hosted(60) except asyncio.TimeoutError: raise NotConnectedError([host]) - 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. + try: # Launch the guests not_connected_guests = [ player for player in guests @@ -666,7 +660,7 @@ async def launch_match( is_host=False, options=make_game_options(guest) ) - try: + await game.wait_launched(60 + 10 * len(guests)) except asyncio.TimeoutError: connected_players = game.get_connected_players() diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index e69613c89..7f375bc26 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -4,10 +4,7 @@ import asyncio import contextlib -import json import random -import urllib.parse -import urllib.request from datetime import datetime from functools import wraps from typing import Optional @@ -46,7 +43,7 @@ Game, GameConnectionState, GameState, - InitMode, + GameType, VisibilityState ) from .geoip_service import GeoIpService @@ -140,7 +137,6 @@ async def ensure_authenticated(self, cmd): "Bottleneck", # sent by the game during reconnect "ask_session", "auth", - "create_account", "hello", "ping", "pong", @@ -222,9 +218,6 @@ async def command_ping(self, msg): async def command_pong(self, msg): pass - async def command_create_account(self, message): - raise ClientError("FAF no longer supports direct registration. Please use the website to register.", recoverable=True) - async def command_coop_list(self, message): """Request for coop map list""" async with self._db.acquire() as conn: @@ -548,14 +541,6 @@ async def command_auth(self, message): ) raise BanError(ban_expiry, ban_reason) - # DEPRECATED: IRC passwords are handled outside of the lobby server. - # This message remains here for backwards compatibility, but the data - # sent is meaningless and can be ignored by clients. - await self.send({ - "command": "irc_password", - "password": "deprecated" - }) - await self.on_player_login( player_id, username, unique_id, auth_method ) @@ -657,10 +642,6 @@ async def on_player_login( await self.send({ "command": "welcome", "me": self.player.to_dict(), - - # For backwards compatibility for old clients. For now. - "id": self.player.id, - "login": username }) # Tell player about everybody online. This must happen after "welcome". @@ -894,7 +875,7 @@ async def command_game_join(self, message): }) return - if game.init_mode != InitMode.NORMAL_LOBBY: + if game.game_type not in (GameType.CUSTOM, GameType.COOP): raise ClientError("The game cannot be joined in this way.") if game.password != password: @@ -909,9 +890,7 @@ async def command_game_join(self, message): @ice_only async def command_game_matchmaking(self, message): - queue_name = str( - message.get("queue_name") or message.get("mod", "ladder1v1") - ) + queue_name = str(message.get("queue_name", "ladder1v1")) state = str(message["state"]) if state == "stop": @@ -946,13 +925,6 @@ async def command_game_matchmaking(self, message): recoverable=True ) - # TODO: Remove this legacy behavior, use party instead - if "faction" in message: - party.set_factions( - self.player, - [Faction.from_value(message["faction"])] - ) - self.ladder_service.start_search( players, queue_name=queue_name, @@ -1078,8 +1050,6 @@ def _prepare_launch_game( # options are. Currently, options for ladder are hardcoded into the # client. "name": game.name, - # DEPRICATED: init_mode can be inferred from game_type - "init_mode": game.init_mode.value, "game_type": game.game_type.value, "rating_type": game.rating_type, **options._asdict() @@ -1087,80 +1057,6 @@ def _prepare_launch_game( return {k: v for k, v in cmd.items() if v is not None} - async def command_modvault(self, message): - type = message["type"] - - async with self._db.acquire() as conn: - if type == "start": - result = await conn.execute("SELECT uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon FROM table_mod ORDER BY likes DESC LIMIT 100") - - for row in result: - uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon = (row[i] for i in range(12)) - try: - link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) - thumbstr = "" - if icon: - thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) - - out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], - comments=[], description=description, played=played, likes=likes, - downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, - ui=ui) - await self.send(out) - except Exception: - self._logger.error(f"Error handling table_mod row (uid: {uid})", exc_info=True) - - elif type == "like": - canLike = True - uid = message["uid"] - result = await conn.execute( - "SELECT uid, name, version, author, ui, date, downloads, " - "likes, played, description, filename, icon, likers FROM " - "`table_mod` WHERE uid = :uid LIMIT 1", - uid=uid - ) - - row = result.fetchone() - uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon, likerList = (row[i] for i in range(13)) - link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) - thumbstr = "" - if icon: - thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) - - out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], - comments=[], description=description, played=played, likes=likes + 1, - downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, - ui=ui) - - try: - likers = json.loads(likerList) - if self.player.id in likers: - canLike = False - else: - likers.append(self.player.id) - except Exception: - likers = [] - - # TODO: Avoid sending all the mod info in the world just because we liked it? - if canLike: - await conn.execute( - "UPDATE mod_stats s " - "JOIN mod_version v ON v.mod_id = s.mod_id " - "SET s.likes = s.likes + 1, likers=:l WHERE v.uid=:id", - l=json.dumps(likers), - id=uid - ) - await self.send(out) - - elif type == "download": - uid = message["uid"] - await conn.execute( - "UPDATE mod_stats s " - "JOIN mod_version v ON v.mod_id = s.mod_id " - "SET downloads=downloads+1 WHERE v.uid = %s", uid) - else: - raise ValueError("invalid type argument") - # DEPRECATED: ICE servers are handled outside of the lobby server. # This message remains here for backwards compatibility, but the list # of servers will always be empty. diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 778407981..089dc6412 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -288,8 +288,6 @@ def to_dict(self): ndigits=2 ), "num_players": self.num_players, - "boundary_80s": [search.boundary_80 for search in self._queue.keys()], - "boundary_75s": [search.boundary_75 for search in self._queue.keys()], # TODO: Remove, the client should query the API for this "team_size": self.team_size, } diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index 962b1b98b..eb0229943 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -1,6 +1,5 @@ import asyncio import itertools -import math import statistics import time from typing import Any, Callable, Optional @@ -112,25 +111,6 @@ def displayed_ratings(self) -> list[float]: """ return [rating.displayed() for rating in self.raw_ratings] - def _nearby_rating_range(self, delta: int) -> tuple[int, int]: - """ - Returns 'boundary' mu values for player matching. Adjust delta for - different game qualities. - """ - mu, _ = self.ratings[0] # Takes the rating of the first player, only works for 1v1 - rounded_mu = int(math.ceil(mu / 10) * 10) # Round to 10 - return rounded_mu - delta, rounded_mu + delta - - @property - def boundary_80(self) -> tuple[int, int]: - """ Achieves roughly 80% quality. """ - return self._nearby_rating_range(200) - - @property - def boundary_75(self) -> tuple[int, int]: - """ Achieves roughly 75% quality. FIXME - why is it MORE restrictive??? """ - return self._nearby_rating_range(100) - @property def failed_matching_attempts(self) -> int: return self._failed_matching_attempts diff --git a/server/servercontext.py b/server/servercontext.py index d6feb95af..36859213c 100644 --- a/server/servercontext.py +++ b/server/servercontext.py @@ -18,7 +18,7 @@ from .core import Service from .decorators import with_logger from .lobbyconnection import LobbyConnection -from .protocol import DisconnectedError, Protocol, QDataStreamProtocol +from .protocol import DisconnectedError, Protocol, SimpleJsonProtocol from .types import Address MiB = 2 ** 20 @@ -36,7 +36,7 @@ def __init__( name: str, connection_factory: Callable[[], LobbyConnection], services: Iterable[Service], - protocol_class: type[Protocol] = QDataStreamProtocol, + protocol_class: type[Protocol] = SimpleJsonProtocol, ): super().__init__() self.name = name diff --git a/tests/conftest.py b/tests/conftest.py index 8d550740e..cbcfffa5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ CoopGame, FeaturedModType, Game, - InitMode, + GameType, ValidityState ) from server.geoip_service import GeoIpService @@ -241,7 +241,7 @@ def make_game(database, uid, players, game_type=Game): players.joining.getGame = mock.AsyncMock(return_value=game) players.peer.getGame = mock.AsyncMock(return_value=game) game.host = players.hosting - game.init_mode = InitMode.NORMAL_LOBBY + game.game_type = GameType.CUSTOM game.name = "Some game name" game.id = uid return game diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 7e0a69184..ab7683da6 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -502,7 +502,7 @@ async def connect_and_sign_in( session = await get_session(proto) await perform_login(proto, credentials) hello = await read_until_command(proto, "welcome", timeout=120) - player_id = hello["id"] + player_id = hello["me"]["id"] return player_id, session, proto diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index d4bb41221..6c04251e6 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -138,10 +138,13 @@ async def open_fa(proto): async def start_search(proto, queue_name="ladder1v1"): + await proto.send_message({ + "command": "set_party_factions", + "factions": ["uef"] + }) await proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef", "queue_name": queue_name }) return await read_until_command( @@ -153,7 +156,11 @@ async def start_search(proto, queue_name="ladder1v1"): ) -async def queue_player_for_matchmaking(user, lobby_server, queue_name="ladder1v1"): +async def queue_player_for_matchmaking( + user, + lobby_server, + queue_name="ladder1v1" +): player_id, _, proto = await connect_and_sign_in(user, lobby_server) await read_until_command(proto, "game_info") await start_search(proto, queue_name) @@ -161,27 +168,21 @@ async def queue_player_for_matchmaking(user, lobby_server, queue_name="ladder1v1 return player_id, proto -async def queue_players_for_matchmaking(lobby_server, queue_name: str = "ladder1v1"): +async def queue_players_for_matchmaking( + lobby_server, + queue_name: str = "ladder1v1" +): player1_id, proto1 = await queue_player_for_matchmaking( ("ladder1", "ladder1"), lobby_server, queue_name ) - player2_id, _, proto2 = await connect_and_sign_in( + player2_id, proto2 = await queue_player_for_matchmaking( ("ladder2", "ladder2"), - lobby_server + lobby_server, + queue_name ) - await read_until_command(proto2, "game_info") - - await proto2.send_message({ - "command": "game_matchmaking", - "state": "start", - "faction": 1, # Python client sends factions as numbers - "queue_name": queue_name - }) - await read_until_command(proto2, "search_info", state="start") - # If the players did not match, this will fail due to a timeout error await read_until_command(proto1, "match_found", timeout=30) await read_until_command(proto2, "match_found") diff --git a/tests/integration_tests/test_login.py b/tests/integration_tests/test_login.py index 179d71214..9fd6f795b 100644 --- a/tests/integration_tests/test_login.py +++ b/tests/integration_tests/test_login.py @@ -89,7 +89,7 @@ async def test_server_ban_revoked_or_expired(lobby_server, user): msg = await proto.read_message() assert msg["command"] == "welcome" - assert msg["login"] == user + assert msg["me"]["login"] == user async def test_server_login_valid(lobby_server): @@ -118,8 +118,6 @@ async def test_server_login_valid(lobby_server): assert msg == { "command": "welcome", "me": me, - "id": 3, - "login": "Rhiza" } msg = await proto.read_message() assert msg == { @@ -163,8 +161,6 @@ async def test_server_login_valid_admin(lobby_server): assert msg == { "command": "welcome", "me": me, - "id": 1, - "login": "test" } msg = await proto.read_message() assert msg == { @@ -207,8 +203,6 @@ async def test_server_login_valid_moderator(lobby_server): assert msg == { "command": "welcome", "me": me, - "id": 20, - "login": "moderator" } msg = await proto.read_message() assert msg == { @@ -280,8 +274,6 @@ async def test_server_login_token_valid(lobby_server, jwk_priv_key, jwk_kid): "unique_id": "some_id" }) - msg = await proto.read_message() - assert msg["command"] == "irc_password" msg = await proto.read_message() me = { "id": 3, @@ -305,8 +297,6 @@ async def test_server_login_token_valid(lobby_server, jwk_priv_key, jwk_kid): assert msg == { "command": "welcome", "me": me, - "id": 3, - "login": "Rhiza" } msg = await proto.read_message() assert msg == { diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 30ba1cbd9..835584146 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -51,7 +51,6 @@ async def test_game_launch_message(lobby_server): "uid": 41956, "mod": "ladder1v1", "name": "ladder1 Vs ladder2", - "init_mode": 1, "game_type": "matchmaker", "rating_type": "ladder_1v1", "team": 2, @@ -183,20 +182,11 @@ async def test_game_matchmaking_start_while_matched(lobby_server): async def test_game_matchmaking_timeout(lobby_server, game_service): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) - msg1, msg2 = await asyncio.gather( - idle_response(proto1, timeout=120), - idle_response(proto2, timeout=120) - ) - # 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 `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 idle_response(proto1, timeout=120) await read_until_command(proto2, "match_cancelled", timeout=120) await read_until_command(proto1, "match_cancelled", timeout=120) - assert msg1["uid"] == msg2["uid"] assert msg1["mod"] == "ladder1v1" - assert msg2["mod"] == "ladder1v1" # Ensure that the game is cleaned up await read_until_command( @@ -207,7 +197,15 @@ async def test_game_matchmaking_timeout(lobby_server, game_service): ) assert game_service._games == {} - # Player's state is reset once they leave the game + # Player's state is not reset immediately + await proto1.send_message({ + "command": "game_matchmaking", + "state": "start", + }) + with pytest.raises(asyncio.TimeoutError): + await read_until_command(proto1, "search_info", state="start", timeout=5) + + # Player's state is only reset once they leave the game await proto1.send_message({ "command": "GameState", "target": "game", @@ -216,18 +214,15 @@ async def test_game_matchmaking_timeout(lobby_server, game_service): await proto1.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef" }) await read_until_command(proto1, "search_info", state="start", timeout=5) - # And not before they've left the game + # But it is reset for the player who didn't make it into the game await proto2.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef" }) - with pytest.raises(asyncio.TimeoutError): - await read_until_command(proto2, "search_info", state="start", timeout=5) + await read_until_command(proto2, "search_info", state="start", timeout=5) @fast_forward(120) @@ -443,14 +438,12 @@ async def test_matchmaker_info_message(lobby_server, mocker): for queue in msg["queues"]: assert "queue_name" in queue assert "team_size" in queue - assert "num_players" in queue assert queue["queue_pop_time"] == "2019-07-01T16:53:21+00:00" assert queue["queue_pop_time_delta"] == math.ceil( config.QUEUE_POP_TIME_MAX / 2 ) - assert queue["boundary_80s"] == [] - assert queue["boundary_75s"] == [] + assert queue["num_players"] == 0 @fast_forward(10) @@ -477,14 +470,12 @@ async def test_command_matchmaker_info(lobby_server, mocker): for queue in msg["queues"]: assert "queue_name" in queue assert "team_size" in queue - assert "num_players" in queue assert queue["queue_pop_time"] == "2019-07-01T16:53:21+00:00" assert queue["queue_pop_time_delta"] == math.ceil( config.QUEUE_POP_TIME_MAX / 2 ) - assert queue["boundary_80s"] == [] - assert queue["boundary_75s"] == [] + assert queue["num_players"] == 0 @fast_forward(10) @@ -511,10 +502,10 @@ async def read_update_msg(): queue_message = next( q for q in msg["queues"] if q["queue_name"] == "ladder1v1" ) - if not queue_message["boundary_80s"]: + if queue_message["num_players"] == 0: continue - assert len(queue_message["boundary_80s"]) == 1 + assert queue_message["num_players"] == 1 return @@ -529,7 +520,7 @@ async def read_update_msg(): msg = await read_until_command(proto, "matchmaker_info") queue_message = next(q for q in msg["queues"] if q["queue_name"] == "ladder1v1") - assert len(queue_message["boundary_80s"]) == 0 + assert queue_message["num_players"] == 0 @fast_forward(10) diff --git a/tests/integration_tests/test_modvault.py b/tests/integration_tests/test_modvault.py deleted file mode 100644 index d615dd8c5..000000000 --- a/tests/integration_tests/test_modvault.py +++ /dev/null @@ -1,55 +0,0 @@ -from .conftest import connect_and_sign_in, read_until_command - - -async def test_modvault_start(lobby_server): - _, _, proto = await connect_and_sign_in( - ("test", "test_password"), - lobby_server - ) - - await read_until_command(proto, "game_info") - - await proto.send_message({ - "command": "modvault", - "type": "start" - }) - - # Make sure all 5 mod version messages are sent - for _ in range(5): - await read_until_command(proto, "modvault_info") - - -async def test_modvault_like(lobby_server): - _, _, proto = await connect_and_sign_in( - ("test", "test_password"), - lobby_server - ) - - await read_until_command(proto, "game_info") - - await proto.send_message({ - "command": "modvault", - "type": "like", - "uid": "FFF" - }) - - msg = await read_until_command(proto, "modvault_info") - # Not going to verify the date - del msg["date"] - - assert msg == { - "command": "modvault_info", - "thumbnail": "", - "link": "http://content.faforever.com/faf/vault/noicon.zip", - "bugreports": [], - "comments": [], - "description": "The best version so far", - "played": 0, - "likes": 1.0, - "downloads": 0, - "uid": "FFF", - "name": "Mod without icon", - "version": 1, - "author": "foo", - "ui": 1 - } diff --git a/tests/integration_tests/test_server_instance.py b/tests/integration_tests/test_server_instance.py index 1b805d6a8..669672d71 100644 --- a/tests/integration_tests/test_server_instance.py +++ b/tests/integration_tests/test_server_instance.py @@ -1,6 +1,5 @@ from server import ServerInstance from server.config import config -from server.protocol import QDataStreamProtocol, SimpleJsonProtocol from tests.utils import fast_forward from .conftest import connect_and_sign_in, read_until @@ -52,33 +51,23 @@ async def test_multiple_contexts( ) broadcast_service.server = instance - await instance.listen(("127.0.0.1", 8111), QDataStreamProtocol) - await instance.listen(("127.0.0.1", 8112), SimpleJsonProtocol) + await instance.listen(("127.0.0.1", 8111)) + await instance.listen(("127.0.0.1", 8112)) ctx_1, ctx_2 = tuple(instance.contexts) - if ctx_1.protocol_class is SimpleJsonProtocol: - ctx_1, ctx_2 = ctx_2, ctx_1 # Connect one client to each context _, _, proto1 = await connect_and_sign_in( - await tmp_user("QDataStreamUser"), ctx_1 + await tmp_user("User"), ctx_1 ) _, _, proto2 = await connect_and_sign_in( - await tmp_user("SimpleJsonUser"), ctx_2 + await tmp_user("User"), ctx_2 ) # Verify that the users can see each other - await read_until( - proto1, - lambda m: has_player(m, "SimpleJsonUser1"), - timeout=5 - ) - await read_until( - proto2, - lambda m: has_player(m, "QDataStreamUser1"), - timeout=5 - ) + await read_until(proto1, lambda m: has_player(m, "User1"), timeout=5) + await read_until(proto2, lambda m: has_player(m, "User2"), timeout=5) # Host a game game_id = await host_game(proto1) diff --git a/tests/integration_tests/test_servercontext.py b/tests/integration_tests/test_servercontext.py index f4d4885e7..eafb97dc1 100644 --- a/tests/integration_tests/test_servercontext.py +++ b/tests/integration_tests/test_servercontext.py @@ -8,7 +8,7 @@ from server import ServerContext from server.core import Service from server.lobbyconnection import LobbyConnection -from server.protocol import DisconnectedError, QDataStreamProtocol +from server.protocol import DisconnectedError, SimpleJsonProtocol from tests.utils import exhaust_callbacks, fast_forward @@ -77,7 +77,7 @@ async def test_serverside_abort( srv, ctx = mock_context reader, writer = await asyncio.open_connection(*srv.sockets[0].getsockname()) with closing(writer): - proto = QDataStreamProtocol(reader, writer) + proto = SimpleJsonProtocol(reader, writer) await proto.send_message({"some_junk": True}) await exhaust_callbacks(event_loop) diff --git a/tests/integration_tests/test_teammatchmaker.py b/tests/integration_tests/test_teammatchmaker.py index e07d795ef..f2445730e 100644 --- a/tests/integration_tests/test_teammatchmaker.py +++ b/tests/integration_tests/test_teammatchmaker.py @@ -41,11 +41,17 @@ async def queue_players_for_matchmaking(lobby_server): read_until_command(proto, "game_info") for proto in protos ]) + await asyncio.gather(*[ + proto.send_message({ + "command": "set_party_factions", + "factions": ["uef"] + }) + for proto in protos + ]) await asyncio.gather(*[ proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef", "queue_name": "tmm2v2" }) for proto in protos @@ -76,19 +82,19 @@ async def test_info_message(lobby_server): "command": "game_matchmaking", "state": "start", "faction": "uef", - "mod": "tmm2v2" + "queue_name": "tmm2v2" }) msg = await read_until_command(proto, "matchmaker_info") assert msg["queues"] for queue in msg["queues"]: - boundaries = queue["boundary_80s"] + num_players = queue["num_players"] if queue["queue_name"] == "tmm2v2": - assert boundaries == [[300, 700]] + assert num_players == 1 else: - assert boundaries == [] + assert num_players == 0 @fast_forward(10) @@ -100,7 +106,7 @@ async def test_game_matchmaking(lobby_server): uid = set(msg["uid"] for msg in msgs) assert len(uid) == 1 for msg in msgs: - assert msg["init_mode"] == 1 + assert msg["game_type"] == "matchmaker" assert "None" not in msg["name"] assert msg["mod"] == "faf" assert msg["expected_players"] == 4 @@ -117,18 +123,27 @@ async def test_game_matchmaking_multiqueue(lobby_server): read_until_command(proto, "game_info") for proto in protos ]) + await protos[0].send_message({ + "command": "set_party_factions", + "factions": ["uef"] + }) await protos[0].send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef", "queue_name": "ladder1v1" }) await read_until_command(protos[0], "search_info", state="start") + await asyncio.gather(*[ + proto.send_message({ + "command": "set_party_factions", + "factions": ["aeon"] + }) + for proto in protos + ]) await asyncio.gather(*[ proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "aeon", "queue_name": "tmm2v2" }) for proto in protos @@ -145,7 +160,7 @@ async def test_game_matchmaking_multiqueue(lobby_server): uid = set(msg["uid"] for msg in msgs) assert len(uid) == 1 for msg in msgs: - assert msg["init_mode"] == 1 + assert msg["game_type"] == "matchmaker" assert "None" not in msg["name"] assert msg["mod"] == "faf" assert msg["expected_players"] == 4 @@ -225,7 +240,7 @@ async def test_game_matchmaking_with_parties(lobby_server): uid = set(msg["uid"] for msg in msgs) assert len(uid) == 1 for i, msg in enumerate(msgs): - assert msg["init_mode"] == 1 + assert msg["game_type"] == "matchmaker" assert "None" not in msg["name"] assert msg["mod"] == "faf" assert msg["expected_players"] == 4 @@ -299,7 +314,7 @@ async def test_newbie_matchmaking_with_parties(lobby_server): uid = set(msg["uid"] for msg in msgs) assert len(uid) == 1 for msg in msgs: - assert msg["init_mode"] == 1 + assert msg["game_type"] == "matchmaker" assert "None" not in msg["name"] assert msg["mod"] == "faf" assert msg["expected_players"] == 4 @@ -318,7 +333,6 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): await protos[0].send_message({ "command": "game_matchmaking", "state": "start", - "faction": "cybran", "queue_name": "ladder1v1" }) await read_until_command(protos[0], "search_info", state="start") @@ -326,7 +340,6 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "seraphim", "queue_name": "tmm2v2" }) for proto in protos @@ -346,6 +359,14 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): # We don't send the `GameState: Lobby` command so the game should time out await read_until_command(protos[0], "match_cancelled", timeout=120) + # Player's state is not reset immediately + await protos[0].send_message({ + "command": "game_matchmaking", + "state": "start", + }) + with pytest.raises(asyncio.TimeoutError): + await read_until_command(protos[1], "search_info", state="start", timeout=5) + # Player's state is reset once they leave the game await protos[0].send_message({ "command": "GameState", @@ -355,7 +376,6 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): await protos[0].send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef" }) await read_until_command( protos[0], @@ -409,7 +429,6 @@ async def test_game_matchmaking_multiqueue_multimatch(lobby_server): proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef", "queue_name": "ladder1v1" }) for proto in protos[:2] @@ -418,7 +437,6 @@ async def test_game_matchmaking_multiqueue_multimatch(lobby_server): proto.send_message({ "command": "game_matchmaking", "state": "start", - "faction": "aeon", "queue_name": "tmm2v2" }) for proto in protos @@ -475,7 +493,6 @@ async def test_game_matchmaking_timeout(lobby_server): await protos[0].send_message({ "command": "game_matchmaking", "state": "start", - "faction": "uef" }) await read_until_command( protos[0], @@ -604,7 +621,7 @@ async def test_ratings_initialized_based_on_global(lobby_server): "command": "game_matchmaking", "state": "start", "faction": "uef", - "mod": "tmm2v2" + "queue_name": "tmm2v2" }) # Need to connect another user to guarantee triggering a message containing @@ -671,7 +688,7 @@ async def test_ratings_initialized_based_on_global_persisted( await proto.send_message({ "command": "game_matchmaking", "state": "start", - "mod": "tmm2v2" + "queue_name": "tmm2v2" }) await read_until_command(proto, "search_info") @@ -771,7 +788,7 @@ async def test_party_cleanup_on_abort(lobby_server): await proto.send_message({ "command": "game_matchmaking", "state": "start", - "mod": "tmm2v2" + "queue_name": "tmm2v2" }) # The queue was successful. This would time out on failure. await read_until_command(proto, "search_info", state="start") diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 7d65cdbb7..83e67f909 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,7 +10,7 @@ from server.games import Game from server.ladder_service import LadderService from server.ladder_service.violation_service import ViolationService -from server.protocol import QDataStreamProtocol +from server.protocol import SimpleJsonProtocol @pytest.fixture(scope="session") @@ -87,7 +87,7 @@ def game_connection( database=database, game=game, player=players.hosting, - protocol=mock.create_autospec(QDataStreamProtocol), + protocol=mock.create_autospec(SimpleJsonProtocol), player_service=player_service, games=game_service ) diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 2e5feaf1a..2b2b979df 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -159,7 +159,6 @@ async def test_start_game_1v1( game = game_service[game_service.game_id_counter] assert player1.lobby_connection.write_launch_game.called - # TODO: Once client supports `match_cancelled` change this to `assert not` assert player2.lobby_connection.write_launch_game.called assert isinstance(game, LadderGame) assert game.rating_type == queue.rating_type @@ -239,8 +238,7 @@ async def test_start_game_timeout( "game_id": 41956 }) assert p1.lobby_connection.write_launch_game.called - # TODO: Once client supports `match_cancelled` change this to `assert not` - assert p2.lobby_connection.write_launch_game.called + assert not p2.lobby_connection.write_launch_game.called assert p1.state is PlayerState.IDLE assert p2.state is PlayerState.IDLE @@ -357,8 +355,7 @@ async def test_start_game_game_closed_by_host( "game_id": 41956 }) assert p1.lobby_connection.write_launch_game.called - # TODO: Once client supports `match_cancelled` change this to `assert not` - assert p2.lobby_connection.write_launch_game.called + assert not p2.lobby_connection.write_launch_game.called assert p1.state is PlayerState.IDLE assert p2.state is PlayerState.IDLE diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 48e812ecc..e363a32da 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -11,7 +11,7 @@ from server.exceptions import BanError, ClientError from server.game_service import GameService from server.gameconnection import GameConnection -from server.games import CustomGame, Game, GameState, InitMode, VisibilityState +from server.games import CustomGame, Game, GameState, GameType, VisibilityState from server.geoip_service import GeoIpService from server.ladder_service import LadderService from server.lobbyconnection import LobbyConnection @@ -20,7 +20,7 @@ from server.party_service import PartyService from server.player_service import PlayerService from server.players import PlayerState -from server.protocol import DisconnectedError, QDataStreamProtocol +from server.protocol import DisconnectedError, SimpleJsonProtocol from server.rating import InclusiveRange, RatingType from server.team_matchmaker import PlayerParty from server.types import Address @@ -69,7 +69,7 @@ def mock_games(): @pytest.fixture def mock_protocol(): - return mock.create_autospec(QDataStreamProtocol(mock.Mock(), mock.Mock())) + return mock.create_autospec(SimpleJsonProtocol(mock.Mock(), mock.Mock())) @pytest.fixture @@ -169,21 +169,6 @@ async def test_command_pong_does_nothing(lobbyconnection): lobbyconnection.send.assert_not_called() -async def test_command_create_account_returns_error(lobbyconnection): - lobbyconnection.send = mock.AsyncMock() - - await lobbyconnection.on_message_received({ - "command": "create_account" - }) - - lobbyconnection.send.assert_called_once_with({ - "command": "notice", - "style": "error", - "text": ("FAF no longer supports direct registration. " - "Please use the website to register.") - }) - - async def test_double_login(lobbyconnection, mock_players, player_factory): lobbyconnection.check_policy_conformity = mock.AsyncMock(return_value=True) old_player = player_factory(lobby_connection_spec="auto") @@ -318,7 +303,6 @@ async def test_command_game_join_calls_join_game( "uid": 42, "mod": "faf", "name": "Test Game Name", - "init_mode": InitMode.NORMAL_LOBBY.value, "game_type": "custom", "rating_type": "global", } @@ -358,7 +342,6 @@ async def test_command_game_join_uid_as_str( "mod": "faf", "uid": 42, "name": "Test Game Name", - "init_mode": InitMode.NORMAL_LOBBY.value, "game_type": "custom", "rating_type": "global", } @@ -377,7 +360,7 @@ async def test_command_game_join_without_password( lobbyconnection.game_service = game_service game = mock.create_autospec(Game) game.state = GameState.LOBBY - game.init_mode = InitMode.NORMAL_LOBBY + game.game_type = GameType.CUSTOM game.password = "password" game.game_mode = "faf" game.id = 42 @@ -422,7 +405,7 @@ async def test_command_game_join_game_not_found( }) -async def test_command_game_join_game_bad_init_mode( +async def test_command_game_join_matchmaker_game( lobbyconnection, game_service, test_game_info, @@ -432,7 +415,7 @@ async def test_command_game_join_game_bad_init_mode( lobbyconnection.game_service = game_service game = mock.create_autospec(Game) game.state = GameState.LOBBY - game.init_mode = InitMode.AUTO_LOBBY + game.game_type = GameType.MATCHMAKER game.id = 42 game.host = players.hosting game_service._games[42] = game @@ -989,8 +972,6 @@ async def test_command_matchmaker_info( "queue_pop_time_delta": 1.0, "team_size": 1, "num_players": 6, - "boundary_80s": [(1800, 2200), (300, 700), (800, 1200)], - "boundary_75s": [(1900, 2100), (400, 600), (900, 1100)] } ] }) diff --git a/tests/unit_tests/test_matchmaker_queue.py b/tests/unit_tests/test_matchmaker_queue.py index db058bc11..ac15699be 100644 --- a/tests/unit_tests/test_matchmaker_queue.py +++ b/tests/unit_tests/test_matchmaker_queue.py @@ -182,15 +182,6 @@ def test_search_no_match_wrong_type(matchmaker_players): assert not s1.matches_with(42) -def test_search_boundaries(matchmaker_players): - p1 = matchmaker_players[0] - s1 = Search([p1]) - assert p1.ratings[RatingType.LADDER_1V1][0] > s1.boundary_80[0] - assert p1.ratings[RatingType.LADDER_1V1][0] < s1.boundary_80[1] - assert p1.ratings[RatingType.LADDER_1V1][0] > s1.boundary_75[0] - assert p1.ratings[RatingType.LADDER_1V1][0] < s1.boundary_75[1] - - def test_search_expansion_controlled_by_failed_matching_attempts(matchmaker_players): p1 = matchmaker_players[1] s1 = Search([p1]) diff --git a/tests/unit_tests/test_protocol.py b/tests/unit_tests/test_protocol.py index b9596d8d3..af9d36023 100644 --- a/tests/unit_tests/test_protocol.py +++ b/tests/unit_tests/test_protocol.py @@ -15,14 +15,22 @@ ) +@pytest.fixture( + scope="session", + params=(QDataStreamProtocol, SimpleJsonProtocol) +) +def protocol_class(request): + return request.param + + @pytest.fixture(scope="session") -def qstream_protocol_context(): +def protocol_context(protocol_class): @asynccontextmanager async def make_protocol(): rsock, wsock = socketpair() with closing(wsock): reader, writer = await asyncio.open_connection(sock=rsock) - proto = QDataStreamProtocol(reader, writer) + proto = protocol_class(reader, writer) yield proto await proto.close() @@ -64,9 +72,9 @@ async def qstream_protocol(reader, writer): await proto.close() -@pytest.fixture(params=(QDataStreamProtocol, SimpleJsonProtocol)) -async def protocol(request, reader, writer): - proto = request.param(reader, writer) +@pytest.fixture +async def protocol(protocol_class, reader, writer): + proto = protocol_class(reader, writer) yield proto await proto.close() @@ -88,7 +96,7 @@ async def do_nothing(client_reader, client_writer): @pytest.fixture async def unix_protocol(unix_srv): (reader, writer) = await asyncio.open_unix_connection("/tmp/test.sock") - proto = QDataStreamProtocol(reader, writer) + proto = SimpleJsonProtocol(reader, writer) yield proto await proto.close() @@ -122,12 +130,12 @@ async def test_QDataStreamProtocol_recv_small_message(qstream_protocol, reader): assert message == {"some_header": True, "legacy": ["Goodbye"]} -async def test_QDataStreamProtocol_recv_malformed_message(qstream_protocol, reader): +async def test_recv_malformed_message(protocol, reader): reader.feed_data(b"\0") reader.feed_eof() - with pytest.raises(asyncio.IncompleteReadError): - await qstream_protocol.read_message() + with pytest.raises((asyncio.IncompleteReadError, json.JSONDecodeError)): + await protocol.read_message() async def test_QDataStreamProtocol_recv_large_array(qstream_protocol, reader): @@ -157,15 +165,14 @@ async def test_QDataStreamProtocol_unpacks_evil_qstring(qstream_protocol, reader "Message": ["message", 10], "with": 1000 }) +@example(message={ + "some_header": True, + "array": [str(i) for i in range(1520)] +}) @settings(max_examples=300) -async def test_QDataStreamProtocol_pack_unpack( - qstream_protocol_context, - message -): - async with qstream_protocol_context() as protocol: - protocol.reader.feed_data( - QDataStreamProtocol.pack_message(json.dumps(message)) - ) +async def test_pack_unpack(protocol_context, message): + async with protocol_context() as protocol: + protocol.reader.feed_data(protocol.encode_message(message)) assert message == await protocol.read_message() @@ -176,19 +183,10 @@ async def test_QDataStreamProtocol_pack_unpack( "Message": ["message", 10], "with": 1000 }) -async def test_QDataStreamProtocol_deterministic(message): - assert ( - QDataStreamProtocol.encode_message(message) == - QDataStreamProtocol.encode_message(message) == - QDataStreamProtocol.encode_message(message) - ) - - -async def test_QDataStreamProtocol_encode_ping_pong(): - assert QDataStreamProtocol.encode_message({"command": "ping"}) == \ - b"\x00\x00\x00\x0c\x00\x00\x00\x08\x00P\x00I\x00N\x00G" - assert QDataStreamProtocol.encode_message({"command": "pong"}) == \ - b"\x00\x00\x00\x0c\x00\x00\x00\x08\x00P\x00O\x00N\x00G" +async def test_deterministic(protocol_class, message): + bytes1 = protocol_class.encode_message(message) + bytes2 = protocol_class.encode_message(message) + assert bytes1 == bytes2 async def test_send_message_simultaneous_writes(unix_protocol): From 42da83ffc1fd0292eb9ffec232732c37f36b6dfe Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 26 Nov 2023 09:23:16 -0900 Subject: [PATCH 2/9] Remove deprecated player_info fields (#980) --- server/players.py | 4 ---- tests/integration_tests/test_login.py | 12 ------------ tests/integration_tests/test_teammatchmaker.py | 6 ------ tests/unit_tests/test_players.py | 3 --- 4 files changed, 25 deletions(-) diff --git a/server/players.py b/server/players.py index 5f8b37129..b0df2bc1a 100644 --- a/server/players.py +++ b/server/players.py @@ -155,10 +155,6 @@ def to_dict(self) -> dict: } for rating_type in self.ratings }, - # DEPRECATED: Use ratings instead - "global_rating": self.ratings[RatingType.GLOBAL], - "ladder_rating": self.ratings[RatingType.LADDER_1V1], - "number_of_games": self.game_count[RatingType.GLOBAL], } return {k: v for k, v in cmd.items() if v is not None} diff --git a/tests/integration_tests/test_login.py b/tests/integration_tests/test_login.py index 9fd6f795b..f08dc0dde 100644 --- a/tests/integration_tests/test_login.py +++ b/tests/integration_tests/test_login.py @@ -111,9 +111,6 @@ async def test_server_login_valid(lobby_server): "number_of_games": 2 } }, - "global_rating": [1650.0, 62.52], - "ladder_rating": [1650.0, 62.52], - "number_of_games": 2 } assert msg == { "command": "welcome", @@ -154,9 +151,6 @@ async def test_server_login_valid_admin(lobby_server): "number_of_games": 5 } }, - "global_rating": [2000.0, 125.0], - "ladder_rating": [2000.0, 125.0], - "number_of_games": 5, } assert msg == { "command": "welcome", @@ -196,9 +190,6 @@ async def test_server_login_valid_moderator(lobby_server): "number_of_games": 0 } }, - "global_rating": [1500, 500], - "ladder_rating": [1500, 500], - "number_of_games": 0 } assert msg == { "command": "welcome", @@ -290,9 +281,6 @@ async def test_server_login_token_valid(lobby_server, jwk_priv_key, jwk_kid): "number_of_games": 2 } }, - "global_rating": [1650.0, 62.52], - "ladder_rating": [1650.0, 62.52], - "number_of_games": 2 } assert msg == { "command": "welcome", diff --git a/tests/integration_tests/test_teammatchmaker.py b/tests/integration_tests/test_teammatchmaker.py index f2445730e..50c1e7368 100644 --- a/tests/integration_tests/test_teammatchmaker.py +++ b/tests/integration_tests/test_teammatchmaker.py @@ -610,9 +610,6 @@ async def test_ratings_initialized_based_on_global(lobby_server): "number_of_games": 5 } }, - "global_rating": [2000.0, 125.0], - "ladder_rating": [2000.0, 125.0], - "number_of_games": 5, } ] } @@ -651,9 +648,6 @@ async def test_ratings_initialized_based_on_global(lobby_server): "number_of_games": 0 } }, - "global_rating": [2000.0, 125.0], - "ladder_rating": [2000.0, 125.0], - "number_of_games": 5, } diff --git a/tests/unit_tests/test_players.py b/tests/unit_tests/test_players.py index 8768b51fb..9e99dc080 100644 --- a/tests/unit_tests/test_players.py +++ b/tests/unit_tests/test_players.py @@ -102,9 +102,6 @@ def test_serialize(): "number_of_games": 0 } }, - "global_rating": (1234, 68), - "ladder_rating": (1500, 230), - "number_of_games": 542, } From a366b71311979f5124a4eb911435eca5114864fd Mon Sep 17 00:00:00 2001 From: Askaholic Date: Fri, 19 Jun 2020 17:31:49 -0800 Subject: [PATCH 3/9] Add MatchOffer class --- server/matchmaker/__init__.py | 3 + server/matchmaker/match_offer.py | 87 ++++++++++++++++++++++++++++ tests/unit_tests/test_match_offer.py | 78 +++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 server/matchmaker/match_offer.py create mode 100644 tests/unit_tests/test_match_offer.py diff --git a/server/matchmaker/__init__.py b/server/matchmaker/__init__.py index 63068ebdf..031824957 100644 --- a/server/matchmaker/__init__.py +++ b/server/matchmaker/__init__.py @@ -5,6 +5,7 @@ games, currently just used for 1v1 ``ladder``. """ from .map_pool import MapPool +from .match_offer import MatchOffer, OfferTimeoutError from .matchmaker_queue import MatchmakerQueue from .pop_timer import PopTimer from .search import CombinedSearch, OnMatchedCallback, Search @@ -12,7 +13,9 @@ __all__ = ( "CombinedSearch", "MapPool", + "MatchOffer", "MatchmakerQueue", + "OfferTimeoutError", "OnMatchedCallback", "PopTimer", "Search", diff --git a/server/matchmaker/match_offer.py b/server/matchmaker/match_offer.py new file mode 100644 index 000000000..4af0efb22 --- /dev/null +++ b/server/matchmaker/match_offer.py @@ -0,0 +1,87 @@ +import asyncio +from datetime import datetime +from typing import Generator, Iterable + +from server.players import Player +from server.timing import datetime_now + + +class OfferTimeoutError(asyncio.TimeoutError): + pass + + +class MatchOffer(object): + """ + Track which players are ready for a match to begin. + + Once a player has become ready, they cannot become unready again. State + changes are eagerly broadcast to other players in the MatchOffer. + """ + + def __init__(self, players: Iterable[Player], expires_at: datetime): + self.expires_at = expires_at + self._players_ready = {player: False for player in players} + self.all_ready = asyncio.Event() + + def get_unready_players(self) -> Generator[Player, None, None]: + return ( + player for player, ready in self._players_ready.items() + if not ready + ) + + def get_ready_players(self) -> Generator[Player, None, None]: + return ( + player for player, ready in self._players_ready.items() + if ready + ) + + def ready_player(self, player: Player) -> None: + """ + Mark a player as ready. + + Broadcasts the state change to other players. + """ + if self._players_ready[player]: + # This client's state is probably out of date + player.write_message({ + "command": "match_info", + **self.to_dict(), + "ready": True + }) + else: + self._players_ready[player] = True + self.write_broadcast_update() + + if not self.all_ready.is_set() and all(self._players_ready.values()): + self.all_ready.set() + + async def wait_ready(self) -> None: + """Wait for all players to have readied up.""" + timeout = (self.expires_at - datetime_now()).total_seconds() + if timeout <= 0: + raise OfferTimeoutError() + + try: + await asyncio.wait_for(self.all_ready.wait(), timeout=timeout) + except asyncio.TimeoutError: + raise OfferTimeoutError() + + def write_broadcast_update(self) -> None: + """Queue the `match_info` message to be sent to all players in the + MatchOffer.""" + info = self.to_dict() + for player, ready in self._players_ready.items(): + player.write_message({ + "command": "match_info", + **info, + "ready": ready + }) + + def to_dict(self) -> dict: + return { + "expires_at": self.expires_at.isoformat(), + "expires_in": (self.expires_at - datetime_now()).total_seconds(), + "players_total": len(self._players_ready), + # Works because `True` is counted as 1 and `False` as 0 + "players_ready": sum(self._players_ready.values()) + } diff --git a/tests/unit_tests/test_match_offer.py b/tests/unit_tests/test_match_offer.py new file mode 100644 index 000000000..b75d7bb19 --- /dev/null +++ b/tests/unit_tests/test_match_offer.py @@ -0,0 +1,78 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +import pytest + +from server.matchmaker import MatchOffer, OfferTimeoutError +from server.timing import datetime_now +from tests.utils import fast_forward + + +@pytest.fixture +async def offer(player_factory): + return MatchOffer( + [player_factory(player_id=i) for i in range(5)], + datetime(2020, 1, 31, 14, 30, 36, tzinfo=timezone.utc) + ) + + +def test_match_offer_api(mocker, offer): + mocker.patch( + "server.matchmaker.match_offer.datetime_now", + return_value=datetime(2020, 1, 31, 14, 30, 16, tzinfo=timezone.utc) + ) + + assert offer.to_dict() == { + "expires_at": "2020-01-31T14:30:36+00:00", + "expires_in": 20, + "players_total": 5, + "players_ready": 0, + } + + assert len(list(offer.get_ready_players())) == 0 + assert len(list(offer.get_unready_players())) == 5 + + +def test_broadcast_called_on_ready(offer): + offer.write_broadcast_update = mock.Mock() + player = next(offer.get_unready_players()) + + offer.ready_player(player) + + offer.write_broadcast_update.assert_called_once() + + +def test_ready_player_bad_key(offer, player_factory): + with pytest.raises(KeyError): + offer.ready_player(player_factory(player_id=42)) + + +@pytest.mark.asyncio +async def test_wait_ready_timeout(offer): + with pytest.raises(OfferTimeoutError): + await offer.wait_ready() + + +@pytest.mark.asyncio +@fast_forward(5) +async def test_wait_ready_timeout_some_ready(offer): + offer.expires_at = datetime_now() + timedelta(seconds=5) + + players = offer.get_unready_players() + p1, p2 = next(players), next(players) + + offer.ready_player(p1) + offer.ready_player(p2) + + with pytest.raises(OfferTimeoutError): + await offer.wait_ready() + + +@pytest.mark.asyncio +async def test_wait_ready(offer): + offer.expires_at = datetime_now() + timedelta(seconds=5) + + for player in offer.get_unready_players(): + offer.ready_player(player) + + await offer.wait_ready() From 187c30097d6e7f03cdaf2eb62e216e08e470764f Mon Sep 17 00:00:00 2001 From: Askaholic Date: Fri, 19 Jun 2020 18:22:58 -0800 Subject: [PATCH 4/9] Wait for all players to accept match before starting game --- server/config.py | 5 +- server/ladder_service/ladder_service.py | 77 ++++++- server/lobbyconnection.py | 6 +- server/matchmaker/matchmaker_queue.py | 2 +- server/matchmaker/search.py | 3 + tests/integration_tests/test_game.py | 58 +++-- tests/integration_tests/test_matchmaker.py | 34 ++- .../test_matchmaker_violations.py | 218 +++++++++++++++++- .../integration_tests/test_teammatchmaker.py | 25 +- tests/unit_tests/test_ladder_service.py | 7 + 10 files changed, 397 insertions(+), 38 deletions(-) diff --git a/server/config.py b/server/config.py index ffa9ff93d..d3df2694b 100644 --- a/server/config.py +++ b/server/config.py @@ -146,7 +146,7 @@ def __init__(self): # The method for choosing map pool rating # Can be "mean", "min", or "max" self.MAP_POOL_RATING_SELECTION = "mean" - # The maximum amount of time in seconds to wait between pops + # The maximum amount of time (in seconds) to wait between pops. self.QUEUE_POP_TIME_MAX = 90 # The number of possible matches we would like to have when the queue # pops. The queue pop time will be adjusted based on the current rate of @@ -154,6 +154,9 @@ def __init__(self): self.QUEUE_POP_DESIRED_MATCHES = 2.5 # How many previous queue sizes to consider self.QUEUE_POP_TIME_MOVING_AVG_SIZE = 5 + # Amount of time (in seconds) that players have to accept a match + # before it will time out. + self.MATCH_OFFER_TIME = 20 self._defaults = { key: value for key, value in vars(self).items() if key.isupper() diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 294e3ea33..329094e0a 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -6,8 +6,10 @@ import random import re import statistics +import weakref from collections import defaultdict -from typing import Awaitable, Callable, Optional +from datetime import timedelta +from typing import Awaitable, Callable, Iterable, Optional import aiocron import humanize @@ -40,11 +42,14 @@ from server.matchmaker import ( MapPool, MatchmakerQueue, + MatchOffer, + OfferTimeoutError, OnMatchedCallback, Search ) from server.metrics import MatchLaunch from server.players import Player, PlayerState +from server.timing import datetime_now from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap @@ -68,6 +73,11 @@ def __init__( self.violation_service = violation_service self._searches: dict[Player, dict[str, Search]] = defaultdict(dict) + # NOTE: We are using a WeakValueDictionary to store the player -> offer + # associations so that we don't need to write any cleanup code. The + # values will automatically be deleted from the dictionary when the + # MatchOffer object is garbage collected / deleted. + self._match_offers = weakref.WeakValueDictionary() self._allow_new_searches = True async def initialize(self) -> None: @@ -467,13 +477,55 @@ def on_match_found( self._clear_search(player, queue.name) - asyncio.create_task(self.start_game(s1.players, s2.players, queue)) + asyncio.create_task(self.confirm_match(s1, s2, queue)) except Exception: self._logger.exception( "Error processing match between searches %s, and %s", s1, s2 ) + async def confirm_match( + self, + s1: Search, + s2: Search, + queue: MatchmakerQueue + ): + try: + all_players = s1.players + s2.players + + offer = self.create_match_offer(all_players) + offer.write_broadcast_update() + await offer.wait_ready() + except OfferTimeoutError: + unready_players = list(offer.get_unready_players()) + assert unready_players, "Unready players should be non-empty if offer timed out" + + self._logger.info( + "Match failed to start. Some players did not ready up in time: %s", + [player.login for player in unready_players] + ) + self._cancel_match(all_players) + + # TODO: Unmatch and return to queue + + self.violation_service.register_violations(unready_players) + + await self.start_game(s1.players, s2.players, queue) + + def create_match_offer(self, players: Iterable[Player]): + offer = MatchOffer( + players, + datetime_now() + timedelta(seconds=config.MATCH_OFFER_TIME) + ) + for player in players: + self._match_offers[player] = offer + return offer + + def ready_player(self, player: Player): + offer = self._match_offers.get(player) + if offer is not None: + offer.ready_player(player) + def start_game( self, team1: list[Player], @@ -607,12 +659,7 @@ def make_game_options(player: Player) -> GameLaunchOptions: if game: await game.on_game_finish() - game_id = game.id if game else None - msg = {"command": "match_cancelled", "game_id": game_id} - for player in all_players: - if player.state == PlayerState.STARTING_AUTOMATCH: - player.state = PlayerState.IDLE - player.write_message(msg) + self._cancel_match(all_players, game) if abandoning_players: self._logger.info( @@ -621,6 +668,20 @@ def make_game_options(player: Player) -> GameLaunchOptions: ) self.violation_service.register_violations(abandoning_players) + def _cancel_match( + self, + players: list[Player], + game: Optional[LadderGame] = None, + ): + msg = {"command": "match_cancelled"} + if game: + msg["game_id"] = game.id + + for player in players: + if player.state == PlayerState.STARTING_AUTOMATCH: + player.state = PlayerState.IDLE + player.write_message(msg) + async def launch_match( self, game: LadderGame, diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 7f375bc26..ff52473cf 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -975,11 +975,7 @@ async def command_game_host(self, message): await self.launch_game(game, is_host=True) async def command_match_ready(self, message): - """ - Replace with full implementation when implemented in client, see: - https://github.com/FAForever/downlords-faf-client/issues/1783 - """ - pass + self.ladder_service.ready_player(self.player) async def launch_game( self, diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 089dc6412..a69613dfa 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -175,7 +175,7 @@ async def find_matches(self) -> None: proposed_matches, unmatched_searches = await loop.run_in_executor( None, self.matchmaker.find, - searches, + (search for search in searches if not search.done()), self.team_size, self.rating_peak, ) diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index eb0229943..e1adbe669 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -226,6 +226,9 @@ def match(self, other: "Search"): ) self._match.set_result(other) + def unmatch(self): + self._match = asyncio.Future() + async def await_match(self): """ Wait for this search to complete diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index 6c04251e6..f167db506 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -156,6 +156,35 @@ async def start_search(proto, queue_name="ladder1v1"): ) +async def accept_match(proto, timeout=5): + await read_until_command(proto, "match_info", timeout=timeout) + await proto.send_message({"command": "match_ready"}) + return await read_until_command( + proto, + "match_info", + ready=True, + timeout=10 + ) + + +async def read_until_match(proto, timeout=30): + # If the players did not match, this will fail due to a timeout error + msg = await read_until_command(proto, "match_found", timeout=timeout) + # Accept match + match_info = await read_until_command(proto, "match_info", timeout=timeout) + await proto.send_message({"command": "match_ready"}) + # Wait for all players to be ready. This could be the same as the message + # notifying us that our match ready was received so we can't use + # `accept_match` here. + await read_until_command( + proto, + "match_info", + players_ready=match_info["players_total"], + timeout=10 + ) + return msg + + async def queue_player_for_matchmaking( user, lobby_server, @@ -183,10 +212,6 @@ async def queue_players_for_matchmaking( queue_name ) - # If the players did not match, this will fail due to a timeout error - await read_until_command(proto1, "match_found", timeout=30) - await read_until_command(proto2, "match_found") - return player1_id, proto1, player2_id, proto2 @@ -204,18 +229,12 @@ async def queue_temp_players_for_matchmaking( tmp_user(queue_name) for _ in range(num_players) ]) - protos = await asyncio.gather(*[ + responses = await asyncio.gather(*[ queue_player_for_matchmaking(user, lobby_server, queue_name) for user in users ]) - # If the players did not match, this will fail due to a timeout error - await asyncio.gather(*[ - read_until_command(proto, "match_found", timeout=30) - for _, proto in protos - ]) - - return protos + return (proto for _, proto in responses) async def get_player_ratings(proto, *names, rating_type="global"): @@ -803,9 +822,11 @@ async def test_ladder_game_draw_bug(lobby_server, database): instead of a draw. """ player1_id, proto1, player2_id, proto2 = await queue_players_for_matchmaking(lobby_server) + protos = (proto1, proto2) + await asyncio.gather(*[read_until_match(proto) for proto in protos]) msg1, msg2 = await asyncio.gather(*[ - client_response(proto) for proto in (proto1, proto2) + client_response(proto) for proto in protos ]) game_id = msg1["uid"] army1 = msg1["map_position"] @@ -819,7 +840,7 @@ async def test_ladder_game_draw_bug(lobby_server, database): (player_id, "Faction", msg["faction"]), (player_id, "Color", msg["map_position"]), ) - for proto in (proto1, proto2): + for proto in protos: await proto.send_message({ "target": "game", "command": "GameState", @@ -836,14 +857,14 @@ async def test_ladder_game_draw_bug(lobby_server, database): [army1, "score 1"], [army2, "defeat -10"] ): - for proto in (proto1, proto2): + for proto in protos: await proto.send_message({ "target": "game", "command": "GameResult", "args": result }) - for proto in (proto1, proto2): + for proto in protos: await proto.send_message({ "target": "game", "command": "GameEnded", @@ -886,7 +907,7 @@ async def test_ladder_game_draw_bug(lobby_server, database): assert row.score == 0 -@fast_forward(15) +@fast_forward(70) async def test_ladder_game_not_joinable(lobby_server): """ We should not be able to join AUTO_LOBBY games using the `game_join` command. @@ -894,7 +915,8 @@ async def test_ladder_game_not_joinable(lobby_server): _, _, test_proto = await connect_and_sign_in( ("test", "test_password"), lobby_server ) - _, proto1, _, _ = await queue_players_for_matchmaking(lobby_server) + _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) await read_until_command(test_proto, "game_info") msg = await read_until_command(proto1, "game_launch") diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 835584146..0f74d42d2 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -23,6 +23,7 @@ queue_players_for_matchmaking, queue_temp_players_for_matchmaking, read_until_launched, + read_until_match, send_player_options ) @@ -31,6 +32,7 @@ async def test_game_launch_message(lobby_server): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) msg1 = await read_until_command(proto1, "game_launch") await open_fa(proto1) msg2 = await read_until_command(proto2, "game_launch") @@ -67,6 +69,7 @@ async def test_game_launch_message_map_generator(lobby_server): queue_name="neroxis1v1" ) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) msg1 = await read_until_command(proto1, "game_launch") await open_fa(proto1) msg2 = await read_until_command(proto2, "game_launch") @@ -87,6 +90,7 @@ async def test_game_launch_message_game_options(lobby_server, tmp_user): queue_name="gameoptions" ) + await asyncio.gather(*[read_until_match(proto) for proto in protos]) msgs = await asyncio.gather(*[ client_response(proto) for _, proto in protos ]) @@ -103,6 +107,7 @@ async def test_game_launch_message_game_options(lobby_server, tmp_user): async def test_game_matchmaking_start(lobby_server, database): host_id, host, guest_id, guest = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (host, guest)]) # The player that queued last will be the host msg = await read_until_command(host, "game_launch") game_id = msg["uid"] @@ -166,8 +171,9 @@ async def test_game_matchmaking_start(lobby_server, database): @fast_forward(15) async def test_game_matchmaking_start_while_matched(lobby_server): - _, proto1, _, _ = await queue_players_for_matchmaking(lobby_server) + _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) # Trying to queue again after match was found should generate an error await proto1.send_message({ "command": "game_matchmaking", @@ -182,6 +188,7 @@ async def test_game_matchmaking_start_while_matched(lobby_server): async def test_game_matchmaking_timeout(lobby_server, game_service): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) msg1 = await idle_response(proto1, timeout=120) await read_until_command(proto2, "match_cancelled", timeout=120) await read_until_command(proto1, "match_cancelled", timeout=120) @@ -229,6 +236,7 @@ async def test_game_matchmaking_timeout(lobby_server, game_service): async def test_game_matchmaking_timeout_guest(lobby_server, game_service): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) msg1, msg2 = await asyncio.gather( client_response(proto1), client_response(proto2) @@ -305,10 +313,12 @@ async def test_game_matchmaking_cancel(lobby_server): await read_until_command(proto, "search_info", timeout=5) -@fast_forward(50) +@fast_forward(120) async def test_game_matchmaking_disconnect(lobby_server): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + # One player disconnects before the game has launched + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) await proto1.close() msg = await read_until_command(proto2, "match_cancelled", timeout=120) @@ -316,12 +326,26 @@ async def test_game_matchmaking_disconnect(lobby_server): assert msg == {"command": "match_cancelled", "game_id": 41956} +@fast_forward(120) +async def test_game_matchmaking_disconnect_no_accept(lobby_server): + _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) + + # One player disconnects before the game has launched + await read_until_command(proto1, "match_found", timeout=30) + await proto1.close() + + msg = await read_until_command(proto2, "match_cancelled", timeout=120) + + assert msg == {"command": "match_cancelled"} + + @pytest.mark.flaky @fast_forward(130) async def test_game_matchmaking_close_fa_and_requeue(lobby_server): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) - _, _ = await asyncio.gather( + await asyncio.gather(*[read_until_match(proto) for proto in (proto1, proto2)]) + await asyncio.gather( client_response(proto1), client_response(proto2) ) @@ -365,6 +389,10 @@ async def test_anti_map_repetition(lobby_server): for _ in range(20): ret = await queue_players_for_matchmaking(lobby_server) player1_id, proto1, player2_id, proto2 = ret + await asyncio.gather( + read_until_match(proto1), + read_until_match(proto2) + ) msg1, msg2 = await asyncio.gather( client_response(proto1), client_response(proto2) diff --git a/tests/integration_tests/test_matchmaker_violations.py b/tests/integration_tests/test_matchmaker_violations.py index 542df8452..24ac64aa1 100644 --- a/tests/integration_tests/test_matchmaker_violations.py +++ b/tests/integration_tests/test_matchmaker_violations.py @@ -4,7 +4,13 @@ from tests.utils import fast_forward from .conftest import connect_and_sign_in, read_until_command -from .test_game import open_fa, queue_players_for_matchmaking, start_search +from .test_game import ( + accept_match, + open_fa, + queue_players_for_matchmaking, + read_until_match, + start_search +) from .test_parties import accept_party_invite, invite_to_party @@ -18,6 +24,10 @@ async def test_violation_for_guest_timeout(mocker, lobby_server): # The player that queued last will be the host async def launch_game_and_timeout_guest(): + await asyncio.gather(*[ + read_until_match(proto) + for proto in (host, guest) + ]) await read_until_command(host, "game_launch") await open_fa(host) await read_until_command(host, "game_info") @@ -33,11 +43,25 @@ async def launch_game_and_timeout_guest(): await launch_game_and_timeout_guest() + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 1, + "time": "2022-02-05T00:00:00+00:00" + } + # Second time searching there is no ban await start_search(host) await start_search(guest) await launch_game_and_timeout_guest() + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 2, + "time": "2022-02-05T00:00:00+00:00" + } + # Third time searching there is a short ban await guest.send_message({ "command": "game_matchmaking", @@ -110,6 +134,180 @@ async def launch_game_and_timeout_guest(): } +@fast_forward(360) +async def test_violation_for_not_accepting_match(mocker, lobby_server): + mock_now = mocker.patch( + "server.ladder_service.violation_service.datetime_now", + return_value=datetime(2022, 2, 5, tzinfo=timezone.utc) + ) + host_id, host, guest_id, guest = await queue_players_for_matchmaking(lobby_server) + + await read_until_command(host, "match_cancelled", timeout=120) + await read_until_command(guest, "match_cancelled", timeout=10) + + # Both players should have received a violation + msg = await read_until_command(host, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 1, + "time": "2022-02-05T00:00:00+00:00" + } + + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 1, + "time": "2022-02-05T00:00:00+00:00" + } + + # Second time searching there is no ban + await start_search(host) + await start_search(guest) + await read_until_command(host, "match_cancelled", timeout=120) + await read_until_command(guest, "match_cancelled", timeout=10) + + # Both players should have received another violation + msg = await read_until_command(host, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 2, + "time": "2022-02-05T00:00:00+00:00" + } + + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 2, + "time": "2022-02-05T00:00:00+00:00" + } + + # Third time searching there is a short ban + for proto in (host, guest): + await proto.send_message({ + "command": "game_matchmaking", + "state": "start", + "queue_name": "ladder1v1" + }) + + # Both players should have been banned + msg = await read_until_command(host, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": host_id, + "expires_at": "2022-02-05T00:10:00+00:00" + }] + } + msg = await read_until_command(guest, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": guest_id, + "expires_at": "2022-02-05T00:10:00+00:00" + }] + } + + mock_now.return_value = datetime(2022, 2, 5, 0, 10, tzinfo=timezone.utc) + await asyncio.sleep(1) + + # Third successful search + await start_search(host) + await start_search(guest) + await read_until_command(host, "match_cancelled", timeout=120) + await read_until_command(guest, "match_cancelled", timeout=10) + + # Both players should have received another violation + msg = await read_until_command(host, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 3, + "time": "2022-02-05T00:10:00+00:00" + } + + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 3, + "time": "2022-02-05T00:10:00+00:00" + } + + # Fourth time searching there is a long ban + for proto in (host, guest): + await proto.send_message({ + "command": "game_matchmaking", + "state": "start", + "queue_name": "ladder1v1" + }) + + # Both players should have been banned + msg = await read_until_command(host, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": host_id, + "expires_at": "2022-02-05T00:40:00+00:00" + }] + } + msg = await read_until_command(guest, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": guest_id, + "expires_at": "2022-02-05T00:40:00+00:00" + }] + } + + mock_now.return_value = datetime(2022, 2, 5, 0, 40, tzinfo=timezone.utc) + await asyncio.sleep(1) + + # Fourth successful search + await start_search(host) + await start_search(guest) + await read_until_command(host, "match_cancelled", timeout=120) + await read_until_command(guest, "match_cancelled", timeout=10) + + # Both players should have received another violation + msg = await read_until_command(host, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 4, + "time": "2022-02-05T00:40:00+00:00" + } + + msg = await read_until_command(guest, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 4, + "time": "2022-02-05T00:40:00+00:00" + } + + # Fifth time searching there is a long ban + for proto in (host, guest): + await proto.send_message({ + "command": "game_matchmaking", + "state": "start", + "queue_name": "ladder1v1" + }) + + # Both players should have been banned + msg = await read_until_command(host, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": host_id, + "expires_at": "2022-02-05T01:10:00+00:00" + }] + } + msg = await read_until_command(guest, "search_timeout") + assert msg == { + "command": "search_timeout", + "timeouts": [{ + "player": guest_id, + "expires_at": "2022-02-05T01:10:00+00:00" + }] + } + + @fast_forward(360) async def test_violation_persisted_across_logins(mocker, lobby_server): mocker.patch( @@ -117,13 +315,22 @@ async def test_violation_persisted_across_logins(mocker, lobby_server): return_value=datetime(2022, 2, 5, tzinfo=timezone.utc) ) host_id, host, _, guest = await queue_players_for_matchmaking(lobby_server) + protos = (host, guest) + await asyncio.gather(*[ + accept_match(proto, timeout=30) + for proto in protos + ]) await read_until_command(host, "match_cancelled", timeout=120) await read_until_command(guest, "match_cancelled", timeout=10) # Second time searching there is no ban await start_search(host) await start_search(guest) + await asyncio.gather(*[ + accept_match(proto, timeout=30) + for proto in protos + ]) await read_until_command(host, "match_cancelled", timeout=120) await read_until_command(guest, "match_cancelled", timeout=10) @@ -174,13 +381,22 @@ async def test_violation_persisted_across_parties(mocker, lobby_server): return_value=datetime(2022, 2, 5, tzinfo=timezone.utc) ) host_id, host, guest_id, guest = await queue_players_for_matchmaking(lobby_server) + protos = (host, guest) + await asyncio.gather(*[ + accept_match(proto, timeout=30) + for proto in protos + ]) await read_until_command(host, "match_cancelled", timeout=120) await read_until_command(guest, "match_cancelled", timeout=10) # Second time searching there is no ban await start_search(host) await start_search(guest) + await asyncio.gather(*[ + accept_match(proto, timeout=30) + for proto in protos + ]) await read_until_command(host, "match_cancelled", timeout=120) await read_until_command(guest, "match_cancelled", timeout=10) diff --git a/tests/integration_tests/test_teammatchmaker.py b/tests/integration_tests/test_teammatchmaker.py index 50c1e7368..c99601c2e 100644 --- a/tests/integration_tests/test_teammatchmaker.py +++ b/tests/integration_tests/test_teammatchmaker.py @@ -13,6 +13,7 @@ from .conftest import connect_and_sign_in, read_until, read_until_command from .test_game import ( + accept_match, client_response, get_player_ratings, idle_response, @@ -62,11 +63,19 @@ async def queue_players_for_matchmaking(lobby_server): read_until_command(proto, "match_found", timeout=30) for proto in protos ]) + await asyncio.gather(*[ + proto.send_message({ + "command": "match_ready", + }) + for proto in protos + ]) + return protos, ids async def matchmaking_client_response(proto): await read_until_command(proto, "match_found", timeout=30) + await accept_match(proto) return await client_response(proto) @@ -148,6 +157,7 @@ async def test_game_matchmaking_multiqueue(lobby_server): }) for proto in protos ]) + await read_until_command(protos[0], "match_found", timeout=30) msg = await read_until_command( protos[0], "search_info", @@ -155,7 +165,19 @@ async def test_game_matchmaking_multiqueue(lobby_server): ) assert msg["state"] == "stop" - msgs = await asyncio.gather(*[client_response(proto) for proto in protos]) + # Since `match_found` comes before `search_info` we can't use + # `matchmaking_client_response` for the first player. + async def _accept_and_respond(proto): + await accept_match(proto) + return await client_response(proto) + + msgs = await asyncio.gather( + _accept_and_respond(protos[0]), + *[ + matchmaking_client_response(proto) + for proto in protos[1:] + ] + ) uid = set(msg["uid"] for msg in msgs) assert len(uid) == 1 @@ -353,6 +375,7 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): ) assert msg["state"] == "stop" + await asyncio.gather(*[accept_match(proto) for proto in protos]) await client_response(protos[0]) await idle_response(protos[1]) diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 2b2b979df..06f6990d3 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -828,6 +828,13 @@ async def test_start_game_called_on_match( search1 = ladder_service._searches[p1]["ladder1v1"] await search1.await_match() + # Wait for offer to be created + await asyncio.sleep(1) + + ladder_service.ready_player(p1) + ladder_service.ready_player(p2) + + await asyncio.sleep(1) ladder_service.write_rating_progress.assert_called() ladder_service.start_game.assert_called_once() From 717353bd268cbd9a58d9576e1049b963829760c1 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 20 Jun 2020 00:18:11 -0800 Subject: [PATCH 5/9] Restart search automatically after a timeout --- server/ladder_service/ladder_service.py | 19 +++++++++-- tests/integration_tests/test_matchmaker.py | 39 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 329094e0a..ae616f51d 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -496,6 +496,8 @@ async def confirm_match( offer = self.create_match_offer(all_players) offer.write_broadcast_update() await offer.wait_ready() + + await self.start_game(s1.players, s2.players, queue) except OfferTimeoutError: unready_players = list(offer.get_unready_players()) assert unready_players, "Unready players should be non-empty if offer timed out" @@ -506,11 +508,24 @@ async def confirm_match( ) self._cancel_match(all_players) - # TODO: Unmatch and return to queue + # Return any player that accepted the match back to the queue + # TODO: make this work with parties + for search in (s1, s2): + for player in search.players: + if player in unready_players: + self.cancel_search(player) + else: + search.unmatch() + player.state = PlayerState.SEARCHING_LADDER + asyncio.create_task(queue.search(search)) self.violation_service.register_violations(unready_players) - await self.start_game(s1.players, s2.players, queue) + except Exception as e: + self._logger.exception( + "Error processing match between searches %s, and %s: %s", + s1, s2, e + ) def create_match_offer(self, players: Iterable[Player]): offer = MatchOffer( diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 0f74d42d2..f9d2589ec 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -184,6 +184,45 @@ async def test_game_matchmaking_start_while_matched(lobby_server): assert msg["text"].startswith("Can't join a queue while ladder1 is in") +@fast_forward(100) +async def test_game_matchmaking_search_after_timeout(lobby_server): + _, proto1 = await queue_player_for_matchmaking( + ("ladder1", "ladder1"), + lobby_server, + "ladder1v1" + ) + _, proto2 = await queue_player_for_matchmaking( + ("ladder2", "ladder2"), + lobby_server, + "ladder1v1" + ) + + await read_until_command(proto1, "match_info") + await read_until_command(proto2, "match_info") + + # Only player 1 readies up + await proto1.send_message({"command": "match_ready"}) + + await read_until_command(proto1, "match_info", timeout=5) + await read_until_command(proto2, "match_info") + + # So the match times out + await read_until_command(proto1, "match_cancelled", timeout=40) + await read_until_command(proto2, "match_cancelled") + + # Player 2 joins the queue again + await proto2.send_message({ + "command": "game_matchmaking", + "state": "start", + "faction": "seraphim", + "mod": "ladder1v1" + }) + + # The players should match + await read_until_command(proto1, "match_info") + await read_until_command(proto2, "match_info") + + @fast_forward(120) async def test_game_matchmaking_timeout(lobby_server, game_service): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) From a914702d15291bf89492b31297671cd77dfd262e Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 1 Jan 2022 18:19:59 -0900 Subject: [PATCH 6/9] Make re-queue work for parties --- server/ladder_service/ladder_service.py | 86 ++++++++++++++-------- tests/integration_tests/test_game.py | 3 +- tests/integration_tests/test_matchmaker.py | 61 ++++++++++++++- 3 files changed, 115 insertions(+), 35 deletions(-) diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index ae616f51d..5286211e3 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -348,20 +348,27 @@ def start_search( on_matched=on_matched ) - for player in players: + self._add_search_to_queue(search, queue) + + def _add_search_to_queue( + self, + search: Search, + queue: MatchmakerQueue, + ): + for player in search.players: player.state = PlayerState.SEARCHING_LADDER self.write_rating_progress(player, queue.rating_type) player.write_message({ "command": "search_info", - "queue_name": queue_name, + "queue_name": queue.name, "state": "start" }) - self._searches[player][queue_name] = search + self._searches[player][queue.name] = search - self._logger.info("%s started searching for %s", search, queue_name) + self._logger.info("%s started searching for %s", search, queue.name) asyncio.create_task(queue.search(search)) @@ -427,26 +434,28 @@ def _clear_search( return search def write_rating_progress(self, player: Player, rating_type: str) -> None: - if player not in self._informed_players: - self._informed_players.add(player) - _, deviation = player.ratings[rating_type] + if player in self._informed_players: + return - if deviation > 490: - player.write_message({ - "command": "notice", - "style": "info", - "text": ( - "Welcome to the matchmaker

The " - "matchmaking system needs to calibrate your skill level; " - "your first few games may be more imbalanced as the " - "system attempts to learn your capability as a player." - "
" - "Afterwards, you'll be more reliably matched up with " - "people of your skill level: so don't worry if your " - "first few games are uneven. This will improve as you " - "play!" - ) - }) + self._informed_players.add(player) + _, deviation = player.ratings[rating_type] + + if deviation > 490: + player.write_message({ + "command": "notice", + "style": "info", + "text": ( + "Welcome to the matchmaker

The " + "matchmaking system needs to calibrate your skill level; " + "your first few games may be more imbalanced as the " + "system attempts to learn your capability as a player." + "
" + "Afterwards, you'll be more reliably matched up with " + "people of your skill level: so don't worry if your " + "first few games are uneven. This will improve as you " + "play!" + ) + }) def on_match_found( self, @@ -508,16 +517,29 @@ async def confirm_match( ) self._cancel_match(all_players) - # Return any player that accepted the match back to the queue - # TODO: make this work with parties + # Return any search that fully accepted the match back to the queue for search in (s1, s2): - for player in search.players: - if player in unready_players: - self.cancel_search(player) - else: - search.unmatch() - player.state = PlayerState.SEARCHING_LADDER - asyncio.create_task(queue.search(search)) + search_players = search.players + search_unready_players = [ + player + for player in unready_players + if player in search_players + ] + if not search_unready_players: + search.unmatch() + self._add_search_to_queue(search, queue) + self._logger.debug( + "%s auto requeued after failed match", + search + ) + else: + for player in search_players: + player.write_message({ + "command": "match_notice", + "unready_players": [ + p.id for p in search_unready_players + ] + }) self.violation_service.register_violations(unready_players) diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index f167db506..61fc0d35d 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -220,13 +220,14 @@ async def queue_temp_players_for_matchmaking( tmp_user, num_players, queue_name, + player_name=None, ): """ Queue an arbitrary number of players for matchmaking in a particular queue by setting up temp users. """ users = await asyncio.gather(*[ - tmp_user(queue_name) + tmp_user(player_name or queue_name) for _ in range(num_players) ]) responses = await asyncio.gather(*[ diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index f9d2589ec..01f4cf3b6 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -418,6 +418,57 @@ async def test_game_matchmaking_close_fa_and_requeue(lobby_server): await read_until_command(proto1, "match_found", timeout=5) +@fast_forward(130) +async def test_game_matchmaking_no_accept_offer_auto_requeue( + lobby_server, + tmp_user, +): + proto1, proto2 = await queue_temp_players_for_matchmaking( + lobby_server, + tmp_user, + num_players=2, + queue_name="ladder1v1", + player_name="Player", + ) + + await read_until_command(proto1, "match_found", timeout=30) + await read_until_command(proto2, "match_found", timeout=5) + + # Only player 1 accepts the match + await read_until_command(proto1, "match_info", timeout=5) + await read_until_command(proto2, "match_info", timeout=5) + await proto1.send_message({"command": "match_ready"}) + await read_until_command(proto1, "match_cancelled", timeout=120) + + # Player 1 is automatically re-added to the queue, but player 2 is not + await read_until_command(proto1, "search_info", state="start", timeout=5) + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" + ) + assert queue_message["num_players"] == 1 + + # A third player joins the queue and is matched with player 1 + proto3, = await queue_temp_players_for_matchmaking( + lobby_server, + tmp_user, + num_players=1, + queue_name="ladder1v1", + player_name="Player", + ) + + await asyncio.gather(*[ + read_until_match(proto) + for proto in (proto1, proto3) + ]) + await asyncio.gather( + client_response(proto1), + client_response(proto3) + ) + + @pytest.mark.flaky @fast_forward(200) async def test_anti_map_repetition(lobby_server): @@ -567,7 +618,9 @@ async def read_update_msg(): msg = await read_until_command(proto, "matchmaker_info") queue_message = next( - q for q in msg["queues"] if q["queue_name"] == "ladder1v1" + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" ) if queue_message["num_players"] == 0: continue @@ -586,7 +639,11 @@ async def read_update_msg(): # Update message because we left the queue msg = await read_until_command(proto, "matchmaker_info") - queue_message = next(q for q in msg["queues"] if q["queue_name"] == "ladder1v1") + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" + ) assert queue_message["num_players"] == 0 From 3aa6f906f38022359555e64246da7df7ae08fd78 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Thu, 14 Dec 2023 11:45:08 -0500 Subject: [PATCH 7/9] Make re-queue work for multiqueue --- server/ladder_service/ladder_service.py | 36 +++- server/matchmaker/matchmaker_queue.py | 2 +- tests/integration_tests/test_matchmaker.py | 211 ++++++++++++++++++++- tests/unit_tests/test_ladder_service.py | 33 +++- 4 files changed, 266 insertions(+), 16 deletions(-) diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 5286211e3..be2452d53 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -385,7 +385,11 @@ def cancel_search( for queue_name in queue_names: self._cancel_search(initiator, queue_name) - def _cancel_search(self, initiator: Player, queue_name: str) -> None: + def _cancel_search( + self, + initiator: Player, + queue_name: str + ) -> Optional[Search]: """ Cancel search for a specific player/queue. """ @@ -397,7 +401,7 @@ def _cancel_search(self, initiator: Player, queue_name: str) -> None: initiator, queue_name ) - return + return None cancelled_search.cancel() for player in cancelled_search.players: @@ -414,6 +418,7 @@ def _cancel_search(self, initiator: Player, queue_name: str) -> None: self._logger.info( "%s stopped searching for %s", cancelled_search, queue_name ) + return cancelled_search def _clear_search( self, @@ -471,8 +476,10 @@ def on_match_found( """ try: msg = {"command": "match_found", "queue_name": queue.name} + original_searches = [] - for player in s1.players + s2.players: + all_players = s1.players + s2.players + for player in all_players: player.state = PlayerState.STARTING_AUTOMATCH player.write_message(msg) @@ -482,11 +489,18 @@ def on_match_found( if name != queue.name ) for queue_name in queue_names: - self._cancel_search(player, queue_name) + search = self._cancel_search(player, queue_name) + if search is not None: + original_searches.append((search, queue_name)) - self._clear_search(player, queue.name) + for player in all_players: + search = self._clear_search(player, queue.name) + if search is not None: + original_searches.append((search, queue.name)) - asyncio.create_task(self.confirm_match(s1, s2, queue)) + asyncio.create_task( + self.confirm_match(s1, s2, queue, original_searches[::-1]) + ) except Exception: self._logger.exception( "Error processing match between searches %s, and %s", @@ -497,7 +511,8 @@ async def confirm_match( self, s1: Search, s2: Search, - queue: MatchmakerQueue + queue: MatchmakerQueue, + original_searches: list[tuple[Search, str]], ): try: all_players = s1.players + s2.players @@ -518,7 +533,7 @@ async def confirm_match( self._cancel_match(all_players) # Return any search that fully accepted the match back to the queue - for search in (s1, s2): + for search, queue_name in original_searches: search_players = search.players search_unready_players = [ player @@ -527,7 +542,10 @@ async def confirm_match( ] if not search_unready_players: search.unmatch() - self._add_search_to_queue(search, queue) + self._add_search_to_queue( + search, + self.queues[queue_name], + ) self._logger.debug( "%s auto requeued after failed match", search diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index a69613dfa..127a1aedd 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -226,7 +226,7 @@ def _register_unmatched_searches( for search in unmatched_searches: search.register_failed_matching_attempt() self._logger.debug( - "Search %s remained unmatched at threshold %f in attempt number %s", + "%s remained unmatched at threshold %f in attempt number %s", search, search.match_threshold, search.failed_matching_attempts ) diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 01f4cf3b6..99cb05e05 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -24,8 +24,10 @@ queue_temp_players_for_matchmaking, read_until_launched, read_until_match, - send_player_options + send_player_options, + start_search ) +from .test_parties import accept_party_invite, invite_to_party @fast_forward(70) @@ -366,7 +368,7 @@ async def test_game_matchmaking_disconnect(lobby_server): @fast_forward(120) -async def test_game_matchmaking_disconnect_no_accept(lobby_server): +async def test_game_matchmaking_disconnect_no_accept_offer(lobby_server): _, proto1, _, proto2 = await queue_players_for_matchmaking(lobby_server) # One player disconnects before the game has launched @@ -459,10 +461,209 @@ async def test_game_matchmaking_no_accept_offer_auto_requeue( player_name="Player", ) - await asyncio.gather(*[ - read_until_match(proto) - for proto in (proto1, proto3) + await read_until_command(proto1, "match_found") + await read_until_command(proto1, "match_info", timeout=5) + await proto1.send_message({"command": "match_ready"}) + + # Check that the queue is emptied correctly + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" + ) + assert queue_message["num_players"] == 0 + + await read_until_match(proto3) + await asyncio.gather( + client_response(proto1), + client_response(proto3) + ) + + +@fast_forward(130) +async def test_game_matchmaking_no_accept_offer_auto_requeue_party( + lobby_server, + tmp_user, +): + users = await asyncio.gather(*[ + tmp_user("Player") + for _ in range(4) + ]) + res = await asyncio.gather(*[ + connect_and_sign_in(user, lobby_server) + for user in users ]) + protos = [proto for _, _, proto in res] + proto1, proto2, proto3, proto4 = protos + id_1, id_2, id_3, id_4 = (id_ for id_, _, _ in res) + + # Set up two parties, (p1, p2) and (p3, p4) + await invite_to_party(proto1, id_2) + await invite_to_party(proto3, id_4) + await read_until_command(proto2, "party_invite", timeout=10) + await read_until_command(proto4, "party_invite", timeout=10) + await accept_party_invite(proto2, id_1) + await accept_party_invite(proto4, id_3) + + # Both parties join the queue + await start_search(proto1, "tmm2v2") + await start_search(proto3, "tmm2v2") + + await asyncio.gather(*( + read_until_command( + proto, + "match_found", + timeout=30, + queue_name="tmm2v2", + ) + for proto in protos + )) + + # Only 1 party accepts the match + await asyncio.gather(*( + read_until_command(proto, "match_info", timeout=5) + for proto in protos + )) + for proto in (proto1, proto2, proto3): + await proto.send_message({"command": "match_ready"}) + + await read_until_command(proto1, "match_cancelled", timeout=120) + + # Player 1 and Player 2 are automatically re-added to the queue + await read_until_command( + proto1, + "search_info", + state="start", + queue_name="tmm2v2", + timeout=10, + ) + await read_until_command( + proto2, + "search_info", + state="start", + queue_name="tmm2v2", + timeout=10, + ) + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "tmm2v2" + ) + assert queue_message["num_players"] == 2 + + # Two more players joins the queue and are matched with player 1 and 2 + proto5, proto6 = await queue_temp_players_for_matchmaking( + lobby_server, + tmp_user, + num_players=2, + queue_name="tmm2v2", + player_name="Player", + ) + + await read_until_command(proto1, "match_found", queue_name="tmm2v2") + await read_until_command(proto1, "match_info", timeout=5) + await proto1.send_message({"command": "match_ready"}) + + # Check that the queue is emptied correctly + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "tmm2v2" + ) + assert queue_message["num_players"] == 0 + + await asyncio.gather(*( + read_until_match(proto) + for proto in (proto2, proto5, proto6) + )) + await asyncio.gather(*( + client_response(proto) + for proto in (proto1, proto2, proto5, proto6) + )) + + +@fast_forward(130) +async def test_game_matchmaking_no_accept_offer_auto_requeue_multiqueue( + lobby_server, + tmp_user, +): + proto1, proto2 = await queue_temp_players_for_matchmaking( + lobby_server, + tmp_user, + num_players=2, + queue_name="ladder1v1", + player_name="Player", + ) + await start_search(proto1, "tmm2v2") + + await read_until_command( + proto1, + "match_found", + timeout=30, + queue_name="ladder1v1", + ) + await read_until_command( + proto2, + "match_found", + timeout=5, + queue_name="ladder1v1", + ) + + # Only player 1 accepts the match + await read_until_command(proto1, "match_info", timeout=5) + await read_until_command(proto2, "match_info", timeout=5) + await proto1.send_message({"command": "match_ready"}) + await read_until_command(proto1, "match_cancelled", timeout=120) + + # Player 1 is automatically re-added to the queues, but player 2 is not + await read_until_command( + proto1, + "search_info", + state="start", + queue_name="ladder1v1", + timeout=10, + ) + await read_until_command( + proto1, + "search_info", + state="start", + queue_name="tmm2v2", + timeout=10, + ) + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" + ) + assert queue_message["num_players"] == 1 + + # A third player joins the queue and is matched with player 1 + proto3, = await queue_temp_players_for_matchmaking( + lobby_server, + tmp_user, + num_players=1, + queue_name="ladder1v1", + player_name="Player", + ) + + await read_until_command(proto1, "match_found") + await read_until_command(proto1, "match_info", timeout=5) + await proto1.send_message({"command": "match_ready"}) + + # Check that the queue is emptied correctly + msg = await read_until_command(proto1, "matchmaker_info", timeout=5) + queue_message = next( + queue + for queue in msg["queues"] + if queue["queue_name"] == "ladder1v1" + ) + assert queue_message["num_players"] == 0 + + await read_until_match(proto3) await asyncio.gather( client_response(proto1), client_response(proto3) diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 06f6990d3..b3f225c88 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -11,7 +11,7 @@ from server.games import LadderGame from server.games.ladder_game import GameClosedError from server.ladder_service import game_name -from server.matchmaker import MapPool, MatchmakerQueue +from server.matchmaker import MapPool, MatchmakerQueue, Search from server.players import PlayerState from server.rating import RatingType from server.types import Map, NeroxisGeneratedMap @@ -135,6 +135,37 @@ async def test_load_from_database_new_data(ladder_service, database): test_queue.find_matches.assert_called() +@fast_forward(30) +async def test_confirm_match_timeout(ladder_service, player_factory): + p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") + p2 = player_factory("Rhiza", player_id=2, lobby_connection_spec="auto") + s1 = Search([p1]) + s2 = Search([p2]) + s1.register_failed_matching_attempt() + + s1.match(s2) + s2.match(s1) + + queue = ladder_service.queues["ladder1v1"] + + async def ready_player(): + await asyncio.sleep(3) + ladder_service.ready_player(p1) + + await asyncio.gather( + ladder_service.confirm_match( + s1, + s2, + queue, + [(s1, "ladder1v1"), (s2, "ladder1v1")] + ), + ready_player() + ) + + assert list(queue._queue) == [s1] + assert s1.failed_matching_attempts == 1 + + @given( player1=st_players("p1", player_id=1, lobby_connection_spec="mock"), player2=st_players("p2", player_id=2, lobby_connection_spec="mock") From d0a504cd15049c2dbc9e9a8e5d5a9bdf03062f18 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Tue, 2 Jan 2024 13:50:19 -0500 Subject: [PATCH 8/9] Refactor search so it can be created without an event loop running --- server/matchmaker/search.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index e1adbe669..6995be6f0 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -40,7 +40,7 @@ def __init__( self.players = players self.rating_type = rating_type self.start_time = start_time or time.time() - self._match = asyncio.get_event_loop().create_future() + self._match: Optional[asyncio.Future] = None self._failed_matching_attempts = 0 self.on_matched = on_matched @@ -173,14 +173,18 @@ def quality_with(self, other: "Search") -> float: @property def is_matched(self) -> bool: - return self._match.done() and not self._match.cancelled() + return ( + self._match + and self._match.done() + and not self._match.cancelled() + ) def done(self) -> bool: - return self._match.done() + return self._match and self._match.done() @property def is_cancelled(self) -> bool: - return self._match.cancelled() + return self._match and self._match.cancelled() def matches_with(self, other: "Search") -> bool: """ @@ -224,23 +228,29 @@ def match(self, other: "Search"): mean, adjusted_mean ) - self._match.set_result(other) + + self._get_match().set_result(other) def unmatch(self): - self._match = asyncio.Future() + self._match = None async def await_match(self): """ Wait for this search to complete """ - await asyncio.wait_for(self._match, None) - return self._match + await asyncio.wait_for(self._get_match(), None) def cancel(self): """ Cancel searching for a match """ - self._match.cancel() + self._get_match().cancel() + + def _get_match(self) -> asyncio.Future: + if self._match is None: + self._match = asyncio.get_running_loop().create_future() + + return self._match def __str__(self) -> str: return ( @@ -343,7 +353,7 @@ async def await_match(self): """ Wait for this search to complete """ - await asyncio.wait({s.await_match() for s in self.searches}) + await asyncio.wait([s.await_match() for s in self.searches]) def cancel(self): """ From a88d3fc6313e1a57db2b26fb8c9cee081a27110a Mon Sep 17 00:00:00 2001 From: Askaholic Date: Tue, 2 Jan 2024 14:10:03 -0500 Subject: [PATCH 9/9] Add tests for get_original_searches --- tests/unit_tests/test_matchmaker_queue.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit_tests/test_matchmaker_queue.py b/tests/unit_tests/test_matchmaker_queue.py index ac15699be..08cd7a71d 100644 --- a/tests/unit_tests/test_matchmaker_queue.py +++ b/tests/unit_tests/test_matchmaker_queue.py @@ -261,12 +261,33 @@ def test_combined_search_attributes(matchmaker_players): p2.ratings[RatingType.LADDER_1V1], p3.ratings[RatingType.LADDER_1V1] ] + assert search.get_original_searches() == [s1, s2] assert search.failed_matching_attempts == 1 search.register_failed_matching_attempt() assert search.failed_matching_attempts == 2 +def test_search_get_original_searches(matchmaker_players): + p1, p2, p3, p4, p5, p6 = matchmaker_players + s1 = Search([p1, p2]) + s2 = Search([p3]) + s3 = Search([p4, p5]) + s4 = Search([p6]) + cs1 = CombinedSearch(s1, s2) + cs2 = CombinedSearch(cs1, s3) + search = CombinedSearch(cs2, s4) + + assert search.get_original_searches() == [cs2, s4] + + +def test_combined_search_nested_get_original_searches(matchmaker_players): + p1 = matchmaker_players[0] + s1 = Search([p1]) + + assert s1.get_original_searches() == [s1] + + def test_queue_time_until_next_pop(queue_factory): team_size = 2 t1 = PopTimer(queue_factory(team_size=team_size))