From d508db12501a3873f40581bc4fb8ecaae4eaa7f6 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 3 Apr 2022 20:38:44 -0800 Subject: [PATCH 1/4] Refactor validity and include it in game_info message --- server/gameconnection.py | 4 +- server/games/coop.py | 27 ++-- server/games/custom_game.py | 25 ++-- server/games/game.py | 161 ++++++++---------------- server/games/typedefs.py | 7 +- server/games/validator.py | 103 +++++++++++++++ server/types.py | 1 + tests/conftest.py | 2 +- tests/unit_tests/test_custom_game.py | 8 +- tests/unit_tests/test_game.py | 151 +++++++++++++--------- tests/unit_tests/test_gameconnection.py | 7 +- tests/unit_tests/test_laddergame.py | 4 +- 12 files changed, 297 insertions(+), 203 deletions(-) create mode 100644 server/games/validator.py diff --git a/server/gameconnection.py b/server/gameconnection.py index a1e128279..5e32ea127 100644 --- a/server/gameconnection.py +++ b/server/gameconnection.py @@ -314,7 +314,9 @@ async def handle_operation_complete( self._logger.warning("OperationComplete called for non-coop game") return - if self.game.validity != ValidityState.COOP_NOT_RANKED: + validity = self.game.get_validity() + if validity is not ValidityState.COOP_NOT_RANKED: + self._logger.info("Game was not valid: %s", validity) return secondary, delta = secondary, str(delta) diff --git a/server/games/coop.py b/server/games/coop.py index b8531822a..5f1e95bfa 100644 --- a/server/games/coop.py +++ b/server/games/coop.py @@ -1,5 +1,7 @@ import asyncio +from server.games.validator import COMMON_RULES, GameOptionRule, Validator + from .game import Game from .typedefs import FA, GameType, InitMode, ValidityState, Victory @@ -8,12 +10,21 @@ class CoopGame(Game): """Class for coop game""" init_mode = InitMode.NORMAL_LOBBY game_type = GameType.COOP + default_validity = ValidityState.COOP_NOT_RANKED + validator = Validator([ + *COMMON_RULES, + GameOptionRule("Victory", Victory.SANDBOX, ValidityState.WRONG_VICTORY_CONDITION), + GameOptionRule("TeamSpawn", "fixed", ValidityState.SPAWN_NOT_FIXED), + GameOptionRule("RevealedCivilians", FA.DISABLED, ValidityState.CIVILIANS_REVEALED), + GameOptionRule("Difficulty", 3, ValidityState.WRONG_DIFFICULTY), + GameOptionRule("Expansion", FA.ENABLED, ValidityState.EXPANSION_DISABLED), + ]) def __init__(self, *args, **kwargs): kwargs["game_mode"] = "coop" super().__init__(*args, **kwargs) - self.validity = ValidityState.COOP_NOT_RANKED + self.is_coop = True self.game_options.update({ "Victory": Victory.SANDBOX, "TeamSpawn": "fixed", @@ -24,20 +35,6 @@ def __init__(self, *args, **kwargs): self.leaderboard_lock = asyncio.Lock() self.leaderboard_saved = False - async def validate_game_mode_settings(self): - """ - Checks which only apply to the coop mode - """ - - valid_options = { - "Victory": (Victory.SANDBOX, ValidityState.WRONG_VICTORY_CONDITION), - "TeamSpawn": ("fixed", ValidityState.SPAWN_NOT_FIXED), - "RevealedCivilians": (FA.DISABLED, ValidityState.CIVILIANS_REVEALED), - "Difficulty": (3, ValidityState.WRONG_DIFFICULTY), - "Expansion": (FA.ENABLED, ValidityState.EXPANSION_DISABLED), - } - await self._validate_game_options(valid_options) - async def process_game_results(self): """ When a coop game ends, we don't expect there to be any game results. diff --git a/server/games/custom_game.py b/server/games/custom_game.py index be9c97d34..588451e0c 100644 --- a/server/games/custom_game.py +++ b/server/games/custom_game.py @@ -1,26 +1,37 @@ import time +from typing import Optional from server.decorators import with_logger +from server.games.validator import COMMON_RULES, NON_COOP_RULES, Validator from server.rating import RatingType from .game import Game from .typedefs import GameType, InitMode, ValidityState +def minimum_length_rule(game: Game) -> Optional[ValidityState]: + if game.launched_at is None: + return + + limit = len(game.players) * 60 + if not game.enforce_rating and time.time() - game.launched_at < limit: + return ValidityState.TOO_SHORT + + @with_logger class CustomGame(Game): init_mode = InitMode.NORMAL_LOBBY game_type = GameType.CUSTOM + validator = Validator([ + *COMMON_RULES, + *NON_COOP_RULES, + minimum_length_rule + ]) - def __init__(self, id, *args, **kwargs): + def __init__(self, *args, **kwargs): new_kwargs = { "rating_type": RatingType.GLOBAL, "setup_timeout": 30 } new_kwargs.update(kwargs) - super().__init__(id, *args, **new_kwargs) - - async def _run_pre_rate_validity_checks(self): - limit = len(self.players) * 60 - if not self.enforce_rating and time.time() - self.launched_at < limit: - await self.mark_invalid(ValidityState.TOO_SHORT) + super().__init__(*args, **new_kwargs) diff --git a/server/games/game.py b/server/games/game.py index 42049eabf..545c2bdf9 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -3,6 +3,7 @@ import json import logging import pathlib +import re import time from collections import defaultdict from typing import Any, Awaitable, Callable, Iterable, Optional @@ -27,13 +28,13 @@ GameResultReports, resolve_game ) +from server.games.validator import COMMON_RULES, NON_COOP_RULES, Validator 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 ( - FA, BasicGameInfo, EndedGameInfo, FeaturedModType, @@ -57,6 +58,11 @@ class Game: """ init_mode = InitMode.NORMAL_LOBBY game_type = GameType.CUSTOM + default_validity = ValidityState.VALID + validator = Validator([ + *COMMON_RULES, + *NON_COOP_RULES, + ]) def __init__( self, @@ -84,7 +90,7 @@ def __init__( self.game_service = game_service self._player_options: dict[int, dict[str, Any]] = defaultdict(dict) self.hosted_at = None - self.launched_at = None + self.launched_at: Optional[float] = None self.finished = False self._logger = logging.getLogger( f"{self.__class__.__qualname__}.{id}" @@ -97,7 +103,6 @@ def __init__( self._players_at_launch: list[Player] = [] self.AIs = {} self.desyncs = 0 - self.validity = ValidityState.VALID self.game_mode = game_mode self.rating_type = rating_type or RatingType.GLOBAL self.displayed_rating_range = displayed_rating_range or InclusiveRange() @@ -132,6 +137,8 @@ def __init__( self.game_options.add_callback("Title", self.on_title_changed) self.mods = {} + self._override_validity: Optional[ValidityState] = None + self._persisted_validity: Optional[ValidityState] = None self._hosted_future = asyncio.Future() self._finish_lock = asyncio.Lock() @@ -274,6 +281,12 @@ def is_even(self) -> bool: team_sizes = set(len(team) for team in teams) return len(team_sizes) == 1 + def get_validity(self) -> ValidityState: + if self._override_validity is not None: + return self._override_validity + + return self.validator.get_one(self) or self.default_validity + def get_team_sets(self) -> list[set[Player]]: """ Returns a list of teams represented as sets of players. @@ -463,12 +476,26 @@ async def on_game_finish(self): elif self.state is GameState.LIVE: self._logger.info("Game finished normally") - if self.desyncs > 20: - await self.mark_invalid(ValidityState.TOO_MANY_DESYNCS) - return - + # Needed by some validity checks + self.state = GameState.ENDED await self.process_game_results() + validity = self.get_validity() + if validity is not self._persisted_validity: + assert validity is not self.default_validity + + self._logger.info("Updating validity to: %s", validity) + async with self._db.acquire() as conn: + await conn.execute( + game_stats.update().where( + game_stats.c.id == self.id + ).values( + validity=validity.value + ) + ) + self._persisted_validity = validity + return + self._process_pending_army_stats() except Exception: # pragma: no cover self._logger.exception("Error during game end") @@ -477,12 +504,8 @@ async def on_game_finish(self): self.game_service.mark_dirty(self) - async def _run_pre_rate_validity_checks(self): - pass - async def process_game_results(self): if not self._results: - await self.mark_invalid(ValidityState.UNKNOWN_RESULT) return await self.persist_results() @@ -494,8 +517,6 @@ async def resolve_game_results(self) -> EndedGameInfo: if self.state not in (GameState.LIVE, GameState.ENDED): raise GameError("Cannot rate game that has not been launched.") - await self._run_pre_rate_validity_checks() - basic_info = self.get_basic_info() team_army_results = [ @@ -509,6 +530,7 @@ async def resolve_game_results(self) -> EndedGameInfo: for team in basic_info.teams ] + validity = self.get_validity() try: # TODO: Remove override once game result messages are reliable team_outcomes = ( @@ -516,8 +538,9 @@ async def resolve_game_results(self) -> EndedGameInfo: or resolve_game(team_player_partial_outcomes) ) except GameResolutionError: - if self.validity is ValidityState.VALID: - await self.mark_invalid(ValidityState.UNKNOWN_RESULT) + if validity is ValidityState.VALID: + self._override_validity = ValidityState.UNKNOWN_RESULT + validity = ValidityState.UNKNOWN_RESULT try: commander_kills = { @@ -529,7 +552,7 @@ async def resolve_game_results(self) -> EndedGameInfo: return EndedGameInfo.from_basic( basic_info, - self.validity, + validity, team_outcomes, commander_kills, team_army_results, @@ -644,71 +667,6 @@ def clear_slot(self, slot_index): for item in to_remove: del self.AIs[item] - async def validate_game_settings(self): - """ - Mark the game invalid if it has non-compliant options - """ - - # Only allow ranked mods - for mod_id in self.mods.keys(): - if mod_id not in self.game_service.ranked_mods: - await self.mark_invalid(ValidityState.BAD_MOD) - return - - if self.has_ai: - await self.mark_invalid(ValidityState.HAS_AI_PLAYERS) - return - if self.is_multi_team: - await self.mark_invalid(ValidityState.MULTI_TEAM) - return - valid_options = { - "AIReplacement": (FA.DISABLED, ValidityState.HAS_AI_PLAYERS), - "FogOfWar": ("explored", ValidityState.NO_FOG_OF_WAR), - "CheatsEnabled": (FA.DISABLED, ValidityState.CHEATS_ENABLED), - "PrebuiltUnits": (FA.DISABLED, ValidityState.PREBUILT_ENABLED), - "NoRushOption": (FA.DISABLED, ValidityState.NORUSH_ENABLED), - "RestrictedCategories": (0, ValidityState.BAD_UNIT_RESTRICTIONS), - "TeamLock": ("locked", ValidityState.UNLOCKED_TEAMS), - "Unranked": (FA.DISABLED, ValidityState.HOST_SET_UNRANKED) - } - if await self._validate_game_options(valid_options) is False: - return - - await self.validate_game_mode_settings() - - async def validate_game_mode_settings(self): - """ - A subset of checks that need to be overridden in coop games. - """ - if self.is_ffa: - await self.mark_invalid(ValidityState.FFA_NOT_RANKED) - return - - if len(self.players) < 2: - await self.mark_invalid(ValidityState.SINGLE_PLAYER) - return - - if None in self.teams or not self.is_even: - await self.mark_invalid(ValidityState.UNEVEN_TEAMS_NOT_RANKED) - return - - valid_options = { - "Victory": (Victory.DEMORALIZATION, ValidityState.WRONG_VICTORY_CONDITION) - } - await self._validate_game_options(valid_options) - - async def _validate_game_options( - self, - valid_options: dict[str, tuple[Any, ValidityState]] - ) -> bool: - for key, value in self.game_options.items(): - if key in valid_options: - valid_value, validity_state = valid_options[key] - if value != valid_value: - await self.mark_invalid(validity_state) - return False - return True - async def launch(self): """ Mark the game as live. @@ -728,7 +686,6 @@ async def launch(self): self.state = GameState.LIVE await self.on_game_launched() - await self.validate_game_settings() self._logger.info("Game launched") @@ -740,17 +697,14 @@ async def on_game_launched(self): async def update_game_stats(self): """ - Runs at game-start to populate the game_stats table (games that start are ones we actually - care about recording stats for, after all). + Runs at game-start to populate the game_stats table (games that start + are ones we actually care about recording stats for, after all). """ assert self.host is not None # Ensure map data is up to date self.map = await self.game_service.get_map(self.map.folder_name) - 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. @@ -760,6 +714,10 @@ async def update_game_stats(self): game_type = str(self.game_options.get("Victory").value) async with self._db.acquire() as conn: + validity = self.get_validity() + if validity is not self.default_validity: + self._logger.info("Game is invalid at launch: %s", validity) + await conn.execute( game_stats.insert().values( id=self.id, @@ -768,9 +726,10 @@ async def update_game_stats(self): host=self.host.id, mapId=self.map.id, gameName=self.name, - validity=self.validity.value, + validity=validity.value, ) ) + self._persisted_validity = validity if self.matchmaker_queue_id is not None: await conn.execute( @@ -828,28 +787,6 @@ async def update_game_player_stats(self): ) raise - async def mark_invalid(self, new_validity_state: ValidityState): - self._logger.info( - "Marked as invalid because: %s", repr(new_validity_state) - ) - self.validity = new_validity_state - - # If we haven't started yet, the invalidity will be persisted to the database when we start. - # Otherwise, we have to do a special update query to write this information out. - if self.state is not GameState.LIVE: - return - - # Currently, we can only end up here if a game desynced or was a custom game that terminated - # too quickly. - async with self._db.acquire() as conn: - await conn.execute( - game_stats.update().where( - game_stats.c.id == self.id - ).values( - validity=new_validity_state.value - ) - ) - def get_army_score(self, army): return self._results.score(army) @@ -915,6 +852,10 @@ def to_dict(self): "state": client_state, "game_type": self.game_type.value, "featured_mod": self.game_mode, + "validity": [ + validity.name.lower() + for validity in self.validator.get_all(self) + ] or [self.default_validity.name.lower()], "sim_mods": self.mods, "mapname": self.map.folder_name, # DEPRECATED: Use `mapname` instead diff --git a/server/games/typedefs.py b/server/games/typedefs.py index 8d8142f7d..44f6c309e 100644 --- a/server/games/typedefs.py +++ b/server/games/typedefs.py @@ -41,9 +41,10 @@ class VisibilityState(Enum): FRIENDS = "friends" -# Identifiers must be kept in sync with the contents of the invalid_game_reasons table. -# New reasons added should have a description added to that table. Identifiers should never be -# reused, and values should never be deleted from invalid_game_reasons. +# Identifiers must be kept in sync with the contents of the invalid_game_reasons +# table. New reasons added should have a description added to that table. +# Identifiers should never be reused, and values should never be deleted from +# invalid_game_reasons. @unique class ValidityState(Enum): VALID = 0 diff --git a/server/games/validator.py b/server/games/validator.py new file mode 100644 index 000000000..125911e05 --- /dev/null +++ b/server/games/validator.py @@ -0,0 +1,103 @@ +from typing import Any, Callable, Optional, Sequence + +from server.games.typedefs import FA, GameState, ValidityState, Victory + +ValidationRule = Callable[["Game"], Optional[ValidityState]] + + +class Validator: + def __init__(self, rules: Sequence[ValidationRule]): + self.rules = rules + + def get_one(self, game: "Game") -> Optional[ValidityState]: + for rule in self.rules: + validity = rule(game) + if validity is not None: + return validity + + def get_all(self, game: "Game") -> list[ValidityState]: + return [ + validity + for rule in self.rules + if (validity := rule(game)) is not None + ] + + +class GameOptionRule: + def __init__(self, key: str, value: Any, validity: ValidityState): + self.key = key + self.value = value + self.validity = validity + + def __call__(self, game: "Game") -> Optional[ValidityState]: + if game.game_options[self.key] != self.value: + return self.validity + + +class PropertyRule: + def __init__(self, name: str, value: Any, validity: ValidityState): + self.name = name + self.value = value + self.validity = validity + + def __call__(self, game: "Game") -> Optional["ValidityState"]: + if getattr(game, self.name) != self.value: + return self.validity + + +def not_desynced_rule(game: "Game") -> Optional[ValidityState]: + if game.desyncs > 20: + return ValidityState.TOO_MANY_DESYNCS + + +def has_results_rule(game: "Game") -> Optional[ValidityState]: + if game.state is GameState.ENDED and not game._results: + return ValidityState.UNKNOWN_RESULT + + +def ranked_mods_rule(game: "Game") -> Optional[ValidityState]: + for mod_id in game.mods.keys(): + if mod_id not in game.game_service.ranked_mods: + return ValidityState.BAD_MOD + + +def ranked_map_rule(game: "Game") -> Optional[ValidityState]: + if not game.map.ranked: + return ValidityState.BAD_MAP + + +def even_teams_rule(game: "Game") -> Optional[ValidityState]: + if None in game.teams or not game.is_even: + return ValidityState.UNEVEN_TEAMS_NOT_RANKED + + +def multi_player_rule(game: "Game") -> Optional[ValidityState]: + # TODO: This validity state is already covered by UNEVEN_TEAMS_NOT_RANKED. + if len(game.players) < 2: + return ValidityState.SINGLE_PLAYER + + +# Rules that apply for all games +COMMON_RULES = ( + not_desynced_rule, + ranked_mods_rule, + PropertyRule("has_ai", False, ValidityState.HAS_AI_PLAYERS), + PropertyRule("is_multi_team", False, ValidityState.MULTI_TEAM), + GameOptionRule("AIReplacement", FA.DISABLED, ValidityState.HAS_AI_PLAYERS), + GameOptionRule("FogOfWar", "explored", ValidityState.NO_FOG_OF_WAR), + GameOptionRule("CheatsEnabled", FA.DISABLED, ValidityState.CHEATS_ENABLED), + GameOptionRule("PrebuiltUnits", FA.DISABLED, ValidityState.PREBUILT_ENABLED), + GameOptionRule("NoRushOption", FA.DISABLED, ValidityState.NORUSH_ENABLED), + GameOptionRule("RestrictedCategories", 0, ValidityState.BAD_UNIT_RESTRICTIONS), + GameOptionRule("TeamLock", "locked", ValidityState.UNLOCKED_TEAMS), + GameOptionRule("Unranked", FA.DISABLED, ValidityState.HOST_SET_UNRANKED) +) +# Rules that apply for everything but coop +NON_COOP_RULES = ( + has_results_rule, + even_teams_rule, + multi_player_rule, + ranked_map_rule, + PropertyRule("is_ffa", False, ValidityState.FFA_NOT_RANKED), + GameOptionRule("Victory", Victory.DEMORALIZATION, ValidityState.WRONG_VICTORY_CONDITION) +) diff --git a/server/types.py b/server/types.py index 4077fd7ee..cf8247cf3 100644 --- a/server/types.py +++ b/server/types.py @@ -39,6 +39,7 @@ def get_map(self) -> "Map": ... class Map(NamedTuple): + # map_version.id id: Optional[int] folder_name: str ranked: bool = False diff --git a/tests/conftest.py b/tests/conftest.py index 7b8934f8e..c8933fc91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -226,7 +226,7 @@ async def ugame(database, players): async def coop_game(database, players): global COOP_GAME_UID game = make_game(database, COOP_GAME_UID, players, game_type=CoopGame) - game.validity = ValidityState.COOP_NOT_RANKED + game.get_validity.return_value = ValidityState.COOP_NOT_RANKED game.leaderboard_saved = False COOP_GAME_UID += 1 return game diff --git a/tests/unit_tests/test_custom_game.py b/tests/unit_tests/test_custom_game.py index 42c0eb691..9eb10e182 100644 --- a/tests/unit_tests/test_custom_game.py +++ b/tests/unit_tests/test_custom_game.py @@ -30,7 +30,7 @@ async def test_rate_game_early_abort_no_enforce( custom_game.launched_at = time.time() - 60 # seconds await custom_game.on_game_finish() - assert custom_game.validity == ValidityState.TOO_SHORT + assert custom_game.get_validity() == ValidityState.TOO_SHORT async def test_rate_game_early_abort_with_enforce( @@ -52,7 +52,7 @@ async def test_rate_game_early_abort_with_enforce( custom_game.launched_at = time.time() - 60 # seconds await custom_game.on_game_finish() - assert custom_game.validity == ValidityState.VALID + assert custom_game.get_validity() == ValidityState.VALID async def test_rate_game_late_abort_no_enforce( @@ -73,7 +73,7 @@ async def test_rate_game_late_abort_no_enforce( custom_game.launched_at = time.time() - 600 # seconds await custom_game.on_game_finish() - assert custom_game.validity == ValidityState.VALID + assert custom_game.get_validity() == ValidityState.VALID async def test_global_rating_higher_after_custom_game_win( @@ -97,5 +97,5 @@ async def test_global_rating_higher_after_custom_game_win( # await game being rated await rating_service._join_rating_queue() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID assert players[0].ratings[RatingType.GLOBAL][0] > old_mean diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index 5e5b10074..6351eca79 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import pathlib import time from typing import Any from unittest import mock @@ -33,7 +34,18 @@ @pytest.fixture async def game(database, game_service, game_stats_service): - return Game(42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL) + game = Game( + 42, + database, + game_service, + game_stats_service, + rating_type=RatingType.GLOBAL + ) + await game.on_scenario_file_changed( + pathlib.PurePath(f"/maps/{game.map.folder_name}/scenario.lua"), + ) + + return game @pytest.fixture @@ -68,7 +80,7 @@ async def test_instance_logging(database, game_stats_service): logger.debug.assert_called_with("%s created", game) -async def test_validate_game_settings(game: Game, game_add_players): +async def test_get_validity(game: Game, game_add_players): settings = [ ("Victory", Victory.SANDBOX, ValidityState.WRONG_VICTORY_CONDITION), ("FogOfWar", "none", ValidityState.NO_FOG_OF_WAR), @@ -97,18 +109,15 @@ async def test_validate_game_settings(game: Game, game_add_players): await check_game_settings(game, settings) - game.validity = ValidityState.VALID - await game.validate_game_settings() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID for player in game.players: game.set_player_option(player.id, "Team", 2) - await game.validate_game_settings() - assert game.validity is ValidityState.UNEVEN_TEAMS_NOT_RANKED + assert game.get_validity() is ValidityState.UNEVEN_TEAMS_NOT_RANKED -async def test_validate_game_settings_coop(coop_game: Game): +async def test_get_validity_coop(coop_game: Game): settings = [ ( "Victory", Victory.DEMORALIZATION, @@ -122,9 +131,7 @@ async def test_validate_game_settings_coop(coop_game: Game): await check_game_settings(coop_game, settings) - coop_game.validity = ValidityState.VALID - await coop_game.validate_game_settings() - assert coop_game.validity is ValidityState.VALID + assert coop_game.get_validity() is ValidityState.COOP_NOT_RANKED async def test_missing_teams_marked_invalid(game: Game, game_add_players): @@ -133,9 +140,7 @@ async def test_missing_teams_marked_invalid(game: Game, game_add_players): game_add_players(game, player_id, team=2) del game._player_options[player_id]["Team"] - await game.validate_game_settings() - - assert game.validity is ValidityState.UNEVEN_TEAMS_NOT_RANKED + assert game.get_validity() is ValidityState.UNEVEN_TEAMS_NOT_RANKED async def check_game_settings( @@ -144,8 +149,7 @@ async def check_game_settings( for key, value, expected in settings: old = game.game_options.get(key) game.game_options[key] = value - await game.validate_game_settings() - assert game.validity is expected + assert game.get_validity() is expected game.game_options[key] = old @@ -164,7 +168,7 @@ async def test_ffa_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.FFA_NOT_RANKED + assert game.get_validity() is ValidityState.FFA_NOT_RANKED async def test_generated_map_is_rated(game, game_add_players): @@ -179,7 +183,7 @@ async def test_generated_map_is_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID async def test_unranked_generated_map_not_rated(game, game_add_players): @@ -190,7 +194,7 @@ async def test_unranked_generated_map_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.BAD_MAP + assert game.get_validity() is ValidityState.BAD_MAP async def test_unranked_mod_not_rated(game, game_add_players): @@ -201,7 +205,7 @@ async def test_unranked_mod_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.BAD_MOD + assert game.get_validity() is ValidityState.BAD_MOD async def test_two_player_ffa_is_rated(game, game_add_players): @@ -211,7 +215,7 @@ async def test_two_player_ffa_is_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID async def test_multi_team_not_rated(game, game_add_players): @@ -223,7 +227,7 @@ async def test_multi_team_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.MULTI_TEAM + assert game.get_validity() is ValidityState.MULTI_TEAM async def test_has_ai_players_not_rated(game, game_add_players): @@ -245,7 +249,7 @@ async def test_has_ai_players_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.HAS_AI_PLAYERS + assert game.get_validity() is ValidityState.HAS_AI_PLAYERS async def test_uneven_teams_not_rated(game, game_add_players): @@ -256,7 +260,7 @@ async def test_uneven_teams_not_rated(game, game_add_players): await game.add_result(0, 1, "victory", 5) game.launched_at = time.time() - 60 * 20 # seconds await game.on_game_finish() - assert game.validity is ValidityState.UNEVEN_TEAMS_NOT_RANKED + assert game.get_validity() is ValidityState.UNEVEN_TEAMS_NOT_RANKED async def test_single_team_not_rated(game, game_add_players): @@ -268,7 +272,7 @@ async def test_single_team_not_rated(game, game_add_players): for i in range(n_players): await game.add_result(0, i, "victory", 5) await game.on_game_finish() - assert game.validity is ValidityState.UNEVEN_TEAMS_NOT_RANKED + assert game.get_validity() is ValidityState.UNEVEN_TEAMS_NOT_RANKED async def test_single_player_not_rated(game, game_add_players): @@ -278,7 +282,7 @@ async def test_single_player_not_rated(game, game_add_players): game.launched_at = time.time() - 60 * 20 await game.add_result(0, 0, "victory", 5) await game.on_game_finish() - assert game.validity is ValidityState.SINGLE_PLAYER + assert game.get_validity() is ValidityState.UNEVEN_TEAMS_NOT_RANKED async def test_game_visible_to_host(game: Game, players): @@ -350,6 +354,35 @@ async def test_invalid_get_player_option_key(game: Game, players): assert game.get_player_option(players.hosting.id, -1) is None +async def test_set_game_option(game: Game): + await game.game_options.set_option("Victory", "sandbox") + assert game.game_options["Victory"] == Victory.SANDBOX + + await game.game_options.set_option("AIReplacement", "On") + assert game.game_options["AIReplacement"] == "On" + + await game.game_options.set_option("Slots", "7") + assert game.max_players == 7 + + await game.game_options.set_option("Title", "All welcome") + assert game.name == "All welcome" + + await game.game_options.set_option("ArbitraryKey", "ArbitraryValue") + assert game.game_options["ArbitraryKey"] == "ArbitraryValue" + + +async def test_set_game_option_scenario_file(game: Game): + # Valid example from a replay + await game.game_options.set_option("ScenarioFile", "/maps/scmp_009/scmp_009_scenario.lua") + assert game.map.file_path == "maps/scmp_009.zip" + + # Examples that document behavior but might not make sense or be necessary + await game.game_options.set_option("ScenarioFile", "C:\\Maps\\Some_Map") + assert game.map.file_path == "maps/some_map.zip" + await game.game_options.set_option("ScenarioFile", "/maps/'some map'/scenario.lua") + assert game.map.file_path == "maps/'some map'.zip" + + async def test_add_game_connection(game: Game, players, mock_game_connection): game.state = GameState.LOBBY mock_game_connection.player = players.hosting @@ -564,7 +597,7 @@ async def test_game_ends_in_mutually_agreed_draw(game: Game, game_add_players): await game.add_result(players[1].id, 1, "mutual_draw", 0) await game.on_game_finish() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID async def test_game_not_ends_in_unilatery_agreed_draw( @@ -580,7 +613,7 @@ async def test_game_not_ends_in_unilatery_agreed_draw( await game.add_result(players.joining.id, 1, "victory", 10) await game.on_game_finish() - assert game.validity is not ValidityState.MUTUAL_DRAW + assert game.get_validity() is not ValidityState.MUTUAL_DRAW async def test_game_is_invalid_due_to_desyncs(game: Game, players): @@ -592,7 +625,7 @@ async def test_game_is_invalid_due_to_desyncs(game: Game, players): game.desyncs = 30 await game.on_game_finish() - assert game.validity is ValidityState.TOO_MANY_DESYNCS + assert game.get_validity() is ValidityState.TOO_MANY_DESYNCS async def test_game_get_player_outcome_ignores_unknown_results( @@ -615,7 +648,7 @@ async def test_on_game_end_single_player_gives_unknown_result(game): await game.on_game_finish() assert game.state is GameState.ENDED - assert game.validity is ValidityState.UNKNOWN_RESULT + assert game.get_validity() is ValidityState.UNKNOWN_RESULT async def test_on_game_end_two_players_is_valid( @@ -632,7 +665,7 @@ async def test_on_game_end_two_players_is_valid( await game.on_game_finish() assert game.state is GameState.ENDED - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID async def test_name_sanitization(game, players): @@ -658,39 +691,41 @@ async def test_to_dict(game, player_factory): players = [ (player_factory(f"{i}", player_id=i, global_rating=rating), result, team) for i, (rating, result, team) in enumerate([ - (Rating(1500, 250), 0, 1), - (Rating(1700, 120), 0, 1), - (Rating(1200, 72), 0, 2), - (Rating(1200, 72), 0, 2), - ], 1)] + (Rating(1500, 250), 0, 2), + (Rating(1700, 120), 0, 2), + (Rating(1200, 72), 0, 3), + (Rating(1200, 72), 0, 3), + ], 1) + ] add_connected_players(game, [player for player, _, _ in players]) for player, _, team in players: game.set_player_option(player.id, "Team", team) - game.set_player_option(player.id, "Army", player.id - 1) + game.set_player_option(player.id, "Army", player.id) game.host = players[0][0] await game.launch() data = game.to_dict() expected = { "command": "game_info", - "visibility": game.visibility.value, - "password_protected": game.password is not None, - "uid": game.id, - "title": game.name, + "visibility": "public", + "password_protected": False, + "uid": 42, + "title": "New Game", "game_type": "custom", "state": "playing", - "featured_mod": game.game_mode, - "sim_mods": game.mods, - "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, + "featured_mod": "faf", + "validity": ["valid"], + "sim_mods": {}, + "mapname": "scmp_007", + "map_file_path": "maps/scmp_007.zip", + "host": "1", + "num_players": 4, + "max_players": 12, "hosted_at": None, "launched_at": game.launched_at, - "rating_type": game.rating_type, - "rating_min": game.displayed_rating_range.lo, - "rating_max": game.displayed_rating_range.hi, - "enforce_rating_range": game.enforce_rating_range, + "rating_type": "global", + "rating_min": None, + "rating_max": None, + "enforce_rating_range": False, "teams_ids": [ { "team_id": team, @@ -744,7 +779,7 @@ async def test_persist_results_not_called_with_no_results( assert len(game.players) == 4 assert len(game._results) == 0 - assert game.validity is ValidityState.UNKNOWN_RESULT + assert game.get_validity() is ValidityState.UNKNOWN_RESULT game.persist_results.assert_not_called() @@ -772,8 +807,10 @@ async def test_persist_results_called_for_unranked(game, game_add_players): game.state = GameState.LOBBY game_add_players(game, 2) await game.launch() - game.validity = ValidityState.BAD_UNIT_RESTRICTIONS + game.game_options["RestrictedCategories"] = 1 + assert game.get_validity() is ValidityState.BAD_UNIT_RESTRICTIONS assert len(game.players) == 2 + await game.add_result(0, 1, "victory", 5) await game.on_game_finish() @@ -886,7 +923,7 @@ async def test_partial_stats_not_affecting_rating_persistence( # await game being rated await rating_service._join_rating_queue() - assert game.validity is ValidityState.VALID + assert game.get_validity() is ValidityState.VALID assert players[0].ratings[RatingType.GLOBAL][0] > old_mean @@ -1105,8 +1142,10 @@ async def test_army_results_present_for_invalid_games(game: Game, game_add_playe game.state = GameState.LOBBY players = (*game_add_players(game, 2, 2), *game_add_players(game, 2, 3)) + game.game_options["CheatsEnabled"] = "On" + assert game.get_validity() is ValidityState.CHEATS_ENABLED + await game.launch() - game.validity = ValidityState.CHEATS_ENABLED for i, player in enumerate(players): for _ in players: diff --git a/tests/unit_tests/test_gameconnection.py b/tests/unit_tests/test_gameconnection.py index 26c47a230..6d9d9b255 100644 --- a/tests/unit_tests/test_gameconnection.py +++ b/tests/unit_tests/test_gameconnection.py @@ -449,14 +449,13 @@ async def test_handle_action_GameOption_ScenarioFile( async def test_handle_action_GameOption_not_host( - game: Game, + game, game_connection: GameConnection, players ): game_connection.player = players.joining - game.game_options = {"Victory": "asdf"} await game_connection.handle_action("GameOption", ["Victory", "sandbox"]) - assert game.game_options == {"Victory": "asdf"} + game.game_options.get("Victory") is Victory.DEMORALIZATION async def test_json_stats( @@ -628,7 +627,7 @@ async def test_handle_action_OperationComplete_invalid( coop_game: CoopGame, game_connection: GameConnection, database ): coop_game.map = Map(None, "prothyon16.v0005") - coop_game.validity = ValidityState.OTHER_UNRANK + coop_game.get_validity.return_value = ValidityState.OTHER_UNRANK game_connection.game = coop_game time_taken = "09:08:07.654321" diff --git a/tests/unit_tests/test_laddergame.py b/tests/unit_tests/test_laddergame.py index 4abf5aa1c..91c8c3da0 100644 --- a/tests/unit_tests/test_laddergame.py +++ b/tests/unit_tests/test_laddergame.py @@ -128,7 +128,7 @@ async def test_rate_game(laddergame: LadderGame, database, game_add_players): await laddergame.game_service._rating_service._join_rating_queue() - assert laddergame.validity is ValidityState.VALID + assert laddergame.get_validity() is ValidityState.VALID assert players[0].ratings[RatingType.LADDER_1V1][0] > player_1_old_mean assert players[1].ratings[RatingType.LADDER_1V1][0] < player_2_old_mean @@ -179,7 +179,7 @@ async def test_persist_rating_victory(laddergame: LadderGame, database, await laddergame.game_service._rating_service._join_rating_queue() - assert laddergame.validity is ValidityState.VALID + assert laddergame.get_validity() is ValidityState.VALID async with database.acquire() as conn: result_after = list(await conn.execute(str(compiled))) From 7d2aa8ad121a8477e268e63b81a3c11b78dd6abf Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 3 Apr 2022 20:41:25 -0800 Subject: [PATCH 2/4] Add test and refactor mark_dirty --- server/gameconnection.py | 4 -- server/games/game.py | 1 - server/lobbyconnection.py | 5 --- tests/integration_tests/test_game.py | 51 ++++++++++++++++++++++ tests/integration_tests/test_matchmaker.py | 16 +++---- tests/integration_tests/test_server.py | 9 ++-- tests/unit_tests/test_lobbyconnection.py | 1 - 7 files changed, 63 insertions(+), 24 deletions(-) diff --git a/server/gameconnection.py b/server/gameconnection.py index 5e32ea127..bf4eb653d 100644 --- a/server/gameconnection.py +++ b/server/gameconnection.py @@ -463,10 +463,6 @@ async def handle_game_state(self, state: str): return elif state == "Lobby": - # TODO: Do we still need to schedule with `ensure_future`? - # - # We do not yield from the task, since we - # need to keep processing other commands while it runs await self._handle_lobby_state() elif state == "Launching": diff --git a/server/games/game.py b/server/games/game.py index 545c2bdf9..9536a88b9 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -3,7 +3,6 @@ import json import logging import pathlib -import re import time from collections import defaultdict from typing import Any, Awaitable, Callable, Iterable, Optional diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 903a11d7b..b0b07b8d7 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -1169,11 +1169,6 @@ def _prepare_launch_game( "args": ["/numgames", self.player.game_count[game.rating_type]], "uid": game.id, "mod": game.game_mode, - # Following parameters may not be used by the client yet. They are - # needed for setting up auto-lobby style matches such as ladder, gw, - # and team machmaking where the server decides what these game - # options are. Currently, options for ladder are hardcoded into the - # client. "name": game.name, # DEPRICATED: init_mode can be inferred from game_type "init_mode": game.init_mode.value, diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index e26f60288..ccc07e94d 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -266,6 +266,57 @@ async def send_player_options(proto, *options): }) +@fast_forward(20) +async def test_game_validity_states(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 + ) + # Set up the game + game_id = await host_game(host_proto) + msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) + assert msg["validity"] == ["single_player"] + + # The host configures themselves, causing them to show up as connected + await send_player_options(host_proto, [host_id, "Team", 1]) + msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) + assert msg["validity"] == ["uneven_teams_not_ranked", "single_player"] + + # Change the map to an unranked map + await host_proto.send_message({ + "target": "game", + "command": "GameOption", + "args": [ + "ScenarioFile", + "/maps/neroxis_map_generator_sneaky_map/sneaky_map_scenario.lua" + ] + }) + msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) + assert msg["validity"] == [ + "uneven_teams_not_ranked", + "single_player", + "bad_map", + ] + + # Another player joins + await join_game(guest_proto, game_id) + await send_player_options(host_proto, [guest_id, "Team", 1]) + msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) + assert msg["validity"] == ["bad_map"] + + # Change the map to a ranked map + await host_proto.send_message({ + "target": "game", + "command": "GameOption", + "args": ["ScenarioFile", "/maps/scmp_001/scmp_001_scenario.lua"] + }) + + msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) + assert msg["validity"] == ["valid"] + + @fast_forward(60) async def test_game_info_messages(lobby_server): host_id, _, host_proto = await connect_and_sign_in( diff --git a/tests/integration_tests/test_matchmaker.py b/tests/integration_tests/test_matchmaker.py index be58d7ae4..fc037cc70 100644 --- a/tests/integration_tests/test_matchmaker.py +++ b/tests/integration_tests/test_matchmaker.py @@ -101,7 +101,7 @@ async def test_game_launch_message_game_options(lobby_server, tmp_user): @pytest.mark.flaky -@fast_forward(15) +@fast_forward(60) async def test_game_matchmaking_start(lobby_server, database): host_id, host, guest_id, guest = await queue_players_for_matchmaking(lobby_server) @@ -121,8 +121,6 @@ async def test_game_matchmaking_start(lobby_server, database): await read_until_command(guest, "game_launch") await open_fa(guest) - await read_until_command(host, "game_info") - await read_until_command(guest, "game_info") await send_player_options( host, (guest_id, "StartSpot", msg["map_position"]), @@ -166,7 +164,7 @@ async def test_game_matchmaking_start(lobby_server, database): assert row.technical_name == "ladder1v1" -@fast_forward(15) +@fast_forward(60) async def test_game_matchmaking_start_while_matched(lobby_server): _, proto1, _, _ = await queue_players_for_matchmaking(lobby_server) @@ -279,7 +277,7 @@ async def test_game_matchmaking_timeout_guest(lobby_server, game_service): await read_until_command(proto2, "search_info", state="start", timeout=5) -@fast_forward(15) +@fast_forward(60) async def test_game_matchmaking_cancel(lobby_server): _, proto = await queue_player_for_matchmaking( ("ladder1", "ladder1"), @@ -423,7 +421,7 @@ async def test_anti_map_repetition(lobby_server): ) -@fast_forward(10) +@fast_forward(60) async def test_matchmaker_info_message(lobby_server, mocker): mocker.patch("server.matchmaker.pop_timer.time", return_value=1_562_000_000) mocker.patch( @@ -454,7 +452,7 @@ async def test_matchmaker_info_message(lobby_server, mocker): assert queue["boundary_75s"] == [] -@fast_forward(10) +@fast_forward(60) async def test_command_matchmaker_info(lobby_server, mocker): mocker.patch("server.matchmaker.pop_timer.time", return_value=1_562_000_000) mocker.patch( @@ -488,7 +486,7 @@ async def test_command_matchmaker_info(lobby_server, mocker): assert queue["boundary_75s"] == [] -@fast_forward(10) +@fast_forward(60) async def test_matchmaker_info_message_on_cancel(lobby_server): _, _, proto = await connect_and_sign_in( ("ladder1", "ladder1"), @@ -533,7 +531,7 @@ async def read_update_msg(): assert len(queue_message["boundary_80s"]) == 0 -@fast_forward(10) +@fast_forward(60) async def test_search_info_messages(lobby_server): _, _, proto = await connect_and_sign_in( ("ladder1", "ladder1"), diff --git a/tests/integration_tests/test_server.py b/tests/integration_tests/test_server.py index ae00663bb..95b8ab124 100644 --- a/tests/integration_tests/test_server.py +++ b/tests/integration_tests/test_server.py @@ -579,10 +579,11 @@ async def test_game_info_broadcast_to_players_in_lobby(lobby_server): game_id = msg["uid"] await join_game(proto2, game_id) - - await read_until_command(proto1, "game_info", teams={"1": ["friends"]}) - await read_until_command(proto2, "game_info", teams={"1": ["friends"]}) - await send_player_options(proto1, [test_id, "Army", 1], [test_id, "Team", 1]) + await send_player_options( + proto1, + [test_id, "Army", 1], + [test_id, "Team", 1] + ) await read_until_command(proto1, "game_info", teams={"1": ["friends", "test"]}) await read_until_command(proto2, "game_info", teams={"1": ["friends", "test"]}) diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 248102f7c..f95399831 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -478,7 +478,6 @@ async def test_command_game_host_calls_host_game_invalid_title( lobbyconnection, mock_games, test_game_info_invalid ): lobbyconnection.send = mock.AsyncMock() - mock_games.create_game = mock.Mock() await lobbyconnection.on_message_received({ "command": "game_host", **test_game_info_invalid From ec6cb1700860fa5483de08da0a6344310bf57402 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 9 Apr 2022 15:52:36 -0800 Subject: [PATCH 3/4] Other minor code cleanup --- server/games/game.py | 2 +- tests/unit_tests/test_game.py | 8 ++++---- tests/unit_tests/test_game_stats_service.py | 6 ++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/server/games/game.py b/server/games/game.py index 9536a88b9..81c90fa91 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -894,7 +894,7 @@ def __eq__(self, other): return self.id == other.id def __hash__(self): - return self.id.__hash__() + return hash(self.id) def __str__(self) -> str: return ( diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index 6351eca79..bbd4cbe44 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -67,12 +67,12 @@ async def game_player_scores(database, game): return set(tuple(f) for f in result.fetchall()) -async def test_initialization(game: Game): +def test_initialization(game: Game): assert game.state is GameState.INITIALIZING assert game.enforce_rating is False -async def test_instance_logging(database, game_stats_service): +def test_instance_logging(database, game_stats_service): logger = logging.getLogger(f"{Game.__qualname__}.5") logger.debug = mock.Mock() mock_parent = mock.Mock() @@ -865,13 +865,13 @@ async def test_get_army_score_conflicting_results_tied(game, game_add_players): assert game.get_army_score(1) == 123 -async def test_equality(game): +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): +def test_hashing(game): assert { game: 1, Game(game.id, mock.Mock(), mock.Mock(), mock.Mock()): 1 diff --git a/tests/unit_tests/test_game_stats_service.py b/tests/unit_tests/test_game_stats_service.py index caf6ce153..9d24677f5 100644 --- a/tests/unit_tests/test_game_stats_service.py +++ b/tests/unit_tests/test_game_stats_service.py @@ -30,8 +30,10 @@ def achievement_service(): @pytest.fixture() -def game_stats_service(event_service, achievement_service): - return GameStatsService(event_service, achievement_service) +async def game_stats_service(event_service, achievement_service): + service = GameStatsService(event_service, achievement_service) + await service.initialize() + return service @pytest.fixture() From 2f71f902dc9fec5b5542eedfb9b40094132f5031 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 9 Apr 2022 16:38:19 -0800 Subject: [PATCH 4/4] Make sure game_info messages are sent when GameOptions change --- tests/integration_tests/test_game.py | 67 ++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index ccc07e94d..e2726e008 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -266,6 +266,52 @@ async def send_player_options(proto, *options): }) +async def send_game_options(proto, *options): + for option in options: + await proto.send_message({ + "target": "game", + "command": "GameOption", + "args": list(option) + }) + + +@fast_forward(20) +async def test_game_info_message_updates(lobby_server): + _, _, proto = await connect_and_sign_in( + ("test", "test_password"), lobby_server + ) + game_id = await host_game(proto) + await read_until_command(proto, "game_info", uid=game_id, timeout=5) + + await send_game_options(proto, ["Title", "Test GameOptions"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert msg["title"] == "Test GameOptions" + + await send_game_options(proto, ["Slots", "7"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert msg["max_players"] == 7 + + await send_game_options(proto, ["ScenarioFile", "/maps/foobar/scenario.lua"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert msg["mapname"] == "foobar" + assert msg["map_file_path"] == "maps/foobar.zip" + + await send_game_options(proto, ["RestrictedCategories", "1"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert "bad_unit_restrictions" in msg["validity"] + + await send_game_options(proto, ["Unranked", "Yes"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert "host_set_unranked" in msg["validity"] + + await send_game_options(proto, ["Victory", "eradication"]) + msg = await read_until_command(proto, "game_info", uid=game_id, timeout=5) + assert "wrong_victory_condition" in msg["validity"] + + # TODO: Some options don't need to trigger game info updates. Pruning these + # could help with performance and reduce network bandwidth + + @fast_forward(20) async def test_game_validity_states(lobby_server): host_id, _, host_proto = await connect_and_sign_in( @@ -285,14 +331,10 @@ async def test_game_validity_states(lobby_server): assert msg["validity"] == ["uneven_teams_not_ranked", "single_player"] # Change the map to an unranked map - await host_proto.send_message({ - "target": "game", - "command": "GameOption", - "args": [ - "ScenarioFile", - "/maps/neroxis_map_generator_sneaky_map/sneaky_map_scenario.lua" - ] - }) + await send_game_options(host_proto, [ + "ScenarioFile", + "/maps/neroxis_map_generator_sneaky_map/sneaky_map_scenario.lua" + ]) msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) assert msg["validity"] == [ "uneven_teams_not_ranked", @@ -307,11 +349,10 @@ async def test_game_validity_states(lobby_server): assert msg["validity"] == ["bad_map"] # Change the map to a ranked map - await host_proto.send_message({ - "target": "game", - "command": "GameOption", - "args": ["ScenarioFile", "/maps/scmp_001/scmp_001_scenario.lua"] - }) + await send_game_options(host_proto, [ + "ScenarioFile", + "/maps/scmp_001/scmp_001_scenario.lua" + ]) msg = await read_until_command(host_proto, "game_info", uid=game_id, timeout=5) assert msg["validity"] == ["valid"]