From 2bc88ecfc2ad2e8867646a193be2c8e5b0378a93 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/ladder_service.py | 70 ++++++++++++++----- server/lobbyconnection.py | 3 + server/matchmaker/matchmaker_queue.py | 6 +- server/matchmaker/search.py | 9 ++- tests/integration_tests/test_game.py | 2 +- tests/integration_tests/test_matchmaker.py | 5 ++ .../integration_tests/test_teammatchmaker.py | 12 +++- tests/unit_tests/test_ladder_service.py | 5 ++ 8 files changed, 89 insertions(+), 23 deletions(-) diff --git a/server/ladder_service.py b/server/ladder_service.py index 2432e40a5..a0095ec01 100644 --- a/server/ladder_service.py +++ b/server/ladder_service.py @@ -2,8 +2,10 @@ import contextlib import itertools 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 @@ -30,7 +32,13 @@ from .decorators import with_logger from .game_service import GameService from .games import FeaturedModType, LadderGame -from .matchmaker import MapPool, MatchmakerQueue, Search +from .matchmaker import ( + MapPool, + MatchmakerQueue, + MatchOffer, + OfferTimeoutError, + Search +) from .players import Player, PlayerState from .protocol import DisconnectedError from .rating import RatingType @@ -65,6 +73,7 @@ def __init__( } self.searches: Dict[str, Dict[Player, Search]] = defaultdict(dict) + self._match_offers = weakref.WeakValueDictionary() async def initialize(self) -> None: # TODO: Starting hardcoded queues here @@ -319,21 +328,48 @@ def start_queue_handlers(self): async def handle_queue_matches(self, queue: MatchmakerQueue): async for s1, s2 in queue.iter_matches(): - try: - msg = {"command": "match_found", "queue": queue.name} - # TODO: Handle disconnection with a client supported message - await asyncio.gather(*[ - player.send_message(msg) - for player in s1.players + s2.players - ]) - asyncio.create_task( - 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 - ) + asyncio.create_task(self.handle_match(queue, s1, s2)) + + async def handle_match( + self, + queue: MatchmakerQueue, + s1: Search, + s2: Search + ): + try: + msg = {"command": "match_found", "queue": queue.name} + all_players = s1.players + s2.players + + for player in all_players: + player.write_message(msg) + + 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: Unmatch and return to 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(players, datetime.now() + timedelta(seconds=20)) + 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, diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 20344a91d..70c28e4ca 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -895,6 +895,9 @@ async def command_game_host(self, message): ) await self.launch_game(game, is_host=True) + async def command_match_ready(self, message): + self.ladder_service.ready_player(self.player) + async def launch_game( self, game, diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index e09889b4c..91eb54553 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -154,7 +154,11 @@ async def find_matches(self) -> None: loop = asyncio.get_running_loop() new_matches = filter( lambda m: self.match(m[0], m[1]), - await loop.run_in_executor(None, make_matches, searches) + await loop.run_in_executor( + None, + make_matches, + (search for search in searches if not search.done()) + ) ) self._matches.extend(new_matches) diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index d78175690..3cb0f744e 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -160,14 +160,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'): @@ -213,6 +213,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 e85545aae..757691481 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -285,7 +285,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_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_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 31301e0fa..1cf0869b6 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -46,6 +46,11 @@ async def queue_players_for_matchmaking(lobby_server): # If the players did not match, this will fail due to a timeout error await read_until_command(proto1, 'match_found') 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 diff --git a/tests/integration_tests/test_teammatchmaker.py b/tests/integration_tests/test_teammatchmaker.py index 129451209..338d892e2 100644 --- a/tests/integration_tests/test_teammatchmaker.py +++ b/tests/integration_tests/test_teammatchmaker.py @@ -44,7 +44,17 @@ async def queue_players_for_matchmaking(lobby_server): async def client_response(proto): - msg = await read_until_command(proto, "game_launch") + await read_until_command(proto, "match_info", timeout=5) + await proto.send_message({"command": "match_ready"}) + await asyncio.wait_for( + read_until( + proto, + lambda msg: + msg["command"] == "match_info" and msg["players_ready"] == 4, + ), + timeout=10, + ) + msg = await read_until_command(proto, "game_launch", timeout=5) # Ensures that the game enters the `LOBBY` state await proto.send_message({ "command": "GameState", diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index e41267101..890bb8e96 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -388,6 +388,11 @@ async def test_start_game_called_on_match( await asyncio.sleep(2) + ladder_service.ready_player(p1) + ladder_service.ready_player(p2) + + await asyncio.sleep(1) + ladder_service.inform_player.assert_called() ladder_service.start_game.assert_called_once()