From 8859ba32408e85d9e797ff9b8975bb3dc36f11ae Mon Sep 17 00:00:00 2001 From: Askaholic Date: Fri, 19 Jun 2020 18:22:58 -0800 Subject: [PATCH] Wait for all players to accept match before starting game --- server/config.py | 5 +- server/ladder_service.py | 59 +++++++++++++++++-- server/lobbyconnection.py | 6 +- server/matchmaker/matchmaker_queue.py | 2 +- server/matchmaker/search.py | 9 ++- tests/integration_tests/test_game.py | 20 ++++++- .../integration_tests/test_teammatchmaker.py | 14 +++++ tests/unit_tests/test_ladder_service.py | 7 +++ 8 files changed, 104 insertions(+), 18 deletions(-) diff --git a/server/config.py b/server/config.py index 6108776dc..51cdbcf09 100644 --- a/server/config.py +++ b/server/config.py @@ -111,7 +111,7 @@ def __init__(self): self.LADDER_ANTI_REPETITION_LIMIT = 2 self.LADDER_SEARCH_EXPANSION_MAX = 0.25 self.LADDER_SEARCH_EXPANSION_STEP = 0.05 - # 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 = 180 # 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 @@ -119,6 +119,9 @@ def __init__(self): self.QUEUE_POP_DESIRED_MATCHES = 4 # 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.py b/server/ladder_service.py index a89714e3b..d3a67b60f 100644 --- a/server/ladder_service.py +++ b/server/ladder_service.py @@ -6,8 +6,10 @@ import json import random import re +import weakref from collections import defaultdict -from typing import Dict, List, Optional, Set, Tuple +from datetime import datetime, timedelta +from typing import Dict, Iterable, List, Optional, Set, Tuple import aiocron from sqlalchemy import and_, func, select, text, true @@ -33,7 +35,14 @@ from .decorators import with_logger from .game_service import GameService from .games import InitMode, LadderGame -from .matchmaker import MapPool, MatchmakerQueue, OnMatchedCallback, Search +from .matchmaker import ( + MapPool, + MatchmakerQueue, + MatchOffer, + OfferTimeoutError, + OnMatchedCallback, + Search +) from .players import Player, PlayerState from .types import GameLaunchOptions, Map, NeroxisGeneratedMap @@ -55,6 +64,7 @@ def __init__( self.queues = {} self._searches: Dict[Player, Dict[str, Search]] = defaultdict(dict) + self._match_offers = weakref.WeakValueDictionary() async def initialize(self) -> None: await self.update_data() @@ -347,15 +357,54 @@ 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() + + await self.start_game(s1.players, s2.players, queue) + except OfferTimeoutError: + self._logger.info( + "Match failed to start. Some players did not ready up in time: %s", + list(player.login for player in offer.get_unready_players()) + ) + # TODO: Refactor duplication + msg = {"command": "match_cancelled"} + for player in all_players: + if player.state == PlayerState.STARTING_AUTOMATCH: + player.state = PlayerState.IDLE + player.write_message(msg) + # TODO: Unmatch and return to 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) + async def start_game( self, team1: List[Player], diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 37d6b55ad..28504a70f 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -996,11 +996,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 1d68be625..4547c6b56 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -165,7 +165,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, ) diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index 124967953..bac80999c 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -165,14 +165,14 @@ def quality_with(self, other: "Search") -> float: return quality([team1, team2]) @property - def is_matched(self): + def is_matched(self) -> bool: return self._match.done() and not self._match.cancelled() - def done(self): + def done(self) -> bool: return self._match.done() @property - def is_cancelled(self): + def is_cancelled(self) -> bool: return self._match.cancelled() def matches_with(self, other: "Search"): @@ -219,6 +219,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 f19a09072..d3f4bfe04 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -53,7 +53,16 @@ async def join_game(proto: Protocol, uid: int): async def client_response(proto): - msg = await read_until_command(proto, "game_launch", timeout=10) + msg = await read_until_command(proto, "match_info", timeout=5) + await proto.send_message({"command": "match_ready"}) + await read_until_command( + proto, + "match_info", + players_ready=msg["players_total"], + timeout=10 + ) + msg = await read_until_command(proto, "game_launch", timeout=5) + await open_fa(proto) return msg @@ -117,6 +126,11 @@ async def queue_players_for_matchmaking(lobby_server, queue_name: str = "ladder1 # 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") + await read_until_command(proto1, "match_info") + await read_until_command(proto2, "match_info") + + await proto1.send_message({"command": "match_ready"}) + await proto2.send_message({"command": "match_ready"}) return proto1, proto2 @@ -487,7 +501,7 @@ async def test_partial_game_ended_rates_game(lobby_server, tmp_user): await read_until_command(host_proto, "player_info", timeout=10) -@fast_forward(15) +@fast_forward(70) async def test_ladder_game_draw_bug(lobby_server, database): """ This simulates the infamous "draw bug" where a player could self destruct @@ -555,7 +569,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. diff --git a/tests/integration_tests/test_teammatchmaker.py b/tests/integration_tests/test_teammatchmaker.py index 62f297081..6c1ff0f05 100644 --- a/tests/integration_tests/test_teammatchmaker.py +++ b/tests/integration_tests/test_teammatchmaker.py @@ -53,6 +53,13 @@ async def queue_players_for_matchmaking(lobby_server): read_until_command(proto, "match_found") for proto in protos ]) + await asyncio.gather(*[ + proto.send_message({ + "command": "match_ready", + }) + for proto in protos + ]) + return protos, ids @@ -321,6 +328,13 @@ async def test_game_matchmaking_multiqueue_timeout(lobby_server): ) assert msg["state"] == "stop" + await asyncio.gather(*[ + proto.send_message({ + "command": "match_ready", + }) + for proto in protos + ]) + # Don't send any GPGNet messages so the match times out await read_until_command(protos[0], "match_cancelled", timeout=120) diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 0109dd091..4a4ad624f 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -648,6 +648,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()