diff --git a/server/db/models.py b/server/db/models.py index 9d7378b7c..fea18f6a6 100644 --- a/server/db/models.py +++ b/server/db/models.py @@ -287,10 +287,13 @@ 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("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 39cda2709..1f856b1fb 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -7,7 +7,7 @@ import re import statistics from collections import defaultdict -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Optional, Iterable import aiocron import humanize @@ -39,13 +39,15 @@ from server.ladder_service.violation_service import ViolationService from server.matchmaker import ( MapPool, + MatchmakerQueueMapPool, MatchmakerQueue, OnMatchedCallback, Search ) from server.metrics import MatchLaunch +from server.player_service import PlayerService from server.players import Player, PlayerState -from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap +from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap, MatchmakerQueueMapPoolVetoData @with_logger @@ -59,12 +61,15 @@ def __init__( self, database: FAFDatabase, game_service: GameService, + player_service: PlayerService, violation_service: ViolationService, ): self._db = database self._informed_players: set[Player] = set() self.game_service = game_service + self.player_service = player_service self.queues = {} + self.pools_veto_data: list[MatchmakerQueueMapPoolVetoData] = [] self.violation_service = violation_service self._searches: dict[Player, dict[str, Search]] = defaultdict(dict) @@ -100,7 +105,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 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( @@ -110,21 +115,33 @@ 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( + MapPool(map_pool_id, map_pool_name, 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()): if queue_name not in db_queues: self.queues[queue_name].shutdown() del self.queues[queue_name] + prev_pools_veto_data = self.pools_veto_data + new_pools_veto_data = self.get_pools_veto_data() + if (new_pools_veto_data != prev_pools_veto_data): + self.pools_veto_data = new_pools_veto_data + for player in self.player_service.all_players: + await player.update_vetoes(self.pools_veto_data) 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"), @@ -150,6 +167,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, @@ -161,7 +179,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( @@ -191,6 +209,9 @@ async def fetch_matchmaker_queues(self, conn): 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") ) @@ -219,7 +240,10 @@ async def fetch_matchmaker_queues(self, conn): info["map_pools"].append(( 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( @@ -523,7 +547,23 @@ def get_displayed_rating(player: Player) -> float: pool = queue.get_map_pool_for_rating(rating) if not pool: raise RuntimeError(f"No map pool available for rating {rating}!") - game_map = pool.choose_map(played_map_ids) + + pool, *_, max_tokens_per_map, minimum_maps_after_veto = queue.map_pools[pool.id] + + vetoes_map = defaultdict(int) + + for m in pool.maps.values(): + for player in all_players: + vetoes_map[m.map_pool_map_version_id] += player.vetoes.get(m.map_pool_map_version_id, 0) + + 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 actually so i am not sure do we need this here or not + 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 + game_map = pool.choose_map(played_map_ids, vetoes_map, max_tokens_per_map) game = self.game_service.create_game( game_class=LadderGame, @@ -673,6 +713,147 @@ async def launch_match( if player not in connected_players ]) + """ + function finds minimal max_tokens_per_map > 0 for given M (minimal_maps_after_veto) > 0 and [iterable] of veto tokens applied for each map in the bracket + max_tokens_per_map - is the amount of veto_tokens required to fully ban a map + minimal_maps_after_veto - minimal sum of map weights, anything lower is forbidden (for map diversity purpuses) + + lets rename max_tokens_per_map to T for simplicity + then weight of map with V tokens applied is max((T - V) / T, 0) + + example: lets say we have A maps with 0 tokens applied, B Maps with 1 token, C Maps with 2 tokens + the inequality to be true: + A * max((T - 0) / T, 0) + B * max((T - 1) / T, 0) + C * max((T - 2) / T, 0) >= M + + max((T - 0) / T, 0) is always 1, so: + B * max((T - 1) / T, 0) + C * max((T - 2) / T, 0) >= M - A + + due to max() function, it splits to 3: + 1) for 0 < T <= 1: 0 >= M - A which is the same as A >= M + 2) for 1 < T <= 2: B * (T - 1) / T >= M - A + 3) for T > 2: B * (T - 1) / T + C * (T - 2) / T >= M - A + + since we are looking for minimal T > 0, we should just check out cases from min T to max T + + in case 1): range T <= 1 + here we trying to find answer using only 0-tokens maps + - if A >= M, then any value 0 < T <= 1 is valid answer, but to avoid division by zero errors, we return 1 + a bit contradicts with "minimal" in function definition, but since veto tokens applied is always an integer, result of 1 will give the same veto system result as 0.0001 + - if A < M, there is no answer in this case + + [1] for every next case: M > A is true (otherwise we would return answer in case 1) + in case 2): range 1 < T <= 2 + here we trying to find answer using maps with 0-1 tokens + B * (T - 1) / T >= M - A + B * (T - 1) >= (M - A) * T + BT - B - MT + AT >= 0 + (B - M + A)T >= B + T >= B / (B + A - M) + note 1: B + A is just amount of maps to which we try to shrink bracket (only maps with 0 and 1 tokens applied) + so we can say that denominator is "map balance", amount of map to which we try to shrink the bracket minus the minimal amount it should be, + so if map balance is negative, the answer should be obviously discarded + in case if map balance is 0 answer also should be discarded because B > 0 and division by zero happens + note 2: since M is guaranteed to be > A due to [1], then A - M is negative, then B + A - M < B, so B / (B + A - M) > 1 + thus, since we're looking for MINIMAL answer, we should just replace > with = : + T = B / (B + A - M) + its always > 1 so we can forget lower border issue in 1 < T <= 2 above + just calculating T, checking if its <= 2, if true - return T, if false - case 3 + + [2] for every next case: B / (B + A - M) > 2 which is the same as B > 2B + 2A - 2M + in case 3): range T > 2 + here we trying to find answer using maps with 0-2 tokens + B * (T - 1) / T + C * (T - 2) / T >= M - A + BT - B + CT - 2C >= (M - A) * T + (B + C + A - M)T >= B + 2C + T >= (B + 2C) / (B + C + A - M) + note 1: now we can see pattern in nominator: its just sum of tokens applied to all maps to wich we currently try to shrink the bracket + its basically 0A + 1B in case 2, and 0A + 1B + 2C in case 3, you can return to A B C definitions above to be sure + note 2: denominator is the same as in case 2, map balance. Again, A - M is negative, so B + C + A - M < B + C + let prove that there is no need to worry about lower border, again: + lets assume that (B + 2C) / (B + C + A - M) < 2 + then B + 2C < 2B + 2C + 2A - 2M + then B < 2B + 2A - 2M + which is contradicts with [2], so again, we can just replace >= with = : + T = (B + 2C) / (B + C + A - M) + just calulating it and return the value + + + case X) + if we had some number D of maps with X tokens, and in case 3 we received result more than X (and discarded it due to exceeding upper border), then we would say that + [3] (B + 2C) / (B + C + A - M) > X which is the same as B + 2C > XB + XC + XA - XM + and our X equation would have lower border of X ofcourse + T = (B + 2C + XD) / (D + B + C + A - M) + now lets prove that T > X: + lets assume that T < X + (B + 2C + XD) / (D + B + C + A - M) < X + B + 2C + XD < XD + XB + XC + XA - XM + B + 2C < XB + XC + XA - XM + which is contradicts with [3] + so, we just proved that we should not worry about lower border in any case + and just solve equations and not inequality for each case except case 1 + + notices: + 1) in every case except case 1, nominator is just sum of tokens applied to all maps in equation + and denominator is map balance + 2) for case 1 we always have tokens_sum equal to 0 + + conclusion: whole solution process is + 1) sorting tokens applied in ascending order + cycle: + 2) including next bunch of maps (with the same tokens applied value) to our group + 3) checking if tokens_sum == 0 + then its case 1, and return 1 if maps_balance > 0 + 4) otherwise + solving equation for current group + and checking the result vs upper border + and upper border is equal to the amount of tokens applied to the map next to last map in our group, or infinity if there is no such one + """ + def calculate_dynamic_tokens_per_map(self, M: float, tokens: Iterable[int]) -> float: + sorted_tokens = sorted(tokens) + # adding infinity as last upper border + sorted_tokens.append(float("inf")) + # t is the maximum amount of tokens applied to any single map in current group + t = 0 + tokens_sum = 0 + for index, tokens in enumerate(sorted_tokens): + # if at [index] our current group is ended + if tokens > t: + maps_balance = index - M + # if our group is only 0-tokened maps + if tokens_sum == 0 and maps_balance >= 0: + return 1 + if maps_balance > 0: + # solving the equation + candidate = tokens_sum / maps_balance + # checking it vs upper border + if candidate <= tokens: + return candidate + t = tokens + tokens_sum += tokens + + # return 0 never happens for correct tokens - M pairs + return 0 + + def get_pools_veto_data(self) -> list[MatchmakerQueueMapPoolVetoData]: + result = [] + for queue in self.queues.values(): + for pool, *_, veto_tokens_per_player, max_tokens_per_map, minimum_maps_after_veto in queue.map_pools.values(): + if max_tokens_per_map == 0 and minimum_maps_after_veto >= len(pool.maps.values()) \ + or max_tokens_per_map != 0 and queue.team_size * 2 * veto_tokens_per_player / max_tokens_per_map > len(pool.maps.values()) - minimum_maps_after_veto: + veto_tokens_per_player = 0 + max_tokens_per_map = 1 + minimum_maps_after_veto = 1 + self._logger.error(f"Wrong vetoes setup detected for pool {pool.id} in queue {queue.id}") + result.append( + MatchmakerQueueMapPoolVetoData( + map_pool_map_version_ids=[map.map_pool_map_version_id for map in 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 get_game_history( self, players: list[Player], diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 903a11d7b..c68c4a751 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -1315,6 +1315,10 @@ 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): + converted = {v["map_pool_map_version_id"]: v["veto_tokens_applied"] for v in message["vetoes"]} + await self.player.update_vetoes(self.ladder_service.pools_veto_data, converted) + 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 c74be0d7a..ba41667df 100644 --- a/server/matchmaker/map_pool.py +++ b/server/matchmaker/map_pool.py @@ -1,6 +1,6 @@ import random from collections import Counter -from typing import Iterable +from typing import Iterable, NamedTuple from ..decorators import with_logger from ..types import Map, MapPoolMap @@ -21,11 +21,11 @@ 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 choose_map(self, played_map_ids: Iterable[int] = (), vetoes_map={}, max_tokens_per_map=1) -> Map: """ - 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. + Select a random map using veto system weights. + The maps which are least played from played_map_ids + and not vetoed by any player are getting x2 weight multiplier. """ if not self.maps: self._logger.critical( @@ -50,10 +50,22 @@ def choose_map(self, played_map_ids: Iterable[int] = ()) -> Map: least_common = least_common[:i] break - weights = [self.maps[id_].weight for id_, _ in least_common] + least_common_ids = {id_ for id_, _ in least_common} - map_id = random.choices(least_common, weights=weights, k=1)[0][0] - return self.maps[map_id].get_map() + # Anti-repetition is temporary disabled + # map_list = list((map.map_pool_map_version_id, map, 2 if (map.id in least_common_ids) and (vetoes_map.get(map.map_pool_map_version_id, 0) == 0) else 1) for map in self.maps.values()) + map_list = list((map.map_pool_map_version_id, map, 1) for map in self.maps.values()) + weights = [max(0, (1 - vetoes_map.get(id, 0) / max_tokens_per_map) * map.weight * least_common_multiplier) for id, map, least_common_multiplier in map_list] + return random.choices(map_list, weights=weights, k=1)[0][1] def __repr__(self) -> str: return f"MapPool({self.id}, {self.name}, {list(self.maps.values())})" + + +class MatchmakerQueueMapPool(NamedTuple): + map_pool: MapPool + min_rating: int | None + max_rating: int | None + veto_tokens_per_player: int + max_tokens_per_map: int + minimum_maps_after_veto: float diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 778407981..0ac7ccbc4 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -11,7 +11,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 @@ -50,7 +50,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 @@ -76,14 +76,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 0380d7602..01ed09a79 100644 --- a/server/players.py +++ b/server/players.py @@ -7,6 +7,7 @@ from enum import Enum, unique from typing import Optional, Union +from .types import MatchmakerQueueMapPoolVetoData from .factions import Faction from .protocol import DisconnectedError from .rating import Leaderboard, PlayerRatings, RatingType @@ -48,6 +49,7 @@ def __init__( lobby_connection: Optional["LobbyConnection"] = None ) -> None: self._faction = Faction.uef + self._vetoes = {} self.id = player_id self.login = login @@ -89,6 +91,46 @@ def faction(self, value: Union[str, int, Faction]) -> None: else: self._faction = Faction.from_value(value) + @property + def vetoes(self) -> dict[int, int]: + return self._vetoes + + @vetoes.setter + def vetoes(self, value: dict[int, int]) -> None: + if not isinstance(value, dict): + raise ValueError("Vetoes must be a dictionary") + if not all(isinstance(key, int) and isinstance(val, int) and val >= 0 for key, val in value.items()): + raise ValueError("Incorrect vetoes dictonary") + self._vetoes = value + + async def update_vetoes(self, pools_vetodata: list[MatchmakerQueueMapPoolVetoData], current: dict = None) -> None: + if current is None: + current = self.vetoes + fixedVetoes = {} + vetoDatas = [] + for (map_pool_map_version_ids, veto_tokens_per_player, max_tokens_per_map, _) in pools_vetodata: + tokens_sum = 0 + for map_id in map_pool_map_version_ids: + new_tokens_applied = max(current.get(map_id, 0), 0) + if (tokens_sum + new_tokens_applied > veto_tokens_per_player): + new_tokens_applied = veto_tokens_per_player - tokens_sum + if (max_tokens_per_map > 0 and new_tokens_applied > max_tokens_per_map): + new_tokens_applied = max_tokens_per_map + if (new_tokens_applied == 0): + continue + vetoDatas.append({"map_pool_map_version_id": map_id, "veto_tokens_applied": new_tokens_applied}) + fixedVetoes[map_id] = new_tokens_applied + tokens_sum += new_tokens_applied + if fixedVetoes == self.vetoes == current: + return + self.vetoes = fixedVetoes + if self.lobby_connection is None: + return + await self.lobby_connection.send({ + "command": "vetoes_changed", + "vetoesData": vetoDatas + }) + def power(self) -> int: """An artifact of the old permission system. The client still uses this number to determine if a player gets a special category in the user list diff --git a/server/types.py b/server/types.py index f96d65317..8ac82120c 100644 --- a/server/types.py +++ b/server/types.py @@ -31,6 +31,13 @@ class GameLaunchOptions(NamedTuple): game_options: Optional[dict[str, Any]] = None +class MatchmakerQueueMapPoolVetoData(NamedTuple): + map_pool_map_version_ids: list[int] + veto_tokens_per_player: int + max_tokens_per_map: int + minimum_maps_after_veto: float + + class MapPoolMap(Protocol): id: int weight: int @@ -44,6 +51,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): @@ -64,6 +72,7 @@ class NeroxisGeneratedMap(NamedTuple): spawns: int map_size_pixels: int weight: int = 1 + map_pool_map_version_id: Optional[int] = None _NAME_PATTERN = re.compile( "neroxis_map_generator_([0-9.]+)_([a-z2-7]+)_([a-z2-7]+)" @@ -75,7 +84,7 @@ def is_neroxis_map(cls, folder_name: str) -> bool: return cls._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" @@ -98,6 +107,7 @@ def of(cls, params: dict, weight: int = 1): spawns, map_size_pixels, weight, + map_pool_map_version_id, ) @staticmethod @@ -137,6 +147,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, )