From ae7f047d7924cce15dab1bd59af46ca2975b773a Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 13 Oct 2022 22:25:42 +0200 Subject: [PATCH] Sync queues of tmm Fixes #924 --- server/ladder_service/ladder_service.py | 161 ++++++--- server/matchmaker/algorithm/bucket_teams.py | 263 -------------- server/matchmaker/algorithm/random_newbies.py | 9 +- .../matchmaker/algorithm/stable_marriage.py | 8 +- .../matchmaker/algorithm/team_matchmaker.py | 42 +-- server/matchmaker/game_candidate.py | 17 + server/matchmaker/matchmaker_queue.py | 125 ++++--- server/matchmaker/pop_timer.py | 27 +- server/matchmaker/search.py | 43 ++- tests/conftest.py | 10 +- tests/unit_tests/test_ladder_service.py | 314 ++++++++++++----- .../test_matchmaker_algorithm_bucket_teams.py | 325 ------------------ ...st_matchmaker_algorithm_stable_marriage.py | 53 +-- ...st_matchmaker_algorithm_team_matchmaker.py | 80 +---- tests/unit_tests/test_matchmaker_queue.py | 191 ++-------- 15 files changed, 556 insertions(+), 1112 deletions(-) delete mode 100644 server/matchmaker/algorithm/bucket_teams.py create mode 100644 server/matchmaker/game_candidate.py delete mode 100644 tests/unit_tests/test_matchmaker_algorithm_bucket_teams.py diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 4538f0717..ff7c8007f 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -41,13 +41,21 @@ MapPool, MatchmakerQueue, OnMatchedCallback, + PopTimer, Search ) +from server.matchmaker.algorithm.team_matchmaker import TeamMatchMaker +from server.matchmaker.search import Match, are_searches_disjoint from server.metrics import MatchLaunch from server.players import Player, PlayerState from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap +def has_no_overlap(match: Match, matches_tmm_searches: set[Search]): + searches_in_match = set(search for team in match for search in team.get_original_searches()) + return are_searches_disjoint(searches_in_match, matches_tmm_searches) + + @with_logger class LadderService(Service): """ @@ -56,11 +64,12 @@ class LadderService(Service): """ def __init__( - self, - database: FAFDatabase, - game_service: GameService, - violation_service: ViolationService, + self, + database: FAFDatabase, + game_service: GameService, + violation_service: ViolationService, ): + self._is_running = True self._db = database self._informed_players: set[Player] = set() self.game_service = game_service @@ -68,10 +77,70 @@ def __init__( self.violation_service = violation_service self._searches: dict[Player, dict[str, Search]] = defaultdict(dict) + self.timer = None + self.matchmaker = TeamMatchMaker() + self.timer = PopTimer() async def initialize(self) -> None: await self.update_data() self._update_cron = aiocron.crontab("*/10 * * * *", func=self.update_data) + await self._initialize_pop_timer() + + async def _initialize_pop_timer(self) -> None: + self.timer.queues = list(self.queues.values()) + asyncio.create_task(self._queue_pop_timer()) + + async def _queue_pop_timer(self) -> None: + """ Periodically tries to match all Searches in the queue. The amount + of time until next queue 'pop' is determined by the number of players + in the queue. + """ + self._logger.debug("MatchmakerQueue pop timer initialized") + while self._is_running: + try: + await self.timer.next_pop() + await self._queue_pop_iteration() + + except asyncio.CancelledError: + break + except Exception: + self._logger.exception( + "Unexpected error during queue pop timer loop!" + ) + # To avoid potential busy loops + await asyncio.sleep(1) + self._logger.error("popping queues stopped") + + async def _queue_pop_iteration(self): + possible_games = list() + for queue in self._queues_without1v1(): + possible_games += await queue.find_matches() + matches_tmm = self.matchmaker.pick_noncolliding_games(possible_games) + matches_tmm = await self._add_matches_from1v1(matches_tmm) + await self._found_matches(matches_tmm) + + async def _add_matches_from1v1(self, matches_tmm): + for queue in self._1v1queues(): + matches_1v1 = await queue.find_matches1v1() + self._logger.debug("Suggested the following matches %s", matches_1v1) + matches_tmm_searches = set(search for match in matches_tmm + for team in match + for search in team.get_original_searches()) + matches_1v1 = [match for match in matches_1v1 if has_no_overlap(match, matches_tmm_searches)] + self._logger.debug("Found the following 1v1 matches %s", matches_1v1) + matches_tmm += matches_1v1 + return matches_tmm + + def _queues_without1v1(self) -> list[MatchmakerQueue]: + return [queue for queue in self.queues.values() if queue.team_size != 1] + + def _1v1queues(self) -> list[MatchmakerQueue]: + return [queue for queue in self.queues.values() if queue.team_size == 1] + + async def _found_matches(self, matches: list[Match]): + for queue in self.queues.values(): + await queue.found_matches([match for match in matches if match[0].queue == queue]) + self.game_service.mark_dirty(queue) async def update_data(self) -> None: async with self._db.acquire() as conn: @@ -81,8 +150,10 @@ async def update_data(self) -> None: for name, info in db_queues.items(): if name not in self.queues: queue = MatchmakerQueue( - self.game_service, - self.on_match_found, + game_service=self.game_service, + on_match_found=self.on_match_found, + timer=self.timer, + matchmaker=self.matchmaker, name=name, queue_id=info["id"], featured_mod=info["mod"], @@ -91,7 +162,6 @@ async def update_data(self) -> None: params=info.get("params") ) self.queues[name] = queue - queue.initialize() else: queue = self.queues[name] queue.featured_mod = info["mod"] @@ -115,7 +185,6 @@ async def update_data(self) -> None: # Remove queues that don't exist anymore for queue_name in list(self.queues.keys()): if queue_name not in db_queues: - self.queues[queue_name].shutdown() del self.queues[queue_name] async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: @@ -222,10 +291,10 @@ async def fetch_matchmaker_queues(self, conn): return matchmaker_queues def start_search( - self, - players: list[Player], - queue_name: str, - on_matched: OnMatchedCallback = lambda _1, _2: None + self, + players: list[Player], + queue_name: str, + on_matched: OnMatchedCallback = lambda _1, _2: None ): timeouts = self.violation_service.get_violations(players) if timeouts: @@ -276,9 +345,10 @@ def start_search( queue = self.queues[queue_name] search = Search( - players, + players=players, rating_type=queue.rating_type, - on_matched=on_matched + on_matched=on_matched, + queue=queue ) for player in players: @@ -299,9 +369,9 @@ def start_search( asyncio.create_task(queue.search(search)) def cancel_search( - self, - initiator: Player, - queue_name: Optional[str] = None + self, + initiator: Player, + queue_name: Optional[str] = None ) -> None: if queue_name is None: queue_names = list(self._searches[initiator].keys()) @@ -333,8 +403,8 @@ def _cancel_search(self, initiator: Player, queue_name: str) -> None: "state": "stop" }) if ( - not self._searches[player] - and player.state == PlayerState.SEARCHING_LADDER + not self._searches[player] + and player.state == PlayerState.SEARCHING_LADDER ): player.state = PlayerState.IDLE self._logger.info( @@ -342,9 +412,9 @@ def _cancel_search(self, initiator: Player, queue_name: str) -> None: ) def _clear_search( - self, - initiator: Player, - queue_name: str + self, + initiator: Player, + queue_name: str ) -> Optional[Search]: """ Remove a search from the searches dictionary. @@ -393,10 +463,10 @@ def write_rating_progress(self, player: Player, rating_type: str) -> None: }) def on_match_found( - self, - s1: Search, - s2: Search, - queue: MatchmakerQueue + self, + s1: Search, + s2: Search, + queue: MatchmakerQueue ) -> None: """ Callback for when a match is generated by a matchmaker queue. @@ -429,10 +499,10 @@ def on_match_found( ) def start_game( - self, - team1: list[Player], - team2: list[Player], - queue: MatchmakerQueue + self, + team1: list[Player], + team2: list[Player], + queue: MatchmakerQueue ) -> Awaitable[None]: # We want assertion errors to trigger when the caller attempts to # create the async function, not when the function starts executing. @@ -441,10 +511,10 @@ def start_game( return self._start_game(team1, team2, queue) async def _start_game( - self, - team1: list[Player], - team2: list[Player], - queue: MatchmakerQueue + self, + team1: list[Player], + team2: list[Player], + queue: MatchmakerQueue ) -> None: self._logger.debug( "Starting %s game between %s and %s", @@ -496,7 +566,7 @@ def get_player_mean(player: Player) -> float: random.shuffle(zipped_teams) for i, player in enumerate( - player for pair in zipped_teams for player in pair + player for pair in zipped_teams for player in pair ): # FA uses lua and lua arrays are 1-indexed slot = i + 1 @@ -577,11 +647,11 @@ def make_game_options(player: Player) -> GameLaunchOptions: self.violation_service.register_violations(abandoning_players) async def launch_match( - self, - game: LadderGame, - host: Player, - guests: list[Player], - make_game_options: Callable[[Player], GameLaunchOptions] + self, + game: LadderGame, + host: Player, + guests: list[Player], + make_game_options: Callable[[Player], GameLaunchOptions] ): # Launch the host if host.lobby_connection is None: @@ -630,10 +700,10 @@ async def launch_match( ]) async def get_game_history( - self, - players: list[Player], - queue_id: int, - limit: int = 3 + self, + players: list[Player], + queue_id: int, + limit: int = 3 ) -> list[int]: async with self._db.acquire() as conn: result = [] @@ -676,8 +746,7 @@ def on_connection_lost(self, conn: "LobbyConnection") -> None: self._informed_players.remove(player) async def shutdown(self): - for queue in self.queues.values(): - queue.shutdown() + self._is_running = False class NotConnectedError(asyncio.TimeoutError): diff --git a/server/matchmaker/algorithm/bucket_teams.py b/server/matchmaker/algorithm/bucket_teams.py deleted file mode 100644 index b082a920e..000000000 --- a/server/matchmaker/algorithm/bucket_teams.py +++ /dev/null @@ -1,263 +0,0 @@ -import itertools -import random -from collections import OrderedDict -from typing import Iterable, Iterator, Optional, TypeVar - -from ...decorators import with_logger -from ..search import CombinedSearch, Match, Search -from .stable_marriage import Matchmaker, StableMarriageMatchmaker, avg_mean - -T = TypeVar("T") -Buckets = dict[Search, list[tuple[Search, float]]] - - -@with_logger -class BucketTeamMatchmaker(Matchmaker): - """ - Uses heuristics to group searches of any size - into CombinedSearches of team_size - and then runs StableMarriageMatchmaker - to produce a list of matches from these. - """ - - def find( - self, searches: Iterable[Search], team_size: int - ) -> tuple[list[Match], list[Search]]: - teams, searches_without_team = self._find_teams(searches, team_size) - - matchmaker1v1 = StableMarriageMatchmaker() - matches, unmatched_searches = matchmaker1v1.find(teams, 1) - - unmatched_searches.extend(searches_without_team) - return matches, unmatched_searches - - @staticmethod - def _find_teams( - searches: Iterable[Search], team_size: int - ) -> tuple[list[Search], list[Search]]: - full_teams = [] - unmatched = searches - need_team = [] - for search in unmatched: - if len(search.players) == team_size: - full_teams.append(search) - else: - need_team.append(search) - - if all(len(s.players) == 1 for s in need_team): - teams, unmatched = _make_teams_from_single(need_team, team_size) - else: - teams, unmatched = _make_teams(need_team, team_size) - full_teams.extend(teams) - - return full_teams, unmatched - - -def _make_teams_from_single( - searches: list[Search], size: int -) -> tuple[list[Search], list[Search]]: - """ - Make teams in the special case where all players are solo queued (no - parties). - - Tries to put players of similar skill on the same team as long as there are - enough such players to form at least 2 teams. If there are not enough - similar players for two teams, then distributes similarly rated players - accross different teams. - - # Algorithm - 1. Group players into "buckets" by rating. This is a sort of heuristic for - determining which players have similar rating. - 2. Create as many games as possible within each bucket. - 3. Create games from remaining players by balancing teams with players from - different buckets. - """ - assert all(len(s.players) == 1 for s in searches) - - # Make buckets - buckets = _make_buckets(searches) - remaining: list[tuple[Search, float]] = [] - - new_searches: list[Search] = [] - # Match up players within buckets - for bucket in buckets.values(): - # Always produce an even number of teams - num_groups = len(bucket) // (size * 2) - num_teams = num_groups * 2 - num_players = num_teams * size - - selected = random.sample(bucket, num_players) - # TODO: Optimize? - remaining.extend(s for s in bucket if s not in selected) - # Sort by trueskill mean - selected.sort(key=lambda item: item[1]) - new_searches.extend(_distribute(selected, size)) - - # Match up players accross buckets - remaining.sort(key=lambda item: item[1]) - while len(remaining) >= size: - if len(remaining) >= 2 * size: - # enough for at least 2 teams - selected = remaining[: 2 * size] - new_searches.extend(_distribute(selected, size)) - else: - selected = remaining[:size] - new_searches.append(CombinedSearch(*[s for s, m in selected])) - - remaining = [item for item in remaining if item not in selected] - - return new_searches, [search for search, _ in remaining] - - -def _make_buckets(searches: list[Search]) -> Buckets: - """ - Group players together by similar rating. - - # Algorithm - 1. Choose a random player as the "pivot". - 2. Find all players with rating within 100 pts of this player and place - them in a bucket. - 3. Repeat with remaining players. - """ - remaining = list(map(lambda s: (s, avg_mean(s)), searches)) - buckets: Buckets = {} - - while remaining: - # Choose a pivot - pivot, mean = random.choice(remaining) - low, high = mean - 100, mean + 100 - - # Partition remaining based on how close their means are - bucket, not_bucket = [], [] - for item in remaining: - (_, other_mean) = item - if low <= other_mean <= high: - bucket.append(item) - else: - not_bucket.append(item) - - buckets[pivot] = bucket - remaining = not_bucket - - return buckets - - -def _distribute( - items: list[tuple[Search, float]], team_size: int -) -> Iterator[CombinedSearch]: - """ - Distributes a sorted list into teams of a given size in a balanced manner. - Player "skill" is determined by their position in the list. - - For example (using numbers to represent list positions) - ``` - _distribute([1,2,3,4], 2) == [[1,4], [2,3]] - ``` - In this simple scenario, one team gets the best and the worst player and - the other player gets the two in the middle. This is the only way of - distributing these 4 items into 2 teams such that there is no obviously - favored team. - """ - num_teams = len(items) // team_size - teams: list[list[Search]] = [[] for _ in range(num_teams)] - half = len(items) // 2 - # Rotate the second half of the list - rotated = items[:half] + rotate(items[half:], half // 2) - for i, (search, _) in enumerate(rotated): - # Distribute the pairs to the appropriate team - teams[i % num_teams].append(search) - - return (CombinedSearch(*team) for team in teams) - - -def _make_teams(searches: list[Search], size: int) -> tuple[list[Search], list[Search]]: - """ - Tries to group as many searches together into teams of the given size as - possible. Returns the new grouped searches, and the remaining searches that - were not succesfully grouped. - - Does not try to balance teams so it should be used only as a last resort. - """ - - searches_by_size = _make_searches_by_size(searches) - - new_searches = [] - for search in searches: - if len(search.players) > size: - continue - - new_search = _make_team_for_search(search, searches_by_size, size) - if new_search: - new_searches.append(new_search) - - return new_searches, list(itertools.chain(*searches_by_size.values())) - - -def _make_searches_by_size(searches: list[Search]) -> dict[int, set[Search]]: - """ - Creates a lookup table indexed by number of players in the search. - """ - - searches_by_size: dict[int, set[Search]] = OrderedDict() - - # Would be easier with defaultdict, but we want to preserve key order - for search in searches: - size = len(search.players) - if size not in searches_by_size: - searches_by_size[size] = set() - searches_by_size[size].add(search) - - return searches_by_size - - -def _make_team_for_search( - search: Search, searches_by_size: dict[int, set[Search]], size: int -) -> Optional[Search]: - """ - Match this search with other searches to create a new team of `size` - members. - """ - - num_players = len(search.players) - if search not in searches_by_size[num_players]: - return None - searches_by_size[num_players].remove(search) - - if num_players == size: - return search - - num_needed = size - num_players - try_size = num_needed - new_search = search - while num_needed > 0: - if try_size == 0: - _uncombine(new_search, searches_by_size) - return None - - try: - other = searches_by_size[try_size].pop() - new_search = CombinedSearch(new_search, other) - num_needed -= try_size - try_size = num_needed - except KeyError: - try_size -= 1 - - return new_search - - -def _uncombine(search: Search, searches_by_size: dict[int, set[Search]]) -> None: - """ - Adds all of the searches in search back to their respective spots in - `searches_by_size`. - """ - - if not isinstance(search, CombinedSearch): - searches_by_size[len(search.players)].add(search) - return - - for s in search.searches: - _uncombine(s, searches_by_size) - - -def rotate(list_: list[T], amount: int) -> list[T]: - return list_[amount:] + list_[:amount] diff --git a/server/matchmaker/algorithm/random_newbies.py b/server/matchmaker/algorithm/random_newbies.py index 70cab50ed..efec7be97 100644 --- a/server/matchmaker/algorithm/random_newbies.py +++ b/server/matchmaker/algorithm/random_newbies.py @@ -7,9 +7,8 @@ class RandomlyMatchNewbies(MatchmakingPolicy1v1): def find( self, searches: Iterable[Search] - ) -> tuple[dict[Search, Search], list[Match]]: + ) -> dict[Search, Search]: self.matches.clear() - searches_remaining_unmatched = set(searches) unmatched_newbies: list[Search] = [] first_opponent = None @@ -25,14 +24,10 @@ def find( newbie1 = unmatched_newbies.pop() newbie2 = unmatched_newbies.pop() self._match(newbie1, newbie2) - searches_remaining_unmatched.discard(newbie1) - searches_remaining_unmatched.discard(newbie2) can_match_last_newbie_with_first_opponent = unmatched_newbies and first_opponent if can_match_last_newbie_with_first_opponent: newbie = unmatched_newbies[0] self._match(newbie, first_opponent) - searches_remaining_unmatched.discard(newbie) - searches_remaining_unmatched.discard(first_opponent) - return self.matches, list(searches_remaining_unmatched) + return self.matches diff --git a/server/matchmaker/algorithm/stable_marriage.py b/server/matchmaker/algorithm/stable_marriage.py index f44ea9bf8..049e3f776 100644 --- a/server/matchmaker/algorithm/stable_marriage.py +++ b/server/matchmaker/algorithm/stable_marriage.py @@ -81,8 +81,8 @@ class StableMarriageMatchmaker(Matchmaker): """ def find( - self, searches: Iterable[Search], team_size: int - ) -> tuple[list[Match], list[Search]]: + self, searches: Iterable[Search], team_size: int = 1 + ) -> list[Match]: if team_size != 1: self._logger.error( "Invalid team size %i for stable marriage matchmaker will be ignored", @@ -105,10 +105,10 @@ def find( ] self._logger.debug("Matching randomly for remaining newbies...") - randomly_matched_newbies, unmatched_searches = RandomlyMatchNewbies().find(remaining_searches) + randomly_matched_newbies = RandomlyMatchNewbies().find(remaining_searches) matches.update(randomly_matched_newbies) - return self._remove_duplicates(matches), unmatched_searches + return self._remove_duplicates(matches) @staticmethod def _remove_duplicates(matches: dict[Search, Search]) -> list[Match]: diff --git a/server/matchmaker/algorithm/team_matchmaker.py b/server/matchmaker/algorithm/team_matchmaker.py index d74b96f3f..21a40e530 100644 --- a/server/matchmaker/algorithm/team_matchmaker.py +++ b/server/matchmaker/algorithm/team_matchmaker.py @@ -2,30 +2,24 @@ import statistics from collections import defaultdict from math import sqrt -from typing import Iterable, NamedTuple +from typing import Iterable from sortedcontainers import SortedList from ...config import config from ...decorators import with_logger -from ..search import CombinedSearch, Match, Search, get_average_rating +from ..game_candidate import GameCandidate +from ..search import ( + CombinedSearch, + Match, + Search, + are_searches_disjoint, + get_average_rating +) from .matchmaker import Matchmaker from .stable_marriage import StableMarriageMatchmaker -class GameCandidate(NamedTuple): - """ - Holds the participating searches and a quality rating for a potential game - from the matchmaker. The quality is not the trueskill quality! - """ - match: Match - quality: float - - @property - def all_searches(self) -> set[Search]: - return set(search for team in self.match for search in team.get_original_searches()) - - class UnevenTeamsException(Exception): pass @@ -56,13 +50,9 @@ class TeamMatchMaker(Matchmaker): 8. pick the first game from the game list and remove all other games that contain the same players 9. repeat 8. until the list is empty """ - - def find(self, searches: Iterable[Search], team_size: int) -> tuple[list[Match], list[Search]]: + def find(self, searches: Iterable[Search], team_size: int) -> list[GameCandidate]: if not searches: - return [], [] - - if team_size == 1: - return StableMarriageMatchmaker().find(searches, 1) + return [] searches = SortedList(searches, key=lambda s: s.average_rating) possible_games = [] @@ -94,13 +84,7 @@ def find(self, searches: Iterable[Search], team_size: int) -> tuple[list[Match], game.match[0].cumulative_rating - game.match[1].cumulative_rating, game.quality ) - - matches = self.pick_noncolliding_games(possible_games) - for match in matches: - for team in match: - for search in team.get_original_searches(): - searches.remove(search) - return matches, list(searches) + return possible_games @staticmethod def pick_neighboring_players(searches: list[Search], index: int, team_size: int) -> list[Search]: @@ -313,7 +297,7 @@ def pick_noncolliding_games(self, games: list[GameCandidate]) -> list[Match]: matches = [] used_searches = set() for game in reversed(games): - if used_searches.isdisjoint(game.all_searches): + if are_searches_disjoint(used_searches, game.all_searches): matches.append(game.match) used_searches.update(game.all_searches) self._logger.debug("used players: %s", [search for search in used_searches]) diff --git a/server/matchmaker/game_candidate.py b/server/matchmaker/game_candidate.py new file mode 100644 index 000000000..cffdce0b4 --- /dev/null +++ b/server/matchmaker/game_candidate.py @@ -0,0 +1,17 @@ +from typing import NamedTuple + +from server.matchmaker.search import Match, Search + + +class GameCandidate(NamedTuple): + """ + Holds the participating searches and a quality rating for a potential game + from the matchmaker. The quality is not the trueskill quality! + """ + match: Match + quality: float + + @property + def all_searches(self) -> set[Search]: + return set(search for team in self.match for search in team.get_original_searches()) + diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index b7f36c37d..77575d105 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -7,12 +7,11 @@ import server.metrics as metrics -from ..asyncio_extensions import SpinLock, synchronized from ..decorators import with_logger from ..players import PlayerState -from .algorithm.team_matchmaker import TeamMatchMaker +from .algorithm.stable_marriage import StableMarriageMatchmaker +from .algorithm.team_matchmaker import GameCandidate, TeamMatchMaker from .map_pool import MapPool -from .pop_timer import PopTimer from .search import Match, Search MatchFoundCallback = Callable[[Search, Search, "MatchmakerQueue"], Any] @@ -41,16 +40,18 @@ def __exit__(self, exc_type, exc_value, traceback): @with_logger class MatchmakerQueue: def __init__( - self, - game_service: "GameService", - on_match_found: MatchFoundCallback, - name: str, - queue_id: int, - featured_mod: str, - rating_type: str, - team_size: int = 1, - params: Optional[dict[str, Any]] = None, - map_pools: Iterable[tuple[MapPool, Optional[int], Optional[int]]] = (), + self, + game_service: "GameService", + on_match_found: MatchFoundCallback, + timer: "PopTimer", + name: str, + queue_id: int, + featured_mod: str, + rating_type: str, + matchmaker: TeamMatchMaker, + team_size: int = 1, + params: Optional[dict[str, Any]] = None, + map_pools: Iterable[tuple[MapPool, Optional[int], Optional[int]]] = (), ): self.game_service = game_service self.name = name @@ -63,17 +64,15 @@ def __init__( self._queue: dict[Search, None] = OrderedDict() self.on_match_found = on_match_found - self._is_running = True - self.timer = PopTimer(self) - - self.matchmaker = TeamMatchMaker() + self.matchmaker = matchmaker + self.timer = timer def add_map_pool( - self, - map_pool: MapPool, - min_rating: Optional[int], - max_rating: Optional[int] + self, + map_pool: MapPool, + min_rating: Optional[int], + max_rating: Optional[int] ) -> None: self.map_pools[map_pool.id] = (map_pool, min_rating, max_rating) @@ -88,44 +87,10 @@ def get_map_pool_for_rating(self, rating: float) -> Optional[MapPool]: def get_game_options(self) -> dict[str, Any]: return self.params.get("GameOptions") or None - def initialize(self): - asyncio.create_task(self.queue_pop_timer()) - @property def num_players(self) -> int: return sum(len(search.players) for search in self._queue.keys()) - async def queue_pop_timer(self) -> None: - """ Periodically tries to match all Searches in the queue. The amount - of time until next queue 'pop' is determined by the number of players - in the queue. - """ - self._logger.debug("MatchmakerQueue initialized for %s", self.name) - while self._is_running: - try: - await self.timer.next_pop() - - await self.find_matches() - - number_of_unmatched_searches = len(self._queue) - metrics.unmatched_searches.labels(self.name).set( - number_of_unmatched_searches - ) - - # Any searches in the queue at this point were unable to find a - # match this round and will have higher priority next round. - - self.game_service.mark_dirty(self) - except asyncio.CancelledError: - break - except Exception: - self._logger.exception( - "Unexpected error during queue pop timer loop!" - ) - # To avoid potential busy loops - await asyncio.sleep(1) - self._logger.info("%s queue stopped", self.name) - async def search(self, search: Search) -> None: """ Search for a match. @@ -148,8 +113,7 @@ async def search(self, search: Search) -> None: if search in self._queue: del self._queue[search] - @synchronized(SpinLock(sleep_duration=1)) - async def find_matches(self) -> None: + async def find_matches(self) -> list[GameCandidate]: """ Perform the matchmaking algorithm. @@ -162,18 +126,44 @@ async def find_matches(self) -> None: searches = list(self._queue.keys()) if self.num_players < 2 * self.team_size: - self._register_unmatched_searches(searches) - return + return [] # Call self.match on all matches and filter out the ones that were cancelled loop = asyncio.get_running_loop() - proposed_matches, unmatched_searches = await loop.run_in_executor( + return await loop.run_in_executor( None, self.matchmaker.find, searches, self.team_size, ) + async def find_matches1v1(self) -> list[Match]: + self._logger.info("Searching for 1v1 matches: %s", self.name) + + searches = list(self._queue.keys()) + + if self.num_players < 2 * self.team_size: + return [] + matchmaker = StableMarriageMatchmaker() + # Call self.match on all matches and filter out the ones that were cancelled + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + matchmaker.find, + searches, + self.team_size, + ) + + def get_unmatched_searches(self, proposed_matches: list[Match]) -> list[Search]: + searches = list(self._queue.keys()) + for match in proposed_matches: + for team in match: + for search in team.get_original_searches(): + searches.remove(search) + return searches + + async def found_matches(self, proposed_matches: list[Match]): + unmatched_searches = self.get_unmatched_searches(proposed_matches) # filter out matches that were cancelled matches: list[Match] = [] for match in proposed_matches: @@ -202,6 +192,10 @@ async def find_matches(self) -> None: self.on_match_found(search1, search2, self) except Exception: self._logger.exception("Match callback raised an exception!") + number_of_unmatched_searches = len(self._queue) + metrics.unmatched_searches.labels(self.name).set( + number_of_unmatched_searches + ) def _report_party_sizes(self, team): for search in team.get_original_searches(): @@ -210,8 +204,8 @@ def _report_party_sizes(self, team): ).inc() def _register_unmatched_searches( - self, - unmatched_searches: list[Search], + self, + unmatched_searches: list[Search], ): """ Tells all unmatched searches that they went through a failed matching @@ -243,8 +237,8 @@ def match(self, s1: Search, s2: Search) -> bool: return False # Additional failsafe. Ideally this check will never fail. if any( - player.state != PlayerState.SEARCHING_LADDER - for player in s1.players + s2.players + player.state != PlayerState.SEARCHING_LADDER + for player in s1.players + s2.players ): self._logger.warning( "Tried to match searches %s and %s while some players had " @@ -264,9 +258,6 @@ def match(self, s1: Search, s2: Search) -> bool: return True - def shutdown(self): - self._is_running = False - def to_dict(self): """ Return a fuzzy representation of the searches currently in the queue diff --git a/server/matchmaker/pop_timer.py b/server/matchmaker/pop_timer.py index c61bce84f..15954478f 100644 --- a/server/matchmaker/pop_timer.py +++ b/server/matchmaker/pop_timer.py @@ -25,8 +25,8 @@ class PopTimer(object): The exact size can be set in config. """ - def __init__(self, queue: "MatchmakerQueue"): - self.queue = queue + def __init__(self): + self.queues = list() # Set up deque's for calculating a moving average self.last_queue_amounts: deque[int] = deque(maxlen=config.QUEUE_POP_TIME_MOVING_AVG_SIZE) self.last_queue_times: deque[float] = deque(maxlen=config.QUEUE_POP_TIME_MOVING_AVG_SIZE) @@ -39,15 +39,16 @@ async def next_pop(self): """ Wait for the timer to pop. """ time_remaining = self.next_queue_pop - time() - self._logger.info("Next %s wave happening in %is", self.queue.name, time_remaining) - metrics.matchmaker_queue_pop.labels(self.queue.name).set(int(time_remaining)) + self._logger.info("Next wave happening in %is", time_remaining) await asyncio.sleep(time_remaining) - num_players = self.queue.num_players - metrics.matchmaker_players.labels(self.queue.name).set(num_players) + for queue in self.queues: + metrics.matchmaker_queue_pop.labels(queue.name).set(int(time_remaining)) + num_players = queue.num_players + metrics.matchmaker_players.labels(queue.name).set(num_players) self._last_queue_pop = time() self.next_queue_pop = self._last_queue_pop + self.time_until_next_pop( - num_players, time_remaining + max([queue.num_players for queue in self.queues]), time_remaining ) def time_until_next_pop(self, num_queued: int, time_queued: float) -> float: @@ -65,19 +66,19 @@ def time_until_next_pop(self, num_queued: int, time_queued: float) -> float: total_times = sum(self.last_queue_times) if total_times: self._logger.debug( - "Queue rate for %s: %f/s", self.queue.name, + "Queue rate for: %f/s", total_players / total_times ) - - players_per_match = self.queue.team_size * 2 - desired_players = config.QUEUE_POP_DESIRED_MATCHES * players_per_match + queues_team_sizes = [queue.team_size for queue in self.queues] + average_players_per_match = (sum(queues_team_sizes) / len(queues_team_sizes)) * 2 + desired_players = config.QUEUE_POP_DESIRED_MATCHES * average_players_per_match # Obtained by solving $ NUM_PLAYERS = rate * time $ for time. next_pop_time = desired_players * total_times / total_players if next_pop_time > config.QUEUE_POP_TIME_MAX: self._logger.info( - "Required time (%.2fs) for %s is larger than max pop time (%ds). " + "Required time (%.2fs) is larger than max pop time (%ds). " "Consider increasing the max pop time", - next_pop_time, self.queue.name, config.QUEUE_POP_TIME_MAX + next_pop_time, config.QUEUE_POP_TIME_MAX ) return config.QUEUE_POP_TIME_MAX return next_pop_time diff --git a/server/matchmaker/search.py b/server/matchmaker/search.py index 99c3a0b87..ce88ed0c8 100644 --- a/server/matchmaker/search.py +++ b/server/matchmaker/search.py @@ -3,7 +3,7 @@ import math import statistics import time -from typing import Any, Callable, Optional +from typing import Any, Callable, Iterable, Optional import trueskill @@ -27,17 +27,29 @@ class Search: Represents the state of a users search for a match. """ + def adjusted_rating(self, player: Player) -> Rating: + """ + Returns an adjusted mean with a simple linear interpolation between current mean and a specified base mean + """ + mean, dev = player.ratings[self.rating_type] + game_count = player.game_count[self.rating_type] + adjusted_mean = ((config.NEWBIE_MIN_GAMES - game_count) * config.NEWBIE_BASE_MEAN + + game_count * mean) / config.NEWBIE_MIN_GAMES + return Rating(adjusted_mean, dev) + def __init__( - self, - players: list[Player], - start_time: Optional[float] = None, - rating_type: str = RatingType.LADDER_1V1, - on_matched: OnMatchedCallback = lambda _1, _2: None + self, + players: list[Player], + queue=None, + start_time: Optional[float] = None, + rating_type: str = RatingType.LADDER_1V1, + on_matched: OnMatchedCallback = lambda _1, _2: None ): assert isinstance(players, list) for player in players: assert player.ratings[rating_type] is not None + self.queue = queue self.players = players self.rating_type = rating_type self.start_time = start_time or time.time() @@ -48,16 +60,6 @@ def __init__( # Precompute this self.quality_against_self = self.quality_with(self) - def adjusted_rating(self, player: Player) -> Rating: - """ - Returns an adjusted mean with a simple linear interpolation between current mean and a specified base mean - """ - mean, dev = player.ratings[self.rating_type] - game_count = player.game_count[self.rating_type] - adjusted_mean = ((config.NEWBIE_MIN_GAMES - game_count) * config.NEWBIE_BASE_MEAN - + game_count * mean) / config.NEWBIE_MIN_GAMES - return Rating(adjusted_mean, dev) - def is_newbie(self, player: Player) -> bool: return player.game_count[self.rating_type] <= config.NEWBIE_MIN_GAMES @@ -291,6 +293,7 @@ def __init__(self, *searches: Search): self.rating_type = rating_type self.searches = searches + self.queue = searches[0].queue @property def players(self) -> list[Player]: @@ -375,3 +378,11 @@ def get_original_searches(self) -> list[Search]: Returns the searches of which this CombinedSearch is comprised """ return list(self.searches) + + +def are_searches_disjoint(match1_searches: Iterable[Search], match2_searches: Iterable[Search]): + for search in match1_searches: + for search2 in match2_searches: + if search.players == search2.players: + return False + return True diff --git a/tests/conftest.py b/tests/conftest.py index a837f94f7..092235be2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,7 @@ from server.geoip_service import GeoIpService from server.lobbyconnection import LobbyConnection from server.matchmaker import MatchmakerQueue +from server.matchmaker.algorithm.team_matchmaker import TeamMatchMaker from server.message_queue_service import MessageQueueService from server.oauth_service import OAuthService from server.player_service import PlayerService @@ -256,6 +257,7 @@ def make_player( global_games=0, ladder_games=0, lobby_connection_spec=None, + ladder_game_count=None, **kwargs ): ratings = {k: v for k, v in { @@ -270,6 +272,8 @@ def make_player( p = Player(login=login, ratings=ratings, game_count=games, **kwargs) p.state = state + if ladder_game_count is not None: + p.game_count[RatingType.LADDER_1V1] = ladder_game_count if lobby_connection_spec: if not isinstance(lobby_connection_spec, str): @@ -372,6 +376,8 @@ def make( nonlocal queue_id queue_id += 1 return MatchmakerQueue( + timer=mock.Mock(), + matchmaker=mock.Mock(), game_service=mock.Mock(), on_match_found=mock.Mock(), name=name, @@ -389,10 +395,12 @@ def matchmaker_queue(game_service) -> MatchmakerQueue: queue = MatchmakerQueue( game_service, mock.Mock(), + mock.Mock(), "ladder1v1test", + 1, FeaturedModType.LADDER_1V1, RatingType.LADDER_1V1, - 1 + TeamMatchMaker() ) return queue diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 34e20868a..d0cfdef25 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -5,12 +5,13 @@ from hypothesis import given, settings from hypothesis import strategies as st -from server import LadderService +from server import LadderService, config from server.db.models import matchmaker_queue, matchmaker_queue_map_pool 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 CombinedSearch, MapPool, MatchmakerQueue, Search +from server.matchmaker.algorithm.team_matchmaker import TeamMatchMaker from server.players import PlayerState from server.rating import RatingType from server.types import Map, NeroxisGeneratedMap @@ -20,6 +21,20 @@ from .strategies import st_players +@pytest.fixture +def matchmaker_players_all_match(player_factory): + return player_factory("Dostya", player_id=1, ladder_rating=(1500, 50), state=PlayerState.SEARCHING_LADDER), \ + player_factory("Brackman", player_id=2, ladder_rating=(1500, 50), state=PlayerState.SEARCHING_LADDER), \ + player_factory("Zoidberg", player_id=3, ladder_rating=(1500, 50), state=PlayerState.SEARCHING_LADDER), \ + player_factory("QAI", player_id=4, ladder_rating=(1500, 50), state=PlayerState.SEARCHING_LADDER), \ + player_factory("Rhiza", player_id=5, ladder_rating=(1500, 50), state=PlayerState.SEARCHING_LADDER) + + +@pytest.fixture +def uninitialized_service(player_factory): + return LadderService(mock.Mock(), mock.Mock(), mock.Mock()) + + async def test_queue_initialization(database, game_service, violation_service): ladder_service = LadderService(database, game_service, violation_service) @@ -29,17 +44,14 @@ def make_mock_queue(*args, **kwargs): return queue with mock.patch( - "server.ladder_service.ladder_service.MatchmakerQueue", - make_mock_queue + "server.ladder_service.ladder_service.MatchmakerQueue", + make_mock_queue ): for name in list(ladder_service.queues.keys()): ladder_service.queues[name] = make_mock_queue() await ladder_service.initialize() - for queue in ladder_service.queues.values(): - queue.initialize.assert_called_once() - async def test_load_from_database(ladder_service, queue_factory): # Insert some outdated data @@ -104,6 +116,7 @@ async def test_load_from_database_new_data(ladder_service, database): await conn.execute(matchmaker_queue.insert().values( id=1000000, technical_name="test", + team_size=4, featured_mod_id=1, leaderboard_id=1, name_key="test.name" @@ -114,13 +127,13 @@ async def test_load_from_database_new_data(ladder_service, database): )) await ladder_service.update_data() - test_queue = ladder_service.queues["test"] assert test_queue.name == "test" - assert test_queue._is_running # Queue pop times are 1 second for tests test_queue.find_matches = mock.AsyncMock() + await ladder_service.initialize() + await asyncio.sleep(1.5) test_queue.find_matches.assert_called() @@ -133,10 +146,10 @@ async def test_load_from_database_new_data(ladder_service, database): @settings(deadline=None) @autocontext("ladder_and_game_service_context", "monkeypatch_context") async def test_start_game_1v1( - ladder_and_game_service, - monkeypatch, - player1, - player2 + ladder_and_game_service, + monkeypatch, + player1, + player2 ): ladder_service, game_service = ladder_and_game_service queue = ladder_service.queues["ladder1v1"] @@ -160,10 +173,10 @@ async def test_start_game_1v1( async def test_start_game_with_game_options( - ladder_service, - game_service, - monkeypatch, - player_factory + ladder_service, + game_service, + monkeypatch, + player_factory ): queue = ladder_service.queues["gameoptions"] p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") @@ -189,9 +202,9 @@ async def test_start_game_with_game_options( @fast_forward(65) async def test_start_game_timeout( - ladder_service: LadderService, - player_factory, - monkeypatch + ladder_service: LadderService, + player_factory, + monkeypatch ): queue = ladder_service.queues["ladder1v1"] p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") @@ -238,9 +251,9 @@ async def test_start_game_timeout( @fast_forward(200) async def test_start_game_timeout_on_send( - ladder_service: LadderService, - player_factory, - monkeypatch + ladder_service: LadderService, + player_factory, + monkeypatch ): queue = ladder_service.queues["ladder1v1"] p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") @@ -251,6 +264,7 @@ async def test_start_game_timeout_on_send( async def wait_forever(*args, **kwargs): await asyncio.sleep(1000) + # Even though launch_game isn't called by start_game, these mocks are # important for the test in case someone refactors the code to call it. p1.lobby_connection.launch_game.side_effect = wait_forever @@ -292,9 +306,9 @@ async def wait_forever(*args, **kwargs): async def test_start_game_game_closed_by_guest( - ladder_service: LadderService, - player_factory, - monkeypatch + ladder_service: LadderService, + player_factory, + monkeypatch ): queue = ladder_service.queues["ladder1v1"] p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") @@ -323,9 +337,9 @@ async def test_start_game_game_closed_by_guest( async def test_start_game_game_closed_by_host( - ladder_service: LadderService, - player_factory, - monkeypatch + ladder_service: LadderService, + player_factory, + monkeypatch ): queue = ladder_service.queues["ladder1v1"] p1 = player_factory("Dostya", player_id=1, lobby_connection_spec="auto") @@ -363,12 +377,12 @@ async def test_start_game_game_closed_by_host( @settings(deadline=None) @autocontext("ladder_and_game_service_context", "monkeypatch_context") async def test_start_game_with_teams( - ladder_and_game_service, - monkeypatch, - player1, - player2, - player3, - player4 + ladder_and_game_service, + monkeypatch, + player1, + player2, + player3, + player4 ): ladder_service, game_service = ladder_and_game_service queue = ladder_service.queues["tmm2v2"] @@ -399,9 +413,9 @@ async def test_start_game_with_teams( @given( team1=st.lists( st.sampled_from(( - make_player("p1", player_id=1, global_rating=(500, 10)), - make_player("p3", player_id=3, global_rating=(1000, 10)), - make_player("p5", player_id=5, global_rating=(2000, 10)) + make_player("p1", player_id=1, global_rating=(500, 10)), + make_player("p3", player_id=3, global_rating=(1000, 10)), + make_player("p5", player_id=5, global_rating=(2000, 10)) )), min_size=3, max_size=3, @@ -409,9 +423,9 @@ async def test_start_game_with_teams( ), team2=st.lists( st.sampled_from(( - make_player("p2", player_id=2, global_rating=(500, 10)), - make_player("p4", player_id=4, global_rating=(1000, 10)), - make_player("p6", player_id=6, global_rating=(2000, 10)) + make_player("p2", player_id=2, global_rating=(500, 10)), + make_player("p4", player_id=4, global_rating=(1000, 10)), + make_player("p6", player_id=6, global_rating=(2000, 10)) )), min_size=3, max_size=3, @@ -421,11 +435,11 @@ async def test_start_game_with_teams( @settings(deadline=None) @autocontext("ladder_and_game_service_context", "monkeypatch_context") async def test_start_game_start_spots( - ladder_and_game_service, - monkeypatch, - queue_factory, - team1, - team2 + ladder_and_game_service, + monkeypatch, + queue_factory, + team1, + team2 ): ladder_service, game_service = ladder_and_game_service queue = queue_factory( @@ -478,9 +492,9 @@ async def test_write_rating_progress(ladder_service: LadderService, player_facto async def test_search_info_message( - ladder_service: LadderService, - player_factory, - queue_factory, + ladder_service: LadderService, + player_factory, + queue_factory, ): ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") @@ -542,9 +556,9 @@ async def test_search_info_message( async def test_start_search_multiqueue( - ladder_service: LadderService, - player_factory, - queue_factory, + ladder_service: LadderService, + player_factory, + queue_factory, ): ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") @@ -566,9 +580,9 @@ async def test_start_search_multiqueue( async def test_start_search_multiqueue_multiple_players( - ladder_service: LadderService, - player_factory, - queue_factory, + ladder_service: LadderService, + player_factory, + queue_factory, ): ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") @@ -612,9 +626,9 @@ async def test_start_search_multiqueue_multiple_players( async def test_game_start_cancels_search( - ladder_service: LadderService, - player_factory, - queue_factory, + ladder_service: LadderService, + player_factory, + queue_factory, ): ladder_service.queues["tmm2v2"] = queue_factory("tmm2v2") @@ -652,8 +666,8 @@ async def test_game_start_cancels_search( async def test_on_match_found_sets_player_state( - ladder_service: LadderService, - player_factory, + ladder_service: LadderService, + player_factory, ): p1 = player_factory( "Dostya", @@ -683,9 +697,9 @@ async def test_on_match_found_sets_player_state( async def test_start_and_cancel_search( - ladder_service: LadderService, - player_factory, - event_loop, + ladder_service: LadderService, + player_factory, + event_loop, ): p1 = player_factory( "Dostya", @@ -709,9 +723,9 @@ async def test_start_and_cancel_search( async def test_start_search_cancels_previous_search( - ladder_service: LadderService, - player_factory, - event_loop, + ladder_service: LadderService, + player_factory, + event_loop, ): p1 = player_factory( "Dostya", @@ -738,9 +752,9 @@ async def test_start_search_cancels_previous_search( async def test_cancel_all_searches( - ladder_service: LadderService, - player_factory, - event_loop, + ladder_service: LadderService, + player_factory, + event_loop, ): p1 = player_factory( "Dostya", @@ -765,8 +779,8 @@ async def test_cancel_all_searches( async def test_cancel_twice( - ladder_service: LadderService, - player_factory, + ladder_service: LadderService, + player_factory, ): p1 = player_factory( "Dostya", @@ -798,8 +812,8 @@ async def test_cancel_twice( @fast_forward(5) async def test_start_game_called_on_match( - ladder_service: LadderService, - player_factory, + ladder_service: LadderService, + player_factory, ): p1 = player_factory( "Dostya", @@ -828,14 +842,14 @@ async def test_start_game_called_on_match( @pytest.mark.parametrize("ratings", ( - (((1500, 500), 0), ((1000, 100), 1000)), - (((1500, 500), 0), ((300, 100), 1000)), - (((400, 100), 10), ((300, 100), 1000)) + (((1500, 500), 0), ((1000, 100), 1000)), + (((1500, 500), 0), ((300, 100), 1000)), + (((400, 100), 10), ((300, 100), 1000)) )) async def test_start_game_map_selection_newbie_pool( - ladder_service: LadderService, - player_factory, - ratings + ladder_service: LadderService, + player_factory, + ratings ): p1 = player_factory( ladder_rating=ratings[0][0], @@ -860,7 +874,7 @@ async def test_start_game_map_selection_newbie_pool( async def test_start_game_map_selection_pros( - ladder_service: LadderService, player_factory + ladder_service: LadderService, player_factory ): p1 = player_factory( ladder_rating=(2000, 50), @@ -885,7 +899,7 @@ async def test_start_game_map_selection_pros( async def test_start_game_map_selection_rating_type( - ladder_service: LadderService, player_factory + ladder_service: LadderService, player_factory ): p1 = player_factory( ladder_rating=(2000, 50), @@ -1024,8 +1038,8 @@ async def test_game_name_many_teams(player_factory): async def test_write_rating_progress_message( - ladder_service: LadderService, - player_factory + ladder_service: LadderService, + player_factory ): player = player_factory(ladder_rating=(1500, 500)) player.write_message = mock.Mock() @@ -1048,8 +1062,8 @@ async def test_write_rating_progress_message( async def test_write_rating_progress_message_2( - ladder_service: LadderService, - player_factory + ladder_service: LadderService, + player_factory ): player = player_factory(ladder_rating=(1500, 400.1235)) player.write_message = mock.Mock() @@ -1063,8 +1077,8 @@ async def test_write_rating_progress_message_2( async def test_write_rating_progress_other_rating( - ladder_service: LadderService, - player_factory + ladder_service: LadderService, + player_factory ): player = player_factory( ladder_rating=(1500, 500), @@ -1080,3 +1094,129 @@ async def test_write_rating_progress_other_rating( assert player.write_message.call_args[0][0].get("command") == "notice" assert player.write_message.call_args[0][0].get("style") == "info" assert "40%" in player.write_message.call_args[0][0].get("text", "") + + +async def test_queue_cancel_while_being_matched_registers_failed_attempt( + matchmaker_queue, matchmaker_players_all_match, uninitialized_service +): + uninitialized_service.queues = {matchmaker_queue.id: matchmaker_queue} + p1, p2, p3, p4, _ = matchmaker_players_all_match + searches = [Search([p1], matchmaker_queue), Search([p2], matchmaker_queue), Search([p3], matchmaker_queue), + Search([p4], matchmaker_queue)] + for search in searches: + asyncio.create_task(matchmaker_queue.search(search)) + + searches[0].cancel() + + await asyncio.sleep(0.01) + await uninitialized_service._queue_pop_iteration() + + for search in searches[1:]: + assert search.is_matched ^ (search.failed_matching_attempts == 1) + + assert sum(search.failed_matching_attempts for search in searches[1:]) == 1 + matchmaker_queue.on_match_found.assert_called_once() + uninitialized_service.game_service.mark_dirty.assert_called_once_with(matchmaker_queue) + + +async def test_queue_pop_communicates_failed_attempts(matchmaker_queue, player_factory, uninitialized_service): + uninitialized_service.queues = {matchmaker_queue.id: matchmaker_queue} + s1 = Search([player_factory("Player1", player_id=1, ladder_rating=(3000, 50), ladder_game_count=1000)], + matchmaker_queue) + s2 = Search([player_factory("Player2", player_id=2, ladder_rating=(1000, 50), ladder_game_count=1000)], + matchmaker_queue) + + matchmaker_queue.push(s1) + assert s1.failed_matching_attempts == 0 + + await uninitialized_service._queue_pop_iteration() + + assert s1.failed_matching_attempts == 1 + + matchmaker_queue.push(s2) + assert s1.failed_matching_attempts == 1 + assert s2.failed_matching_attempts == 0 + + await uninitialized_service._queue_pop_iteration() + + # These searches should not have been matched + assert s1.failed_matching_attempts == 2 + assert s2.failed_matching_attempts == 1 + + +async def test_queue_many(matchmaker_queue, player_factory, uninitialized_service): + uninitialized_service.queues = {matchmaker_queue.id: matchmaker_queue} + p1, p2, p3 = player_factory("Dostya", ladder_rating=(2200, 150), state=PlayerState.SEARCHING_LADDER, + ladder_game_count=1000), \ + player_factory("Brackman", ladder_rating=(1500, 150), state=PlayerState.SEARCHING_LADDER, + ladder_game_count=1000), \ + player_factory("Zoidberg", ladder_rating=(1500, 125), state=PlayerState.SEARCHING_LADDER, + ladder_game_count=1000) + + s1 = Search([p1], matchmaker_queue) + s2 = Search([p2], matchmaker_queue) + s3 = Search([p3], matchmaker_queue) + matchmaker_queue.push(s1) + matchmaker_queue.push(s2) + matchmaker_queue.push(s3) + + await uninitialized_service._queue_pop_iteration() + + assert not s1.is_matched + assert s2.is_matched + assert s3.is_matched + matchmaker_queue.on_match_found.assert_called_once_with( + s2, s3, matchmaker_queue + ) + + +def make_searches(ratings, player_factory, matchmaker_queue): + return [Search([player_factory(ladder_rating=(r+300, 100), + ladder_game_count=1000, + state=PlayerState.SEARCHING_LADDER)], matchmaker_queue) for r in ratings] + + +async def test_team_matchmaker_algorithm(uninitialized_service, matchmaker_queue, player_factory): + matchmaker_queue.team_size = 4 + + matchmaker = TeamMatchMaker() + s = make_searches( + [1300, 900, 1250, 902, 1100, 1150, 1304, 950, 1150, 1200, 1090], + player_factory, matchmaker_queue) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop(), s.pop()])) + matchmaker_queue.push(s.pop()) + matchmaker_queue.push(s.pop()) + + uninitialized_service.queues["tmm"] = matchmaker_queue + await uninitialized_service._queue_pop_iteration() + args = matchmaker_queue.on_match_found.call_args.args + s1 = args[0] + s2 = args[1] + + assert matchmaker.assign_game_quality((s1, s2), 4).quality > config.MINIMUM_GAME_QUALITY + + +async def test_team_matchmaker_algorithm_2(uninitialized_service, matchmaker_queue, player_factory): + matchmaker_queue.team_size = 4 + + matchmaker = TeamMatchMaker() + s = make_searches( + [1300, 900, 1250, 902, 1100, 1250, 1304, 950, 1150, 1200, 1290, 1090, 1105], + player_factory, matchmaker_queue) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop()])) + matchmaker_queue.push(CombinedSearch(*[s.pop(), s.pop(), s.pop(), s.pop()])) + matchmaker_queue.push(s.pop()) + matchmaker_queue.push(s.pop()) + + uninitialized_service.queues["tmm"] = matchmaker_queue + await uninitialized_service._queue_pop_iteration() + args = matchmaker_queue.on_match_found.call_args.args + s1 = args[0] + s2 = args[1] + + assert matchmaker.assign_game_quality((s1, s2), 4).quality > config.MINIMUM_GAME_QUALITY diff --git a/tests/unit_tests/test_matchmaker_algorithm_bucket_teams.py b/tests/unit_tests/test_matchmaker_algorithm_bucket_teams.py deleted file mode 100644 index c9369ca0f..000000000 --- a/tests/unit_tests/test_matchmaker_algorithm_bucket_teams.py +++ /dev/null @@ -1,325 +0,0 @@ -import math -import random - -import pytest -from hypothesis import assume, given -from hypothesis import strategies as st - -from server import config -from server.matchmaker import Search, algorithm -from server.matchmaker.algorithm.bucket_teams import ( - BucketTeamMatchmaker, - _make_teams, - _make_teams_from_single -) -from server.rating import RatingType - -from .strategies import st_searches_list - - -@pytest.fixture(scope="module") -def player_factory(player_factory): - def make( - mean: int = 1500, - deviation: int = 500, - ladder_games: int = config.NEWBIE_MIN_GAMES + 1, - name=None, - **kwargs - ): - """Make a player with the given ratings""" - player = player_factory( - ladder_rating=(mean, deviation), - ladder_games=ladder_games, - login=name, - lobby_connection_spec=None, - **kwargs - ) - return player - - return make - - -@pytest.mark.parametrize("make_teams_func", (_make_teams, _make_teams_from_single)) -@given( - searches=st_searches_list(max_players=1), - size=st.integers(min_value=1, max_value=10), -) -def test_make_teams_single_correct_size(searches, size, make_teams_func): - matched, _ = make_teams_func(searches, size) - - assume(matched != []) - - for search in matched: - assert len(search.players) == size - - -def test_make_teams_single_2v2_large_pool(player_factory): - """ - When we have a large number of players all with similar ratings, we want - teams to be formed by putting players with the same rating on the same team. - """ - - # Large enough so the test is unlikely to pass by chance - num = 40 - - searches = [ - Search([player_factory(random.uniform(950, 1050), 10, name=f"p{i}")]) - for i in range(num) - ] - searches += [ - Search([player_factory(random.uniform(450, 550), 10, name=f"p{i}")]) - for i in range(num) - ] - matched, non_matched = _make_teams_from_single(searches, size=2) - - assert matched != [] - assert non_matched == [] - - for search in matched: - p1, p2 = search.players - p1_mean, _ = p1.ratings[RatingType.LADDER_1V1] - p2_mean, _ = p2.ratings[RatingType.LADDER_1V1] - - assert math.fabs(p1_mean - p2_mean) <= 100 - - -def test_make_teams_single_2v2_small_pool(player_factory): - """ - When we have a small number of players, we want teams to be formed by - distributing players of equal skill to different teams so that we can - maximize the chances of getting a match. - """ - - # Try a bunch of times so it is unlikely to pass by chance - for _ in range(20): - searches = [ - Search([player_factory(random.gauss(1000, 5), 10, name=f"p{i}")]) - for i in range(2) - ] - searches += [ - Search([player_factory(random.gauss(500, 5), 10, name=f"r{i}")]) - for i in range(2) - ] - matched, non_matched = _make_teams_from_single(searches, size=2) - - assert matched != [] - assert non_matched == [] - - for search in matched: - p1, p2 = search.players - # Order doesn't matter - if p1.ratings[RatingType.LADDER_1V1][0] > 900: - assert p2.ratings[RatingType.LADDER_1V1][0] < 600 - else: - assert p1.ratings[RatingType.LADDER_1V1][0] < 600 - assert p2.ratings[RatingType.LADDER_1V1][0] > 900 - - -def test_make_buckets_performance(bench, player_factory): - NUM_SEARCHES = 1000 - searches = [ - Search([player_factory(random.gauss(1500, 200), 500, ladder_games=1)]) - for _ in range(NUM_SEARCHES) - ] - - with bench: - algorithm.bucket_teams._make_buckets(searches) - - assert bench.elapsed() < 0.15 - - -def test_make_teams_1(player_factory): - teams = [ - [ - player_factory(name="p1"), - player_factory(name="p2"), - player_factory(name="p3"), - ], - [player_factory(name="p4"), player_factory(name="p5")], - [player_factory(name="p6"), player_factory(name="p7")], - [player_factory(name="p8")], - [player_factory(name="p9")], - [player_factory(name="p10")], - [player_factory(name="p11")], - ] - do_test_make_teams(teams, team_size=3, total_unmatched=2, unmatched_sizes={1}) - - -def test_make_teams_2(player_factory): - teams = [ - [ - player_factory(name="p1"), - player_factory(name="p2"), - player_factory(name="p3"), - ], - [player_factory(name="p4"), player_factory(name="p5")], - [player_factory(name="p6"), player_factory(name="p7")], - [player_factory(name="p8")], - [player_factory(name="p9")], - [player_factory(name="p10")], - [player_factory(name="p11")], - ] - do_test_make_teams(teams, team_size=2, total_unmatched=1, unmatched_sizes={3}) - - -def test_make_teams_3(player_factory): - teams = [[player_factory(name=f"p{i+1}")] for i in range(9)] - do_test_make_teams(teams, team_size=4, total_unmatched=1, unmatched_sizes={1}) - - -def test_make_teams_4(player_factory): - teams = [[player_factory()] for i in range(9)] - teams += [[player_factory(), player_factory()] for i in range(5)] - teams += [[player_factory(), player_factory(), player_factory()] for i in range(15)] - teams += [ - [player_factory(), player_factory(), player_factory(), player_factory()] - for i in range(4) - ] - do_test_make_teams(teams, team_size=4, total_unmatched=7, unmatched_sizes={3, 2}) - - -def test_make_teams_5(player_factory): - teams = [ - [ - player_factory(name="p1"), - player_factory(name="p2"), - player_factory(name="p3"), - ], - [player_factory(name="p4"), player_factory(name="p5")], - [player_factory(name="p6"), player_factory(name="p7")], - ] - do_test_make_teams(teams, team_size=4, total_unmatched=1, unmatched_sizes={3}) - - -def do_test_make_teams(teams, team_size, total_unmatched, unmatched_sizes): - searches = [Search(t) for t in teams] - - matched, non_matched = _make_teams(searches, size=team_size) - players_non_matched = [s.players for s in non_matched] - - for s in matched: - assert len(s.players) == team_size - assert len(players_non_matched) == total_unmatched - for players in players_non_matched: - assert len(players) in unmatched_sizes - - -def test_distribute_pairs_1(player_factory): - players = [ - player_factory(1500, 500, name=f"p{i+1}", player_id=i+1) - for i in range(4) - ] - searches = [(Search([player]), 0) for player in players] - p1, p2, p3, p4 = players - - grouped = [ - search.players for search in algorithm.bucket_teams._distribute(searches, 2) - ] - assert grouped == [[p1, p4], [p2, p3]] - - -def test_distribute_pairs_2(player_factory): - players = [ - player_factory(1500, 500, name=f"p{i+1}", player_id=i+1) - for i in range(8) - ] - searches = [(Search([player]), 0) for player in players] - p1, p2, p3, p4, p5, p6, p7, p8 = players - - grouped = [ - search.players for search in algorithm.bucket_teams._distribute(searches, 2) - ] - assert grouped == [[p1, p7], [p2, p8], [p3, p5], [p4, p6]] - - -def test_distribute_triples(player_factory): - players = [ - player_factory(1500, 500, name=f"p{i+1}", player_id=i+1) - for i in range(6) - ] - searches = [(Search([player]), 0) for player in players] - p1, p2, p3, p4, p5, p6 = players - - grouped = [ - search.players for search in algorithm.bucket_teams._distribute(searches, 3) - ] - - assert grouped == [[p1, p3, p6], [p2, p5, p4]] - - -def test_BucketTeamMatchmaker_1v1(player_factory): - num_players = 6 - players = [player_factory(1500, 500, name=f"p{i+1}") for i in range(num_players)] - searches = [Search([player]) for player in players] - - team_size = 1 - matchmaker = BucketTeamMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) - - assert len(matches) == num_players / 2 / team_size - assert len(unmatched_searches) == num_players - 2 * team_size * len(matches) - - -def test_BucketTeamMatchmaker_2v2_single_searches(player_factory): - num_players = 12 - players = [player_factory(1500, 500, name=f"p{i+1}") for i in range(num_players)] - searches = [Search([player]) for player in players] - - team_size = 2 - matchmaker = BucketTeamMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) - - assert len(matches) == num_players / 2 / team_size - assert len(unmatched_searches) == num_players - 2 * team_size * len(matches) - - -def test_BucketTeamMatchmaker_2v2_full_party_searches(player_factory): - num_players = 12 - players = [player_factory(1500, 500, name=f"p{i+1}") for i in range(num_players)] - searches = [Search([players[i], players[i + 1]]) for i in range(0, len(players), 2)] - - team_size = 2 - matchmaker = BucketTeamMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) - - assert len(matches) == num_players / 2 / team_size - assert len(unmatched_searches) == num_players - 2 * team_size * len(matches) - - -def test_BucketTeammatchmaker_2v2_mixed_party_sizes(player_factory): - num_players = 24 - players = [player_factory(1500, 500, name=f"p{i+1}") for i in range(num_players)] - searches = [ - Search([players[i], players[i + 1]]) for i in range(0, len(players) // 2, 2) - ] - searches.extend([Search([player]) for player in players[len(players) // 2:]]) - - team_size = 2 - matchmaker = BucketTeamMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) - - assert len(matches) == num_players / 2 / team_size - assert len(unmatched_searches) == num_players - 2 * team_size * len(matches) - - -def test_2v2_count_unmatched_searches(player_factory): - players = [ - player_factory(500, 100, name="lowRating_unmatched_1"), - player_factory(500, 100, name="lowRating_unmatched_2"), - player_factory(1500, 100, name="midRating_matched_1"), - player_factory(1500, 100, name="midRating_matched_2"), - player_factory(1500, 100, name="midRating_matched_3"), - player_factory(1500, 100, name="midRating_matched_4"), - player_factory(2000, 100, name="highRating_unmatched_1"), - ] - searches = [Search([player]) for player in players] - - team_size = 2 - matchmaker = BucketTeamMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) - - assert len(matches) == 1 - number_of_unmatched_players = sum( - len(search.players) for search in unmatched_searches - ) - assert number_of_unmatched_players == 3 diff --git a/tests/unit_tests/test_matchmaker_algorithm_stable_marriage.py b/tests/unit_tests/test_matchmaker_algorithm_stable_marriage.py index e75058811..661584c5f 100644 --- a/tests/unit_tests/test_matchmaker_algorithm_stable_marriage.py +++ b/tests/unit_tests/test_matchmaker_algorithm_stable_marriage.py @@ -274,10 +274,9 @@ def test_random_newbie_matching_is_symmetric(player_factory): s6 = Search([player_factory(600, 500, name="p6", ladder_games=5)]) searches = [s1, s2, s3, s4, s5, s6] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == len(searches) - assert len(unmatched_searches) == 0 for search in matches: opponent = matches[search] @@ -291,12 +290,10 @@ def test_newbies_are_forcefully_matched_with_newbies(player_factory): pro.register_failed_matching_attempt() searches = [newbie1, pro, newbie2] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert matches[newbie1] == newbie2 assert matches[newbie2] == newbie1 - assert unmatched_searches == [pro] - def test_newbie_team_matched_with_newbie_team(player_factory): newbie1 = Search([ @@ -309,11 +306,10 @@ def test_newbie_team_matched_with_newbie_team(player_factory): ]) searches = [newbie1, newbie2] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert matches[newbie1] == newbie2 assert matches[newbie2] == newbie1 - assert len(unmatched_searches) == 0 def test_partial_newbie_team_matched_with_newbie_team(player_factory): @@ -327,11 +323,10 @@ def test_partial_newbie_team_matched_with_newbie_team(player_factory): ]) searches = [partial_newbie, newbie] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert matches[partial_newbie] == newbie assert matches[newbie] == partial_newbie - assert len(unmatched_searches) == 0 def test_newbie_and_top_rated_team_not_matched_randomly(player_factory): @@ -345,10 +340,9 @@ def test_newbie_and_top_rated_team_not_matched_randomly(player_factory): ]) searches = [newbie_and_top_rated, newbie] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert not matches - assert len(unmatched_searches) == len(searches) def test_unmatched_newbies_forcefully_match_pros(player_factory): @@ -356,15 +350,13 @@ def test_unmatched_newbies_forcefully_match_pros(player_factory): pro = Search([player_factory(1400, 10, ladder_games=100)]) searches = [newbie, pro] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) # No match if the pro is on their first attempt assert len(matches) == 0 - assert len(unmatched_searches) == 2 pro.register_failed_matching_attempt() - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == 2 - assert len(unmatched_searches) == 0 def test_newbie_team_matched_with_pro_team(player_factory): @@ -378,15 +370,13 @@ def test_newbie_team_matched_with_pro_team(player_factory): ]) searches = [newbie, pro] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) # No match if the pros are on their first attempt assert len(matches) == 0 - assert len(unmatched_searches) == 2 pro.register_failed_matching_attempt() - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == 2 - assert len(unmatched_searches) == 0 def test_unmatched_newbies_do_not_forcefully_match_top_players(player_factory): @@ -395,10 +385,9 @@ def test_unmatched_newbies_do_not_forcefully_match_top_players(player_factory): top_player.register_failed_matching_attempt() searches = [newbie, top_player] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == 0 - assert len(unmatched_searches) == 2 def test_newbie_team_dos_not_match_with_top_players_team(player_factory): @@ -413,10 +402,9 @@ def test_newbie_team_dos_not_match_with_top_players_team(player_factory): top_player.register_failed_matching_attempt() searches = [newbie, top_player] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == 0 - assert len(unmatched_searches) == 2 def unmatched_newbie_teams_do_not_forcefully_match_pros(player_factory): @@ -442,10 +430,9 @@ def test_odd_number_of_unmatched_newbies(player_factory): pro.register_failed_matching_attempt() searches = [newbie1, pro, newbie2, newbie3] - matches, unmatched_searches = stable_marriage.RandomlyMatchNewbies().find(searches) + matches = stable_marriage.RandomlyMatchNewbies().find(searches) assert len(matches) == 4 - assert len(unmatched_searches) == 0 def test_matchmaker(player_factory): @@ -474,13 +461,12 @@ def test_matchmaker(player_factory): ] team_size = 1 matchmaker = stable_marriage.StableMarriageMatchmaker() - match_pairs, unmatched_searches = matchmaker.find(searches, team_size) + match_pairs = matchmaker.find(searches, team_size) match_sets = [set(pair) for pair in match_pairs] assert {newbie_that_matches1, newbie_that_matches2} in match_sets assert {pro_that_matches1, pro_that_matches2} in match_sets assert {newbie_force_matched, pro_alone} in match_sets - assert unmatched_searches == [top_player] for match_pair in match_pairs: assert top_player not in match_pair @@ -507,11 +493,10 @@ def test_matchmaker_random_only(player_factory): searches = (newbie1, newbie2) team_size = 1 matchmaker = stable_marriage.StableMarriageMatchmaker() - match_pairs, unmatched_searches = matchmaker.find(searches, team_size) + match_pairs = matchmaker.find(searches, team_size) match_sets = [set(pair) for pair in match_pairs] assert {newbie1, newbie2} in match_sets - assert len(unmatched_searches) == 0 def test_find_will_not_match_low_quality_games(player_factory): @@ -522,13 +507,13 @@ def test_find_will_not_match_low_quality_games(player_factory): team_size = 1 matchmaker = stable_marriage.StableMarriageMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) + matches = matchmaker.find(searches, team_size) assert len(matches) == 0 - assert len(unmatched_searches) == len(searches) def test_unmatched_searches_without_newbies(player_factory): + #TODO migrate players = [ player_factory(100, 10, name="lowRating_unmatched_1"), player_factory(500, 10, name="lowRating_unmatched_2"), @@ -543,11 +528,10 @@ def test_unmatched_searches_without_newbies(player_factory): team_size = 1 matchmaker = stable_marriage.StableMarriageMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) + matches = matchmaker.find(searches, team_size) expected_number_of_matches = 2 assert len(matches) == expected_number_of_matches - assert len(unmatched_searches) == len(searches) - 2 * team_size * expected_number_of_matches def test_unmatched_searches_with_newbies(player_factory): @@ -573,8 +557,7 @@ def test_unmatched_searches_with_newbies(player_factory): team_size = 1 matchmaker = stable_marriage.StableMarriageMatchmaker() - matches, unmatched_searches = matchmaker.find(searches, team_size) + matches = matchmaker.find(searches, team_size) expected_number_of_matches = 5 assert len(matches) == expected_number_of_matches - assert len(unmatched_searches) == len(searches) - 2 * team_size * expected_number_of_matches diff --git a/tests/unit_tests/test_matchmaker_algorithm_team_matchmaker.py b/tests/unit_tests/test_matchmaker_algorithm_team_matchmaker.py index b7fe7f690..1fbf74fb6 100644 --- a/tests/unit_tests/test_matchmaker_algorithm_team_matchmaker.py +++ b/tests/unit_tests/test_matchmaker_algorithm_team_matchmaker.py @@ -26,10 +26,10 @@ def player_factory(player_factory): player_id_counter = 0 def make( - mean: int = 1500, - deviation: int = 500, - ladder_games: int = config.NEWBIE_MIN_GAMES+1, - name=None + mean: int = 1500, + deviation: int = 500, + ladder_games: int = config.NEWBIE_MIN_GAMES + 1, + name=None ): """Make a player with the given ratings""" nonlocal player_id_counter @@ -42,6 +42,7 @@ def make( ) player_id_counter += 1 return player + return make @@ -64,54 +65,6 @@ def test_team_matchmaker_performance(player_factory, bench, caplog): assert bench.elapsed() < 0.5 -def test_team_matchmaker_algorithm(player_factory): - matchmaker = TeamMatchMaker() - s = make_searches( - [1251, 1116, 1038, 1332, 1271, 1142, 1045, 1347, 1359, 1348, 1227, 1118, 1058, 1338, 1271, 1137, 1025], - player_factory) - c1 = CombinedSearch(*[s.pop(), s.pop()]) - c2 = CombinedSearch(*[s.pop(), s.pop()]) - c3 = CombinedSearch(*[s.pop(), s.pop()]) - c4 = CombinedSearch(*[s.pop(), s.pop(), s.pop()]) - s.append(c1) - s.append(c2) - s.append(c3) - s.append(c4) - - matches, unmatched = matchmaker.find(s, 4) - - assert set(matches[1][0].get_original_searches()) == {c1, s[2], s[5]} - assert set(matches[1][1].get_original_searches()) == {c3, s[1], s[6]} - assert set(matches[0][0].get_original_searches()) == {c4, s[4]} - assert set(matches[0][1].get_original_searches()) == {c2, s[0], s[3]} - assert set(unmatched) == {s[7]} - for match in matches: - assert matchmaker.assign_game_quality(match, 4).quality > config.MINIMUM_GAME_QUALITY - - -def test_team_matchmaker_algorithm_2(player_factory): - matchmaker = TeamMatchMaker() - s = make_searches( - [227, 1531, 1628, 1722, 1415, 1146, 937, 1028, 1315, 1236, 1125, 1252, 1185, 1333, 1263, 1184, 1037], - player_factory) - c1 = CombinedSearch(*[s.pop(), s.pop()]) - c2 = CombinedSearch(*[s.pop(), s.pop()]) - c3 = CombinedSearch(*[s.pop(), s.pop()]) - c4 = CombinedSearch(*[s.pop(), s.pop(), s.pop()]) - s.append(c1) - s.append(c2) - s.append(c3) - s.append(c4) - - matches, unmatched = matchmaker.find(s, 4) - - assert set(matches[0][0].get_original_searches()) == {c4, s[4]} - assert set(matches[0][1].get_original_searches()) == {c2, c3} - assert set(unmatched) == {s[0], s[1], s[2], s[3], s[5], s[6], s[7], c1} - for match in matches: - assert matchmaker.assign_game_quality(match, 4).quality > config.MINIMUM_GAME_QUALITY - - @pytest.mark.slow @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @given(st_searches_list_with_player_size(min_size=2, max_players=4, max_size=8)) @@ -206,10 +159,9 @@ def test_ignore_impossible_team_splits(player_factory): s.append(c2) s.append(c3) - matches, unmatched = TeamMatchMaker().find(s, 4) + matches = TeamMatchMaker().find(s, 4) assert matches == [] - assert set(unmatched) == set(s) @given(team_a=st_searches(4), team_b=st_searches(4)) @@ -265,11 +217,11 @@ def test_game_quality_time_bonus(s): num_newbies = team_a.num_newbies() + team_b.num_newbies() assert ( - quality_before - # player number / team size * time bonus - + 6 / 3 * config.TIME_BONUS - + num_newbies / 3 * config.NEWBIE_TIME_BONUS - == pytest.approx(quality_after) + quality_before + # player number / team size * time bonus + + 6 / 3 * config.TIME_BONUS + + num_newbies / 3 * config.NEWBIE_TIME_BONUS + == pytest.approx(quality_after) ) @@ -292,11 +244,11 @@ def test_game_quality_max_time_bonus(s): num_newbies = team_a.num_newbies() + team_b.num_newbies() assert ( - quality_before - # player number / team size * time bonus - + 6 / 3 * config.MAXIMUM_TIME_BONUS - + num_newbies / 3 * config.MAXIMUM_NEWBIE_TIME_BONUS - == pytest.approx(quality_after) + quality_before + # player number / team size * time bonus + + 6 / 3 * config.MAXIMUM_TIME_BONUS + + num_newbies / 3 * config.MAXIMUM_NEWBIE_TIME_BONUS + == pytest.approx(quality_after) ) diff --git a/tests/unit_tests/test_matchmaker_queue.py b/tests/unit_tests/test_matchmaker_queue.py index 0228bccc2..6e7ffb049 100644 --- a/tests/unit_tests/test_matchmaker_queue.py +++ b/tests/unit_tests/test_matchmaker_queue.py @@ -43,7 +43,7 @@ def matchmaker_players_all_match(player_factory): player_factory("Rhiza", player_id=5, ladder_rating=(1500, 50)) -def test_get_game_options_empty(queue_factory): +def test_get_game_options_empty(queue_factory, event_loop): queue1 = queue_factory(params={}) queue2 = queue_factory(params={"GameOptions": {}}) @@ -59,10 +59,10 @@ def test_get_game_options(queue_factory): def test_newbie_detection(matchmaker_players): pro, joe, _, _, _, newbie = matchmaker_players - pro_search = Search([pro]) - newbie_search = Search([newbie]) - newb_team_search = Search([joe, newbie]) - pro_team_search = Search([pro, joe]) + pro_search = Search([pro], mock.Mock()) + newbie_search = Search([newbie], mock.Mock()) + newb_team_search = Search([joe, newbie], mock.Mock()) + pro_team_search = Search([pro, joe], mock.Mock()) assert pro_search.has_newbie() is False assert pro_search.is_newbie(pro) is False @@ -74,7 +74,7 @@ def test_newbie_detection(matchmaker_players): def test_newbies_have_adjusted_rating(matchmaker_players): pro, _, _, _, _, newbie = matchmaker_players - s1, s6 = Search([pro]), Search([newbie]) + s1, s6 = Search([pro], mock.Mock()), Search([newbie], mock.Mock()) assert s1.ratings[0] == pro.ratings[RatingType.LADDER_1V1] assert s6.ratings[0] < newbie.ratings[RatingType.LADDER_1V1] @@ -82,34 +82,34 @@ def test_newbies_have_adjusted_rating(matchmaker_players): @given(rating=st_rating()) def test_search_threshold(player_factory, rating): player = player_factory("Player", ladder_rating=rating) - s = Search([player]) + s = Search([player], mock.Mock()) assert s.match_threshold <= 1 assert s.match_threshold >= 0 def test_search_threshold_of_single_old_players_is_high(player_factory): old_player = player_factory("experienced_player", ladder_rating=(1500, 50)) - s = Search([old_player]) + s = Search([old_player], mock.Mock()) assert s.match_threshold >= 0.6 def test_search_threshold_of_team_old_players_is_high(player_factory): old_player = player_factory("experienced_player", ladder_rating=(1500, 50)) another_old_player = player_factory("another experienced_player", ladder_rating=(1600, 60)) - s = Search([old_player, another_old_player]) + s = Search([old_player, another_old_player], mock.Mock()) assert s.match_threshold >= 0.6 def test_search_threshold_of_single_new_players_is_low(player_factory): new_player = player_factory("new_player", ladder_rating=(1500, 500), ladder_games=1) - s = Search([new_player]) + s = Search([new_player], mock.Mock()) assert s.match_threshold <= 0.4 def test_search_threshold_of_team_new_players_is_low(player_factory): new_player = player_factory("new_player", ladder_rating=(1500, 500), ladder_games=1) another_new_player = player_factory("another_new_player", ladder_rating=(1450, 450), ladder_games=1) - s = Search([new_player, another_new_player]) + s = Search([new_player, another_new_player], mock.Mock()) assert s.match_threshold <= 0.4 @@ -117,56 +117,56 @@ def test_search_threshold_of_team_new_players_is_low(player_factory): def test_search_quality_equivalence(player_factory, rating1, rating2): p1 = player_factory("Player1", ladder_rating=rating1) p2 = player_factory("Player2", ladder_rating=rating2) - s1 = Search([p1]) - s2 = Search([p2]) + s1 = Search([p1], mock.Mock()) + s2 = Search([p2], mock.Mock()) assert s1.quality_with(s2) == s2.quality_with(s1) def test_search_quality(matchmaker_players): p1, _, p3, _, p5, p6 = matchmaker_players - s1, s3, s5, s6 = Search([p1]), Search([p3]), Search([p5]), Search([p6]) + s1, s3, s5, s6 = Search([p1], mock.Mock()), Search([p3], mock.Mock()), Search([p5], mock.Mock()), Search([p6], mock.Mock()) assert s3.quality_with(s5) > 0.7 and s1.quality_with(s6) < 0.2 def test_search_match(matchmaker_players): p1, _, _, p4, _, _ = matchmaker_players - s1, s4 = Search([p1]), Search([p4]) + s1, s4 = Search([p1], mock.Mock()), Search([p4], mock.Mock()) assert s1.matches_with(s4) def test_search_threshold_low_enough_to_play_yourself(matchmaker_players): for player in matchmaker_players: - s = Search([player]) + s = Search([player], mock.Mock()) assert s.matches_with(s) def test_search_team_match(matchmaker_players): p1, p2, p3, p4, _, _ = matchmaker_players - s1, s4 = Search([p1, p3]), Search([p2, p4]) + s1, s4 = Search([p1, p3], mock.Mock()), Search([p2, p4], mock.Mock()) assert s1.matches_with(s4) def test_search_team_not_match(matchmaker_players): p1, p2, p3, p4, _, _ = matchmaker_players - s1, s4 = Search([p1, p4]), Search([p2, p3]) + s1, s4 = Search([p1, p4], mock.Mock()), Search([p2, p3], mock.Mock()) assert not s1.matches_with(s4) def test_search_no_match(matchmaker_players): p1, p2, _, _, _, _ = matchmaker_players - s1, s2 = Search([p1]), Search([p2]) + s1, s2 = Search([p1], mock.Mock()), Search([p2], mock.Mock()) assert not s1.matches_with(s2) def test_search_no_match_wrong_type(matchmaker_players): p1, _, _, _, _, _ = matchmaker_players - s1 = Search([p1]) + s1 = Search([p1], mock.Mock()) assert not s1.matches_with(42) def test_search_boundaries(matchmaker_players): p1 = matchmaker_players[0] - s1 = Search([p1]) + s1 = Search([p1], mock.Mock()) 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] @@ -175,7 +175,7 @@ def test_search_boundaries(matchmaker_players): def test_search_expansion_controlled_by_failed_matching_attempts(matchmaker_players): p1 = matchmaker_players[1] - s1 = Search([p1]) + s1 = Search([p1], mock.Mock()) assert s1.search_expansion == 0.0 @@ -194,7 +194,7 @@ def test_search_expansion_controlled_by_failed_matching_attempts(matchmaker_play def test_search_expansion_for_top_players(matchmaker_players): p1 = matchmaker_players[0] - s1 = Search([p1]) + s1 = Search([p1], mock.Mock()) assert s1.search_expansion == 0.0 @@ -213,7 +213,7 @@ def test_search_expansion_for_top_players(matchmaker_players): async def test_search_await(matchmaker_players): p1, p2, _, _, _, _ = matchmaker_players - s1, s2 = Search([p1]), Search([p2]) + s1, s2 = Search([p1], mock.Mock()), Search([p2], mock.Mock()) assert not s1.matches_with(s2) await_coro = asyncio.create_task(s1.await_match()) s1.match(s2) @@ -223,8 +223,8 @@ async def test_search_await(matchmaker_players): def test_combined_search_attributes(matchmaker_players): p1, p2, p3, _, _, _ = matchmaker_players - s1 = Search([p1, p2]) - s2 = Search([p3]) + s1 = Search([p1, p2], mock.Mock()) + s2 = Search([p3], mock.Mock()) s2.register_failed_matching_attempt() search = CombinedSearch(s1, s2) assert search.players == [p1, p2, p3] @@ -241,8 +241,10 @@ def test_combined_search_attributes(matchmaker_players): def test_queue_time_until_next_pop(queue_factory): team_size = 2 - t1 = PopTimer(queue_factory(team_size=team_size)) - t2 = PopTimer(queue_factory(team_size=team_size)) + t1 = PopTimer() + t1.queues = [queue_factory(team_size=team_size)] + t2 = PopTimer() + t2.queues = [queue_factory(team_size=team_size)] desired_players = config.QUEUE_POP_DESIRED_MATCHES * team_size * 2 @@ -267,7 +269,8 @@ def test_queue_time_until_next_pop(queue_factory): def test_queue_pop_time_moving_average_size(queue_factory): - t1 = PopTimer(queue_factory()) + t1 = PopTimer() + t1.queues = [queue_factory()] for _ in range(100): t1.time_until_next_pop(100, 1) @@ -357,28 +360,6 @@ def test_queue_multiple_map_pools( assert queue.get_map_pool_for_rating(rating) is None -async def test_queue_many(matchmaker_queue, player_factory): - p1, p2, p3 = player_factory("Dostya", ladder_rating=(2200, 150)), \ - player_factory("Brackman", ladder_rating=(1500, 150)), \ - player_factory("Zoidberg", ladder_rating=(1500, 125)) - - s1 = Search([p1]) - s2 = Search([p2]) - s3 = Search([p3]) - matchmaker_queue.push(s1) - matchmaker_queue.push(s2) - matchmaker_queue.push(s3) - - await matchmaker_queue.find_matches() - - assert not s1.is_matched - assert s2.is_matched - assert s3.is_matched - matchmaker_queue.on_match_found.assert_called_once_with( - s2, s3, matchmaker_queue - ) - - async def test_queue_race(matchmaker_queue, player_factory): p1, p2, p3 = player_factory("Dostya", ladder_rating=(2300, 150)), \ player_factory("Brackman", ladder_rating=(2200, 150)), \ @@ -390,9 +371,9 @@ async def find_matches(): try: await asyncio.gather( - asyncio.wait_for(matchmaker_queue.search(Search([p1])), 0.1), - asyncio.wait_for(matchmaker_queue.search(Search([p2])), 0.1), - asyncio.wait_for(matchmaker_queue.search(Search([p3])), 0.1), + asyncio.wait_for(matchmaker_queue.search(Search([p1], mock.Mock())), 0.1), + asyncio.wait_for(matchmaker_queue.search(Search([p2], mock.Mock())), 0.1), + asyncio.wait_for(matchmaker_queue.search(Search([p3], mock.Mock())), 0.1), asyncio.create_task(find_matches()) ) except (asyncio.TimeoutError, asyncio.CancelledError): @@ -402,7 +383,7 @@ async def find_matches(): async def test_queue_cancel(matchmaker_queue, matchmaker_players): - s1, s2 = Search([matchmaker_players[1]]), Search([matchmaker_players[2]]) + s1, s2 = Search([matchmaker_players[1]], mock.Mock()), Search([matchmaker_players[2]], mock.Mock()) matchmaker_queue.push(s1) s1.cancel() try: @@ -415,103 +396,3 @@ async def test_queue_cancel(matchmaker_queue, matchmaker_players): matchmaker_queue.on_match_found.assert_not_called() -async def test_queue_mid_cancel(matchmaker_queue, matchmaker_players_all_match): - _, p1, p2, p3, _ = matchmaker_players_all_match - (s1, s2, s3) = (Search([p1]), - Search([p2]), - Search([p3])) - asyncio.create_task(matchmaker_queue.search(s1)) - asyncio.create_task(matchmaker_queue.search(s2)) - s1.cancel() - - async def find_matches(): - await asyncio.sleep(0.01) - await matchmaker_queue.find_matches() - - try: - await asyncio.gather( - asyncio.wait_for(matchmaker_queue.search(s3), 0.1), - asyncio.create_task(find_matches()) - ) - except asyncio.CancelledError: - pass - - assert not s1.is_matched - assert s2.is_matched - assert s3.is_matched - assert len(matchmaker_queue._queue) == 0 - matchmaker_queue.on_match_found.assert_called_once_with( - s2, s3, matchmaker_queue - ) - - -async def test_queue_cancel_while_being_matched_registers_failed_attempt( - matchmaker_queue, matchmaker_players_all_match -): - p1, p2, p3, p4, _ = matchmaker_players_all_match - searches = [Search([p1]), Search([p2]), Search([p3]), Search([p4])] - for search in searches: - asyncio.create_task(matchmaker_queue.search(search)) - - searches[0].cancel() - - await asyncio.sleep(0.01) - await matchmaker_queue.find_matches() - - for search in searches[1:]: - assert search.is_matched ^ (search.failed_matching_attempts == 1) - - assert sum(search.failed_matching_attempts for search in searches[1:]) == 1 - matchmaker_queue.on_match_found.assert_called_once() - - -async def test_find_matches_synchronized(queue_factory): - is_matching = False - - def find(*args): - nonlocal is_matching - - assert not is_matching, "Function call not synchronized" - is_matching = True - - time.sleep(0.2) - - is_matching = False - return [], [] - - queues = [queue_factory(f"Queue{i}") for i in range(5)] - # Ensure that find_matches does not short circuit - for queue in queues: - queue._queue = { - mock.Mock(players=[1]): 1, - mock.Mock(players=[2]): 2 - } - queue.find_teams = mock.Mock() - queue._register_unmatched_searches = mock.Mock() - queue.matchmaker.find = mock.Mock(side_effect=find) - - await asyncio.gather(*[ - queue.find_matches() for queue in queues - ]) - - -async def test_queue_pop_communicates_failed_attempts(matchmaker_queue, player_factory): - s1 = Search([player_factory("Player1", player_id=1, ladder_rating=(3000, 50))]) - s2 = Search([player_factory("Player2", player_id=2, ladder_rating=(1000, 50))]) - - matchmaker_queue.push(s1) - assert s1.failed_matching_attempts == 0 - - await matchmaker_queue.find_matches() - - assert s1.failed_matching_attempts == 1 - - matchmaker_queue.push(s2) - assert s1.failed_matching_attempts == 1 - assert s2.failed_matching_attempts == 0 - - await matchmaker_queue.find_matches() - - # These searches should not have been matched - assert s1.failed_matching_attempts == 2 - assert s2.failed_matching_attempts == 1