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..477252568 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -100,7 +100,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( @@ -112,7 +112,10 @@ async def update_data(self) -> None: queue.add_map_pool( MapPool(map_pool_id, map_pool_name, map_list), min_rating, - max_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()): @@ -125,6 +128,7 @@ async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: 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 +154,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, @@ -191,6 +196,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 +227,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 +534,25 @@ 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, _, _, veto_tokens_per_player, max_tokens_per_map, minimum_maps_after_veto = queue.map_pools[pool.id] + + vetoesMap = defaultdict(int) + tokensTotalPerPlayer = defaultdict(int) + + for (id, map) in pool.maps.items(): + for player in all_players: + vetoesMap[map.map_pool_map_version_id] += player.vetoes.get(map.map_pool_map_version_id, 0) + tokensTotalPerPlayer[player.id] += player.vetoes.get(map.map_pool_map_version_id, 0) + + for player in all_players: + if (tokensTotalPerPlayer[player.id] > veto_tokens_per_player): + raise RuntimeError(f"Player {player.id} has too many vetoes!") + + if (max_tokens_per_map == 0): + max_tokens_per_map = self.calculate_dynamic_tokens_per_map(minimum_maps_after_veto, vetoesMap.values()) + + game_map = pool.choose_map(played_map_ids, vetoesMap, max_tokens_per_map) game = self.game_service.create_game( game_class=LadderGame, @@ -673,6 +702,26 @@ async def launch_match( if player not in connected_players ]) + def calculate_dynamic_tokens_per_map(self, M: float, tokens: list[int]) -> float: + sorted_tokens = sorted(tokens) + if (sorted_tokens.count(0) >= M): + return 1 + + result = 1; last = 0; index = 0 + while (index < len(sorted_tokens)): + (index, last) = next(((i, el) for i, el in enumerate(sorted_tokens) if el > last), (len(sorted_tokens) - 1, sorted_tokens[-1])) + index += 1 + divider = index - M + if (divider <= 0): + continue + + result = sum(sorted_tokens[:index]) / divider + upperLimit = sorted_tokens[index] if index < len(sorted_tokens) else float('inf') + if (result <= upperLimit): + return result + + raise Exception("Failed to calculate dynamic tokens per map: impossible vetoes setup") + async def get_game_history( self, players: list[Player], diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 903a11d7b..c7a8cee92 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"]} + self.player.vetoes = converted + async def send_warning(self, message: str, fatal: bool = False): """ Display a warning message to the client diff --git a/server/matchmaker/map_pool.py b/server/matchmaker/map_pool.py index c74be0d7a..9067c8b1b 100644 --- a/server/matchmaker/map_pool.py +++ b/server/matchmaker/map_pool.py @@ -21,11 +21,13 @@ 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] = (), vetoesMap = None, max_tokens_per_map = 1) -> Map: + if vetoesMap is None: + vetoesMap = {} """ - 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 +52,16 @@ 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] - map_id = random.choices(least_common, weights=weights, k=1)[0][0] - return self.maps[map_id].get_map() + + least_common_ids = {id_ for id_, _ in least_common} + + #multiply weight by 2 if map is least common and not vetoed by anyone + mapList = list((map.map_pool_map_version_id, map, 2 if (map.id in least_common_ids) and (vetoesMap.get(map.map_pool_map_version_id) == 0) else 1) for id, map in self.maps.items()) + + weights = [max(0, (1 - vetoesMap.get(id, 0) / max_tokens_per_map) * map.weight * least_common_multiplier) for id, map, least_common_multiplier in mapList] + map = random.choices(mapList, weights=weights, k=1)[0][1] + return map def __repr__(self) -> str: return f"MapPool({self.id}, {self.name}, {list(self.maps.values())})" diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 778407981..0605e8a4e 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -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[tuple[MapPool, Optional[int], Optional[int], int, int, float]] = (), ): self.game_service = game_service self.name = name @@ -78,12 +78,15 @@ def add_map_pool( self, map_pool: MapPool, min_rating: Optional[int], - max_rating: Optional[int] + max_rating: Optional[int], + veto_tokens_per_player: int, + max_tokens_per_map: int, + minimum_maps_after_veto: float, ) -> None: - self.map_pools[map_pool.id] = (map_pool, min_rating, max_rating) + self.map_pools[map_pool.id] = (map_pool, min_rating, max_rating, veto_tokens_per_player, max_tokens_per_map, minimum_maps_after_veto) 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..23de3933b 100644 --- a/server/players.py +++ b/server/players.py @@ -48,6 +48,7 @@ def __init__( lobby_connection: Optional["LobbyConnection"] = None ) -> None: self._faction = Faction.uef + self._vetoes = {} self.id = player_id self.login = login @@ -89,6 +90,18 @@ 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) for key, val in value.items()): + raise ValueError("Vetoes dictionary must contain only integer keys and values") + self._vetoes = value + 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..22e02424c 100644 --- a/server/types.py +++ b/server/types.py @@ -40,6 +40,7 @@ def get_map(self) -> "Map": ... class Map(NamedTuple): id: Optional[int] + map_pool_map_version_id: Optional[int] folder_name: str ranked: bool = False # Map pool only @@ -60,6 +61,7 @@ def get_map(self) -> "Map": class NeroxisGeneratedMap(NamedTuple): id: int + map_pool_map_version_id: Optional[int] version: str spawns: int map_size_pixels: int @@ -137,6 +139,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, )