Skip to content

Commit

Permalink
issue/#649 Allow host to set rating range limitations to game (#651)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Askaholic authored Sep 14, 2020
1 parent 22a8e9c commit 2932f86
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 92 deletions.
4 changes: 4 additions & 0 deletions integration_tests/test_matchmaking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
}
20 changes: 5 additions & 15 deletions server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 30 additions & 2 deletions server/games/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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
Expand Down
24 changes: 4 additions & 20 deletions server/games/typedefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Victory(Enum):
ERADICATION = 2
SANDBOX = 3


@unique
class GameType(Enum):
COOP = 0
Expand All @@ -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.
Expand Down
29 changes: 22 additions & 7 deletions server/lobbyconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -909,14 +914,24 @@ 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,
game_mode=game_mode,
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)

Expand Down
32 changes: 31 additions & 1 deletion server/rating.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import DefaultDict, Tuple, TypeVar, Union
from typing import DefaultDict, Optional, Tuple, TypeVar, Union

from trueskill import Rating

Expand Down Expand Up @@ -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
)
22 changes: 8 additions & 14 deletions tests/integration_tests/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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"):
Expand Down
Loading

0 comments on commit 2932f86

Please sign in to comment.