Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tournament game service #921

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
766 changes: 766 additions & 0 deletions .editorconfig

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
from .rating_service.rating_service import RatingService
from .servercontext import ServerContext
from .stats.game_stats_service import GameStatsService
from .tournament_service import TournamentService

__author__ = "Askaholic, Chris Kitching, Dragonfire, Gael Honorez, Jeroen De Dauw, Crotalus, Michael Søndergaard, Michel Jung"
__contact__ = "admin@faforever.com"
Expand All @@ -134,6 +135,7 @@
"MessageQueueService",
"OAuthService",
"PartyService",
"TournamentService",
"PlayerService",
"RatingService",
"RatingService",
Expand All @@ -145,6 +147,7 @@
"run_control_server",
)


logger = logging.getLogger("server")

if config.ENABLE_METRICS:
Expand Down Expand Up @@ -197,6 +200,7 @@ def __init__(
party_service=self.services["party_service"],
rating_service=self.services["rating_service"],
oauth_service=self.services["oauth_service"],
tournament_service=self.services["tournament_service"],
)

def write_broadcast(
Expand Down
12 changes: 8 additions & 4 deletions server/game_service.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""
Manages the lifecycle of active games
"""

import asyncio
from collections import Counter
from typing import Optional, Union, ValuesView

import aiocron
from sqlalchemy import select

from server.config import config

from . import metrics
from .core import Service
from .db import FAFDatabase
Expand Down Expand Up @@ -145,7 +144,7 @@ def create_game(
visibility=VisibilityState.PUBLIC,
host: Optional[Player] = None,
name: Optional[str] = None,
mapname: Optional[str] = None,
map_name: Optional[str] = None,
password: Optional[str] = None,
matchmaker_queue_id: Optional[int] = None,
**kwargs
Expand All @@ -159,7 +158,7 @@ def create_game(
"id_": game_id,
"host": host,
"name": name,
"map_": mapname,
"map_name": map_name,
"game_mode": game_mode,
"game_service": self,
"game_stats_service": self.game_stats_service,
Expand Down Expand Up @@ -262,3 +261,8 @@ async def publish_game_results(self, game_results: EndedGameInfo):
metrics.rated_games.labels(game_results.rating_type).inc()
# TODO: Remove when rating service starts listening to message queue
await self._rating_service.enqueue(result_dict)


class NotConnectedError(asyncio.TimeoutError):
def __init__(self, players: list[Player]):
self.players = players
3 changes: 1 addition & 2 deletions server/gameconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,8 @@ async def handle_game_option(self, key: str, value: Any):
raw = repr(value)
self.game.map_scenario_path = \
raw.replace("\\", "/").replace("//", "/").replace("'", "")
self.game.map_file_path = "maps/{}.zip".format(
self.game.map_name = \
self.game.map_scenario_path.split("/")[2].lower()
)
elif key == "Title":
with contextlib.suppress(ValueError):
self.game.name = value
Expand Down
2 changes: 2 additions & 0 deletions server/games/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .custom_game import CustomGame
from .game import Game, GameError
from .ladder_game import LadderGame
from .tournament_game import TournamentGame
from .typedefs import (
FeaturedModType,
GameConnectionState,
Expand Down Expand Up @@ -41,6 +42,7 @@ class FeaturedMod(NamedTuple):
"GameType",
"InitMode",
"LadderGame",
"TournamentGame",
"ValidityState",
"Victory",
"VisibilityState",
Expand Down
48 changes: 40 additions & 8 deletions server/games/game.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import json
import logging
import re
import time
from collections import defaultdict
from typing import Any, Iterable, Optional
Expand Down Expand Up @@ -47,6 +48,9 @@ class GameError(Exception):
pass


MAP_FILE_PATH_PATTERN = re.compile(r"maps/(.+)\.zip")


class Game:
"""
Object that lasts for the lifetime of a game on FAF.
Expand All @@ -62,7 +66,7 @@ def __init__(
game_stats_service: "GameStatsService",
host: Optional[Player] = None,
name: str = "None",
map_: str = "SCMP_007",
map_name: str = "SCMP_007",
game_mode: str = FeaturedModType.FAF,
matchmaker_queue_id: Optional[int] = None,
rating_type: Optional[str] = None,
Expand All @@ -89,7 +93,7 @@ def __init__(
self.host = host
self.name = name
self.map_id = None
self.map_file_path = f"maps/{map_}.zip"
self.map_name = map_name
self.map_scenario_path = None
self.password = None
self._players_at_launch: list[Player] = []
Expand Down Expand Up @@ -153,6 +157,30 @@ def set_name_unchecked(self, value: str):
max_len = game_stats.c.gameName.type.length
self._name = value[:max_len]

@property
def map_name(self):
return self._map_name

@map_name.setter
def map_name(self, name: str):
self._map_name = name
self._map_file_path = f"maps/{name}.zip"

@property
def map_file_path(self):
return self._map_file_path

@map_file_path.setter
def map_file_path(self, path: str):
m = re.match(MAP_FILE_PATH_PATTERN, path)
if m is None:
raise ValueError(
"Map path must start with 'maps/' and end with '.zip'"
)

self._map_name = m.group(1)
self._map_file_path = path

@property
def armies(self) -> frozenset[int]:
return frozenset(
Expand Down Expand Up @@ -253,7 +281,7 @@ def get_team_sets(self) -> list[set[Player]]:
raise GameError(
"Missing team for at least one player. (player, team): {}"
.format([(player, self.get_player_option(player.id, "Team"))
for player in self.players])
for player in self.players])
)

teams = defaultdict(set)
Expand Down Expand Up @@ -439,7 +467,7 @@ async def on_game_finish(self):
await self.process_game_results()

self._process_pending_army_stats()
except Exception: # pragma: no cover
except Exception: # pragma: no cover
self._logger.exception("Error during game end")
finally:
self.state = GameState.ENDED
Expand Down Expand Up @@ -565,6 +593,7 @@ async def persist_results(self):
def get_basic_info(self) -> BasicGameInfo:
return BasicGameInfo(
self.id,
self.game_type,
self.rating_type,
self.map_id,
self.game_mode,
Expand Down Expand Up @@ -936,10 +965,7 @@ def map_folder_name(self) -> str:
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"
return self.map_name

def __eq__(self, other):
if not isinstance(other, Game):
Expand All @@ -955,3 +981,9 @@ def __str__(self) -> str:
f"Game({self.id}, {self.host.login if self.host else ''}, "
f"{self.map_file_path})"
)

def wait_launched(self, param):
pass

def wait_hosted(self, param):
pass
Comment on lines +985 to +989
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need these in the base class? They only make sense for automatch games.

12 changes: 12 additions & 0 deletions server/games/tournament_game.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import logging

from . import LadderGame
from .typedefs import GameType

logger = logging.getLogger(__name__)


class TournamentGame(LadderGame):
"""Class for tournament games"""

game_type = GameType.TOURNAMENT
3 changes: 3 additions & 0 deletions server/games/typedefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class GameType(Enum):
COOP = "coop"
CUSTOM = "custom"
MATCHMAKER = "matchmaker"
TOURNAMENT = "tournament"


@unique
Expand Down Expand Up @@ -90,12 +91,14 @@ class BasicGameInfo(NamedTuple):
Holds basic information about a game that does not change after launch.
Fields:
- game_id: id of the game
- game_type: type of the game
- rating_type: str (e.g. "ladder1v1")
- map_id: id of the map used
- game_mode: name of the featured mod
"""

game_id: int
game_type: GameType
rating_type: Optional[str]
map_id: int
game_mode: str
Expand Down
19 changes: 7 additions & 12 deletions server/ladder_service/ladder_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
matchmaker_queue_map_pool
)
from server.decorators import with_logger
from server.game_service import GameService
from server.games import InitMode, LadderGame
from server.game_service import GameService, NotConnectedError
from server.games import Game, InitMode, LadderGame
from server.games.ladder_game import GameClosedError
from server.ladder_service.game_name import game_name
from server.ladder_service.violation_service import ViolationService
Expand Down Expand Up @@ -563,23 +563,23 @@ def get_player_mean(player: Player) -> float:
if game_options:
game.gameOptions.update(game_options)

mapname = re.match("maps/(.+).zip", map_path).group(1)
map_name = re.match("maps/(.+).zip", map_path).group(1)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we refactor the regex?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where to?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about just making use of the properties you added to the Game class? The map_file_path is already being set on line 532.

map_name = game.map_name

# FIXME: Database filenames contain the maps/ prefix and .zip suffix.
# Really in the future, just send a better description

self._logger.debug("Starting ladder game: %s", game)

def make_game_options(player: Player) -> GameLaunchOptions:
return GameLaunchOptions(
mapname=mapname,
mapname=map_name,
expected_players=len(all_players),
game_options=game_options,
team=game.get_player_option(player.id, "Team"),
faction=game.get_player_option(player.id, "Faction"),
map_position=game.get_player_option(player.id, "StartSpot")
)

await self.launch_match(game, host, all_guests, make_game_options)
await self.launch_server_made_game(game, host, all_guests, make_game_options)
self._logger.debug("Ladder game launched successfully %s", game)
metrics.matches.labels(queue.name, MatchLaunch.SUCCESSFUL).inc()
except Exception as e:
Expand Down Expand Up @@ -623,9 +623,9 @@ def make_game_options(player: Player) -> GameLaunchOptions:
)
self.violation_service.register_violations(abandoning_players)

async def launch_match(
async def launch_server_made_game(
self,
game: LadderGame,
game: Game,
host: Player,
guests: list[Player],
make_game_options: Callable[[Player], GameLaunchOptions]
Expand Down Expand Up @@ -725,8 +725,3 @@ def on_connection_lost(self, conn: "LobbyConnection") -> None:
async def shutdown(self):
for queue in self.queues.values():
queue.shutdown()


class NotConnectedError(asyncio.TimeoutError):
def __init__(self, players: list[Player]):
self.players = players
12 changes: 10 additions & 2 deletions server/lobbyconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from .protocol import DisconnectedError, Protocol
from .rating import InclusiveRange, RatingType
from .rating_service import RatingService
from .tournament_service import TournamentService
from .types import Address, GameLaunchOptions


Expand All @@ -75,6 +76,7 @@ def __init__(
party_service: PartyService,
rating_service: RatingService,
oauth_service: OAuthService,
tournament_service: TournamentService,
):
self._db = database
self.geoip_service = geoip
Expand All @@ -86,6 +88,7 @@ def __init__(
self.party_service = party_service
self.rating_service = rating_service
self.oauth_service = oauth_service
self.tournament_service = tournament_service
self._authenticated = False
self.player: Optional[Player] = None
self.game_connection: Optional[GameConnection] = None
Expand Down Expand Up @@ -946,7 +949,7 @@ async def command_game_host(self, message):
raise ClientError("Title must contain only ascii characters.")

mod = message.get("mod") or FeaturedModType.FAF
mapname = message.get("mapname") or "scmp_007"
map_name = message.get("mapname") or "scmp_007"
password = message.get("password")
game_mode = mod.lower()
rating_min = message.get("rating_min")
Expand All @@ -965,14 +968,19 @@ async def command_game_host(self, message):
game_class=game_class,
host=self.player,
name=title,
mapname=mapname,
map_name=map_name,
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)

@player_idle("ready up for a tournament game")
async def command_is_ready_response(self, message):
assert isinstance(self.player, Player)
await self.tournament_service.on_is_ready_response(message, self.player)

async def command_match_ready(self, message):
"""
Replace with full implementation when implemented in client, see:
Expand Down
Loading