diff --git a/server/__init__.py b/server/__init__.py index 616145de2..6dd21b9f7 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -126,6 +126,7 @@ from .gameconnection import GameConnection from .geoip_service import GeoIpService from .ladder_service import LadderService +from .ladder_service.veto_system import VetoService from .ladder_service.violation_service import ViolationService from .lobbyconnection import LobbyConnection from .message_queue_service import MessageQueueService @@ -157,6 +158,7 @@ "RatingService", "RatingService", "ServerInstance", + "VetoService", "ViolationService", "game_service", "protocol", @@ -204,6 +206,7 @@ def __init__( party_service=self.services["party_service"], rating_service=self.services["rating_service"], oauth_service=self.services["oauth_service"], + veto_service=self.services["veto_service"], ) def write_broadcast( diff --git a/server/config.py b/server/config.py index a0812ee04..4a185247b 100644 --- a/server/config.py +++ b/server/config.py @@ -137,7 +137,9 @@ def __init__(self): self.GEO_IP_DATABASE_MAX_AGE_DAYS = 22 self.LADDER_1V1_OUTCOME_OVERRIDE = True - self.LADDER_ANTI_REPETITION_LIMIT = 2 + self.LADDER_ANTI_REPETITION_LIMIT = 1 + self.LADDER_ANTI_REPETITION_WEIGHT_BASE_THRESHOLDS = [0.75, 0.5] + self.LADDER_ANTI_REPETITION_REPEAT_COUNTS_FACTOR = 0.8 self.LADDER_SEARCH_EXPANSION_MAX = 0.25 self.LADDER_SEARCH_EXPANSION_STEP = 0.05 self.LADDER_TOP_PLAYER_SEARCH_EXPANSION_MAX = 0.3 diff --git a/server/db/models.py b/server/db/models.py index 87fcad262..5934b5e45 100644 --- a/server/db/models.py +++ b/server/db/models.py @@ -295,10 +295,14 @@ matchmaker_queue_map_pool = Table( "matchmaker_queue_map_pool", metadata, - Column("matchmaker_queue_id", Integer, ForeignKey("matchmaker_queue.id"), nullable=False), - Column("map_pool_id", Integer, ForeignKey("map_pool.id"), nullable=False), - Column("min_rating", Integer), - Column("max_rating", Integer), + Column("id", Integer, primary_key=True), + Column("matchmaker_queue_id", Integer, ForeignKey("matchmaker_queue.id"), nullable=False), + Column("map_pool_id", Integer, ForeignKey("map_pool.id"), nullable=False), + Column("min_rating", Integer), + Column("max_rating", Integer), + Column("veto_tokens_per_player", Integer, nullable=False), + Column("max_tokens_per_map", Integer, nullable=False), + Column("minimum_maps_after_veto", Float, nullable=False), ) teamkills = Table( diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 3a5d722ea..472a2ac96 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -8,7 +8,15 @@ import re import statistics from collections import defaultdict -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Optional +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Optional, + cast +) import aiocron import humanize @@ -37,16 +45,23 @@ from server.games import InitMode, LadderGame from server.games.ladder_game import GameClosedError from server.ladder_service.game_name import game_name +from server.ladder_service.veto_system import VetoService from server.ladder_service.violation_service import ViolationService from server.matchmaker import ( MapPool, MatchmakerQueue, + MatchmakerQueueMapPool, OnMatchedCallback, Search ) from server.metrics import MatchLaunch from server.players import Player, PlayerState -from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap +from server.types import ( + GameLaunchOptions, + Map, + MapPoolMap, + NeroxisGeneratedMap +) if TYPE_CHECKING: from server.lobbyconnection import LobbyConnection @@ -66,12 +81,14 @@ def __init__( database: FAFDatabase, game_service: GameService, violation_service: ViolationService, + veto_service: VetoService, ): self._db = database self._informed_players: set[Player] = set() self.game_service = game_service self.queues: dict[str, MatchmakerQueue] = {} self.violation_service = violation_service + self.veto_service = veto_service self._searches: dict[Player, dict[str, Search]] = defaultdict(dict) self._allow_new_searches = True @@ -106,7 +123,7 @@ async def update_data(self) -> None: queue.team_size = info["team_size"] queue.rating_peak = await self.fetch_rating_peak(info["rating_type"]) queue.map_pools.clear() - for map_pool_id, min_rating, max_rating in info["map_pools"]: + for matchmaker_queue_map_pool_id, map_pool_id, min_rating, max_rating, veto_tokens_per_player, max_tokens_per_map, minimum_maps_after_veto in info["map_pools"]: map_pool_name, map_list = map_pool_maps[map_pool_id] if not map_list: self._logger.warning( @@ -116,9 +133,15 @@ async def update_data(self) -> None: name ) queue.add_map_pool( - MapPool(map_pool_id, map_pool_name, map_list), - min_rating, - max_rating + MatchmakerQueueMapPool( + matchmaker_queue_map_pool_id, + MapPool(map_pool_id, map_pool_name, cast(list[MapPoolMap], map_list)), + min_rating, + max_rating, + veto_tokens_per_player, + max_tokens_per_map, + minimum_maps_after_veto + ) ) # Remove queues that don't exist anymore for queue_name in list(self.queues.keys()): @@ -126,11 +149,16 @@ async def update_data(self) -> None: self.queues[queue_name].shutdown() del self.queues[queue_name] + affected_players = self.veto_service.update_pools_veto_config(self.queues) + for player in affected_players: + self.cancel_search(player) + async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: result = await conn.execute( select( map_pool.c.id, map_pool.c.name, + map_pool_map_version.c.id.label("map_pool_map_version_id"), map_pool_map_version.c.weight, map_pool_map_version.c.map_params, map_version.c.id.label("map_id"), @@ -159,6 +187,7 @@ async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: map_list.append( Map( id=row.map_id, + map_pool_map_version_id=row.map_pool_map_version_id, folder_name=folder_name, ranked=row.ranked, weight=row.weight, @@ -170,7 +199,7 @@ async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: map_type = params["type"] if map_type == "neroxis": map_list.append( - NeroxisGeneratedMap.of(params, row.weight) + NeroxisGeneratedMap.of(params, row.weight, row.map_pool_map_version_id) ) else: self._logger.warning( @@ -197,9 +226,13 @@ async def fetch_matchmaker_queues(self, conn): matchmaker_queue.c.technical_name, matchmaker_queue.c.team_size, matchmaker_queue.c.params, + matchmaker_queue_map_pool.c.id.label("matchmaker_queue_map_pool_id"), matchmaker_queue_map_pool.c.map_pool_id, matchmaker_queue_map_pool.c.min_rating, matchmaker_queue_map_pool.c.max_rating, + matchmaker_queue_map_pool.c.veto_tokens_per_player, + matchmaker_queue_map_pool.c.max_tokens_per_map, + matchmaker_queue_map_pool.c.minimum_maps_after_veto, game_featuredMods.c.gamemod, leaderboard.c.technical_name.label("rating_type") ) @@ -228,9 +261,13 @@ async def fetch_matchmaker_queues(self, conn): info["team_size"] = row.team_size info["params"] = json.loads(row.params) if row.params else None info["map_pools"].append(( + row.matchmaker_queue_map_pool_id, row.map_pool_id, row.min_rating, - row.max_rating + row.max_rating, + row.veto_tokens_per_player, + row.max_tokens_per_map, + row.minimum_maps_after_veto )) except Exception: self._logger.warning( @@ -532,10 +569,25 @@ def get_displayed_rating(player: Player) -> float: ) rating = func(ratings) - pool = queue.get_map_pool_for_rating(rating) - if not pool: + map_pool = queue.get_map_pool_for_rating(rating) + if not map_pool: raise RuntimeError(f"No map pool available for rating {rating}!") - game_map = pool.choose_map(played_map_ids) + + self._logger.debug( + "______queue.map_pools[map_pool.id]________________: %s", + queue.map_pools[map_pool.id], + ) + initial_weights = self.veto_service.generate_initial_weights_for_match( + all_players, + queue.map_pools[map_pool.id], + ) + self._logger.debug( + "______initial_weights________________: %s", + initial_weights, + ) + game_map = map_pool.choose_map(played_map_ids, initial_weights) + + self._logger.debug("______game_map________________: %s", game_map) game = self.game_service.create_game( game_class=LadderGame, @@ -587,7 +639,8 @@ def make_game_options(player: Player) -> GameLaunchOptions: game_options=game_options, team=game.get_player_option(player.id, "Team"), faction=game.get_player_option(player.id, "Faction"), - map_position=game.get_player_option(player.id, "StartSpot") + map_position=game.get_player_option(player.id, "StartSpot"), + map_pool_map_version_id=game_map.map_pool_map_version_id ) await self.launch_match(game, host, all_guests, make_game_options) diff --git a/server/ladder_service/veto_system.py b/server/ladder_service/veto_system.py new file mode 100644 index 000000000..7a1296a90 --- /dev/null +++ b/server/ladder_service/veto_system.py @@ -0,0 +1,335 @@ +import logging +from collections import Counter, defaultdict +from typing import Any, ClassVar, Iterable, Optional + +from server.core import Service +from server.decorators import with_logger +from server.exceptions import ClientError +from server.matchmaker import MatchmakerQueue, MatchmakerQueueMapPool +from server.player_service import PlayerService +from server.players import Player +from server.types import MatchmakerQueueMapPoolVetoData + +BracketID = int +MapPoolMapVersionId = int +VetoTokensApplied = int +VetoesMap = dict[MapPoolMapVersionId, VetoTokensApplied] + + +class PlayerVetoes: + def __init__(self): + self._vetoes: dict[BracketID, VetoesMap] = {} + + def get_vetoes_for_bracket(self, bracket_id: BracketID) -> VetoesMap: + return self._vetoes.get(bracket_id, {}) + + def to_dict(self) -> dict: + return { + "vetoes": [ + { + "map_pool_map_version_id": map_id, + "veto_tokens_applied": tokens, + "matchmaker_queue_map_pool_id": bracket_id + } + for bracket_id, vetoes in self._vetoes.items() + for map_id, tokens in vetoes.items() + ] + } + + +@with_logger +class VetoService(Service): + _logger: ClassVar[logging.Logger] + + def __init__(self, player_service: PlayerService): + self.player_service = player_service + + self.pools_veto_data: list[MatchmakerQueueMapPoolVetoData] = [] + + def update_pools_veto_config(self, queues: dict[str, MatchmakerQueue]) -> list[Player]: + """ + Update the cached veto config to match the new queues. + + Returns list of players whose vetoes were force-adjusted due to config changes. + These players should be removed from matchmaking queues. + """ + + pools_vetodata = self.extract_pools_veto_config(queues) + + if self.pools_veto_data == pools_vetodata: + return [] + + self.pools_veto_data = pools_vetodata + + pool_maps_by_bracket = { + pool_data.matchmaker_queue_map_pool_id: set(pool_data.map_pool_map_version_ids) + for pool_data in self.pools_veto_data + } + + affected_players = [] + for player in self.player_service.all_players: + # TODO: Can we avoid force adjusting veto selections for players. + adjusted_vetoes = self._adjust_vetoes(player.vetoes._vetoes) + + if adjusted_vetoes != player.vetoes._vetoes: + tokens_amount_for_some_map_was_reduced = any( + map_id in pool_maps_by_bracket.get(bracket, set()) + and original_tokens > adjusted_vetoes.get(bracket, {}).get(map_id, 0) + for bracket, bracket_vetoes in player.vetoes._vetoes.items() + for map_id, original_tokens in bracket_vetoes.items() + ) + player.vetoes._vetoes = adjusted_vetoes + player.write_message({ + "command": "vetoes_info", + "forced": tokens_amount_for_some_map_was_reduced, + **player.vetoes.to_dict(), + }) + if tokens_amount_for_some_map_was_reduced: + affected_players.append(player) + + return affected_players + + def extract_pools_veto_config( + self, + queues: dict[str, MatchmakerQueue], + ) -> list[MatchmakerQueueMapPoolVetoData]: + result = [] + for queue in queues.values(): + for matchmaker_queue_map_pool in queue.map_pools.values(): + veto_tokens_per_player = matchmaker_queue_map_pool.veto_tokens_per_player + max_tokens_per_map = matchmaker_queue_map_pool.max_tokens_per_map + minimum_maps_after_veto = matchmaker_queue_map_pool.minimum_maps_after_veto + + if not _is_valid_veto_config_for_queue(queue, matchmaker_queue_map_pool): + veto_tokens_per_player = 0 + max_tokens_per_map = 1 + minimum_maps_after_veto = 1 + self._logger.error( + "Wrong vetoes setup detected for pool %s in queue %s", + matchmaker_queue_map_pool.map_pool.id, + queue.id, + ) + + result.append( + MatchmakerQueueMapPoolVetoData( + matchmaker_queue_map_pool_id=matchmaker_queue_map_pool.id, + map_pool_map_version_ids=[ + map.map_pool_map_version_id + for map in matchmaker_queue_map_pool.map_pool.maps.values() + ], + veto_tokens_per_player=veto_tokens_per_player, + max_tokens_per_map=max_tokens_per_map, + minimum_maps_after_veto=minimum_maps_after_veto + ) + ) + + return result + + async def set_player_vetoes( + self, + player: Player, + new_vetoes: dict[BracketID, VetoesMap], + ): + """Validates and sets vetoes based on new vetoes and pool constraints.""" + if not _is_valid_vetoes(new_vetoes): + raise ClientError("invalid veto data") + + adjusted_vetoes = self._adjust_vetoes(new_vetoes) + + if adjusted_vetoes != player.vetoes._vetoes: + player.vetoes._vetoes = adjusted_vetoes + if adjusted_vetoes != new_vetoes: + await player.send_message({ + "command": "vetoes_info", + "forced": False, + **player.vetoes.to_dict(), + }) + + def _adjust_vetoes( + self, + new_vetoes: dict[BracketID, VetoesMap], + ) -> dict[BracketID, VetoesMap]: + # TODO: How can we avoid doing this adjustment? It would be better to + # simply check if the veto selection is valid, and return an error if + # not so that the client can display that to the user and force a new + # selection. Or, if we expect the client to do that already, we can + # simply raise a ClientError on invalid veto selections. + adjusted_vetoes = {} + for bracket_id, map_ids, total_tokens, max_per_map, _ in self.pools_veto_data: + bracket_vetoes = _adjust_vetoes_for_bracket( + new_vetoes.get(bracket_id, {}), + map_ids, + total_tokens, + max_per_map, + ) + if bracket_vetoes: + adjusted_vetoes[bracket_id] = bracket_vetoes + + return adjusted_vetoes + + def generate_initial_weights_for_match( + self, + players_in_match, + matchmaker_queue_map_pool: MatchmakerQueueMapPool, + ) -> dict[int, float]: + ( + pool_id, + pool, + *_, + max_tokens_per_map, + minimum_maps_after_veto + ) = matchmaker_queue_map_pool + + vetoes_map: dict[int, int] = defaultdict(int) + + for m in pool.maps.values(): + for player in players_in_match: + bracket_vetoes = player.vetoes.get_vetoes_for_bracket(pool_id) + vetoes_map[m.map_pool_map_version_id] += bracket_vetoes.get(m.map_pool_map_version_id, 0) + + self._logger.debug("______vetoes_map________________: %s", vetoes_map) + + if max_tokens_per_map == 0: + max_tokens_per_map = self.calculate_dynamic_tokens_per_map(minimum_maps_after_veto, vetoes_map.values()) + # This should never happen + if max_tokens_per_map == 0: + self._logger.error( + "calculate_dynamic_tokens_per_map received impossible " + "vetoes setup, all vetoes cancelled for a match", + ) + vetoes_map = {} + max_tokens_per_map = 1 + + return { + m.map_pool_map_version_id: max( + 0, + 1 - vetoes_map.get(m.map_pool_map_version_id, 0) / max_tokens_per_map, + ) + for m in pool.maps.values() + } + + def calculate_dynamic_tokens_per_map( + self, + M: float, + tokens_applied_to_maps: Iterable[int], + ) -> float: + """ + Calculate the smallest positive T such that the sum of weights w = max((T - V)/T, 0) for each map is at least M, + where V is the number of tokens applied to that map. If the condition is met with maps that have zero tokens, returns 1. + + The function groups maps by the number of tokens applied to them and processes these groups in ascending order of token values. + For each group, it checks if a solution T exists such that the sum of weights for the maps considered so far is at least M. + If a solution is found, it returns that T. If no solution is found after considering all maps, it returns 0. + """ + def calculate_solution( + tokens_sum: float, + map_count: int, + M: float, + upper_bound: Optional[float], + ) -> Optional[float]: + if tokens_sum == 0 and map_count >= M: + return 1 + if map_count > M: + candidate = tokens_sum / (map_count - M) + if upper_bound is None or candidate <= upper_bound: + return candidate + return None + + # grouping maps with the same tokens applied count + group_sizes = Counter(tokens_applied_to_maps) + sorted_tokens = sorted(group_sizes.keys()) + + total_map_count_in_selected_groups = 0 + total_tokens_applied_to_selected_groups = 0.0 + + for i, token in enumerate(sorted_tokens): + next_map_group_size = group_sizes[token] + total_map_count_in_selected_groups += next_map_group_size + total_tokens_applied_to_selected_groups += token * next_map_group_size + upper_bound = sorted_tokens[i + 1] if i < len(sorted_tokens) - 1 else None + solution = calculate_solution(total_tokens_applied_to_selected_groups, total_map_count_in_selected_groups, M, upper_bound) + if solution is not None: + return solution + + return 0 + + +def _is_valid_veto_config_for_queue( + queue: MatchmakerQueue, + queue_config: MatchmakerQueueMapPool, +) -> bool: + num_maps = len(queue_config.map_pool.maps) + + if (queue_config.minimum_maps_after_veto > num_maps): + return False + + if ( + queue_config.max_tokens_per_map == 0 + and queue_config.minimum_maps_after_veto == num_maps + and queue_config.veto_tokens_per_player > 0 + ): + return False + + if queue_config.max_tokens_per_map != 0: + total_players = queue.team_size * 2 + vetoable_maps_per_player = queue_config.veto_tokens_per_player / queue_config.max_tokens_per_map + vetoable_maps = total_players * vetoable_maps_per_player + if vetoable_maps > num_maps - queue_config.minimum_maps_after_veto: + return False + + return True + + +def _is_valid_vetoes(vetoes: Any) -> bool: + return ( + isinstance(vetoes, dict) + and all( + isinstance(k, int) + and isinstance(v, dict) + and all( + isinstance(mk, int) and isinstance(mv, int) and mv >= 0 + for mk, mv in v.items() + ) + for k, v in vetoes.items() + ) + ) + + +def _adjust_vetoes_for_bracket( + new_bracket_vetoes: VetoesMap, + map_ids: list[MapPoolMapVersionId], + total_tokens: int, + max_per_map: float, +) -> VetoesMap: + assert total_tokens >= 0 + + adjusted_vetoes = {} + tokens_sum = 0 + for map_id in map_ids: + tokens = new_bracket_vetoes.get(map_id, 0) + tokens_applied = _cap_tokens( + tokens, + total_tokens - tokens_sum, + max_per_map, + ) + + if tokens_applied > 0: + adjusted_vetoes[map_id] = tokens_applied + tokens_sum += tokens_applied + + return adjusted_vetoes + + +def _cap_tokens( + tokens: int, + total_remaining: int, + max_per_map: float, +) -> int: + assert total_remaining >= 0 + + applied = min(max(tokens, 0), total_remaining) + + if max_per_map > 0: + applied = int(min(applied, max_per_map)) + + return applied diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 094df34b2..5b5ec2e10 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -53,6 +53,7 @@ ) from .geoip_service import GeoIpService from .ladder_service import LadderService +from .ladder_service.veto_system import VetoService from .oauth_service import OAuthService from .party_service import PartyService from .player_service import PlayerService @@ -109,6 +110,7 @@ def __init__( party_service: PartyService, rating_service: RatingService, oauth_service: OAuthService, + veto_service: VetoService, ): self._db = database self.geoip_service = geoip @@ -118,6 +120,7 @@ def __init__( self.party_service = party_service self.rating_service = rating_service self.oauth_service = oauth_service + self.veto_service = veto_service self._authenticated = False self.player: Optional[Player] = None self.game_connection: Optional[GameConnection] = None @@ -1386,6 +1389,22 @@ async def command_set_party_factions(self, message): self.party_service.set_factions(self.player, list(factions)) + async def command_set_player_vetoes(self, message): + assert self.player is not None + + vetoes = {} + for v in message["vetoes"]: + matchmaker_queue_map_pool = v.get("matchmaker_queue_map_pool_id") + map_pool_map_version_id = v["map_pool_map_version_id"] + veto_tokens_applied = v["veto_tokens_applied"] + + if matchmaker_queue_map_pool not in vetoes: + vetoes[matchmaker_queue_map_pool] = {} + + vetoes[matchmaker_queue_map_pool][map_pool_map_version_id] = veto_tokens_applied + + await self.veto_service.set_player_vetoes(self.player, vetoes) + async def send_warning(self, message: str, fatal: bool = False): """ Display a warning message to the client diff --git a/server/matchmaker/__init__.py b/server/matchmaker/__init__.py index 63068ebdf..43934aec7 100644 --- a/server/matchmaker/__init__.py +++ b/server/matchmaker/__init__.py @@ -4,7 +4,7 @@ Used for keeping track of queues of players wanting to play specific kinds of games, currently just used for 1v1 ``ladder``. """ -from .map_pool import MapPool +from .map_pool import MapPool, MatchmakerQueueMapPool from .matchmaker_queue import MatchmakerQueue from .pop_timer import PopTimer from .search import CombinedSearch, OnMatchedCallback, Search @@ -12,6 +12,7 @@ __all__ = ( "CombinedSearch", "MapPool", + "MatchmakerQueueMapPool", "MatchmakerQueue", "OnMatchedCallback", "PopTimer", diff --git a/server/matchmaker/map_pool.py b/server/matchmaker/map_pool.py index 193782947..189b50e62 100644 --- a/server/matchmaker/map_pool.py +++ b/server/matchmaker/map_pool.py @@ -1,7 +1,9 @@ import logging import random from collections import Counter -from typing import ClassVar, Iterable +from typing import ClassVar, Iterable, NamedTuple, Optional + +from server.config import config from ..decorators import with_logger from ..types import Map, MapPoolMap @@ -24,39 +26,96 @@ def __init__( def set_maps(self, maps: Iterable[MapPoolMap]) -> None: self.maps = {map_.id: map_ for map_ in maps} - def choose_map(self, played_map_ids: Iterable[int] = ()) -> Map: + def apply_antirepetition_adjustment(self, initial_weights: dict[int, float], played_map_ids: Iterable[int], base_thresholds: list[float], repeat_factor: float) -> dict[int, float]: """ - Select a random map who's id does not appear in `played_map_ids`. If - all map ids appear in the list, then pick one that appears the least - amount of times. + Transfers weights from played maps to not-played (if possible) or less-played (otherwise) maps, + base_thresholds and repeat_factor adjusts the level of respect to the veto system: + base_thresholds used to determine, which not-played maps are available as transfer targets + the bigger the repeat_factor, the stronger algo tries to get rid of maps with playcount >= 2 + """ + notzero_weights = {map_id: weight for map_id, weight in initial_weights.items() if weight > 0} + repetition_counts = Counter(map_id for map_id in played_map_ids if map_id in notzero_weights) + adjusted_weights = notzero_weights.copy() + + def get_notrepeated_weight_transfer_targets(current_weight, rep_count): + thresholds = list(base_thresholds) + factor = repeat_factor ** (rep_count - 1) + if factor < 1: + thresholds.extend(t * factor for t in base_thresholds if t * factor < base_thresholds[-1]) + + for threshold in thresholds: + targets = [ + target_id for target_id in notzero_weights + if repetition_counts.get(target_id, 0) == 0 and + notzero_weights[target_id] >= threshold * current_weight + ] + if targets: + return targets + return [] + + def get_repeated_weight_transfer_targets(current_weight, rep_count): + return [ + target_id for target_id in notzero_weights + if 0 < (target_rep := repetition_counts.get(target_id, 0)) < rep_count and + notzero_weights[target_id] >= repeat_factor ** (rep_count - target_rep) * current_weight + ] + + def transfer_weight_proportionally(from_id, to_ids): + v = adjusted_weights[from_id] + adjusted_weights[from_id] = 0 + weight_sum = sum(notzero_weights[c] for c in to_ids) + for c in to_ids: + adjusted_weights[c] += (notzero_weights[c] / weight_sum) * v + + for map_id, rep_count in repetition_counts.most_common(): + current_weight = notzero_weights[map_id] + notrepeated_targets = get_notrepeated_weight_transfer_targets(current_weight, rep_count) + repeated_targets = get_repeated_weight_transfer_targets(current_weight, rep_count) + weight_transfer_targets = notrepeated_targets or repeated_targets + if weight_transfer_targets: + transfer_weight_proportionally(map_id, weight_transfer_targets) + + return adjusted_weights + + def choose_map(self, played_map_ids: Iterable[int] = (), initial_weights: Optional[dict[int, float]] = None) -> Map: + """ + Selects a random map using veto system weights with an anti-repetition adjustment. """ if not self.maps: - self._logger.critical( - "Trying to choose a map from an empty map pool: %s", self.name - ) + self._logger.critical("Trying to choose a map from an empty map pool: %s", self.name) raise RuntimeError(f"Map pool {self.name} not set!") - # Make sure the counter has every available map - counter = Counter(self.maps.keys()) - counter.update(id_ for id_ in played_map_ids if id_ in self.maps) + self._logger.debug("______initial_played_map_ids___________: %s", list(played_map_ids)) + played_map_pool_map_version_ids = [ + self.maps[id].map_pool_map_version_id + for id in played_map_ids + if id in self.maps + ] + self._logger.debug("______played_map_pool_map_version_ids_________: %s", played_map_pool_map_version_ids) - least_common = counter.most_common()[::-1] - least_count = 1 - for id_, count in least_common: - if isinstance(self.maps[id_], Map): - least_count = count - break + map_list = [(m.map_pool_map_version_id, m) for m in self.maps.values()] - # Trim off the maps with higher play counts - for i, (_, count) in enumerate(least_common): - if count > least_count: - least_common = least_common[:i] - break + if initial_weights is None: + initial_weights = {mp_mv_id: 1.0 for mp_mv_id, _ in map_list} - weights = [self.maps[id_].weight for id_, _ in least_common] - - map_id = random.choices(least_common, weights=weights, k=1)[0][0] - return self.maps[map_id].get_map() + adjusted_weights = self.apply_antirepetition_adjustment( + initial_weights, played_map_pool_map_version_ids, config.LADDER_ANTI_REPETITION_WEIGHT_BASE_THRESHOLDS, config.LADDER_ANTI_REPETITION_REPEAT_COUNTS_FACTOR + ) + self._logger.debug("______adjusted_weights________________: %s", adjusted_weights) + self._logger.debug("______map_list________________: %s", map_list) + final_weights = [adjusted_weights.get(mp_mv_id, 0) * m.weight for mp_mv_id, m in map_list] + self._logger.debug("______final_weights________________: %s", final_weights) + return random.choices([map for _, map in map_list], weights=final_weights, k=1)[0].get_map() # nosec B311 def __repr__(self) -> str: return f"MapPool({self.id}, {self.name}, {list(self.maps.values())})" + + +class MatchmakerQueueMapPool(NamedTuple): + id: int + map_pool: MapPool + min_rating: Optional[int] + max_rating: Optional[int] + veto_tokens_per_player: int = 0 + max_tokens_per_map: float = 0 + minimum_maps_after_veto: float = 1 diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 0cc343edd..466b16eb5 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -12,7 +12,7 @@ from ..decorators import with_logger from ..players import PlayerState from .algorithm.team_matchmaker import TeamMatchMaker -from .map_pool import MapPool +from .map_pool import MapPool, MatchmakerQueueMapPool from .pop_timer import PopTimer from .search import Match, Search @@ -56,7 +56,7 @@ def __init__( rating_type: str, team_size: int = 1, params: Optional[dict[str, Any]] = None, - map_pools: Iterable[tuple[MapPool, Optional[int], Optional[int]]] = (), + map_pools: Iterable[MatchmakerQueueMapPool] = (), ): self.game_service = game_service self.name = name @@ -66,7 +66,7 @@ def __init__( self.team_size = team_size self.rating_peak = 1000.0 self.params = params or {} - self.map_pools = {info[0].id: info for info in map_pools} + self.map_pools = {info.map_pool.id: info for info in map_pools} self._queue: dict[Search, None] = OrderedDict() self.on_match_found = on_match_found @@ -82,14 +82,12 @@ def is_running(self) -> bool: def add_map_pool( self, - map_pool: MapPool, - min_rating: Optional[int], - max_rating: Optional[int] + matchmaker_queue_map_pool: MatchmakerQueueMapPool ) -> None: - self.map_pools[map_pool.id] = (map_pool, min_rating, max_rating) + self.map_pools[matchmaker_queue_map_pool.map_pool.id] = matchmaker_queue_map_pool def get_map_pool_for_rating(self, rating: float) -> Optional[MapPool]: - for map_pool, min_rating, max_rating in self.map_pools.values(): + for _, map_pool, min_rating, max_rating, *_, in self.map_pools.values(): if min_rating is not None and rating < min_rating: continue if max_rating is not None and rating > max_rating: diff --git a/server/players.py b/server/players.py index a61aa2f43..a78357933 100644 --- a/server/players.py +++ b/server/players.py @@ -2,11 +2,13 @@ Player type definitions """ +import logging from collections import defaultdict from contextlib import suppress from enum import Enum, unique -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, ClassVar, Optional, Union +from .decorators import with_logger from .factions import Faction from .protocol import DisconnectedError from .rating import Leaderboard, PlayerRatings, RatingType @@ -29,7 +31,9 @@ class PlayerState(Enum): STARTING_GAME = 7 +@with_logger class Player: + _logger: ClassVar[logging.Logger] """ Standard player object used for representing signed-in players. @@ -52,7 +56,9 @@ def __init__( game_count: Optional[dict[str, int]] = None, lobby_connection: Optional["LobbyConnection"] = None ) -> None: + from server.ladder_service.veto_system import PlayerVetoes self._faction = Faction.uef + self.vetoes = PlayerVetoes() # The player_id of the user in the `login` table of the database. self.id = player_id diff --git a/server/types.py b/server/types.py index 6c60011fd..eb0abf5ab 100644 --- a/server/types.py +++ b/server/types.py @@ -29,11 +29,22 @@ class GameLaunchOptions(NamedTuple): expected_players: Optional[int] = None map_position: Optional[int] = None game_options: Optional[dict[str, Any]] = None + map_pool_map_version_id: Optional[int] = None + + +class MatchmakerQueueMapPoolVetoData(NamedTuple): + matchmaker_queue_map_pool_id: int + map_pool_map_version_ids: list[int] + veto_tokens_per_player: int + max_tokens_per_map: float + minimum_maps_after_veto: float class MapPoolMap(Protocol): @property def id(self) -> Optional[int]: ... + @property + def map_pool_map_version_id(self) -> int: ... @property def weight(self) -> int: ... @@ -47,6 +58,7 @@ class Map(NamedTuple): ranked: bool = False # Map pool only weight: int = 1 + map_pool_map_version_id: Optional[int] = None @property def file_path(self) -> str: @@ -70,6 +82,7 @@ class NeroxisGeneratedMap(NamedTuple): spawns: int map_size_pixels: int weight: int = 1 + map_pool_map_version_id: Optional[int] = None @classmethod def is_neroxis_map(cls, folder_name: str) -> bool: @@ -77,7 +90,7 @@ def is_neroxis_map(cls, folder_name: str) -> bool: return _NEROXIS_MAP_NAME_PATTERN.fullmatch(folder_name) is not None @classmethod - def of(cls, params: dict, weight: int = 1): + def of(cls, params: dict, weight: int = 1, map_pool_map_version_id: Optional[int] = None): """Create a NeroxisGeneratedMap from params dict""" assert params["type"] == "neroxis" @@ -100,6 +113,7 @@ def of(cls, params: dict, weight: int = 1): spawns, map_size_pixels, weight, + map_pool_map_version_id, ) @staticmethod @@ -132,6 +146,7 @@ def get_map(self) -> Map: folder_name=folder_name, ranked=True, weight=self.weight, + map_pool_map_version_id=self.map_pool_map_version_id, ) @@ -139,6 +154,7 @@ def get_map(self) -> Map: # the map argument in unit tests. MAP_DEFAULT = Map( id=None, + map_pool_map_version_id=None, folder_name="scmp_007", ranked=False, ) diff --git a/tests/data/test-data.sql b/tests/data/test-data.sql index 0c99d8aab..bdc40b713 100644 --- a/tests/data/test-data.sql +++ b/tests/data/test-data.sql @@ -75,6 +75,10 @@ insert into login (id, login, email, password, create_time) values (104, 'ladder_ban', 'ladder_ban@example.com', SHA2('ladder_ban', 256), '2000-01-01 00:00:00'), (105, 'tmm1', 'tmm1@example.com', SHA2('tmm1', 256), '2000-01-01 00:00:00'), (106, 'tmm2', 'tmm2@example.com', SHA2('tmm2', 256), '2000-01-01 00:00:00'), + (107, 'ladder801', 'ladder801@example.com', SHA2('ladder801', 256), '2000-01-01 00:00:00'), + (108, 'ladder802', 'ladder802@example.com', SHA2('ladder802', 256), '2000-01-01 00:00:00'), + (109, 'ladder1001', 'ladder1001@example.com', SHA2('ladder1001', 256), '2000-01-01 00:00:00'), + (110, 'ladder1002', 'ladder1002@example.com', SHA2('ladder1002', 256), '2000-01-01 00:00:00'), (200, 'banme', 'banme@example.com', SHA2('banme', 256), '2000-01-01 00:00:00'), (201, 'ban_revoked', 'ban_revoked@example.com', SHA2('ban_revoked', 256), '2000-01-01 00:00:00'), (202, 'ban_expired', 'ban_expired@example.com', SHA2('ban_expired', 256), '2000-01-01 00:00:00'), @@ -127,7 +131,15 @@ insert into leaderboard_rating (login_id, mean, deviation, total_games, leaderbo (102, 1500, 500, 0, 1), (102, 1500, 500, 0, 2), (105, 500, 100, 20, 3), - (106, 900, 75, 20, 3) + (106, 900, 75, 20, 3), + (107, 951, 50, 5, 1), + (107, 951, 50, 5, 2), + (108, 952, 50, 5, 1), + (108, 952, 50, 5, 2), + (109, 1151, 50, 5, 1), + (109, 1151, 50, 5, 2), + (110, 1152, 50, 5, 1), + (110, 1152, 50, 5, 2) ; -- UniqueID_exempt @@ -289,27 +301,27 @@ insert into map_pool (id, name) values (3, "Large maps"), (4, "Generated Maps with Errors"); -insert into map_pool_map_version (map_pool_id, map_version_id, weight, map_params) values - (1, 15, 1, NULL), (1, 16, 1, NULL), (1, 17, 1, NULL), - (2, 11, 1, NULL), (2, 14, 1, NULL), (2, 15, 1, NULL), (2, 16, 1, NULL), (2, 17, 1, NULL), - (3, 1, 1, NULL), (3, 2, 1, NULL), (3, 3, 1, NULL), - (4, NULL, 1, '{"type": "neroxis", "size": 512, "spawns": 2, "version": "0.0.0"}'), - (4, NULL, 1, '{"type": "neroxis", "size": 768, "spawns": 2, "version": "0.0.0"}'), +insert into map_pool_map_version (id, map_pool_id, map_version_id, weight, map_params) values + (1, 1, 15, 1, NULL), (2, 1, 16, 1, NULL), (3, 1, 17, 1, NULL), + (4, 2, 11, 1, NULL), (5, 2, 14, 1, NULL), (6, 2, 15, 1, NULL), (7, 2, 16, 1, NULL), (8, 2, 17, 1, NULL), + (9,3, 1, 1, NULL), (10, 3, 2, 1, NULL), (11, 3, 3, 1, NULL), + (12, 4, NULL, 1, '{"type": "neroxis", "size": 512, "spawns": 2, "version": "0.0.0"}'), + (13, 4, NULL, 1, '{"type": "neroxis", "size": 768, "spawns": 2, "version": "0.0.0"}'), -- Bad Generated Map Parameters should not be included in pool - (4, NULL, 1, '{"type": "neroxis"...'), - (4, NULL, 1, '{"type": "neroxis", "size": 513, "spawns": 2, "version": "0.0.0"}'), - (4, NULL, 1, '{"type": "neroxis", "size": 0, "spawns": 2, "version": "0.0.0"}'), - (4, NULL, 1, '{"type": "neroxis", "size": 512, "spawns": 3, "version": "0.0.0"}'), - (4, NULL, 1, '{"type": "beroxis", "size": 512, "spawns": 2, "version": "0.0.0"}'); - -insert into matchmaker_queue_map_pool (matchmaker_queue_id, map_pool_id, min_rating, max_rating) values - (1, 1, NULL, 800), - (1, 2, 800, NULL), - (1, 3, 1000, NULL), - (2, 3, NULL, NULL), - (4, 4, NULL, NULL), - (5, 1, NULL, NULL), - (6, 1, NULL, NULL); + (14, 4, NULL, 1, '{"type": "neroxis"...'), + (15, 4, NULL, 1, '{"type": "neroxis", "size": 513, "spawns": 2, "version": "0.0.0"}'), + (16, 4, NULL, 1, '{"type": "neroxis", "size": 0, "spawns": 2, "version": "0.0.0"}'), + (17, 4, NULL, 1, '{"type": "neroxis", "size": 512, "spawns": 3, "version": "0.0.0"}'), + (18, 4, NULL, 1, '{"type": "beroxis", "size": 512, "spawns": 2, "version": "0.0.0"}'); + +insert into matchmaker_queue_map_pool (id, matchmaker_queue_id, map_pool_id, min_rating, max_rating, veto_tokens_per_player, max_tokens_per_map, minimum_maps_after_veto) values + (1, 1, 1, NULL, 800, 1, 1, 1.0), + (2, 1, 2, 800, 999, 2, 0, 1.0), + (3, 1, 3, 1000, NULL, 2, 2, 1.0), + (4, 2, 3, NULL, NULL, 1, 2, 1.0), + (5, 4, 4, NULL, NULL, 0, 1, 1.0), + (6, 5, 1, NULL, NULL, 0, 1, 1.0), + (7, 6, 1, NULL, NULL, 0, 1, 1.0); insert into friends_and_foes (user_id, subject_id, `status`) values (1, 400, 'FOE'), diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index b77ce2be4..9f0d51c07 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -30,6 +30,7 @@ OAuthService, PartyService, ServerInstance, + VetoService, ViolationService ) from server.config import config @@ -47,9 +48,20 @@ def mock_games(): @pytest.fixture -async def ladder_service(mocker, database, game_service, violation_service): +async def ladder_service( + mocker, + database, + game_service, + violation_service, + veto_service, +): mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 1) - ladder_service = LadderService(database, game_service, violation_service) + ladder_service = LadderService( + database, + game_service, + violation_service, + veto_service, + ) await ladder_service.initialize() yield ladder_service await ladder_service.shutdown() @@ -63,6 +75,14 @@ async def violation_service(): await service.shutdown() +@pytest.fixture +async def veto_service(player_service): + service = VetoService(player_service) + await service.initialize() + yield service + await service.shutdown() + + @pytest.fixture async def party_service(game_service): service = PartyService(game_service) @@ -140,6 +160,7 @@ async def lobby_server_factory( party_service, oauth_service, violation_service, + veto_service, policy_server, jwks_server, ): @@ -161,6 +182,7 @@ async def make_lobby_server(config): "party_service": party_service, "oauth_service": oauth_service, "violation_service": violation_service, + "veto_service": veto_service, }) # Set up the back reference broadcast_service.server = instance diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index e76312b47..082699005 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -168,9 +168,59 @@ async def start_search(proto, queue_name="ladder1v1"): ) -async def queue_player_for_matchmaking(user, lobby_server, queue_name="ladder1v1"): +async def end_game_as_draw(protos, game_id): + if not protos: + raise ValueError("Need at least 1 proto") + + for proto in protos: + await proto.send_message({ + "command": "GameState", + "target": "game", + "args": ["Launching"] + }) + + for proto in protos: + await read_until_launched(proto, game_id) + + for proto in protos: + for i in range(1, len(protos) + 1): + await proto.send_message({ + "command": "GameResult", + "target": "game", + "args": [i, "draw 0"] + }) + + for proto in protos: + await proto.send_message({ + "command": "GameEnded", + "target": "game", + "args": [] + }) + + +def gen_vetoes(veto_tuples): + return [ + { + "matchmaker_queue_map_pool_id": matchmaker_queue_map_pool_id, + "map_pool_map_version_id": map_pool_map_version_id, + "veto_tokens_applied": veto_tokens_applied + } + for matchmaker_queue_map_pool_id, map_pool_map_version_id, veto_tokens_applied in veto_tuples + ] + + +async def queue_player_for_matchmaking(user, lobby_server, queue_name="ladder1v1", vetoes=None): + if vetoes is None: + vetoes = [] player_id, _, proto = await connect_and_sign_in(user, lobby_server) await read_until_command(proto, "game_info") + + if vetoes: + await proto.send_message({ + "command": "set_player_vetoes", + "vetoes": vetoes + }) + await start_search(proto, queue_name) return player_id, proto diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index 55362bbf0..e5b358d95 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -45,6 +45,8 @@ async def test_game_launch_message(lobby_server): assert "scmp_015" in msg1["mapname"] del msg1["mapname"] + mpmv_id = msg1["map_pool_map_version_id"] + assert mpmv_id in [1, 2, 3] assert msg1 == { "command": "game_launch", "args": ["/numgames", 0], @@ -57,7 +59,8 @@ async def test_game_launch_message(lobby_server): "team": 2, "faction": 1, "expected_players": 2, - "map_position": 1 + "map_position": 1, + "map_pool_map_version_id": mpmv_id } diff --git a/tests/integration_tests/test_matchmaker_vetoes.py b/tests/integration_tests/test_matchmaker_vetoes.py new file mode 100644 index 000000000..822ce2712 --- /dev/null +++ b/tests/integration_tests/test_matchmaker_vetoes.py @@ -0,0 +1,186 @@ +from server.players import PlayerState +from tests.utils import fast_forward + +from .conftest import connect_and_sign_in, read_until_command +from .test_game import ( + client_response, + end_game_as_draw, + gen_vetoes, + queue_player_for_matchmaking +) + + +async def test_vetoes_are_assigned_to_player_with_adjusting(lobby_server, player_service): + async def test_vetoes(proto, vetoes, expected_vetoes): + await proto.send_message({ + "command": "set_player_vetoes", + "vetoes": vetoes + }) + if vetoes != expected_vetoes: + msg = await read_until_command(proto, "vetoes_info") + assert msg["vetoes"] == expected_vetoes + else: + # modern problems require modern solutions + await proto.send_message({"command": "ping"}) + await read_until_command(proto, "pong") + assert player_service.get_player(player_id).vetoes.to_dict()["vetoes"] == expected_vetoes + + player_id, _, proto = await connect_and_sign_in(("test", "test_password"), lobby_server) + await read_until_command(proto, "game_info") + await test_vetoes(proto, gen_vetoes([(1, 1, 1)]), gen_vetoes([(1, 1, 1)])) + await test_vetoes(proto, gen_vetoes([(1, 1, 2)]), gen_vetoes([(1, 1, 1)])) + await test_vetoes(proto, gen_vetoes([(1, 2, 1)]), gen_vetoes([(1, 2, 1)])) + await test_vetoes(proto, gen_vetoes([(1, 1, 0)]), gen_vetoes([])) + + +@fast_forward(60) +async def test_if_veto_bans_working(lobby_server, mocker): + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 0.02) + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MIN", 0.01) + + for _ in range(20): + _, proto1 = await queue_player_for_matchmaking( + ("ladder1", "ladder1"), lobby_server, "ladder1v1", gen_vetoes([(1, 1, 1)]) + ) + _, proto2 = await queue_player_for_matchmaking( + ("ladder2", "ladder2"), lobby_server, "ladder1v1", gen_vetoes([(1, 2, 1)]) + ) + + await read_until_command(proto1, "match_found", timeout=10) + await read_until_command(proto2, "match_found", timeout=10) + + msg1 = await client_response(proto1) + + chosen_map_pool_version_id = msg1["map_pool_map_version_id"] + assert chosen_map_pool_version_id == 3 + + await end_game_as_draw([proto1, proto2], msg1["uid"]) + + +@fast_forward(60) +async def test_dynamic_max_tokens_per_map(lobby_server, mocker): + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 0.02) + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MIN", 0.01) + + for _ in range(20): + _, proto1 = await queue_player_for_matchmaking( + ("ladder801", "ladder801"), lobby_server, "ladder1v1", gen_vetoes([(2, 4, 1), (2, 5, 1)]) + ) + _, proto2 = await queue_player_for_matchmaking( + ("ladder802", "ladder802"), lobby_server, "ladder1v1", gen_vetoes([(2, 6, 1), (2, 7, 1)]) + ) + + await read_until_command(proto1, "match_found", timeout=10) + await read_until_command(proto2, "match_found", timeout=10) + + msg1 = await client_response(proto1) + + chosen_map_pool_version_id = msg1["map_pool_map_version_id"] + assert chosen_map_pool_version_id == 8 + await end_game_as_draw([proto1, proto2], msg1["uid"]) + + +@fast_forward(60) +async def test_partial_vetoes(lobby_server, mocker): + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 0.02) + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MIN", 0.01) + chosen_maps = set() + + for _ in range(20): + _, proto1 = await queue_player_for_matchmaking( + ("ladder1001", "ladder1001"), lobby_server, "ladder1v1", gen_vetoes([(3, 9, 1), (3, 11, 1)]) + ) + _, proto2 = await queue_player_for_matchmaking( + ("ladder1002", "ladder1002"), lobby_server, "ladder1v1", gen_vetoes([(3, 9, 1), (3, 10, 1)]) + ) + + await read_until_command(proto1, "match_found", timeout=10) + await read_until_command(proto2, "match_found", timeout=10) + + msg1 = await client_response(proto1) + + chosen_map_pool_version_id = msg1["map_pool_map_version_id"] + chosen_maps.add(chosen_map_pool_version_id) + assert chosen_map_pool_version_id in [10, 11], f"Expected map 10 or 11, got {chosen_map_pool_version_id}" + await end_game_as_draw([proto1, proto2], msg1["uid"]) + + assert chosen_maps == {10, 11}, f"Expected games on both maps 10 and 11, got {chosen_maps}" + + +@fast_forward(120) +async def test_vetoes_tmm(lobby_server, mocker): + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 0.02) + mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MIN", 0.01) + for _ in range(20): + player_vetoes = [ + ("ladder1", gen_vetoes([(4, 9, 1)])), + ("ladder2", gen_vetoes([(4, 9, 1)])), + ("ladder3", gen_vetoes([(4, 11, 1)])), + ("ladder4", gen_vetoes([(4, 11, 1)])), + ] + + players = [] + for username, vetoes in player_vetoes: + _, proto = await queue_player_for_matchmaking( + (username, username), lobby_server, "tmm2v2", vetoes + ) + players.append(proto) + + for proto in players: + await read_until_command(proto, "match_found", timeout=30) + + msg1 = await client_response(players[0]) + chosen_map_pool_version_id = msg1["map_pool_map_version_id"] + assert chosen_map_pool_version_id == 10 + await end_game_as_draw(players, msg1["uid"]) + + +@fast_forward(120) +async def test_pool_config_changes_causing_forced_update_and_stops_search(player_service, lobby_server, database, ladder_service): + player_id, proto = await queue_player_for_matchmaking( + ("test", "test_password"), lobby_server, "ladder1v1", gen_vetoes([(1, 1, 1)]) + ) + player = player_service.get_player(player_id) + assert player.state == PlayerState.SEARCHING_LADDER + try: + async with database.acquire() as conn: + await conn.execute( + "UPDATE matchmaker_queue_map_pool SET veto_tokens_per_player = 0 WHERE id = 1" + ) + await ladder_service.update_data() + msg = await read_until_command(proto, "vetoes_info", timeout=10) + assert msg.get("forced") is True + assert msg["vetoes"] == gen_vetoes([]) + assert player.vetoes.to_dict()["vetoes"] == gen_vetoes([]) + assert player.state == PlayerState.IDLE + finally: + async with database.acquire() as conn: + await conn.execute( + "UPDATE matchmaker_queue_map_pool SET veto_tokens_per_player = 1 WHERE id = 1" + ) + + +@fast_forward(120) +async def test_map_pool_changes_causing_silent_update_and_not_stops_search(player_service, lobby_server, database, ladder_service): + player_id, proto = await queue_player_for_matchmaking( + ("test", "test_password"), lobby_server, "ladder1v1", gen_vetoes([(1, 1, 1)]) + ) + player = player_service.get_player(player_id) + assert player.state == PlayerState.SEARCHING_LADDER + + try: + async with database.acquire() as conn: + await conn.execute( + "DELETE FROM map_pool_map_version WHERE id = 1" + ) + await ladder_service.update_data() + msg = await read_until_command(proto, "vetoes_info", timeout=10) + + assert msg.get("forced") is False + assert msg["vetoes"] == gen_vetoes([]) + assert player.state == PlayerState.SEARCHING_LADDER + finally: + async with database.acquire() as conn: + await conn.execute( + "REPLACE INTO map_pool_map_version (id, map_pool_id, map_version_id, weight, map_params) VALUES (1, 1, 15, 1, NULL)" + ) diff --git a/tests/integration_tests/test_server_instance.py b/tests/integration_tests/test_server_instance.py index 2100b5e2c..304c70e87 100644 --- a/tests/integration_tests/test_server_instance.py +++ b/tests/integration_tests/test_server_instance.py @@ -33,6 +33,7 @@ async def test_multiple_contexts( party_service, rating_service, oauth_service, + veto_service, ): config.USE_POLICY_SERVER = False @@ -49,7 +50,8 @@ async def test_multiple_contexts( "ladder_service": ladder_service, "rating_service": rating_service, "party_service": party_service, - "oauth_service": oauth_service + "oauth_service": oauth_service, + "veto_service": veto_service, } ) broadcast_service.server = instance diff --git a/tests/integration_tests/test_servercontext.py b/tests/integration_tests/test_servercontext.py index 83b1cf371..d1732a260 100644 --- a/tests/integration_tests/test_servercontext.py +++ b/tests/integration_tests/test_servercontext.py @@ -59,6 +59,7 @@ def make_connection() -> LobbyConnection: party_service=mock.Mock(), rating_service=mock.Mock(), oauth_service=mock.Mock(), + veto_service=mock.Mock(), ) ctx = ServerContext("TestServer", make_connection, [mock_service]) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index a1d9ffe12..07e608265 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,7 +10,9 @@ from server.gameconnection import GameConnection, GameConnectionState from server.games import Game from server.ladder_service import LadderService +from server.ladder_service.veto_system import VetoService from server.ladder_service.violation_service import ViolationService +from server.player_service import PlayerService from server.protocol import QDataStreamProtocol @@ -23,9 +25,10 @@ def ladder_and_game_service_context( async def make_ladder_and_game_service(): async with database_context(request) as database: with mock.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 1): + player_service = PlayerService(database) game_service = GameService( database, - player_service=mock.Mock(), + player_service=player_service, game_stats_service=mock.Mock(), rating_service=mock.Mock(), message_queue_service=mock.Mock( @@ -33,20 +36,26 @@ async def make_ladder_and_game_service(): ) ) violation_service = ViolationService() + veto_service = VetoService(player_service) ladder_service = LadderService( database, game_service, - violation_service + violation_service, + veto_service, ) + await player_service.initialize() await game_service.initialize() await violation_service.initialize() + await veto_service.initialize() await ladder_service.initialize() yield ladder_service, game_service + await player_service.shutdown() await game_service.shutdown() await violation_service.shutdown() + await veto_service.shutdown() await ladder_service.shutdown() return make_ladder_and_game_service @@ -58,9 +67,15 @@ async def ladder_service( database, game_service, violation_service, + veto_service, ): mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 1) - ladder_service = LadderService(database, game_service, violation_service) + ladder_service = LadderService( + database, + game_service, + violation_service, + veto_service, + ) await ladder_service.initialize() yield ladder_service await ladder_service.shutdown() @@ -74,6 +89,14 @@ async def violation_service(): await service.shutdown() +@pytest.fixture +async def veto_service(player_service): + service = VetoService(player_service) + await service.initialize() + yield service + await service.shutdown() + + @pytest.fixture async def game_connection( request, diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index 406602197..09a824b98 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -11,7 +11,7 @@ from server.games import LadderGame from server.games.ladder_game import GameClosedError from server.ladder_service import game_name -from server.matchmaker import MapPool, MatchmakerQueue +from server.matchmaker import MapPool, MatchmakerQueue, MatchmakerQueueMapPool from server.players import PlayerState from server.rating import RatingType from server.types import Map, NeroxisGeneratedMap @@ -21,8 +21,18 @@ from .strategies import st_players -async def test_queue_initialization(database, game_service, violation_service): - ladder_service = LadderService(database, game_service, violation_service) +async def test_queue_initialization( + database, + game_service, + violation_service, + veto_service, +): + ladder_service = LadderService( + database, + game_service, + violation_service, + veto_service, + ) def make_mock_queue(*args, **kwargs): queue = mock.create_autospec(MatchmakerQueue) @@ -58,40 +68,40 @@ async def test_load_from_database(ladder_service, queue_factory): assert queue.rating_type == "ladder_1v1" assert queue.rating_peak == 1000.0 assert len(queue.map_pools) == 3 - assert list(queue.map_pools[1][0].maps.values()) == [ - Map(15, "scmp_015", ranked=True), - Map(16, "scmp_015.v0002", ranked=True), - Map(17, "scmp_015.v0003", ranked=True), + assert list(queue.map_pools[1][1].maps.values()) == [ + Map(15, "scmp_015", ranked=True, map_pool_map_version_id=1), + Map(16, "scmp_015.v0002", ranked=True, map_pool_map_version_id=2), + Map(17, "scmp_015.v0003", ranked=True, map_pool_map_version_id=3), ] - assert list(queue.map_pools[2][0].maps.values()) == [ - Map(11, "scmp_011", ranked=True), - Map(14, "scmp_014", ranked=True), - Map(15, "scmp_015", ranked=True), - Map(16, "scmp_015.v0002", ranked=True), - Map(17, "scmp_015.v0003", ranked=True), + assert list(queue.map_pools[2][1].maps.values()) == [ + Map(11, "scmp_011", ranked=True, map_pool_map_version_id=4), + Map(14, "scmp_014", ranked=True, map_pool_map_version_id=5), + Map(15, "scmp_015", ranked=True, map_pool_map_version_id=6), + Map(16, "scmp_015.v0002", ranked=True, map_pool_map_version_id=7), + Map(17, "scmp_015.v0003", ranked=True, map_pool_map_version_id=8), ] - assert list(queue.map_pools[3][0].maps.values()) == [ - Map(1, "scmp_001", ranked=True), - Map(2, "scmp_002", ranked=True), - Map(3, "scmp_003", ranked=True), + assert list(queue.map_pools[3][1].maps.values()) == [ + Map(1, "scmp_001", ranked=True, map_pool_map_version_id=9), + Map(2, "scmp_002", ranked=True, map_pool_map_version_id=10), + Map(3, "scmp_003", ranked=True, map_pool_map_version_id=11), ] queue = ladder_service.queues["neroxis1v1"] assert queue.name == "neroxis1v1" assert len(queue.map_pools) == 1 - assert list(queue.map_pools[4][0].maps.values()) == [ + assert list(queue.map_pools[4][1].maps.values()) == [ NeroxisGeneratedMap.of({ "version": "0.0.0", "spawns": 2, "size": 512, "type": "neroxis" - }), + }, map_pool_map_version_id=12), NeroxisGeneratedMap.of({ "version": "0.0.0", "spawns": 2, "size": 768, "type": "neroxis" - }), + }, map_pool_map_version_id=13), ] queue = ladder_service.queues["tmm2v2"] @@ -444,9 +454,12 @@ async def test_start_game_start_spots( rating_type=RatingType.GLOBAL ) queue.add_map_pool( - MapPool(1, "test", [Map(1, "scmp_007")]), - min_rating=None, - max_rating=None + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "test", [Map(1, "scmp_007", map_pool_map_version_id=1)]), + min_rating=None, + max_rating=None, + ), ) monkeypatch.setattr(LadderGame, "wait_hosted", mock.AsyncMock()) @@ -839,25 +852,56 @@ async def test_start_game_called_on_match( (((400, 100), 10), ((300, 100), 1000)) )) async def test_start_game_map_selection_newbie_pool( + mocker, ladder_service: LadderService, player_factory, ratings ): p1 = player_factory( + login="Test1", ladder_rating=ratings[0][0], ladder_games=ratings[0][1], ) p2 = player_factory( + login="Test2", ladder_rating=ratings[1][0], ladder_games=ratings[1][1], ) queue = ladder_service.queues["ladder1v1"] queue.map_pools.clear() - newbie_map_pool = mock.Mock() - full_map_pool = mock.Mock() - queue.add_map_pool(newbie_map_pool, None, 500) - queue.add_map_pool(full_map_pool, 500, None) + newbie_map_pool = MapPool( + map_pool_id=1, + name="newbie_map_pool", + maps=[Map(15, "scmp_015", ranked=True)], + ) + mocker.patch.object(newbie_map_pool, "choose_map") + full_map_pool = MapPool( + map_pool_id=2, + name="full_map_pool", + maps=[ + Map(15, "scmp_015", ranked=True), + Map(16, "scmp_016", ranked=True), + Map(17, "scmp_017", ranked=True), + ], + ) + mocker.patch.object(full_map_pool, "choose_map") + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=newbie_map_pool, + min_rating=None, + max_rating=500, + ), + ) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=2, + map_pool=full_map_pool, + min_rating=500, + max_rating=None, + ), + ) await ladder_service.start_game([p1], [p2], queue) @@ -866,7 +910,9 @@ async def test_start_game_map_selection_newbie_pool( async def test_start_game_map_selection_pros( - ladder_service: LadderService, player_factory + mocker, + ladder_service: LadderService, + player_factory, ): p1 = player_factory( ladder_rating=(2000, 50), @@ -879,10 +925,38 @@ async def test_start_game_map_selection_pros( queue = ladder_service.queues["ladder1v1"] queue.map_pools.clear() - newbie_map_pool = mock.Mock() - full_map_pool = mock.Mock() - queue.add_map_pool(newbie_map_pool, None, 500) - queue.add_map_pool(full_map_pool, 500, None) + newbie_map_pool = MapPool( + map_pool_id=1, + name="newbie_map_pool", + maps=[Map(15, "scmp_015", ranked=True)], + ) + mocker.patch.object(newbie_map_pool, "choose_map") + full_map_pool = MapPool( + map_pool_id=2, + name="full_map_pool", + maps=[ + Map(15, "scmp_015", ranked=True), + Map(16, "scmp_016", ranked=True), + Map(17, "scmp_017", ranked=True), + ], + ) + mocker.patch.object(full_map_pool, "choose_map") + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=newbie_map_pool, + min_rating=None, + max_rating=500, + ), + ) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=2, + map_pool=full_map_pool, + min_rating=500, + max_rating=None, + ), + ) await ladder_service.start_game([p1], [p2], queue) @@ -891,7 +965,9 @@ async def test_start_game_map_selection_pros( async def test_start_game_map_selection_rating_type( - ladder_service: LadderService, player_factory + mocker, + ladder_service: LadderService, + player_factory, ): p1 = player_factory( ladder_rating=(2000, 50), @@ -909,10 +985,38 @@ async def test_start_game_map_selection_rating_type( queue = ladder_service.queues["ladder1v1"] queue.rating_type = RatingType.GLOBAL queue.map_pools.clear() - newbie_map_pool = mock.Mock() - full_map_pool = mock.Mock() - queue.add_map_pool(newbie_map_pool, None, 500) - queue.add_map_pool(full_map_pool, 500, None) + newbie_map_pool = MapPool( + map_pool_id=1, + name="newbie_map_pool", + maps=[Map(15, "scmp_015", ranked=True)], + ) + mocker.patch.object(newbie_map_pool, "choose_map") + full_map_pool = MapPool( + map_pool_id=2, + name="full_map_pool", + maps=[ + Map(15, "scmp_015", ranked=True), + Map(16, "scmp_016", ranked=True), + Map(17, "scmp_017", ranked=True), + ], + ) + mocker.patch.object(full_map_pool, "choose_map") + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=newbie_map_pool, + min_rating=None, + max_rating=500, + ), + ) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=2, + map_pool=full_map_pool, + min_rating=500, + max_rating=None, + ), + ) await ladder_service.start_game([p1], [p2], queue) diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 24cc60e4f..ea764e84d 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -15,6 +15,7 @@ from server.games import CustomGame, Game, GameState, InitMode, VisibilityState from server.geoip_service import GeoIpService from server.ladder_service import LadderService +from server.ladder_service.veto_system import VetoService from server.lobbyconnection import LobbyConnection from server.matchmaker import Search from server.oauth_service import OAuthService @@ -95,8 +96,9 @@ async def lobbyconnection( players=mock_players, ladder_service=mock.create_autospec(LadderService), party_service=mock.create_autospec(PartyService), + rating_service=rating_service, oauth_service=mock.create_autospec(OAuthService), - rating_service=rating_service + veto_service=mock.create_autospec(VetoService), ) lc.player = mock_player diff --git a/tests/unit_tests/test_map_pool.py b/tests/unit_tests/test_map_pool.py index 69e0ba35e..d71315add 100644 --- a/tests/unit_tests/test_map_pool.py +++ b/tests/unit_tests/test_map_pool.py @@ -24,31 +24,31 @@ def make(map_pool_id=0, name="Test Pool", maps=()): def test_choose_map(map_pool_factory): map_pool = map_pool_factory(maps=[ - Map(1, "some_map.v001"), - Map(2, "some_map.v001"), - Map(3, "some_map.v001"), - Map(4, "choose_me.v001"), + Map(1, "some_map.v001", map_pool_map_version_id=1), + Map(2, "some_map.v001", map_pool_map_version_id=2), + Map(3, "some_map.v001", map_pool_map_version_id=3), + Map(4, "choose_me.v001", map_pool_map_version_id=4), ]) # Make the probability very low that the test passes because we got lucky for _ in range(20): chosen_map = map_pool.choose_map([1, 2, 3]) - assert chosen_map == Map(4, "choose_me.v001") + assert chosen_map == Map(4, "choose_me.v001", map_pool_map_version_id=4) @pytest.mark.flaky def test_choose_map_with_weights(map_pool_factory): map_pool = map_pool_factory(maps=[ - Map(1, "some_map.v001", weight=1), - Map(2, "some_map.v001", weight=1), - Map(3, "some_map.v001", weight=1), - Map(4, "choose_me.v001", weight=10000000), + Map(1, "some_map.v001", weight=1, map_pool_map_version_id=1), + Map(2, "some_map.v001", weight=1, map_pool_map_version_id=2), + Map(3, "some_map.v001", weight=1, map_pool_map_version_id=3), + Map(4, "choose_me.v001", weight=10000000, map_pool_map_version_id=4), ]) # Make the probability very low that the test passes because we got lucky for _ in range(20): chosen_map = map_pool.choose_map() - assert chosen_map == Map(4, "choose_me.v001", weight=10000000) + assert chosen_map == Map(4, "choose_me.v001", weight=10000000, map_pool_map_version_id=4) def test_choose_map_generated_map(map_pool_factory): @@ -62,7 +62,7 @@ def test_choose_map_generated_map(map_pool_factory): "spawns": 2, "size": 512, "type": "neroxis" - }), + }, map_pool_map_version_id=1), ]) chosen_map = map_pool.choose_map([]) @@ -89,9 +89,9 @@ def test_choose_map_generated_map(map_pool_factory): def test_choose_map_all_maps_played(map_pool_factory): maps = [ - Map(1, "some_map.v001"), - Map(2, "some_map.v001"), - Map(3, "some_map.v001"), + Map(1, "some_map.v001", map_pool_map_version_id=1), + Map(2, "some_map.v001", map_pool_map_version_id=2), + Map(3, "some_map.v001", map_pool_map_version_id=3), ] map_pool = map_pool_factory(maps=maps) @@ -101,17 +101,18 @@ def test_choose_map_all_maps_played(map_pool_factory): assert chosen_map in maps -def test_choose_map_all_played_but_generated_map_doesnt_dominate(map_pool_factory): +def test_choose_map_all_played_except_generated_map(map_pool_factory): + generated_map = NeroxisGeneratedMap.of({ + "version": "0.0.0", + "spawns": 2, + "size": 512, + "type": "neroxis" + }) maps = [ - Map(1, "some_map.v001", weight=1000000), - Map(2, "some_map.v001", weight=1000000), - Map(3, "some_map.v001", weight=1000000), - NeroxisGeneratedMap.of({ - "version": "0.0.0", - "spawns": 2, - "size": 512, - "type": "neroxis" - }), + Map(1, "some_map.v001", weight=1000000, map_pool_map_version_id=1), + Map(2, "some_map.v001", weight=1000000, map_pool_map_version_id=2), + Map(3, "some_map.v001", weight=1000000, map_pool_map_version_id=3), + generated_map, ] map_pool = map_pool_factory(maps=maps) @@ -120,15 +121,14 @@ def test_choose_map_all_played_but_generated_map_doesnt_dominate(map_pool_factor chosen_map = map_pool.choose_map([1, 2, 3]) assert chosen_map is not None - assert chosen_map in maps - assert chosen_map.id in [1, 2, 3] + assert chosen_map.id == generated_map.id def test_choose_map_all_maps_played_not_in_pool(map_pool_factory): maps = [ - Map(1, "some_map.v001"), - Map(2, "some_map.v001"), - Map(3, "some_map.v001"), + Map(1, "some_map.v001", map_pool_map_version_id=1), + Map(2, "some_map.v001", map_pool_map_version_id=2), + Map(3, "some_map.v001", map_pool_map_version_id=3), ] map_pool = map_pool_factory(maps=maps) @@ -151,7 +151,7 @@ def test_choose_map_all_maps_played_returns_least_played(map_pool_factory): ] maps = [ - Map(i + 1, "some_map.v001") for i in range(num_maps) + Map(i + 1, "some_map.v001", map_pool_map_version_id=i+1) for i in range(num_maps) ] # Shuffle the list so that `choose_map` can't just return the first map random.shuffle(maps) @@ -160,19 +160,19 @@ def test_choose_map_all_maps_played_returns_least_played(map_pool_factory): chosen_map = map_pool.choose_map(played_map_ids) # Map 1 was played only once - assert chosen_map == Map(1, "some_map.v001") + assert chosen_map == Map(1, "some_map.v001", map_pool_map_version_id=1) @given(history=st.lists(st.integers())) def test_choose_map_single_map(map_pool_factory, history): map_pool = map_pool_factory(maps=[ - Map(1, "choose_me.v001"), + Map(1, "choose_me.v001", map_pool_map_version_id=1), ]) # Make the probability very low that the test passes because we got lucky for _ in range(20): chosen_map = map_pool.choose_map(history) - assert chosen_map == Map(1, "choose_me.v001") + assert chosen_map == Map(1, "choose_me.v001", map_pool_map_version_id=1) def test_choose_map_raises_on_empty_map_pool(map_pool_factory): @@ -180,3 +180,115 @@ def test_choose_map_raises_on_empty_map_pool(map_pool_factory): with pytest.raises(RuntimeError): map_pool.choose_map([]) + + +@pytest.mark.parametrize( + "initial_weights, played_map_ids, base_thresholds, repeat_factor, expected_adjusted_weights", + [ + # All Maps Played Equally + ( + {1: 1, 2: 1, 3: 1}, + [1, 2, 3], + [0.5], + 0.8, + {1: 1, 2: 1, 3: 1} + ), + # Testing Redistribution: should be proportional to initial weights + ( + {1: 0.6, 2: 0.5, 3: 1}, + [1], + [0.5], + 0.8, + {1: 0, 2: 0.7, 3: 1.4} + ), + # High Threshold, No Redistribution to half-banned map + ( + {1: 1, 2: 1, 3: 0.5}, + [1, 1, 2], + [1], + 0.8, + {1: 0, 2: 2, 3: 0.5} + ), + # Low first Threshold, Redistribution to half-banned map happens + ( + {1: 1, 2: 1, 3: 0.5}, + [1, 1, 2], + [0.5], + 0.8, + {1: 0, 2: 0, 3: 2.5} + ), + # Secondary Threshold triggers Redistribution with help of repeat_factor + ( + {1: 1, 2: 0.8, 3: 0.4}, + [1, 1, 2], + [1, 0.5], + 0.8, + {1: 0, 2: 0, 3: 2.2} + ), + # Threshold is very high but repeat_factor handles high playcounts + ( + {1: 1, 2: 0.9 * 0.8 ** 7}, + [1] * 8, + [0.9], + 0.8, + {1: 0, 2: 1 + 0.9 * 0.8 ** 7} + ), + # Empty Played Map IDs + ( + {1: 1, 2: 1, 3: 1}, + [], + [0.5], + 0.8, + {1: 1, 2: 1, 3: 1} + ), + # Played Maps Not in Pool + ( + {1: 1, 2: 1, 3: 1}, + [1, 1, 4], + [0.5], + 0.8, + {1: 0, 2: 1.5, 3: 1.5} + ), + # Really complex redistribution test + # 1 -> [5], [2,3] -> [5,6,8], [7,9] -> 5 + ( + {1: 0.9, 2: 0.6, 3: 0.4, 4: 0.2, 5: 0.75, 6: 0.6, 7: 1, 8: 0.5, 9: 1}, + [1, 1, 2, 3, 7, 9], + [0.75, 0.5], + 0.8, + { + 1: 0, + 2: 0, + 3: 0, + 4: 0.2, + 5: pytest.approx(4.055405, rel=1e-6), + 6: pytest.approx(0.924324, rel=1e-6), + 7: 0, + 8: pytest.approx(0.770270, rel=1e-6), + 9: 0 + } + ), + # Same test but maps and played_ids are in the different order + ( + {4: 0.2, 9: 1, 7: 1, 6: 0.6, 5: 0.75, 8: 0.5, 1: 0.9, 3: 0.4, 2: 0.6}, + [9, 7, 2, 1, 3, 1], + [0.75, 0.5], + 0.8, + { + 1: 0, + 2: 0, + 3: 0, + 4: 0.2, + 5: pytest.approx(4.055405, rel=1e-6), + 6: pytest.approx(0.924324, rel=1e-6), + 7: 0, + 8: pytest.approx(0.770270, rel=1e-6), + 9: 0 + } + ), + ], +) +def test_apply_antirepetition_adjustment(map_pool_factory, initial_weights, played_map_ids, base_thresholds, repeat_factor, expected_adjusted_weights): + map_pool = map_pool_factory() + adjusted = map_pool.apply_antirepetition_adjustment(initial_weights, played_map_ids, base_thresholds, repeat_factor) + assert adjusted == expected_adjusted_weights diff --git a/tests/unit_tests/test_matchmaker_queue.py b/tests/unit_tests/test_matchmaker_queue.py index 134c31ce5..3f1b473c3 100644 --- a/tests/unit_tests/test_matchmaker_queue.py +++ b/tests/unit_tests/test_matchmaker_queue.py @@ -8,7 +8,12 @@ from hypothesis import strategies as st from server.config import config -from server.matchmaker import CombinedSearch, MapPool, Search +from server.matchmaker import ( + CombinedSearch, + MapPool, + MatchmakerQueueMapPool, + Search +) from server.players import PlayerState from server.rating import RatingType @@ -286,7 +291,14 @@ def test_queue_map_pools_empty(queue_factory, rating): def test_queue_map_pools_any_range(queue_factory, rating): queue = queue_factory() map_pool = MapPool(0, "pool") - queue.add_map_pool(map_pool, None, None) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=map_pool, + min_rating=None, + max_rating=None, + ) + ) assert queue.get_map_pool_for_rating(rating) is map_pool @@ -295,7 +307,14 @@ def test_queue_map_pools_any_range(queue_factory, rating): def test_queue_map_pools_lower_bound(queue_factory, rating, low): queue = queue_factory() map_pool = MapPool(0, "pool") - queue.add_map_pool(map_pool, low, None) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=map_pool, + min_rating=low, + max_rating=None, + ) + ) if rating < low: assert queue.get_map_pool_for_rating(rating) is None @@ -307,7 +326,14 @@ def test_queue_map_pools_lower_bound(queue_factory, rating, low): def test_queue_map_pools_upper_bound(queue_factory, rating, high): queue = queue_factory() map_pool = MapPool(0, "pool") - queue.add_map_pool(map_pool, None, high) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=map_pool, + min_rating=None, + max_rating=high, + ) + ) if rating > high: assert queue.get_map_pool_for_rating(rating) is None @@ -319,7 +345,14 @@ def test_queue_map_pools_upper_bound(queue_factory, rating, high): def test_queue_map_pools_bound(queue_factory, rating, low, high): queue = queue_factory() map_pool = MapPool(0, "pool") - queue.add_map_pool(map_pool, low, high) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=map_pool, + min_rating=low, + max_rating=high, + ) + ) if low <= rating <= high: assert queue.get_map_pool_for_rating(rating) is map_pool @@ -340,8 +373,22 @@ def test_queue_multiple_map_pools( queue = queue_factory() map_pool1 = MapPool(0, "pool1") map_pool2 = MapPool(1, "pool2") - queue.add_map_pool(map_pool1, low1, high1) - queue.add_map_pool(map_pool2, low2, high2) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=1, + map_pool=map_pool1, + min_rating=low1, + max_rating=high1, + ) + ) + queue.add_map_pool( + MatchmakerQueueMapPool( + id=2, + map_pool=map_pool2, + min_rating=low2, + max_rating=high2, + ) + ) if low1 <= rating <= high1: assert queue.get_map_pool_for_rating(rating) is map_pool1 diff --git a/tests/unit_tests/test_veto_system.py b/tests/unit_tests/test_veto_system.py new file mode 100644 index 000000000..67c66b41c --- /dev/null +++ b/tests/unit_tests/test_veto_system.py @@ -0,0 +1,227 @@ +from unittest import mock + +import pytest + +from server.ladder_service.veto_system import ( + _cap_tokens, + _is_valid_veto_config_for_queue +) +from server.matchmaker import MapPool, MatchmakerQueueMapPool + + +def test_is_valid_veto_config_for_queue(queue_factory): + queue = queue_factory(team_size=1) + + # minimum_maps_after_veto larger than map pool size + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=0, + max_tokens_per_map=0, + minimum_maps_after_veto=10, + ) + ) is False + + # minimum_maps_after_veto equal to map pool size + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=0, + max_tokens_per_map=0, + minimum_maps_after_veto=1, + ) + ) is True + + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=0, + max_tokens_per_map=1, + minimum_maps_after_veto=2, + ) + ) is False + + # Each player can veto 1 map. 2 maps vetoed total + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock(), mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=1, + max_tokens_per_map=1, + minimum_maps_after_veto=1, + ) + ) is False + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock() for _ in range(3)]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=1, + max_tokens_per_map=1, + minimum_maps_after_veto=1, + ) + ) is True + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock() for _ in range(4)]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=1, + max_tokens_per_map=1, + minimum_maps_after_veto=1, + ) + ) is True + + # minimum_maps_after_veto is zero + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock(), mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=0, + max_tokens_per_map=0, + minimum_maps_after_veto=0, + ) + ) is True + + # max_tokens_per_map > veto_tokens_per_player + assert _is_valid_veto_config_for_queue( + queue, + MatchmakerQueueMapPool( + id=1, + map_pool=MapPool(1, "pool", maps=[mock.Mock(), mock.Mock()]), + min_rating=None, + max_rating=None, + veto_tokens_per_player=1, + max_tokens_per_map=2, + minimum_maps_after_veto=1, + ) + ) is True + + +def test_cap_tokens(): + # Number of tokens is less than all caps + assert _cap_tokens( + tokens=3, + total_remaining=10, + max_per_map=4, + ) == 3 + + # Number of tokens is more than max per map + assert _cap_tokens( + tokens=10, + total_remaining=10, + max_per_map=4, + ) == 4 + + # Number of tokens is more than remaining total + assert _cap_tokens( + tokens=10, + total_remaining=1, + max_per_map=10, + ) == 1 + + # Max per map is zero + assert _cap_tokens( + tokens=10, + total_remaining=5, + max_per_map=0, + ) == 5 + + # Remaining is zero + assert _cap_tokens( + tokens=10, + total_remaining=0, + max_per_map=5, + ) == 0 + + +@pytest.mark.parametrize("M, tokens, expected", [ + # Only 0-token maps, sufficient to meet M + (2.0, [0, 0, 0], 1.0), + # Three maps with 0 tokens, M=2 < 3, should return T=1 because taking all 0-token maps is enough + + # Impossible setup, 1 full map required but the only map available is partially vetoed + (1, [1], 0), + # function returns 0 for bad input + + # Include maps with 1 token + (2.0, [0, 1, 1], 2.0), + # One 0-token map (sum=1) isn't enough for M=2, include two 1-token maps, T=2 satisfies + + # Non-integer M + (1.5, [0, 1, 1], 4/3), + # M=1.5, 0-token sum=1 < M, include 1-token maps, T=4/3 ≈ 1.333, sum=1.5 + + # Include maps with 2 tokens + (2.5, [0, 0, 2], 4.0), + # Two 0-token maps (sum=2) < M=2.5, include 2-token map, T=4, sum=2.5 + + # Another test because why not + (1.9, [0, 1], 10.0), + # M=1.9, one 0-token (sum=1) < M, include 1-token, final case T=10, sum=1.9 + + # More complex case + (3.5, [0, 0, 1, 1, 2], 8/3), + # M=3.5, 0 and 1-token maps insufficient, all maps give T=8/3 ≈ 2.667, sum=3.5 + + # All maps have 1 token, no 0-token maps + (1.0, [1, 1, 1], 1.5), + # T=1.5, each weight=1/3, sum=1 + + # All maps have 1 token, small M + (0.5, [1, 1, 1], 1.2), + # M=0.5, all 1-token maps, T=1.2, each weight=1/6, sum=0.5 + + # Mix of 0 and higher tokens, 0-tokens sufficient + (1.0, [0, 2], 1.0), + # M=1, one 0-token map suffices, T=1, sum=1 + + # Mix of 0 and 2 tokens + (1.5, [0, 2], 4.0), + # M=1.5, 0-token sum=1 < M, include 2-token, T=4, sum=1.5 + + # Larger set, T matches next token boundary + (4.0, [0, 0, 0, 1, 1, 2, 2], 2.0), + # M=4, 0 and 1-token maps (5 maps), T=2, sum=4 + + # Larger set, T in final case + (5.0, [0, 0, 0, 1, 1, 2, 2], 3.0), + # M=5, all maps included, T=3, sum=5 + + # Float M + (4.5, [0, 0, 0, 1, 1, 2, 2], 2.4), + # M=4.5, all maps, T=2.4, sum=4.5 + + # The same test, just checking that order of maps doesn't matter + (4.5, [2, 1, 2, 0, 1, 0, 0], 2.4), + # M=4.5, all maps, T=2.4, sum=4.5 +]) +def test_veto_service_calculate_dynamic_tokens_per_map(veto_service, M, tokens, expected): + result = veto_service.calculate_dynamic_tokens_per_map(M, tokens) + assert result == pytest.approx(expected, rel=1e-9) + # Verify that the result produces a sum >= M + if result != 0: + total_weight = sum(max((result - v) / result, 0) for v in tokens) + assert total_weight >= M - 1e-9 # Account for floating-point errors