From 2932f863432ff2e9ff0d33d203be2f24546c8aee Mon Sep 17 00:00:00 2001 From: Askaholic Date: Mon, 14 Sep 2020 19:43:22 +0000 Subject: [PATCH] issue/#649 Allow host to set rating range limitations to game (#651) * Allow host to set rating range limitations to game * Ensure that in memory friends/foes are always up to date with database * Also broadcast lobby updates to players that already joined the game * Add tests for `Game.is_visible_to_player` * Refactor VisibilityState to use builtin enum functions --- integration_tests/test_matchmaking.py | 4 + server/__init__.py | 20 ++--- server/games/game.py | 32 ++++++- server/games/typedefs.py | 24 +----- server/lobbyconnection.py | 29 +++++-- server/rating.py | 32 ++++++- tests/integration_tests/test_game.py | 22 ++--- tests/integration_tests/test_server.py | 104 +++++++++++++++++++++++ tests/unit_tests/test_game.py | 77 +++++++++++------ tests/unit_tests/test_lobbyconnection.py | 34 ++++++-- 10 files changed, 286 insertions(+), 92 deletions(-) diff --git a/integration_tests/test_matchmaking.py b/integration_tests/test_matchmaking.py index c04a9a317..ca2b533b6 100644 --- a/integration_tests/test_matchmaking.py +++ b/integration_tests/test_matchmaking.py @@ -55,5 +55,9 @@ async def test_ladder_1v1_match(test_client): "num_players": 0, "max_players": 2, "launched_at": None, + "rating_type": "ladder_1v1", + "rating_min": None, + "rating_max": None, + "enforce_rating_range": False, "teams": {} } diff --git a/server/__init__.py b/server/__init__.py index 777476680..8f59ee643 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -23,7 +23,7 @@ from .db import FAFDatabase from .game_service import GameService from .gameconnection import GameConnection -from .games import GameState, VisibilityState +from .games import GameState from .geoip_service import GeoIpService from .ice_servers.nts import TwilioNTS from .ladder_service import LadderService @@ -168,22 +168,12 @@ def do_report_dirties(): # So we're going to be broadcasting this to _somebody_... message = game.to_dict() - # These games shouldn't be broadcast, but instead privately sent - # to those who are allowed to see them. - if game.visibility == VisibilityState.FRIENDS: - # To see this game, you must have an authenticated - # connection and be a friend of the host, or the host. - def validation_func(conn): - return conn.player.id in game.host.friends or \ - conn.player == game.host - else: - def validation_func(conn): - return conn.player.id not in game.host.foes - self.write_broadcast( message, - lambda conn: - conn.authenticated and validation_func(conn) + lambda conn: ( + conn.authenticated + and game.is_visible_to_player(conn.player) + ) ) @at_interval(45, loop=self.loop) diff --git a/server/games/game.py b/server/games/game.py index af04437f3..6d7990b0e 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -19,7 +19,7 @@ GameResultReports, resolve_game ) -from server.rating import RatingType +from server.rating import InclusiveRange, RatingType from ..abc.base_game import GameConnectionState, InitMode from ..players import Player, PlayerState @@ -60,6 +60,8 @@ def __init__( map_: str = "SCMP_007", game_mode: str = FeaturedModType.FAF, rating_type: Optional[str] = None, + displayed_rating_range: Optional[InclusiveRange] = None, + enforce_rating_range: bool = False, max_players: int = 12 ): self._db = database @@ -89,6 +91,8 @@ def __init__( 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() + self.enforce_rating_range = enforce_rating_range self.state = GameState.INITIALIZING self._connections = {} self.enforce_rating = False @@ -782,6 +786,26 @@ def report_army_stats(self, stats_json): self._army_stats_list = json.loads(stats_json)["stats"] self._process_pending_army_stats() + def is_visible_to_player(self, player: Player) -> bool: + if player == self.host or player in self.players: + return True + + mean, dev = player.ratings[self.rating_type] + displayed_rating = mean - 3 * dev + if ( + self.enforce_rating_range + and displayed_rating not in self.displayed_rating_range + ): + return False + + if self.host is None: + return False + + if self.visibility is VisibilityState.FRIENDS: + return player.id in self.host.friends + else: + return player.id not in self.host.foes + def to_dict(self): client_state = { GameState.LOBBY: "open", @@ -791,7 +815,7 @@ def to_dict(self): }.get(self.state, "closed") return { "command": "game_info", - "visibility": VisibilityState.to_string(self.visibility), + "visibility": self.visibility.value, "password_protected": self.password is not None, "uid": self.id, "title": self.name, @@ -805,6 +829,10 @@ def to_dict(self): "num_players": len(self.players), "max_players": self.max_players, "launched_at": self.launched_at, + "rating_type": self.rating_type, + "rating_min": self.displayed_rating_range.lo, + "rating_max": self.displayed_rating_range.hi, + "enforce_rating_range": self.enforce_rating_range, "teams": { team: [ player.login for player in self.players diff --git a/server/games/typedefs.py b/server/games/typedefs.py index 9b1c2b5aa..958a94e46 100644 --- a/server/games/typedefs.py +++ b/server/games/typedefs.py @@ -20,6 +20,7 @@ class Victory(Enum): ERADICATION = 2 SANDBOX = 3 + @unique class GameType(Enum): COOP = 0 @@ -46,28 +47,11 @@ def to_string(self) -> Optional[str]: GameType.MATCHMAKER: "matchmaker", }.get(self) + @unique class VisibilityState(Enum): - PUBLIC = 0 - FRIENDS = 1 - - @staticmethod - def from_string(value: str) -> Optional["VisibilityState"]: - """ - :param value: The string to convert from - - :return: VisibilityState or None if the string is not valid - """ - return { - "public": VisibilityState.PUBLIC, - "friends": VisibilityState.FRIENDS, - }.get(value) - - def to_string(self) -> Optional[str]: - return { - VisibilityState.PUBLIC: "public", - VisibilityState.FRIENDS: "friends", - }.get(self) + PUBLIC = "public" + FRIENDS = "friends" # Identifiers must be kept in sync with the contents of the invalid_game_reasons table. diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 10aef5fc3..21e604de1 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -40,6 +40,7 @@ from .player_service import PlayerService from .players import Player, PlayerState from .protocol import DisconnectedError, Protocol +from .rating import InclusiveRange, RatingType from .types import Address, GameLaunchOptions @@ -271,8 +272,10 @@ async def send_game_list(self): async def command_social_remove(self, message): if "friend" in message: subject_id = message["friend"] + player_attr = self.player.friends elif "foe" in message: subject_id = message["foe"] + player_attr = self.player.foes else: await self.abort("No-op social_remove.") return @@ -283,13 +286,18 @@ async def command_social_remove(self, message): friends_and_foes.c.subject_id == subject_id ))) + with contextlib.suppress(KeyError): + player_attr.remove(subject_id) + async def command_social_add(self, message): if "friend" in message: status = "FRIEND" subject_id = message["friend"] + player_attr = self.player.friends elif "foe" in message: status = "FOE" subject_id = message["foe"] + player_attr = self.player.foes else: return @@ -300,6 +308,8 @@ async def command_social_add(self, message): subject_id=subject_id, )) + player_attr.add(subject_id) + async def kick(self): await self.send({ "command": "notice", @@ -887,12 +897,7 @@ async def command_game_host(self, message): await self.abort_connection_if_banned() - visibility = VisibilityState.from_string(message.get("visibility")) - if not isinstance(visibility, VisibilityState): - # Protocol violation. - await self.abort("{} sent a nonsense visibility code: {}".format(self.player.login, message.get("visibility"))) - return - + visibility = VisibilityState(message["visibility"]) title = message.get("title") or f"{self.player.login}'s game" try: @@ -909,6 +914,13 @@ async def command_game_host(self, message): mapname = message.get("mapname") or "scmp_007" password = message.get("password") game_mode = mod.lower() + rating_min = message.get("rating_min") + rating_max = message.get("rating_max") + enforce_rating_range = bool(message.get("enforce_rating_range", False)) + if rating_min is not None: + rating_min = float(rating_min) + if rating_max is not None: + rating_max = float(rating_max) game = self.game_service.create_game( visibility=visibility, @@ -916,7 +928,10 @@ async def command_game_host(self, message): host=self.player, name=title, mapname=mapname, - password=password + password=password, + rating_type=RatingType.GLOBAL, + displayed_rating_range=InclusiveRange(rating_min, rating_max), + enforce_rating_range=enforce_rating_range ) await self.launch_game(game, is_host=True) diff --git a/server/rating.py b/server/rating.py index 4a62a9ad9..3a286edf5 100644 --- a/server/rating.py +++ b/server/rating.py @@ -1,4 +1,4 @@ -from typing import DefaultDict, Tuple, TypeVar, Union +from typing import DefaultDict, Optional, Tuple, TypeVar, Union from trueskill import Rating @@ -47,3 +47,33 @@ def __getitem__(self, key: K) -> Tuple[float, float]: return tmm_2v2_rating else: return super().__getitem__(key) + + +class InclusiveRange(): + """ + A simple inclusive range. + + # Examples + assert 10 in InclusiveRange() + assert 10 in InclusiveRange(0) + assert 10 in InclusiveRange(0, 10) + assert -1 not in InclusiveRange(0, 10) + assert 11 not in InclusiveRange(0, 10) + """ + def __init__(self, lo: Optional[float] = None, hi: Optional[float] = None): + self.lo = lo + self.hi = hi + + def __contains__(self, rating: float) -> bool: + if self.lo is not None and rating < self.lo: + return False + if self.hi is not None and rating > self.hi: + return False + return True + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, type(self)) + and self.lo == other.lo + and self.hi == other.hi + ) diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index 4d7768b11..1b20a0e45 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -22,17 +22,7 @@ async def host_game(proto: Protocol) -> int: msg = await read_until_command(proto, "game_launch") game_id = int(msg["uid"]) - # Simulate FA opening - await proto.send_message({ - "target": "game", - "command": "GameState", - "args": ["Idle"] - }) - await proto.send_message({ - "target": "game", - "command": "GameState", - "args": ["Lobby"] - }) + await open_fa(proto) return game_id @@ -43,8 +33,14 @@ async def join_game(proto: Protocol, uid: int): "uid": uid }) await read_until_command(proto, "game_launch") + await open_fa(proto) + # HACK: Yield long enough for the server to process our message + await asyncio.sleep(0.5) + + +async def open_fa(proto): + """Simulate FA opening""" - # Simulate FA opening await proto.send_message({ "target": "game", "command": "GameState", @@ -55,8 +51,6 @@ async def join_game(proto: Protocol, uid: int): "command": "GameState", "args": ["Lobby"] }) - # HACK: Yield long enough for the server to process our message - await asyncio.sleep(0.5) async def get_player_ratings(proto, *names, rating_type="global"): diff --git a/tests/integration_tests/test_server.py b/tests/integration_tests/test_server.py index efe5bdde6..fdeefcdb9 100644 --- a/tests/integration_tests/test_server.py +++ b/tests/integration_tests/test_server.py @@ -14,6 +14,7 @@ read_until, read_until_command ) +from .test_game import join_game, open_fa, send_player_options pytestmark = pytest.mark.asyncio TEST_ADDRESS = ("127.0.0.1", None) @@ -144,6 +145,108 @@ async def test_game_info_broadcast_to_friends(lobby_server): await read_until_command(proto3, "game_info", timeout=1) +@pytest.mark.parametrize("limit", ( + (None, 1000), + (1500, 1700), + (1500, None), +)) +@fast_forward(5) +async def test_game_info_not_broadcast_out_of_rating_range(lobby_server, limit): + # Rhiza has displayed rating of 1462 + _, _, proto1 = await connect_and_sign_in( + ("test", "test_password"), lobby_server + ) + _, _, proto2 = await connect_and_sign_in( + ("Rhiza", "puff_the_magic_dragon"), lobby_server + ) + await read_until_command(proto1, "game_info") + await read_until_command(proto2, "game_info") + + await proto1.send_message({ + "command": "game_host", + "title": "No noobs!", + "mod": "faf", + "visibility": "public", + "rating_min": limit[0], + "rating_max": limit[1], + "enforce_rating_range": True + }) + + msg = await read_until_command(proto1, "game_info") + + assert msg["featured_mod"] == "faf" + assert msg["title"] == "No noobs!" + assert msg["visibility"] == "public" + + with pytest.raises(asyncio.TimeoutError): + await read_until_command(proto2, "game_info", timeout=1) + + +@fast_forward(10) +async def test_game_info_broadcast_to_players_in_lobby(lobby_server): + # test is the friend of friends + _, _, proto1 = await connect_and_sign_in( + ("friends", "friends"), lobby_server + ) + test_id, _, proto2 = await connect_and_sign_in( + ("test", "test_password"), lobby_server + ) + await read_until_command(proto1, "game_info") + await read_until_command(proto2, "game_info") + + await proto1.send_message({ + "command": "game_host", + "title": "Friends Only", + "mod": "faf", + "visibility": "friends" + }) + + # The host and his friend should see the game + await read_until_command(proto1, "game_info") + await read_until_command(proto2, "game_info") + await open_fa(proto1) + # The host joins which changes the lobby state + msg = await read_until_command(proto1, "game_info") + msg2 = await read_until_command(proto2, "game_info") + + assert msg == msg2 + assert msg["featured_mod"] == "faf" + assert msg["title"] == "Friends Only" + assert msg["visibility"] == "friends" + assert msg["state"] == "open" + + game_id = msg["uid"] + await join_game(proto2, game_id) + + await read_until_command(proto1, "game_info") + await read_until_command(proto2, "game_info") + await send_player_options(proto1, [test_id, "Army", 1]) + await read_until_command(proto1, "game_info") + await read_until_command(proto2, "game_info") + + # Now we unfriend the person in the lobby + await proto1.send_message({ + "command": "social_remove", + "friend": test_id + }) + # And change some game options to trigger a new update message + await proto1.send_message({ + "target": "game", + "command": "GameOption", + "args": ["Title", "New Title"] + }) + + # The host and the other player in the lobby should see the game even + # though they are not friends anymore + msg = await read_until_command(proto1, "game_info", timeout=2) + msg2 = await read_until_command(proto2, "game_info", timeout=2) + + assert msg == msg2 + assert msg["featured_mod"] == "faf" + assert msg["title"] == "New Title" + assert msg["visibility"] == "friends" + + @pytest.mark.parametrize("user", [ ("test", "test_password"), ("ban_revoked", "ban_revoked"), @@ -193,6 +296,7 @@ async def test_host_missing_fields(event_loop, lobby_server, player_service): assert msg["map_file_path"] == "maps/scmp_007.zip" assert msg["featured_mod"] == "faf" + @fast_forward(5) async def test_host_coop_game(lobby_server): player_id, session, proto = await connect_and_sign_in( diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index f0c32744e..33872ebb7 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -19,7 +19,7 @@ VisibilityState ) from server.games.game_results import GameOutcome -from server.rating import RatingType +from server.rating import InclusiveRange, RatingType from tests.unit_tests.conftest import ( add_connected_player, add_connected_players, @@ -30,22 +30,19 @@ pytestmark = pytest.mark.asyncio -@pytest.yield_fixture -def game(event_loop, database, game_service, game_stats_service): - game = Game(42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL) - yield game +@pytest.fixture +def game(database, game_service, game_stats_service): + return Game(42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL) -@pytest.yield_fixture -def coop_game(event_loop, database, game_service, game_stats_service): - game = CoopGame(42, database, game_service, game_stats_service) - yield game +@pytest.fixture +def coop_game(database, game_service, game_stats_service): + return CoopGame(42, database, game_service, game_stats_service) @pytest.yield_fixture -def custom_game(event_loop, database, game_service, game_stats_service): - game = CustomGame(42, database, game_service, game_stats_service) - yield game +def custom_game(database, game_service, game_stats_service): + return CustomGame(42, database, game_service, game_stats_service) async def game_player_scores(database, game): @@ -239,6 +236,45 @@ async def test_single_team_not_rated(game, game_add_players): assert game.validity is ValidityState.UNEVEN_TEAMS_NOT_RANKED +async def test_game_visible_to_host(game: Game, players): + game.host = players.hosting + game.visibility = None # Ensure that visibility is not checked + assert game.is_visible_to_player(players.hosting) + + +async def test_game_visible_to_players(game: Game, players): + game.host = players.hosting + game.visibility = None # Ensure that visibility is not checked + game.state = GameState.LOBBY + add_connected_player(game, players.joining) + assert game.is_visible_to_player(players.joining) + + +async def test_game_not_visible_to_foes(game: Game, players): + game.host = players.hosting + players.hosting.foes.add(players.joining.id) + assert not game.is_visible_to_player(players.joining) + + +async def test_game_visible_to_friends(game: Game, players): + game.host = players.hosting + game.visibility = VisibilityState.FRIENDS + players.hosting.friends.add(players.joining.id) + assert game.is_visible_to_player(players.joining) + + +async def test_game_visible_for_rating(game: Game, players): + game.enforce_rating_range = True + game.displayed_rating_range = InclusiveRange(2000, None) + game.host = players.hosting + + players.joining.ratings[RatingType.GLOBAL] = (1500, 1) + assert not game.is_visible_to_player(players.joining) + + players.joining.ratings[RatingType.GLOBAL] = (2100, 1) + assert game.is_visible_to_player(players.joining) + + async def test_set_player_option(game, players, mock_game_connection): game.state = GameState.LOBBY mock_game_connection.player = players.hosting @@ -541,7 +577,7 @@ async def test_to_dict(game, player_factory): data = game.to_dict() expected = { "command": "game_info", - "visibility": VisibilityState.to_string(game.visibility), + "visibility": game.visibility.value, "password_protected": game.password is not None, "uid": game.id, "title": game.sanitize_name(game.name), @@ -555,6 +591,10 @@ async def test_to_dict(game, player_factory): "num_players": len(game.players), "max_players": game.max_players, "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, "teams": { team: [ player.login for player in game.players @@ -831,17 +871,6 @@ async def test_game_outcomes_conflicting(game: Game, database, players): # No guarantees on scores for conflicting results. -async def test_visibility_states(): - states = [("public", VisibilityState.PUBLIC), - ("friends", VisibilityState.FRIENDS)] - - for string_value, enum_value in states: - assert ( - VisibilityState.from_string(string_value) == enum_value - and VisibilityState.to_string(enum_value) == string_value - ) - - async def test_is_even(game: Game, game_add_players): game.state = GameState.LOBBY game_add_players(game, 4, team=2) diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index c439c2872..00c1cc4fb 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -9,12 +9,12 @@ from asynctest import CoroutineMock from sqlalchemy import and_, select -from server import GameState, VisibilityState, config from server.abc.base_game import InitMode +from server.config import config from server.db.models import ban, friends_and_foes from server.game_service import GameService from server.gameconnection import GameConnection -from server.games import CustomGame, Game +from server.games import CustomGame, Game, GameState, VisibilityState from server.geoip_service import GeoIpService from server.ice_servers.nts import TwilioNTS from server.ladder_service import LadderService @@ -23,7 +23,7 @@ from server.player_service import PlayerService from server.players import PlayerState from server.protocol import DisconnectedError, QDataStreamProtocol -from server.rating import RatingType +from server.rating import InclusiveRange, RatingType from server.types import Address pytestmark = pytest.mark.asyncio @@ -33,7 +33,7 @@ def test_game_info(): return { "title": "Test game", - "visibility": VisibilityState.to_string(VisibilityState.PUBLIC), + "visibility": VisibilityState.PUBLIC.value, "mod": "faf", "mapname": "scmp_007", "password": None, @@ -46,7 +46,7 @@ def test_game_info(): def test_game_info_invalid(): return { "title": "Title with non ASCI char \xc3", - "visibility": VisibilityState.to_string(VisibilityState.PUBLIC), + "visibility": VisibilityState.PUBLIC.value, "mod": "faf", "mapname": "scmp_007", "password": None, @@ -231,10 +231,9 @@ async def test_double_login_disconnected(lobbyconnection, mock_players, player_f lobbyconnection.abort.assert_not_called() -async def test_command_game_host_creates_game(lobbyconnection, - mock_games, - test_game_info, - players): +async def test_command_game_host_creates_game( + lobbyconnection, mock_games, test_game_info, players +): lobbyconnection.player = players.hosting await lobbyconnection.on_message_received({ "command": "game_host", @@ -247,6 +246,9 @@ async def test_command_game_host_creates_game(lobbyconnection, "visibility": VisibilityState.PUBLIC, "password": test_game_info["password"], "mapname": test_game_info["mapname"], + "rating_type": RatingType.GLOBAL, + "displayed_rating_range": InclusiveRange(None, None), + "enforce_rating_range": False } mock_games.create_game.assert_called_with(**expected_call) @@ -628,6 +630,7 @@ async def test_command_social_add_friend(lobbyconnection, database): friends = await get_friends(lobbyconnection.player.id, database) assert friends == [] + assert lobbyconnection.player.friends == set() await lobbyconnection.on_message_received({ "command": "social_add", @@ -636,6 +639,7 @@ async def test_command_social_add_friend(lobbyconnection, database): friends = await get_friends(lobbyconnection.player.id, database) assert friends == [2] + assert lobbyconnection.player.friends == {2} async def test_command_social_remove_friend(lobbyconnection, database): @@ -643,6 +647,7 @@ async def test_command_social_remove_friend(lobbyconnection, database): friends = await get_friends(lobbyconnection.player.id, database) assert friends == [1] + lobbyconnection.player.friends = {1} await lobbyconnection.on_message_received({ "command": "social_remove", @@ -651,6 +656,17 @@ async def test_command_social_remove_friend(lobbyconnection, database): friends = await get_friends(lobbyconnection.player.id, database) assert friends == [] + assert lobbyconnection.player.friends == set() + + # Removing twice does nothing + await lobbyconnection.on_message_received({ + 'command': 'social_remove', + 'friend': 1 + }) + + friends = await get_friends(lobbyconnection.player.id, database) + assert friends == [] + assert lobbyconnection.player.friends == set() async def test_command_ice_servers(