Skip to content

Commit f5442eb

Browse files
committed
Tournament game service
Closes #675
1 parent 6768256 commit f5442eb

28 files changed

Lines changed: 1593 additions & 52 deletions

.editorconfig

Lines changed: 766 additions & 0 deletions
Large diffs are not rendered by default.

server/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
from .rating_service.rating_service import RatingService
118118
from .servercontext import ServerContext
119119
from .stats.game_stats_service import GameStatsService
120+
from .tournament_service import TournamentService
120121

121122
__author__ = "Askaholic, Chris Kitching, Dragonfire, Gael Honorez, Jeroen De Dauw, Crotalus, Michael Søndergaard, Michel Jung"
122123
__contact__ = "admin@faforever.com"
@@ -134,6 +135,7 @@
134135
"MessageQueueService",
135136
"OAuthService",
136137
"PartyService",
138+
"TournamentService",
137139
"PlayerService",
138140
"RatingService",
139141
"RatingService",
@@ -145,6 +147,7 @@
145147
"run_control_server",
146148
)
147149

150+
148151
logger = logging.getLogger("server")
149152

150153
if config.ENABLE_METRICS:
@@ -197,6 +200,7 @@ def __init__(
197200
party_service=self.services["party_service"],
198201
rating_service=self.services["rating_service"],
199202
oauth_service=self.services["oauth_service"],
203+
tournament_service=self.services["tournament_service"],
200204
)
201205

202206
def write_broadcast(

server/game_service.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
"""
22
Manages the lifecycle of active games
33
"""
4-
4+
import asyncio
55
from collections import Counter
66
from typing import Optional, Union, ValuesView
77

88
import aiocron
99
from sqlalchemy import select
1010

1111
from server.config import config
12-
1312
from . import metrics
1413
from .core import Service
1514
from .db import FAFDatabase
@@ -145,7 +144,7 @@ def create_game(
145144
visibility=VisibilityState.PUBLIC,
146145
host: Optional[Player] = None,
147146
name: Optional[str] = None,
148-
mapname: Optional[str] = None,
147+
map_name: Optional[str] = None,
149148
password: Optional[str] = None,
150149
matchmaker_queue_id: Optional[int] = None,
151150
**kwargs
@@ -159,7 +158,7 @@ def create_game(
159158
"id_": game_id,
160159
"host": host,
161160
"name": name,
162-
"map_": mapname,
161+
"map_name": map_name,
163162
"game_mode": game_mode,
164163
"game_service": self,
165164
"game_stats_service": self.game_stats_service,
@@ -262,3 +261,8 @@ async def publish_game_results(self, game_results: EndedGameInfo):
262261
metrics.rated_games.labels(game_results.rating_type).inc()
263262
# TODO: Remove when rating service starts listening to message queue
264263
await self._rating_service.enqueue(result_dict)
264+
265+
266+
class NotConnectedError(asyncio.TimeoutError):
267+
def __init__(self, players: list[Player]):
268+
self.players = players

server/gameconnection.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,8 @@ async def handle_game_option(self, key: str, value: Any):
236236
raw = repr(value)
237237
self.game.map_scenario_path = \
238238
raw.replace("\\", "/").replace("//", "/").replace("'", "")
239-
self.game.map_file_path = "maps/{}.zip".format(
239+
self.game.map_name = \
240240
self.game.map_scenario_path.split("/")[2].lower()
241-
)
242241
elif key == "Title":
243242
with contextlib.suppress(ValueError):
244243
self.game.name = value

server/games/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .custom_game import CustomGame
99
from .game import Game, GameError
1010
from .ladder_game import LadderGame
11+
from .tournament_game import TournamentGame
1112
from .typedefs import (
1213
FeaturedModType,
1314
GameConnectionState,
@@ -41,6 +42,7 @@ class FeaturedMod(NamedTuple):
4142
"GameType",
4243
"InitMode",
4344
"LadderGame",
45+
"TournamentGame",
4446
"ValidityState",
4547
"Victory",
4648
"VisibilityState",

server/games/game.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import json
33
import logging
4+
import re
45
import time
56
from collections import defaultdict
67
from typing import Any, Iterable, Optional
@@ -47,6 +48,9 @@ class GameError(Exception):
4748
pass
4849

4950

51+
MAP_FILE_PATH_PATTERN = re.compile(r"maps/(.+)\.zip")
52+
53+
5054
class Game:
5155
"""
5256
Object that lasts for the lifetime of a game on FAF.
@@ -62,7 +66,7 @@ def __init__(
6266
game_stats_service: "GameStatsService",
6367
host: Optional[Player] = None,
6468
name: str = "None",
65-
map_: str = "SCMP_007",
69+
map_name: str = "SCMP_007",
6670
game_mode: str = FeaturedModType.FAF,
6771
matchmaker_queue_id: Optional[int] = None,
6872
rating_type: Optional[str] = None,
@@ -89,7 +93,7 @@ def __init__(
8993
self.host = host
9094
self.name = name
9195
self.map_id = None
92-
self.map_file_path = f"maps/{map_}.zip"
96+
self.map_name = map_name
9397
self.map_scenario_path = None
9498
self.password = None
9599
self._players_at_launch: list[Player] = []
@@ -153,6 +157,30 @@ def set_name_unchecked(self, value: str):
153157
max_len = game_stats.c.gameName.type.length
154158
self._name = value[:max_len]
155159

160+
@property
161+
def map_name(self):
162+
return self._map_name
163+
164+
@map_name.setter
165+
def map_name(self, name: str):
166+
self._map_name = name
167+
self._map_file_path = f"maps/{name}.zip"
168+
169+
@property
170+
def map_file_path(self):
171+
return self._map_file_path
172+
173+
@map_file_path.setter
174+
def map_file_path(self, path: str):
175+
m = re.match(MAP_FILE_PATH_PATTERN, path)
176+
if m is None:
177+
raise ValueError(
178+
"Map path must start with 'maps/' and end with '.zip'"
179+
)
180+
181+
self._map_name = m.group(1)
182+
self._map_file_path = path
183+
156184
@property
157185
def armies(self) -> frozenset[int]:
158186
return frozenset(
@@ -253,7 +281,7 @@ def get_team_sets(self) -> list[set[Player]]:
253281
raise GameError(
254282
"Missing team for at least one player. (player, team): {}"
255283
.format([(player, self.get_player_option(player.id, "Team"))
256-
for player in self.players])
284+
for player in self.players])
257285
)
258286

259287
teams = defaultdict(set)
@@ -439,7 +467,7 @@ async def on_game_finish(self):
439467
await self.process_game_results()
440468

441469
self._process_pending_army_stats()
442-
except Exception: # pragma: no cover
470+
except Exception: # pragma: no cover
443471
self._logger.exception("Error during game end")
444472
finally:
445473
self.state = GameState.ENDED
@@ -565,6 +593,7 @@ async def persist_results(self):
565593
def get_basic_info(self) -> BasicGameInfo:
566594
return BasicGameInfo(
567595
self.id,
596+
self.game_type,
568597
self.rating_type,
569598
self.map_id,
570599
self.game_mode,
@@ -936,10 +965,7 @@ def map_folder_name(self) -> str:
936965
try:
937966
return str(self.map_scenario_path.split("/")[2]).lower()
938967
except (IndexError, AttributeError):
939-
if self.map_file_path:
940-
return self.map_file_path[5:-4].lower()
941-
else:
942-
return "scmp_009"
968+
return self.map_name
943969

944970
def __eq__(self, other):
945971
if not isinstance(other, Game):
@@ -955,3 +981,9 @@ def __str__(self) -> str:
955981
f"Game({self.id}, {self.host.login if self.host else ''}, "
956982
f"{self.map_file_path})"
957983
)
984+
985+
def wait_launched(self, param):
986+
pass
987+
988+
def wait_hosted(self, param):
989+
pass

server/games/tournament_game.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import logging
2+
3+
from . import LadderGame
4+
from .typedefs import GameType
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class TournamentGame(LadderGame):
10+
"""Class for tournament games"""
11+
12+
game_type = GameType.TOURNAMENT

server/games/typedefs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class GameType(Enum):
3333
COOP = "coop"
3434
CUSTOM = "custom"
3535
MATCHMAKER = "matchmaker"
36+
TOURNAMENT = "tournament"
3637

3738

3839
@unique
@@ -90,12 +91,14 @@ class BasicGameInfo(NamedTuple):
9091
Holds basic information about a game that does not change after launch.
9192
Fields:
9293
- game_id: id of the game
94+
- game_type: type of the game
9395
- rating_type: str (e.g. "ladder1v1")
9496
- map_id: id of the map used
9597
- game_mode: name of the featured mod
9698
"""
9799

98100
game_id: int
101+
game_type: GameType
99102
rating_type: Optional[str]
100103
map_id: int
101104
game_mode: str

server/ladder_service/ladder_service.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
matchmaker_queue_map_pool
3535
)
3636
from server.decorators import with_logger
37-
from server.game_service import GameService
38-
from server.games import InitMode, LadderGame
37+
from server.game_service import GameService, NotConnectedError
38+
from server.games import Game, InitMode, LadderGame
3939
from server.games.ladder_game import GameClosedError
4040
from server.ladder_service.game_name import game_name
4141
from server.ladder_service.violation_service import ViolationService
@@ -563,23 +563,23 @@ def get_player_mean(player: Player) -> float:
563563
if game_options:
564564
game.gameOptions.update(game_options)
565565

566-
mapname = re.match("maps/(.+).zip", map_path).group(1)
566+
map_name = re.match("maps/(.+).zip", map_path).group(1)
567567
# FIXME: Database filenames contain the maps/ prefix and .zip suffix.
568568
# Really in the future, just send a better description
569569

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

572572
def make_game_options(player: Player) -> GameLaunchOptions:
573573
return GameLaunchOptions(
574-
mapname=mapname,
574+
mapname=map_name,
575575
expected_players=len(all_players),
576576
game_options=game_options,
577577
team=game.get_player_option(player.id, "Team"),
578578
faction=game.get_player_option(player.id, "Faction"),
579579
map_position=game.get_player_option(player.id, "StartSpot")
580580
)
581581

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

626-
async def launch_match(
626+
async def launch_server_made_game(
627627
self,
628-
game: LadderGame,
628+
game: Game,
629629
host: Player,
630630
guests: list[Player],
631631
make_game_options: Callable[[Player], GameLaunchOptions]
@@ -725,8 +725,3 @@ def on_connection_lost(self, conn: "LobbyConnection") -> None:
725725
async def shutdown(self):
726726
for queue in self.queues.values():
727727
queue.shutdown()
728-
729-
730-
class NotConnectedError(asyncio.TimeoutError):
731-
def __init__(self, players: list[Player]):
732-
self.players = players

server/lobbyconnection.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from .protocol import DisconnectedError, Protocol
5959
from .rating import InclusiveRange, RatingType
6060
from .rating_service import RatingService
61+
from .tournament_service import TournamentService
6162
from .types import Address, GameLaunchOptions
6263

6364

@@ -75,6 +76,7 @@ def __init__(
7576
party_service: PartyService,
7677
rating_service: RatingService,
7778
oauth_service: OAuthService,
79+
tournament_service: TournamentService,
7880
):
7981
self._db = database
8082
self.geoip_service = geoip
@@ -86,6 +88,7 @@ def __init__(
8688
self.party_service = party_service
8789
self.rating_service = rating_service
8890
self.oauth_service = oauth_service
91+
self.tournament_service = tournament_service
8992
self._authenticated = False
9093
self.player: Optional[Player] = None
9194
self.game_connection: Optional[GameConnection] = None
@@ -946,7 +949,7 @@ async def command_game_host(self, message):
946949
raise ClientError("Title must contain only ascii characters.")
947950

948951
mod = message.get("mod") or FeaturedModType.FAF
949-
mapname = message.get("mapname") or "scmp_007"
952+
map_name = message.get("mapname") or "scmp_007"
950953
password = message.get("password")
951954
game_mode = mod.lower()
952955
rating_min = message.get("rating_min")
@@ -965,14 +968,19 @@ async def command_game_host(self, message):
965968
game_class=game_class,
966969
host=self.player,
967970
name=title,
968-
mapname=mapname,
971+
map_name=map_name,
969972
password=password,
970973
rating_type=RatingType.GLOBAL,
971974
displayed_rating_range=InclusiveRange(rating_min, rating_max),
972975
enforce_rating_range=enforce_rating_range
973976
)
974977
await self.launch_game(game, is_host=True)
975978

979+
@player_idle("ready up for a tournament game")
980+
async def command_is_ready_response(self, message):
981+
assert isinstance(self.player, Player)
982+
await self.tournament_service.on_is_ready_response(message, self.player)
983+
976984
async def command_match_ready(self, message):
977985
"""
978986
Replace with full implementation when implemented in client, see:

0 commit comments

Comments
 (0)