diff --git a/Pipfile b/Pipfile index 5104a3fc5..74d879ec7 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,7 @@ aio_pika = "~=8.2" aiocron = "*" aiohttp = "*" aiomysql = {git = "https://github.com/aio-libs/aiomysql"} +cachetools = "*" docopt = "*" humanize = ">=2.6.0" maxminddb = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 33dcdb526..615d77df8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4d529b570c113ec473c190611dff3214e3c53b6e91cb90fd53d49249c2638849" + "sha256": "fbf667c3fa0630610daae2298439d383883041c04a430f2c17a66fc02e641701" }, "pipfile-spec": 6, "requires": { @@ -152,6 +152,15 @@ "markers": "python_version >= '3.7'", "version": "==23.1.0" }, + "cachetools": { + "hashes": [ + "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", + "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==5.3.2" + }, "cffi": { "hashes": [ "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", diff --git a/server/game_service.py b/server/game_service.py index 1e44bfa42..3b74361d7 100644 --- a/server/game_service.py +++ b/server/game_service.py @@ -7,14 +7,15 @@ from typing import Optional, Union, ValuesView import aiocron -from sqlalchemy import select +from cachetools import LRUCache +from sqlalchemy import func, select from server.config import config from . import metrics from .core import Service from .db import FAFDatabase -from .db.models import game_featuredMods +from .db.models import game_featuredMods, map_version from .decorators import with_logger from .exceptions import DisabledError from .games import ( @@ -30,6 +31,7 @@ from .message_queue_service import MessageQueueService from .players import Player from .rating_service import RatingService +from .types import MAP_DEFAULT, Map, NeroxisGeneratedMap @with_logger @@ -57,12 +59,15 @@ def __init__( self._allow_new_games = False self._drain_event = None - # Populated below in really_update_static_ish_data. + # Populated below in update_data. self.featured_mods = dict() # A set of mod ids that are allowed in ranked games self.ranked_mods: set[str] = set() + # A cache of map_version info needed by Game + self.map_info_cache = LRUCache(maxsize=256) + # The set of active games self._games: dict[int, Game] = dict() @@ -96,14 +101,16 @@ async def update_data(self): time we need, but which can in principle change over time. """ async with self._db.acquire() as conn: - rows = await conn.execute(select( - game_featuredMods.c.id, - game_featuredMods.c.gamemod, - game_featuredMods.c.name, - game_featuredMods.c.description, - game_featuredMods.c.publish, - game_featuredMods.c.order - ).select_from(game_featuredMods)) + rows = await conn.execute( + select( + game_featuredMods.c.id, + game_featuredMods.c.gamemod, + game_featuredMods.c.name, + game_featuredMods.c.description, + game_featuredMods.c.publish, + game_featuredMods.c.order + ) + ) for row in rows: self.featured_mods[row.gamemod] = FeaturedMod( @@ -115,11 +122,51 @@ async def update_data(self): row.order ) - result = await conn.execute("SELECT uid FROM table_mod WHERE ranked = 1") + result = await conn.execute( + "SELECT uid FROM table_mod WHERE ranked = 1" + ) # Turn resultset into a list of uids self.ranked_mods = {row.uid for row in result} + async def get_map(self, folder_name: str) -> Map: + folder_name = folder_name.lower() + filename = f"maps/{folder_name}.zip" + + map = self.map_info_cache.get(filename) + if map is not None: + return map + + async with self._db.acquire() as conn: + result = await conn.execute( + select( + map_version.c.id, + map_version.c.filename, + map_version.c.ranked, + ) + .where( + func.lower(map_version.c.filename) == filename + ) + ) + row = result.fetchone() + if not row: + # The map requested is not in the database. This is fine as + # players may be using privately shared or generated maps that + # are not in the vault. + return Map( + id=None, + folder_name=folder_name, + ranked=NeroxisGeneratedMap.is_neroxis_map(folder_name), + ) + + map = Map( + id=row.id, + folder_name=folder_name, + ranked=row.ranked + ) + self.map_info_cache[filename] = map + return map + def mark_dirty(self, obj: Union[Game, MatchmakerQueue]): if isinstance(obj, Game): self._dirty_games.add(obj) @@ -150,7 +197,7 @@ def create_game( visibility=VisibilityState.PUBLIC, host: Optional[Player] = None, name: Optional[str] = None, - mapname: Optional[str] = None, + map: Map = MAP_DEFAULT, password: Optional[str] = None, matchmaker_queue_id: Optional[int] = None, **kwargs @@ -164,10 +211,10 @@ def create_game( game_id = self.create_uid() game_args = { "database": self._db, - "id_": game_id, + "id": game_id, "host": host, "name": name, - "map_": mapname, + "map": map, "game_mode": game_mode, "game_service": self, "game_stats_service": self.game_stats_service, diff --git a/server/gameconnection.py b/server/gameconnection.py index e447e9668..ed0a1321e 100644 --- a/server/gameconnection.py +++ b/server/gameconnection.py @@ -21,8 +21,7 @@ GameConnectionState, GameError, GameState, - ValidityState, - Victory + ValidityState ) from .games.typedefs import FA from .player_service import PlayerService @@ -134,7 +133,7 @@ async def _handle_lobby_state(self): """ player_state = self.player.state if player_state == PlayerState.HOSTING: - await self.send_HostGame(self.game.map_folder_name) + await self.send_HostGame(self.game.map.folder_name) self.game.set_hosted() # If the player is joining, we connect him to host # followed by the rest of the players. @@ -228,25 +227,7 @@ async def handle_game_option(self, key: str, value: Any): if not self.is_host(): return - if key == "Victory": - self.game.gameOptions["Victory"] = Victory.__members__.get( - value.upper(), None - ) - else: - self.game.gameOptions[key] = value - - if key == "Slots": - self.game.max_players = int(value) - elif key == "ScenarioFile": - raw = repr(value) - self.game.map_scenario_path = \ - raw.replace("\\", "/").replace("//", "/").replace("'", "") - self.game.map_file_path = "maps/{}.zip".format( - self.game.map_scenario_path.split("/")[2].lower() - ) - elif key == "Title": - with contextlib.suppress(ValueError): - self.game.name = value + await self.game.game_options.set_option(key, value) self._mark_dirty() @@ -339,13 +320,13 @@ async def handle_operation_complete( async with self._db.acquire() as conn: result = await conn.execute( select(coop_map.c.id).where( - coop_map.c.filename == self.game.map_file_path + coop_map.c.filename == self.game.map.file_path ) ) row = result.fetchone() if not row: self._logger.debug( - "can't find coop map: %s", self.game.map_file_path + "can't find coop map: %s", self.game.map.file_path ) return mission = row.id diff --git a/server/games/__init__.py b/server/games/__init__.py index 87c7603f5..631171a9f 100644 --- a/server/games/__init__.py +++ b/server/games/__init__.py @@ -6,7 +6,7 @@ from .coop import CoopGame from .custom_game import CustomGame -from .game import Game, GameError +from .game import Game, GameError, GameOptions from .ladder_game import LadderGame from .typedefs import ( FeaturedModType, @@ -37,6 +37,7 @@ class FeaturedMod(NamedTuple): "Game", "GameConnectionState", "GameError", + "GameOptions", "GameState", "GameType", "InitMode", diff --git a/server/games/coop.py b/server/games/coop.py index e556de87a..b6a4b27e9 100644 --- a/server/games/coop.py +++ b/server/games/coop.py @@ -12,7 +12,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.validity = ValidityState.COOP_NOT_RANKED - self.gameOptions.update({ + self.game_options.update({ "Victory": Victory.SANDBOX, "TeamSpawn": "fixed", "RevealedCivilians": "No", diff --git a/server/games/custom_game.py b/server/games/custom_game.py index c71523bc4..be9c97d34 100644 --- a/server/games/custom_game.py +++ b/server/games/custom_game.py @@ -12,13 +12,13 @@ class CustomGame(Game): init_mode = InitMode.NORMAL_LOBBY game_type = GameType.CUSTOM - def __init__(self, id_, *args, **kwargs): + def __init__(self, id, *args, **kwargs): new_kwargs = { "rating_type": RatingType.GLOBAL, "setup_timeout": 30 } new_kwargs.update(kwargs) - super().__init__(id_, *args, **new_kwargs) + super().__init__(id, *args, **new_kwargs) async def _run_pre_rate_validity_checks(self): limit = len(self.players) * 60 diff --git a/server/games/game.py b/server/games/game.py index a16eece6c..78f1ab3dd 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -1,9 +1,11 @@ import asyncio +import contextlib import json import logging +import pathlib import time from collections import defaultdict -from typing import Any, Iterable, Optional +from typing import Any, Awaitable, Callable, Iterable, Optional from sqlalchemy import and_, bindparam from sqlalchemy.exc import DBAPIError @@ -27,6 +29,7 @@ ) from server.rating import InclusiveRange, RatingType from server.timing import datetime_now +from server.types import MAP_DEFAULT, Map from ..players import Player, PlayerState from .typedefs import ( @@ -57,13 +60,13 @@ class Game: def __init__( self, - id_: int, + id: int, database: "FAFDatabase", game_service: "GameService", game_stats_service: "GameStatsService", host: Optional[Player] = None, - name: str = "None", - map_: str = "SCMP_007", + name: str = "New Game", + map: Map = MAP_DEFAULT, game_mode: str = FeaturedModType.FAF, matchmaker_queue_id: Optional[int] = None, rating_type: Optional[str] = None, @@ -72,8 +75,9 @@ def __init__( max_players: int = 12, setup_timeout: int = 60, ): + self.id = id self._db = database - self._results = GameResultReports(id_) + self._results = GameResultReports(id) self._army_stats_list = [] self._players_with_unsent_army_stats = [] self._game_stats_service = game_stats_service @@ -83,16 +87,12 @@ def __init__( self.launched_at = None self.finished = False self._logger = logging.getLogger( - f"{self.__class__.__qualname__}.{id_}" + f"{self.__class__.__qualname__}.{id}" ) - self.id = id_ self.visibility = VisibilityState.PUBLIC - self.max_players = max_players self.host = host self.name = name - self.map_id = None - self.map_file_path = f"maps/{map_}.zip" - self.map_scenario_path = None + self.map = map self.password = None self._players_at_launch: list[Player] = [] self.AIs = {} @@ -107,18 +107,29 @@ def __init__( self._connections = {} self._configured_player_ids: set[int] = set() self.enforce_rating = False - self.gameOptions = { - "FogOfWar": "explored", - "GameSpeed": "normal", - "Victory": Victory.DEMORALIZATION, - "CheatsEnabled": "false", - "PrebuiltUnits": "Off", - "NoRushOption": "Off", - "TeamLock": "locked", - "AIReplacement": "Off", - "RestrictedCategories": 0, - "Unranked": "No" - } + self.game_options = GameOptions( + id, + { + "AIReplacement": "Off", + "CheatsEnabled": "false", + "FogOfWar": "explored", + "GameSpeed": "normal", + "NoRushOption": "Off", + "PrebuiltUnits": "Off", + "RestrictedCategories": 0, + "ScenarioFile": (pathlib.PurePath(map.scenario_file)), + "Slots": max_players, + "TeamLock": "locked", + "Unranked": "No", + "Victory": Victory.DEMORALIZATION, + } + ) + self.game_options.add_async_callback( + "ScenarioFile", + self.on_scenario_file_changed, + ) + self.game_options.add_callback("Title", self.on_title_changed) + self.mods = {} self._hosted_future = asyncio.Future() self._finish_lock = asyncio.Lock() @@ -132,6 +143,18 @@ async def timeout_game(self, timeout: int = 60): self._logger.debug("Game setup timed out, cancelling game") await self.on_game_finish() + async def on_scenario_file_changed(self, scenario_path: pathlib.PurePath): + try: + map_folder_name = scenario_path.parts[2].lower() + except IndexError: + return + + self.map = await self.game_service.get_map(map_folder_name) + + def on_title_changed(self, title: str): + with contextlib.suppress(ValueError): + self.name = title + @property def name(self): return self._name @@ -141,8 +164,13 @@ def name(self, value: str): """ Verifies that names only contain ascii characters. """ + value = value.strip() + if not value.isascii(): - raise ValueError("Name must be ascii!") + raise ValueError("Game title must be ascii!") + + if not value: + raise ValueError("Game title must not be empty!") self.set_name_unchecked(value) @@ -155,6 +183,10 @@ def set_name_unchecked(self, value: str): max_len = game_stats.c.gameName.type.length self._name = value[:max_len] + @property + def max_players(self) -> int: + return self.game_options["Slots"] + @property def armies(self) -> frozenset[int]: return frozenset( @@ -327,13 +359,11 @@ def _process_army_stats_for_player(self, player): try: if ( len(self._army_stats_list) == 0 - or self.gameOptions["CheatsEnabled"] != "false" + or self.game_options["CheatsEnabled"] != "false" ): return self._players_with_unsent_army_stats.remove(player) - # Stat processing contacts the API and can take quite a while so - # we don't want to await it asyncio.create_task( self._game_stats_service.process_game_stats( player, self, self._army_stats_list @@ -558,7 +588,7 @@ def get_basic_info(self) -> BasicGameInfo: return BasicGameInfo( self.id, self.rating_type, - self.map_id, + self.map.id, self.game_mode, list(self.mods.keys()), self.get_team_sets(), @@ -661,12 +691,13 @@ async def validate_game_mode_settings(self): await self._validate_game_options(valid_options) async def _validate_game_options( - self, valid_options: dict[str, tuple[Any, ValidityState]] + self, + valid_options: dict[str, tuple[Any, ValidityState]] ) -> bool: - for key, value in self.gameOptions.items(): + for key, value in self.game_options.items(): if key in valid_options: - (valid_value, validity_state) = valid_options[key] - if valid_value != self.gameOptions[key]: + valid_value, validity_state = valid_options[key] + if value != valid_value: await self.mark_invalid(validity_state) return False return True @@ -707,34 +738,19 @@ async def update_game_stats(self): """ assert self.host is not None - async with self._db.acquire() as conn: - # Determine if the map is blacklisted, and invalidate the game for - # ranking purposes if so, and grab the map id at the same time. - result = await conn.execute( - "SELECT id, ranked FROM map_version " - "WHERE lower(filename) = lower(:filename)", - filename=self.map_file_path - ) - row = result.fetchone() - - is_generated = (self.map_file_path and "neroxis_map_generator" in self.map_file_path) + # Ensure map data is up to date + self.map = await self.game_service.get_map(self.map.folder_name) - if row: - self.map_id = row.id - - if ( - self.validity is ValidityState.VALID - and ((row and not row.ranked) or (not row and not is_generated)) - ): + if self.validity is ValidityState.VALID and not self.map.ranked: await self.mark_invalid(ValidityState.BAD_MAP) modId = self.game_service.featured_mods[self.game_mode].id # Write out the game_stats record. - # In some cases, games can be invalidated while running: we check for those cases when - # the game ends and update this record as appropriate. + # In some cases, games can be invalidated while running: we check for + # those cases when the game ends and update this record as appropriate. - game_type = str(self.gameOptions.get("Victory").value) + game_type = str(self.game_options.get("Victory").value) async with self._db.acquire() as conn: await conn.execute( @@ -743,7 +759,7 @@ async def update_game_stats(self): gameType=game_type, gameMod=modId, host=self.host.id, - mapId=self.map_id, + mapId=self.map.id, gameName=self.name, validity=self.validity.value, ) @@ -893,8 +909,9 @@ def to_dict(self): "game_type": self.game_type.value, "featured_mod": self.game_mode, "sim_mods": self.mods, - "mapname": self.map_folder_name, - "map_file_path": self.map_file_path, + "mapname": self.map.folder_name, + # DEPRECATED: Use `mapname` instead + "map_file_path": self.map.file_path, "host": self.host.login if self.host else "", "num_players": len(connected_players), "max_players": self.max_players, @@ -923,19 +940,6 @@ def to_dict(self): } } - @property - def map_folder_name(self) -> str: - """ - Map folder name - """ - try: - return str(self.map_scenario_path.split("/")[2]).lower() - except (IndexError, AttributeError): - if self.map_file_path: - return self.map_file_path[5:-4].lower() - else: - return "scmp_009" - def __eq__(self, other): if not isinstance(other, Game): return False @@ -948,5 +952,100 @@ def __hash__(self): def __str__(self) -> str: return ( f"Game({self.id}, {self.host.login if self.host else ''}, " - f"{self.map_file_path})" + f"{self.map.file_path})" ) + + +class GameOptions(dict): + def __init__(self, id: int, *args, **kwargs): + super().__init__(*args, **kwargs) + self._logger = logging.getLogger( + f"{self.__class__.__qualname__}.{id}" + ) + self.callbacks = defaultdict(list) + self.async_callbacks = defaultdict(list) + + def add_callback(self, key: str, callback: Callable[[Any], Any]): + self.callbacks[key].append(callback) + + def add_async_callback( + self, + key: str, + callback: Callable[[Any], Awaitable[Any]], + ): + self.async_callbacks[key].append(callback) + + async def set_option(self, k: str, v: Any) -> None: + v = self._set_option(k, v) + self._run_sync_callbacks(k, v) + + await asyncio.gather(*( + self._log_async_exception( + async_callback(v), + k, + v, + ) + for async_callback in self.async_callbacks.get(k, ()) + )) + + def __setitem__(self, k: str, v: Any) -> None: + v = self._set_option(k, v) + self._run_sync_callbacks(k, v) + + for async_callback in self.async_callbacks.get(k, ()): + asyncio.create_task( + self._log_async_exception( + async_callback(v), + k, + v, + ) + ) + + def _set_option(self, k: str, v: Any) -> Any: + """ + Set the new value potentially transforming it first. Returns the value + that was set. + """ + if k == "Victory" and not isinstance(v, Victory): + victory = Victory.__members__.get(v.upper()) + if victory is None: + victory = self.get("Victory") + self._logger.warning( + "Invalid victory type '%s'! Using '%s' instead.", + v, + victory.name if victory else None, + ) + return + v = victory + elif k == "Slots": + v = int(v) + elif k == "ScenarioFile": + # Convert to a posix path. Since posix paths are also interpreted + # the same way as windows paths (but not the other way around!) we + # can do this by parsing as a PureWindowsPath first + v = pathlib.PurePath(pathlib.PureWindowsPath(v).as_posix()) + + super().__setitem__(k, v) + + return v + + def _run_sync_callbacks(self, k: str, v: Any): + for callback in self.callbacks.get(k, ()): + try: + callback(v) + except Exception: + self._logger.exception( + "Error running callback for '%s' (value %r)", + k, + v, + ) + + async def _log_async_exception(self, coro: Awaitable[Any], k: str, v: Any): + try: + return await coro + except Exception: + self._logger.exception( + "Error running async callback for '%s' (value %r)", + k, + v, + ) diff --git a/server/games/ladder_game.py b/server/games/ladder_game.py index 0e5ea8e0a..475196bcb 100644 --- a/server/games/ladder_game.py +++ b/server/games/ladder_game.py @@ -27,8 +27,8 @@ class LadderGame(Game): init_mode = InitMode.AUTO_LOBBY game_type = GameType.MATCHMAKER - def __init__(self, id_, *args, **kwargs): - super().__init__(id_, *args, **kwargs) + def __init__(self, id, *args, **kwargs): + super().__init__(id, *args, **kwargs) self._launch_future = asyncio.Future() async def wait_hosted(self, timeout: float): diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index e7046493d..ee73d3061 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -22,10 +22,7 @@ game_player_stats, game_stats, leaderboard, - leaderboard_rating_journal -) -from server.db.models import map as t_map -from server.db.models import ( + leaderboard_rating_journal, map_pool, map_pool_map_version, map_version, @@ -132,11 +129,10 @@ async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: map_pool_map_version.c.map_params, map_version.c.id.label("map_id"), map_version.c.filename, - t_map.c.display_name + map_version.c.ranked, ).select_from( map_pool.outerjoin(map_pool_map_version) .outerjoin(map_version) - .outerjoin(t_map) ) ) map_pool_maps = {} @@ -147,8 +143,17 @@ async def fetch_map_pools(self, conn) -> dict[int, tuple[str, list[Map]]]: map_pool_maps[id_] = (name, list()) _, map_list = map_pool_maps[id_] if row.map_id is not None: + # Database filenames contain the maps/ prefix and .zip suffix. + # This comes from the content server which hosts the files at + # https://content.faforever.com/maps/name.zip + folder_name = re.match(r"maps/(.+)\.zip", row.filename).group(1) map_list.append( - Map(row.map_id, row.display_name, row.filename, row.weight) + Map( + id=row.map_id, + folder_name=folder_name, + ranked=row.ranked, + weight=row.weight, + ) ) elif row.map_params is not None: try: @@ -518,19 +523,19 @@ 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}!") - _, _, map_path, _ = pool.choose_map(played_map_ids) + game_map = pool.choose_map(played_map_ids) game = self.game_service.create_game( game_class=LadderGame, game_mode=queue.featured_mod, host=host, name="Matchmaker Game", + map=game_map, matchmaker_queue_id=queue.id, rating_type=queue.rating_type, - max_players=len(all_players) + max_players=len(all_players), ) game.init_mode = InitMode.AUTO_LOBBY - game.map_file_path = map_path game.set_name_unchecked(game_name(team1, team2)) team1 = sorted(team1, key=get_displayed_rating) @@ -559,17 +564,13 @@ def get_displayed_rating(player: Player) -> float: game_options = queue.get_game_options() if game_options: - game.gameOptions.update(game_options) - - mapname = re.match("maps/(.+).zip", map_path).group(1) - # FIXME: Database filenames contain the maps/ prefix and .zip suffix. - # Really in the future, just send a better description + game.game_options.update(game_options) self._logger.debug("Starting ladder game: %s", game) def make_game_options(player: Player) -> GameLaunchOptions: return GameLaunchOptions( - mapname=mapname, + mapname=game_map.folder_name, expected_players=len(all_players), game_options=game_options, team=game.get_player_option(player.id, "Team"), diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 245ed204e..c536c758d 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -949,9 +949,12 @@ async def command_game_host(self, message): title = message.get("title") or f"{self.player.login}'s game" if not title.isascii(): raise ClientError("Title must contain only ascii characters.") + if not title.strip(): + raise ClientError("Title must not be empty.") mod = message.get("mod") or FeaturedModType.FAF mapname = message.get("mapname") or "scmp_007" + game_map = await self.game_service.get_map(mapname) password = message.get("password") game_mode = mod.lower() rating_min = message.get("rating_min") @@ -970,7 +973,7 @@ async def command_game_host(self, message): game_class=game_class, host=self.player, name=title, - mapname=mapname, + map=game_map, password=password, rating_type=RatingType.GLOBAL, displayed_rating_range=InclusiveRange(rating_min, rating_max), diff --git a/server/types.py b/server/types.py index 2f4e1f243..f96d65317 100644 --- a/server/types.py +++ b/server/types.py @@ -4,6 +4,7 @@ import base64 import random +import re from typing import Any, NamedTuple, Optional, Protocol @@ -38,11 +39,21 @@ def get_map(self) -> "Map": ... class Map(NamedTuple): - id: int - name: str - path: str + id: Optional[int] + folder_name: str + ranked: bool = False + # Map pool only weight: int = 1 + @property + def file_path(self): + """A representation of the map name as it looks in the database""" + return f"maps/{self.folder_name}.zip" + + @property + def scenario_file(self): + return f"/maps/{self.folder_name}/{self.folder_name}_scenario.lua" + def get_map(self) -> "Map": return self @@ -54,8 +65,18 @@ class NeroxisGeneratedMap(NamedTuple): map_size_pixels: int weight: int = 1 + _NAME_PATTERN = re.compile( + "neroxis_map_generator_([0-9.]+)_([a-z2-7]+)_([a-z2-7]+)" + ) + + @classmethod + def is_neroxis_map(cls, folder_name: str) -> bool: + """Check if mapname is map generator""" + return cls._NAME_PATTERN.fullmatch(folder_name) is not None + @classmethod def of(cls, params: dict, weight: int = 1): + """Create a NeroxisGeneratedMap from params dict""" assert params["type"] == "neroxis" map_size_pixels = int(params["size"]) @@ -71,12 +92,22 @@ def of(cls, params: dict, weight: int = 1): raise Exception("spawns is not a multiple of 2") version = params["version"] - return NeroxisGeneratedMap( - -int.from_bytes(bytes(f"{version}_{spawns}_{map_size_pixels}", encoding="ascii"), "big"), + return cls( + cls._get_id(version, spawns, map_size_pixels), version, spawns, map_size_pixels, - weight + weight, + ) + + @staticmethod + def _get_id(version: str, spawns: int, map_size_pixels: int) -> int: + return -int.from_bytes( + bytes( + f"{version}_{spawns}_{map_size_pixels}", + encoding="ascii" + ), + "big" ) def get_map(self) -> Map: @@ -85,11 +116,27 @@ def get_map(self) -> Map: parameters are specified hand back None """ seed_bytes = random.getrandbits(64).to_bytes(8, "big") + seed_str = base64.b32encode(seed_bytes).decode("ascii").replace("=", "").lower() + size_byte = (self.map_size_pixels // 64).to_bytes(1, "big") spawn_byte = self.spawns.to_bytes(1, "big") option_bytes = spawn_byte + size_byte - seed_str = base64.b32encode(seed_bytes).decode("ascii").replace("=", "").lower() option_str = base64.b32encode(option_bytes).decode("ascii").replace("=", "").lower() - map_name = f"neroxis_map_generator_{self.version}_{seed_str}_{option_str}" - map_path = f"maps/{map_name}.zip" - return Map(self.id, map_name, map_path, self.weight) + + folder_name = f"neroxis_map_generator_{self.version}_{seed_str}_{option_str}" + + return Map( + id=self.id, + folder_name=folder_name, + ranked=True, + weight=self.weight, + ) + + +# Default for map argument in game/game_service. Games are only created without +# the map argument in unit tests. +MAP_DEFAULT = Map( + id=None, + folder_name="scmp_007", + ranked=False, +) diff --git a/tests/integration_tests/test_coop.py b/tests/integration_tests/test_coop.py index f2b1f539f..7d2c1c031 100644 --- a/tests/integration_tests/test_coop.py +++ b/tests/integration_tests/test_coop.py @@ -9,18 +9,18 @@ from .test_game import host_game, send_player_options -@fast_forward(5) +@fast_forward(100) async def test_host_coop_game(lobby_server): _, _, proto = await connect_and_sign_in( ("test", "test_password"), lobby_server ) - await read_until_command(proto, "game_info") + await read_until_command(proto, "game_info", timeout=10) await host_game(proto, mod="coop", title="") - msg = await read_until_command(proto, "game_info") + msg = await read_until_command(proto, "game_info", timeout=10) assert msg["title"] == "test's game" assert msg["mapname"] == "scmp_007" @@ -29,7 +29,7 @@ async def test_host_coop_game(lobby_server): assert msg["game_type"] == "coop" -@fast_forward(30) +@fast_forward(100) async def test_single_player_game_recorded(lobby_server, database): test_id, _, proto = await connect_and_sign_in( ("test", "test_password"), lobby_server diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index d5b451205..d4bb41221 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -60,10 +60,11 @@ async def setup_game_1v1( host_id: int, guest_proto: Protocol, guest_id: int, - mod: str = "faf" + mod: str = "faf", + **kwargs, ): # Set up the game - game_id = await host_game(host_proto, mod=mod) + game_id = await host_game(host_proto, mod=mod, **kwargs) await join_game(guest_proto, game_id) # Set player options await send_player_options( @@ -328,6 +329,70 @@ async def test_game_info_messages(lobby_server): assert msg["launched_at"] <= time.time() +@fast_forward(60) +async def test_game_info_messages_for_game_options(lobby_server): + _, _, proto = await connect_and_sign_in( + ("test", "test_password"), lobby_server + ) + await read_until_command(proto, "game_info", timeout=10) + + await host_game(proto, title="Test Game") + + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["title"] == "Test Game" + assert msg["launched_at"] is None + + # Send valid game options that will cause changes to `game_info` messages + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["Title", "New title set in lobby"] + }) + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["title"] == "New title set in lobby" + + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["Slots", "4"] + }) + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["max_players"] == 4 + + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["ScenarioFile", "/maps/new_map/new_map_scenario.lua"] + }) + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["mapname"] == "new_map" + + # Send some invalid game options + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["Title", ""] + }) + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["title"] == "New title set in lobby" + + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["Slots", "1.5"] + }) + with pytest.raises(asyncio.TimeoutError): + await read_until_command(proto, "game_info", timeout=5) + + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["ScenarioFile", "/bad_scenario_file/"] + }) + msg = await read_until_command(proto, "game_info", timeout=10) + assert msg["mapname"] == "new_map" + + @fast_forward(60) async def test_game_ended_rates_game(lobby_server): host_id, _, host_proto = await connect_and_sign_in( @@ -485,6 +550,70 @@ async def test_game_ended_broadcasts_rating_update(lobby_server, channel): await asyncio.wait_for(mq_proto_all.read_message(), timeout=10) +@fast_forward(60) +async def test_neroxis_map_generator_rates_game(lobby_server): + host_id, _, host_proto = await connect_and_sign_in( + ("test", "test_password"), lobby_server + ) + guest_id, _, guest_proto = await connect_and_sign_in( + ("Rhiza", "puff_the_magic_dragon"), lobby_server + ) + await read_until_command(guest_proto, "game_info") + ratings = await get_player_ratings(host_proto, "test", "Rhiza") + + await setup_game_1v1( + host_proto, + host_id, + guest_proto, + guest_id, + mapname="neroxis_map_generator_1.9.0_vtz4hwq7uqsj2_byiaeaati43euqd7cq", + ) + await host_proto.send_message({ + "target": "game", + "command": "EnforceRating", + "args": [] + }) + + # End the game + # Reports results + for proto in (host_proto, guest_proto): + await proto.send_message({ + "target": "game", + "command": "GameResult", + "args": [1, "victory 10"] + }) + await proto.send_message({ + "target": "game", + "command": "GameResult", + "args": [2, "defeat -10"] + }) + # Report GameEnded + for proto in (host_proto, guest_proto): + await proto.send_message({ + "target": "game", + "command": "GameEnded", + "args": [] + }) + + # Check that the ratings were updated + new_ratings = await get_player_ratings(host_proto, "test", "Rhiza") + + assert ratings["test"][0] < new_ratings["test"][0] + assert ratings["Rhiza"][0] > new_ratings["Rhiza"][0] + + # Now disconnect both players + for proto in (host_proto, guest_proto): + await proto.send_message({ + "target": "game", + "command": "GameState", + "args": ["Ended"] + }) + + # The game should only be rated once + with pytest.raises(asyncio.TimeoutError): + await read_until_command(host_proto, "player_info", timeout=10) + + @fast_forward(30) async def test_double_host_message(lobby_server): _, _, proto = await connect_and_sign_in( diff --git a/tests/integration_tests/test_server.py b/tests/integration_tests/test_server.py index 7cb4fabf6..a1199e2d9 100644 --- a/tests/integration_tests/test_server.py +++ b/tests/integration_tests/test_server.py @@ -175,7 +175,6 @@ async def test_graceful_shutdown( lobby_instance, lobby_server, tmp_user, - monkeypatch ): _, _, proto = await connect_and_sign_in( await tmp_user("Player"), @@ -622,7 +621,7 @@ async def test_info_broadcast_to_rabbitmq(lobby_server, channel): _, _, proto = await connect_and_sign_in( ("test", "test_password"), lobby_server ) - await read_until_command(proto, "game_info") + await read_until_command(proto, "game_info", timeout=10) # matchmaker_info is broadcast whenever the timer pops await read_until_command(mq_proto_all, "matchmaker_info") @@ -640,6 +639,7 @@ async def test_info_broadcast_to_rabbitmq(lobby_server, channel): ("ban_expired", "ban_expired"), ("No_UID", "his_pw") ]) +@fast_forward(10) async def test_game_host_authenticated(lobby_server, user): _, _, proto = await connect_and_sign_in(user, lobby_server) await read_until_command(proto, "game_info") @@ -651,15 +651,15 @@ async def test_game_host_authenticated(lobby_server, user): "visibility": "public", }) - msg = await read_until_command(proto, "game_launch") + msg = await read_until_command(proto, "game_launch", timeout=10) assert msg["mod"] == "faf" assert "args" in msg assert isinstance(msg["uid"], int) -@fast_forward(5) -async def test_host_missing_fields(event_loop, lobby_server, player_service): +@fast_forward(10) +async def test_host_missing_fields(lobby_server): player_id, session, proto = await connect_and_sign_in( ("test", "test_password"), lobby_server @@ -671,10 +671,10 @@ async def test_host_missing_fields(event_loop, lobby_server, player_service): "command": "game_host", "mod": "", "visibility": "public", - "title": "" + "title": "", }) - msg = await read_until_command(proto, "game_info") + msg = await read_until_command(proto, "game_info", timeout=10) assert msg["title"] == "test's game" assert msg["game_type"] == "custom" @@ -683,6 +683,56 @@ async def test_host_missing_fields(event_loop, lobby_server, player_service): assert msg["featured_mod"] == "faf" +@fast_forward(10) +async def test_host_game_name_only_spaces(lobby_server): + player_id, session, proto = await connect_and_sign_in( + ("test", "test_password"), + lobby_server + ) + + await read_until_command(proto, "game_info") + + await proto.send_message({ + "command": "game_host", + "mod": "", + "visibility": "public", + "title": " ", + }) + + msg = await read_until_command(proto, "notice", timeout=10) + + assert msg == { + "command": "notice", + "style": "error", + "text": "Title must not be empty.", + } + + +@fast_forward(10) +async def test_host_game_name_non_ascii(lobby_server): + player_id, session, proto = await connect_and_sign_in( + ("test", "test_password"), + lobby_server + ) + + await read_until_command(proto, "game_info") + + await proto.send_message({ + "command": "game_host", + "mod": "", + "visibility": "public", + "title": "ÇÒÖL GÃMÊ" + }) + + msg = await read_until_command(proto, "notice", timeout=10) + + assert msg == { + "command": "notice", + "style": "error", + "text": "Title must contain only ascii characters." + } + + async def test_play_game_while_queueing(lobby_server): player_id, session, proto = await connect_and_sign_in( ("test", "test_password"), diff --git a/tests/unit_tests/test_coop_game.py b/tests/unit_tests/test_coop_game.py index ce16aaf06..9a776cd67 100644 --- a/tests/unit_tests/test_coop_game.py +++ b/tests/unit_tests/test_coop_game.py @@ -1,15 +1,19 @@ from unittest import mock from server.games import CoopGame +from server.types import Map async def test_create_coop_game(database): CoopGame( - id_=0, + id=0, database=database, host=mock.Mock(), name="Some game", - map_="some_map", + map=Map( + id=None, + folder_name="some_map" + ), game_mode="coop", game_service=mock.Mock(), game_stats_service=mock.Mock() diff --git a/tests/unit_tests/test_custom_game.py b/tests/unit_tests/test_custom_game.py index f8e88183a..42c0eb691 100644 --- a/tests/unit_tests/test_custom_game.py +++ b/tests/unit_tests/test_custom_game.py @@ -8,12 +8,14 @@ @pytest.fixture -async def custom_game(event_loop, database, game_service, game_stats_service): +async def custom_game(database, game_service, game_stats_service): return CustomGame(42, database, game_service, game_stats_service) async def test_rate_game_early_abort_no_enforce( - game_service, game_stats_service, custom_game, player_factory): + custom_game: CustomGame, + player_factory +): custom_game.state = GameState.LOBBY players = [ player_factory("Dostya", player_id=1, global_rating=(1500, 500)), @@ -32,7 +34,9 @@ async def test_rate_game_early_abort_no_enforce( async def test_rate_game_early_abort_with_enforce( - game_service, game_stats_service, custom_game, player_factory): + custom_game: CustomGame, + player_factory, +): custom_game.state = GameState.LOBBY players = [ player_factory("Dostya", player_id=1, global_rating=(1500, 500)), @@ -52,7 +56,9 @@ async def test_rate_game_early_abort_with_enforce( async def test_rate_game_late_abort_no_enforce( - game_service, game_stats_service, custom_game, player_factory): + custom_game: CustomGame, + player_factory, +): custom_game.state = GameState.LOBBY players = [ player_factory("Dostya", player_id=1, global_rating=(1500, 500)), @@ -71,7 +77,10 @@ async def test_rate_game_late_abort_no_enforce( async def test_global_rating_higher_after_custom_game_win( - custom_game: CustomGame, game_add_players, rating_service): + custom_game: CustomGame, + game_add_players, + rating_service, +): game = custom_game game.state = GameState.LOBBY players = game_add_players(game, 2) diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index 7b3fb950d..c8e0fca39 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -22,6 +22,7 @@ from server.games.game_results import ArmyOutcome from server.games.typedefs import FeaturedModType from server.rating import InclusiveRange, RatingType +from server.types import Map from tests.unit_tests.conftest import ( add_connected_player, add_connected_players, @@ -141,11 +142,11 @@ async def check_game_settings( game: Game, settings: list[tuple[str, Any, ValidityState]] ): for key, value, expected in settings: - old = game.gameOptions.get(key) - game.gameOptions[key] = value + old = game.game_options.get(key) + game.game_options[key] = value await game.validate_game_settings() assert game.validity is expected - game.gameOptions[key] = old + game.game_options[key] = old async def test_add_result_unknown(game, game_add_players): @@ -168,7 +169,11 @@ async def test_ffa_not_rated(game, game_add_players): async def test_generated_map_is_rated(game, game_add_players): game.state = GameState.LOBBY - game.map_file_path = "maps/neroxis_map_generator_1.0.0_1234.zip" + game.map = Map( + None, + "neroxis_map_generator_1.0.0_23g6m_aiea", + ranked=True + ) game_add_players(game, 2, team=1) await game.launch() await game.add_result(0, 1, "victory", 5) @@ -179,7 +184,7 @@ async def test_generated_map_is_rated(game, game_add_players): async def test_unranked_generated_map_not_rated(game, game_add_players): game.state = GameState.LOBBY - game.map_file_path = "maps/neroxis_map_generator_sneaky_map.zip" + game.map = Map(None, "neroxis_map_generator_sneaky_map") game_add_players(game, 2, team=1) await game.launch() await game.add_result(0, 1, "victory", 5) @@ -258,7 +263,6 @@ async def test_single_team_not_rated(game, game_add_players): n_players = 4 game.state = GameState.LOBBY game_add_players(game, n_players, team=2) - print(game._player_options) await game.launch() game.launched_at = time.time() - 60 * 20 for i in range(n_players): @@ -609,6 +613,12 @@ async def test_name_sanitization(game, players): with pytest.raises(ValueError): game.name = "Hello ⏴⏵⏶⏷⏸⏹⏺⏻♿" + with pytest.raises(ValueError): + game.name = " \n\n\t" + + with pytest.raises(ValueError): + game.name = "" + game.name = "A" * 256 assert game.name == "A" * 128 @@ -643,8 +653,8 @@ async def test_to_dict(game, player_factory): "state": "playing", "featured_mod": game.game_mode, "sim_mods": game.mods, - "mapname": game.map_folder_name, - "map_file_path": game.map_file_path, + "mapname": game.map.folder_name, + "map_file_path": game.map.file_path, "host": game.host.login, "num_players": len(game.players), "max_players": game.max_players, @@ -794,6 +804,7 @@ async def test_get_army_score_conflicting_results_tied(game, game_add_players): async def test_equality(game): assert game == game assert game != Game(5, mock.Mock(), mock.Mock(), mock.Mock()) + assert game != "a string" async def test_hashing(game): @@ -1010,7 +1021,7 @@ async def test_game_results(game: Game, players): } ] assert result_dict["game_id"] == game.id - assert result_dict["map_id"] == game.map_id + assert result_dict["map_id"] == game.map.id assert result_dict["featured_mod"] == "faf" assert result_dict["sim_mod_ids"] == [] diff --git a/tests/unit_tests/test_game_options.py b/tests/unit_tests/test_game_options.py new file mode 100644 index 000000000..a85de48c4 --- /dev/null +++ b/tests/unit_tests/test_game_options.py @@ -0,0 +1,119 @@ +import asyncio +import pathlib +from unittest import mock + +import pytest + +from server.games import GameOptions, Victory + + +@pytest.fixture +def game_options() -> GameOptions: + return GameOptions(0) + + +def test_type_transformations(game_options): + game_options["Victory"] = "sandbox" + assert game_options["Victory"] is Victory.SANDBOX + + game_options["Slots"] = "10" + assert game_options["Slots"] == 10 + + game_options["ScenarioFile"] = "/maps/map_name/map_name_scenario.lua" + assert game_options["ScenarioFile"] == pathlib.PurePath( + "/maps/map_name/map_name_scenario.lua" + ) + + +def test_type_transformations_invalid(game_options, caplog): + with caplog.at_level("WARNING"): + game_options["Victory"] = "invalid" + + assert "Invalid victory type 'invalid'! Using 'None' instead." in caplog.messages + assert "Victory" not in game_options + + with pytest.raises(ValueError): + game_options["Slots"] = "foobar" + + assert "Slots" not in game_options + + +async def test_callbacks(game_options): + callback = mock.Mock() + callback2 = mock.Mock() + async_callback = mock.AsyncMock() + + game_options.add_callback("OneCallback", callback) + game_options.add_callback("ManyCallbacks", callback) + game_options.add_callback("ManyCallbacks", callback2) + game_options.add_async_callback("ManyCallbacks", async_callback) + + game_options["OneCallback"] = "Some Value" + callback.assert_called_once_with("Some Value") + + game_options["ManyCallbacks"] = "Another Value" + callback.assert_called_with("Another Value") + callback2.assert_called_once_with("Another Value") + async_callback.assert_called_once_with("Another Value") + + async_callback.assert_not_awaited() + await asyncio.sleep(0) + async_callback.assert_awaited_once_with("Another Value") + + +async def test_await_callbacks(game_options): + callback = mock.Mock() + callback2 = mock.Mock() + async_callback = mock.AsyncMock() + + game_options.add_callback("OneCallback", callback) + game_options.add_callback("ManyCallbacks", callback) + game_options.add_callback("ManyCallbacks", callback2) + game_options.add_async_callback("ManyCallbacks", async_callback) + + await game_options.set_option("OneCallback", "Some Value") + callback.assert_called_once_with("Some Value") + + await game_options.set_option("ManyCallbacks", "Another Value") + callback.assert_called_with("Another Value") + callback2.assert_called_once_with("Another Value") + async_callback.assert_awaited_once_with("Another Value") + + +async def test_callback_error(game_options, caplog): + callback = mock.Mock() + async_callback = mock.AsyncMock() + + def raises_error(_): + raise RuntimeError("test") + + game_options.add_callback("Foo", raises_error) + game_options.add_callback("Foo", callback) + game_options.add_async_callback("Foo", async_callback) + + with caplog.at_level("TRACE"): + game_options["Foo"] = "Some Value" + + callback.assert_called_once_with("Some Value") + async_callback.assert_called_once_with("Some Value") + assert "Error running callback for 'Foo' (value 'Some Value')" in caplog.messages + + +async def test_async_callback_error(game_options, caplog): + callback = mock.Mock() + async_callback = mock.AsyncMock() + + async def async_raises_error(_): + raise RuntimeError("test") + + game_options.add_callback("Foo", callback) + game_options.add_async_callback("Foo", async_raises_error) + game_options.add_async_callback("Foo", async_callback) + + with caplog.at_level("TRACE"): + game_options["Foo"] = "Some Value" + await asyncio.sleep(0) + + callback.assert_called_once_with("Some Value") + async_callback.assert_called_once_with("Some Value") + assert "Error running async callback for 'Foo' (value 'Some Value')" in caplog.messages diff --git a/tests/unit_tests/test_game_rating.py b/tests/unit_tests/test_game_rating.py index 5ef4f8d65..cb1b6613f 100644 --- a/tests/unit_tests/test_game_rating.py +++ b/tests/unit_tests/test_game_rating.py @@ -120,14 +120,14 @@ def get_published_results_by_player_id(mock_service): @pytest.fixture -async def game(event_loop, database, game_service, game_stats_service): +async def game(database, game_service, game_stats_service): return Game( 42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL ) @pytest.fixture -async def custom_game(event_loop, database, game_service, game_stats_service): +async def custom_game(database, game_service, game_stats_service): return CustomGame(42, database, game_service, game_stats_service) diff --git a/tests/unit_tests/test_gameconnection.py b/tests/unit_tests/test_gameconnection.py index 0780cdb5b..47efb5b16 100644 --- a/tests/unit_tests/test_gameconnection.py +++ b/tests/unit_tests/test_gameconnection.py @@ -19,11 +19,12 @@ ) from server.players import PlayerState from server.protocol import DisconnectedError +from server.types import Map from tests.utils import exhaust_callbacks @pytest.fixture -async def real_game(event_loop, database, game_service, game_stats_service): +async def real_game(database, game_service, game_stats_service): return Game(42, database, game_service, game_stats_service) @@ -140,13 +141,12 @@ async def test_handle_action_GameState_lobby_sends_HostGame( players ): game_connection.player = players.hosting - game.map_file_path = "maps/some_map.zip" - game.map_folder_name = "some_map" + game.map = Map(None, "some_map") await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks(event_loop) - assert_message_sent(game_connection, "HostGame", [game.map_folder_name]) + assert_message_sent(game_connection, "HostGame", [game.map.folder_name]) async def test_handle_action_GameState_lobby_calls_ConnectToHost( @@ -160,8 +160,6 @@ async def test_handle_action_GameState_lobby_calls_ConnectToHost( game_connection.player = players.joining players.joining.game = game game.host = players.hosting - game.map_file_path = "maps/some_map.zip" - game.map_folder_name = "some_map" await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks(event_loop) @@ -183,8 +181,7 @@ async def test_handle_action_GameState_lobby_calls_ConnectToPeer( players.joining.game = game game.host = players.hosting - game.map_file_path = "maps/some_map.zip" - game.map_folder_name = "some_map" + game.map = Map(None, "some_map") peer_conn = mock.Mock() players.peer.game_connection = peer_conn game.connections = [peer_conn] @@ -229,8 +226,7 @@ async def test_handle_action_GameState_lobby_calls_abort( players.joining.game = game game.host = players.hosting game.host.state = PlayerState.IDLE - game.map_file_path = "maps/some_map.zip" - game.map_folder_name = "some_map" + game.map = Map(None, "some_map") await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks(event_loop) @@ -403,23 +399,58 @@ async def test_cannot_parse_game_results( async def test_handle_action_GameOption( - game: Game, - game_connection: GameConnection + real_game: Game, + game_connection: GameConnection, + players, ): - game.gameOptions = {"AIReplacement": "Off"} + game = real_game + game.host = players.hosting + game_connection.player = players.hosting + game_connection.game = real_game + + game.game_options.clear() await game_connection.handle_action("GameOption", ["Victory", "sandbox"]) - assert game.gameOptions["Victory"] == Victory.SANDBOX + assert game.game_options["Victory"] == Victory.SANDBOX await game_connection.handle_action("GameOption", ["AIReplacement", "On"]) - assert game.gameOptions["AIReplacement"] == "On" + assert game.game_options["AIReplacement"] == "On" await game_connection.handle_action("GameOption", ["Slots", "7"]) assert game.max_players == 7 - # I don't know what these paths actually look like - await game_connection.handle_action("GameOption", ["ScenarioFile", "C:\\Maps\\Some_Map"]) - assert game.map_file_path == "maps/some_map.zip" + # This is a contrived example. Windows style paths might not actually show + # up, but the server has historically supported them. + await game_connection.handle_action( + "GameOption", + ["ScenarioFile", "C:\\Maps\\Some_Map\\Some_Map_scenario.lua"] + ) + assert game.map.file_path == "maps/some_map.zip" + assert game.map.folder_name == "some_map" await game_connection.handle_action("GameOption", ["Title", "All welcome"]) assert game.name == "All welcome" await game_connection.handle_action("GameOption", ["ArbitraryKey", "ArbitraryValue"]) - assert game.gameOptions["ArbitraryKey"] == "ArbitraryValue" + assert game.game_options["ArbitraryKey"] == "ArbitraryValue" + + +@pytest.mark.parametrize("scenario_file", ( + "/maps/x1mp_002/x1mp_002_scenario.lua", + "/MAPS/X1MP_002/X1MP_002_SCENARIO.LUA", + "////maps/////x1mp_002////x1mp_002_scenario.lua", +)) +async def test_handle_action_GameOption_ScenarioFile( + real_game: Game, + game_connection: GameConnection, + players, + scenario_file, +): + game = real_game + game.host = players.hosting + game_connection.player = players.hosting + game_connection.game = real_game + + await game_connection.handle_action( + "GameOption", + ["ScenarioFile", scenario_file] + ) + assert game.map.file_path == "maps/x1mp_002.zip" + assert game.map.folder_name == "x1mp_002" async def test_handle_action_GameOption_not_host( @@ -428,9 +459,9 @@ async def test_handle_action_GameOption_not_host( players ): game_connection.player = players.joining - game.gameOptions = {"Victory": "asdf"} + game.game_options = {"Victory": "asdf"} await game_connection.handle_action("GameOption", ["Victory", "sandbox"]) - assert game.gameOptions == {"Victory": "asdf"} + assert game.game_options == {"Victory": "asdf"} async def test_json_stats( @@ -525,7 +556,7 @@ async def test_handle_action_OperationComplete( database, ): coop_game.id = 1 # reuse existing corresponding game_stats row - coop_game.map_file_path = "maps/prothyon16.v0005.zip" + coop_game.map = Map(None, "prothyon16.v0005") game_connection.game = coop_game time_taken = "09:08:07.654321" @@ -549,7 +580,7 @@ async def test_handle_action_OperationComplete( async def test_handle_action_OperationComplete_primary_incomplete( primary, coop_game: CoopGame, game_connection: GameConnection, database ): - coop_game.map_file_path = "maps/prothyon16.v0005.zip" + coop_game.map = Map(None, "prothyon16.v0005") game_connection.game = coop_game time_taken = "09:08:07.654321" @@ -572,7 +603,7 @@ async def test_handle_action_OperationComplete_primary_incomplete( async def test_handle_action_OperationComplete_non_coop_game( ugame: Game, game_connection: GameConnection, database ): - ugame.map_file_path = "maps/prothyon16.v0005.zip" + ugame.map = Map(None, "prothyon16.v0005") game_connection.game = ugame time_taken = "09:08:07.654321" @@ -595,7 +626,7 @@ async def test_handle_action_OperationComplete_non_coop_game( async def test_handle_action_OperationComplete_invalid( coop_game: CoopGame, game_connection: GameConnection, database ): - coop_game.map_file_path = "maps/prothyon16.v0005.zip" + coop_game.map = Map(None, "prothyon16.v0005") coop_game.validity = ValidityState.OTHER_UNRANK game_connection.game = coop_game time_taken = "09:08:07.654321" @@ -619,7 +650,7 @@ async def test_handle_action_OperationComplete_invalid( async def test_handle_action_OperationComplete_duplicate( coop_game: CoopGame, game_connection: GameConnection, database, caplog ): - coop_game.map_file_path = "maps/prothyon16.v0005.zip" + coop_game.map = Map(None, "prothyon16.v0005") game_connection.game = coop_game time_taken = "09:08:07.654321" diff --git a/tests/unit_tests/test_games_service.py b/tests/unit_tests/test_games_service.py index 55fd74674..c2f25ff67 100644 --- a/tests/unit_tests/test_games_service.py +++ b/tests/unit_tests/test_games_service.py @@ -12,6 +12,7 @@ VisibilityState ) from server.players import PlayerState +from server.types import Map from tests.unit_tests.conftest import add_connected_player from tests.utils import fast_forward @@ -37,6 +38,8 @@ async def test_graceful_shutdown(game_service): with pytest.raises(DisabledError): game_service.create_game( game_mode="faf", + map=Map(None, "SCMP_007"), + ) @@ -66,7 +69,7 @@ async def test_create_game(players, game_service): game_mode="faf", host=players.hosting, name="Test", - mapname="SCMP_007", + map=Map(None, "SCMP_007"), password=None ) assert game is not None @@ -83,7 +86,7 @@ async def test_all_games(players, game_service): game_mode="faf", host=players.hosting, name="Test", - mapname="SCMP_007", + map=Map(None, "SCMP_007"), password=None ) assert game in game_service.pending_games @@ -110,7 +113,7 @@ async def test_create_game_other_gamemode(players, game_service): game_mode="labwars", host=players.hosting, name="Test", - mapname="SCMP_007", + map=Map(None, "SCMP_007"), password=None ) assert game is not None @@ -125,7 +128,7 @@ async def test_close_lobby_games(players, game_service): game_mode="faf", host=players.hosting, name="Test", - mapname="SCMP_007", + map=Map(None, "SCMP_007"), password=None ) game.state = GameState.LOBBY diff --git a/tests/unit_tests/test_ladder_service.py b/tests/unit_tests/test_ladder_service.py index b62b063a3..2e5feaf1a 100644 --- a/tests/unit_tests/test_ladder_service.py +++ b/tests/unit_tests/test_ladder_service.py @@ -59,21 +59,21 @@ async def test_load_from_database(ladder_service, queue_factory): assert queue.rating_peak == 1000.0 assert len(queue.map_pools) == 3 assert list(queue.map_pools[1][0].maps.values()) == [ - Map(id=15, name="SCMP_015", path="maps/scmp_015.zip"), - Map(id=16, name="SCMP_015", path="maps/scmp_015.v0002.zip"), - Map(id=17, name="SCMP_015", path="maps/scmp_015.v0003.zip"), + 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][0].maps.values()) == [ - Map(id=11, name="SCMP_011", path="maps/scmp_011.zip"), - Map(id=14, name="SCMP_014", path="maps/scmp_014.zip"), - Map(id=15, name="SCMP_015", path="maps/scmp_015.zip"), - Map(id=16, name="SCMP_015", path="maps/scmp_015.v0002.zip"), - Map(id=17, name="SCMP_015", path="maps/scmp_015.v0003.zip"), + 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[3][0].maps.values()) == [ - Map(id=1, name="SCMP_001", path="maps/scmp_001.zip"), - Map(id=2, name="SCMP_002", path="maps/scmp_002.zip"), - Map(id=3, name="SCMP_003", path="maps/scmp_003.zip"), + Map(1, "scmp_001", ranked=True), + Map(2, "scmp_002", ranked=True), + Map(3, "scmp_003", ranked=True), ] queue = ladder_service.queues["neroxis1v1"] @@ -190,8 +190,8 @@ async def test_start_game_with_game_options( assert game.rating_type == queue.rating_type assert game.max_players == 2 - assert game.gameOptions["Share"] == "ShareUntilDeath" - assert game.gameOptions["UnitCap"] == 500 + assert game.game_options["Share"] == "ShareUntilDeath" + assert game.game_options["UnitCap"] == 500 LadderGame.wait_launched.assert_called_once() @@ -444,7 +444,7 @@ async def test_start_game_start_spots( rating_type=RatingType.GLOBAL ) queue.add_map_pool( - MapPool(1, "test", [Map(1, "scmp_007", "maps/scmp_007.zip")]), + MapPool(1, "test", [Map(1, "scmp_007")]), min_rating=None, max_rating=None ) diff --git a/tests/unit_tests/test_laddergame.py b/tests/unit_tests/test_laddergame.py index 424060dc8..4abf5aa1c 100644 --- a/tests/unit_tests/test_laddergame.py +++ b/tests/unit_tests/test_laddergame.py @@ -14,7 +14,7 @@ @pytest.fixture() async def laddergame(database, game_service, game_stats_service): return LadderGame( - id_=465312, + id=465312, database=database, game_service=game_service, game_stats_service=game_stats_service, diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 3714dd75b..61af68d68 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -78,8 +78,7 @@ def mock_geoip(): @pytest.fixture -def lobbyconnection( - event_loop, +async def lobbyconnection( database, mock_protocol, mock_games, @@ -242,7 +241,7 @@ async def test_command_game_host_creates_game( "host": players.hosting, "visibility": VisibilityState.PUBLIC, "password": test_game_info["password"], - "mapname": test_game_info["mapname"], + "map": await mock_games.get_map(test_game_info["mapname"]), "rating_type": RatingType.GLOBAL, "displayed_rating_range": InclusiveRange(None, None), "enforce_rating_range": False @@ -288,7 +287,6 @@ async def test_command_game_host_creates_correct_game( async def test_command_game_join_calls_join_game( - mocker, database, lobbyconnection, game_service, diff --git a/tests/unit_tests/test_map_pool.py b/tests/unit_tests/test_map_pool.py index caee75d9b..69e0ba35e 100644 --- a/tests/unit_tests/test_map_pool.py +++ b/tests/unit_tests/test_map_pool.py @@ -24,30 +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", "maps/some_map.v001.zip"), - Map(2, "some_map", "maps/some_map.v001.zip"), - Map(3, "some_map", "maps/some_map.v001.zip"), - Map(4, "CHOOSE_ME", "maps/choose_me.v001.zip"), + Map(1, "some_map.v001"), + Map(2, "some_map.v001"), + Map(3, "some_map.v001"), + Map(4, "choose_me.v001"), ]) # 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 == (4, "CHOOSE_ME", "maps/choose_me.v001.zip", 1) + assert chosen_map == Map(4, "choose_me.v001") +@pytest.mark.flaky def test_choose_map_with_weights(map_pool_factory): map_pool = map_pool_factory(maps=[ - Map(1, "some_map", "maps/some_map.v001.zip", 1), - Map(2, "some_map", "maps/some_map.v001.zip", 1), - Map(3, "some_map", "maps/some_map.v001.zip", 1), - Map(4, "CHOOSE_ME", "maps/choose_me.v001.zip", 10000000), + 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), ]) # 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 == (4, "CHOOSE_ME", "maps/choose_me.v001.zip", 10000000) + assert chosen_map == Map(4, "choose_me.v001", weight=10000000) def test_choose_map_generated_map(map_pool_factory): @@ -74,16 +75,23 @@ def test_choose_map_generated_map(map_pool_factory): seed_match = "[0-9a-z]{13}" assert chosen_map.id == map_id - assert re.match(f"maps/neroxis_map_generator_{version}_{seed_match}_{option_str}.zip", chosen_map.path) - assert re.match(f"neroxis_map_generator_{version}_{seed_match}_{option_str}", chosen_map.name) + assert re.match( + f"maps/neroxis_map_generator_{version}_{seed_match}_{option_str}.zip", + chosen_map.file_path, + ) + assert re.match( + f"neroxis_map_generator_{version}_{seed_match}_{option_str}", + chosen_map.folder_name, + ) + assert chosen_map.ranked is True assert chosen_map.weight == 1 def test_choose_map_all_maps_played(map_pool_factory): maps = [ - Map(1, "some_map", "maps/some_map.v001.zip"), - Map(2, "some_map", "maps/some_map.v001.zip"), - Map(3, "some_map", "maps/some_map.v001.zip"), + Map(1, "some_map.v001"), + Map(2, "some_map.v001"), + Map(3, "some_map.v001"), ] map_pool = map_pool_factory(maps=maps) @@ -95,9 +103,9 @@ def test_choose_map_all_maps_played(map_pool_factory): def test_choose_map_all_played_but_generated_map_doesnt_dominate(map_pool_factory): maps = [ - Map(1, "some_map", "maps/some_map.v001.zip", 1000000), - Map(2, "some_map", "maps/some_map.v001.zip", 1000000), - Map(3, "some_map", "maps/some_map.v001.zip", 1000000), + 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, @@ -118,9 +126,9 @@ def test_choose_map_all_played_but_generated_map_doesnt_dominate(map_pool_factor def test_choose_map_all_maps_played_not_in_pool(map_pool_factory): maps = [ - Map(1, "some_map", "maps/some_map.v001.zip"), - Map(2, "some_map", "maps/some_map.v001.zip"), - Map(3, "some_map", "maps/some_map.v001.zip"), + Map(1, "some_map.v001"), + Map(2, "some_map.v001"), + Map(3, "some_map.v001"), ] map_pool = map_pool_factory(maps=maps) @@ -143,7 +151,7 @@ def test_choose_map_all_maps_played_returns_least_played(map_pool_factory): ] maps = [ - Map(i + 1, "some_map", "maps/some_map.v001.zip") for i in range(num_maps) + Map(i + 1, "some_map.v001") for i in range(num_maps) ] # Shuffle the list so that `choose_map` can't just return the first map random.shuffle(maps) @@ -152,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 == (1, "some_map", "maps/some_map.v001.zip", 1) + assert chosen_map == Map(1, "some_map.v001") @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", "maps/choose_me.v001.zip"), + Map(1, "choose_me.v001"), ]) # 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 == (1, "CHOOSE_ME", "maps/choose_me.v001.zip", 1) + assert chosen_map == Map(1, "choose_me.v001") def test_choose_map_raises_on_empty_map_pool(map_pool_factory):