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

Issue/#675 Matchmaking requests from RabbitMQ #788

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion server/game_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def create_game(
"id_": game_id,
"host": host,
"name": name,
"map_": mapname,
"map_name": mapname,
"game_mode": game_mode,
"game_service": self,
"game_stats_service": self.game_stats_service,
Expand Down
3 changes: 1 addition & 2 deletions server/gameconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,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
36 changes: 30 additions & 6 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 @@ -43,6 +44,8 @@
VisibilityState
)

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


class GameError(Exception):
pass
Expand All @@ -63,7 +66,7 @@
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 Down Expand Up @@ -91,7 +94,7 @@
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 @@ -155,6 +158,30 @@
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(

Check warning on line 178 in server/games/game.py

View check run for this annotation

Codecov / codecov/patch

server/games/game.py#L178

Added line #L178 was not covered by tests
"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 @@ -930,10 +957,7 @@
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"
Askaholic marked this conversation as resolved.
Show resolved Hide resolved
return self.map_name

def __eq__(self, other):
if not isinstance(other, Game):
Expand Down
191 changes: 183 additions & 8 deletions server/ladder_service/ladder_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import asyncio
import json
import random
import re
import statistics
from collections import defaultdict
from typing import Awaitable, Callable, Optional

import aio_pika
import aiocron
import humanize
from sqlalchemy import and_, func, select, text, true
Expand All @@ -35,6 +35,7 @@
)
from server.decorators import with_logger
from server.exceptions import DisabledError
from server.factions import Faction
from server.game_service import GameService
from server.games import InitMode, LadderGame
from server.games.ladder_game import GameClosedError
Expand All @@ -46,7 +47,9 @@
OnMatchedCallback,
Search
)
from server.message_queue_service import MessageQueueService
from server.metrics import MatchLaunch
from server.player_service import PlayerService
from server.players import Player, PlayerState
from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap

Expand All @@ -62,20 +65,38 @@
self,
database: FAFDatabase,
game_service: GameService,
message_queue_service: MessageQueueService,
player_service: PlayerService,
violation_service: ViolationService,
):
self._db = database
self._informed_players: set[Player] = set()
self.game_service = game_service
self.queues = {}
self.message_queue_service = message_queue_service
self.player_service = player_service
self.violation_service = violation_service
self.queues = {}

self._initialized = False
self._informed_players: set[Player] = set()
self._searches: dict[Player, dict[str, Search]] = defaultdict(dict)
self._allow_new_searches = True

async def initialize(self) -> None:
if self._initialized:
return

await self.update_data()
await self.message_queue_service.declare_exchange(
config.MQ_EXCHANGE_NAME
)
await self.message_queue_service.consume(
config.MQ_EXCHANGE_NAME,
"request.match.create",
self.handle_mq_matchmaking_request
)

self._update_cron = aiocron.crontab("*/10 * * * *", func=self.update_data)
self._initialized = True

async def update_data(self) -> None:
async with self._db.acquire() as conn:
Expand Down Expand Up @@ -433,6 +454,158 @@
)
})

async def handle_mq_matchmaking_request(
self,
message: aio_pika.IncomingMessage
):
try:
game = await self._handle_mq_matchmaking_request(message)
except Exception as e:
if isinstance(e, NotConnectedError):
code = "launch_failed"
args = [{"player_id": player.id} for player in e.players]
elif isinstance(e, json.JSONDecodeError):
code = "invalid_request"
args = [{"message": str(e)}]

Check warning on line 469 in server/ladder_service/ladder_service.py

View check run for this annotation

Codecov / codecov/patch

server/ladder_service/ladder_service.py#L468-L469

Added lines #L468 - L469 were not covered by tests
elif isinstance(e, KeyError):
code = "invalid_request"
args = [{"message": f"missing '{e.args[0]}'"}]
elif isinstance(e, InvalidRequestError):
code = e.code
args = e.args
else:
code = "unknown"
args = e.args

Check warning on line 478 in server/ladder_service/ladder_service.py

View check run for this annotation

Codecov / codecov/patch

server/ladder_service/ladder_service.py#L477-L478

Added lines #L477 - L478 were not covered by tests

await self.message_queue_service.publish(
config.MQ_EXCHANGE_NAME,
"error.match.create",
{"error_code": code, "args": args},
correlation_id=message.correlation_id
)
else:
await self.message_queue_service.publish(
config.MQ_EXCHANGE_NAME,
"success.match.create",
{"game_id": game.id},
correlation_id=message.correlation_id
)

async def _handle_mq_matchmaking_request(
self,
message: aio_pika.IncomingMessage
):
self._logger.debug(
"Got matchmaking request: %s", message.correlation_id
)
request = json.loads(message.body)
# TODO: Use id instead of name?
queue_name = request.get("matchmaker_queue")
map_name = request["map_name"]
game_name = request["game_name"]
participants = request["participants"]
featured_mod = request.get("featured_mod")
game_options = request.get("game_options")

if not featured_mod and not queue_name:
raise KeyError("featured_mod")

if queue_name and queue_name not in self.queues:
raise InvalidRequestError(
"invalid_request",
{"message": f"invalid queue '{queue_name}'"},
)

if not participants:
raise InvalidRequestError(
"invalid_request",
{"message": "empty participants"},
)

player_ids = [participant["player_id"] for participant in participants]
missing_players = [
id for id in player_ids if self.player_service[id] is None
]
if missing_players:
raise InvalidRequestError(
"players_not_found",
*[{"player_id": id} for id in missing_players]
)

all_players = [
self.player_service[player_id] for player_id in player_ids
]
non_idle_players = [
player for player in all_players
if player.state != PlayerState.IDLE
]
if non_idle_players:
raise InvalidRequestError(
"invalid_state",
*[
{"player_id": player.id, "state": player.state.name}
for player in all_players
]
)

queue = self.queues[queue_name] if queue_name else None
featured_mod = featured_mod or queue.featured_mod
host = all_players[0]
guests = all_players[1:]

for player in all_players:
player.state = PlayerState.STARTING_AUTOMATCH

game = None
try:
game = self.game_service.create_game(
game_class=LadderGame,
game_mode=featured_mod,
host=host,
name="Matchmaker Game",
mapname=map_name,
matchmaker_queue_id=queue.id if queue else None,
rating_type=queue.rating_type if queue else None,
max_players=len(participants)
)
game.init_mode = InitMode.AUTO_LOBBY
game.set_name_unchecked(game_name)

for participant in participants:
player_id = participant["player_id"]
faction = Faction.from_value(participant["faction"])
team = participant["team"]
slot = participant["slot"]

game.set_player_option(player_id, "Faction", faction.value)
game.set_player_option(player_id, "Team", team)
game.set_player_option(player_id, "StartSpot", slot)
game.set_player_option(player_id, "Army", slot)
game.set_player_option(player_id, "Color", slot)

def make_game_options(player: Player) -> GameLaunchOptions:
return GameLaunchOptions(
mapname=game.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, guests, make_game_options)

return game
except Exception:
if game:
await game.on_game_finish()

for player in all_players:
if player.state == PlayerState.STARTING_AUTOMATCH:
player.state = PlayerState.IDLE

raise

def on_match_found(
self,
s1: Search,
Expand Down Expand Up @@ -561,15 +734,11 @@
if game_options:
game.gameOptions.update(game_options)

mapname = re.match("maps/(.+).zip", map_path).group(1)
# 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=game.map_name,
expected_players=len(all_players),
game_options=game_options,
team=game.get_player_option(player.id, "Team"),
Expand Down Expand Up @@ -734,3 +903,9 @@
class NotConnectedError(asyncio.TimeoutError):
def __init__(self, players: list[Player]):
self.players = players


class InvalidRequestError(Exception):
def __init__(self, code: str, *args):
super().__init__(*args)
self.code = code
2 changes: 1 addition & 1 deletion server/matchmaker/matchmaker_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def get_map_pool_for_rating(self, rating: float) -> Optional[MapPool]:
continue
return map_pool

def get_game_options(self) -> dict[str, Any]:
def get_game_options(self) -> Optional[dict[str, Any]]:
return self.params.get("GameOptions") or None

def initialize(self):
Expand Down
Loading