Skip to content

Commit 4b597cc

Browse files
committed
Accept matchmaking requests from rabbitmq
1 parent 0dbdc1a commit 4b597cc

7 files changed

Lines changed: 463 additions & 29 deletions

File tree

server/ladder_service.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from collections import defaultdict
99
from typing import Dict, List, Optional, Set, Tuple
1010

11+
import aio_pika
1112
import aiocron
1213
from sqlalchemy import and_, func, select, text, true
1314

@@ -30,9 +31,12 @@
3031
matchmaker_queue_map_pool
3132
)
3233
from .decorators import with_logger
34+
from .factions import Faction
3335
from .game_service import GameService
3436
from .games import Game, InitMode, LadderGame
3537
from .matchmaker import MapPool, MatchmakerQueue, OnMatchedCallback, Search
38+
from .message_queue_service import MessageQueueService
39+
from .player_service import PlayerService
3640
from .players import Player, PlayerState
3741
from .protocol import DisconnectedError
3842
from .types import GameLaunchOptions, Map, NeroxisGeneratedMap
@@ -55,17 +59,35 @@ def __init__(
5559
self,
5660
database: FAFDatabase,
5761
game_service: GameService,
62+
player_service: PlayerService,
63+
message_queue_service: MessageQueueService
5864
):
5965
self._db = database
6066
self._informed_players: Set[Player] = set()
6167
self.game_service = game_service
68+
self.player_service = player_service
69+
self.message_queue_service = message_queue_service
6270
self.queues = {}
71+
self._initialized = False
6372

6473
self._searches: Dict[Player, Dict[str, Search]] = defaultdict(dict)
6574

6675
async def initialize(self) -> None:
76+
if self._initialized:
77+
return
78+
6779
await self.update_data()
80+
await self.message_queue_service.declare_exchange(
81+
config.MQ_EXCHANGE_NAME
82+
)
83+
await self.message_queue_service.consume(
84+
config.MQ_EXCHANGE_NAME,
85+
"request.match.create",
86+
self.handle_mq_matchmaking_request
87+
)
88+
6889
self._update_cron = aiocron.crontab("*/10 * * * *", func=self.update_data)
90+
self._initialized = True
6991

7092
async def update_data(self) -> None:
7193
async with self._db.acquire() as conn:
@@ -325,6 +347,135 @@ def write_rating_progress(self, player: Player, rating_type: str) -> None:
325347
)
326348
})
327349

350+
async def handle_mq_matchmaking_request(
351+
self,
352+
message: aio_pika.IncomingMessage
353+
):
354+
try:
355+
game = await self._handle_mq_matchmaking_request(message)
356+
except Exception as e:
357+
if isinstance(e, GameLaunchError):
358+
code = "launch_failed"
359+
args = [{"player_id": player.id} for player in e.players]
360+
elif isinstance(e, json.JSONDecodeError):
361+
code = "malformed_request"
362+
args = [{"message": str(e)}]
363+
elif isinstance(e, KeyError):
364+
code = "malformed_request"
365+
args = [{"message": f"missing {e.args[0]}"}]
366+
else:
367+
code, *args = e.args
368+
369+
await self.message_queue_service.publish(
370+
config.MQ_EXCHANGE_NAME,
371+
"error.match.create",
372+
{"error_code": code, "args": args},
373+
correlation_id=message.correlation_id
374+
)
375+
else:
376+
await self.message_queue_service.publish(
377+
config.MQ_EXCHANGE_NAME,
378+
"success.match.create",
379+
{"game_id": game.id},
380+
correlation_id=message.correlation_id
381+
)
382+
383+
async def _handle_mq_matchmaking_request(
384+
self,
385+
message: aio_pika.IncomingMessage
386+
):
387+
self._logger.debug(
388+
"Got matchmaking request: %s", message.correlation_id
389+
)
390+
request = json.loads(message.body)
391+
# TODO: Use id instead of name?
392+
queue_name = request.get("matchmaker_queue")
393+
map_name = request["map_name"]
394+
game_name = request["game_name"]
395+
participants = request["participants"]
396+
featured_mod = request.get("featured_mod")
397+
if not featured_mod and not queue_name:
398+
raise KeyError("featured_mod")
399+
400+
if queue_name and queue_name not in self.queues:
401+
raise Exception("invalid_request", "invalid queue")
402+
403+
if not participants:
404+
raise Exception("invalid_request", "empty participants")
405+
406+
player_ids = [participant["player_id"] for participant in participants]
407+
missing_players = [
408+
id for id in player_ids if self.player_service[id] is None
409+
]
410+
if missing_players:
411+
raise Exception(
412+
"players_not_found",
413+
*[{"player_id": id} for id in missing_players]
414+
)
415+
416+
all_players = [
417+
self.player_service[player_id] for player_id in player_ids
418+
]
419+
non_idle_players = [
420+
player for player in all_players
421+
if player.state != PlayerState.IDLE
422+
]
423+
if non_idle_players:
424+
raise Exception(
425+
"invalid_state",
426+
[
427+
{"player_id": player.id, "state": player.state.name}
428+
for player in all_players
429+
]
430+
)
431+
432+
queue = self.queues[queue_name] if queue_name else None
433+
featured_mod = featured_mod or queue.featured_mod
434+
host = all_players[0]
435+
guests = all_players[1:]
436+
437+
for player in all_players:
438+
player.state = PlayerState.STARTING_AUTOMATCH
439+
440+
try:
441+
game = self.game_service.create_game(
442+
game_class=LadderGame,
443+
game_mode=featured_mod,
444+
host=host,
445+
name="Matchmaker Game",
446+
mapname=map_name,
447+
matchmaker_queue_id=queue.id if queue else None,
448+
rating_type=queue.rating_type if queue else None,
449+
max_players=len(participants)
450+
)
451+
game.init_mode = InitMode.AUTO_LOBBY
452+
game.set_name_unchecked(game_name)
453+
454+
for participant in participants:
455+
player_id = participant["player_id"]
456+
faction = Faction.from_value(participant["faction"])
457+
team = participant["team"]
458+
slot = participant["slot"]
459+
460+
game.set_player_option(player_id, "Faction", faction.value)
461+
game.set_player_option(player_id, "Team", team)
462+
game.set_player_option(player_id, "StartSpot", slot)
463+
game.set_player_option(player_id, "Army", slot)
464+
game.set_player_option(player_id, "Color", slot)
465+
466+
await self.launch_game(game, host, guests)
467+
468+
return game
469+
except Exception:
470+
self._logger.exception("")
471+
await game.on_game_end()
472+
473+
for player in all_players:
474+
if player.state == PlayerState.STARTING_AUTOMATCH:
475+
player.state = PlayerState.IDLE
476+
477+
raise
478+
328479
def on_match_found(
329480
self,
330481
s1: Search,
@@ -465,7 +616,7 @@ async def launch_game(
465616
def game_options(player: Player) -> GameLaunchOptions:
466617
return options._replace(
467618
team=game.get_player_option(player.id, "Team"),
468-
faction=player.faction,
619+
faction=game.get_player_option(player.id, "Faction"),
469620
map_position=game.get_player_option(player.id, "StartSpot")
470621
)
471622

server/message_queue_service.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import asyncio
66
import json
7-
from typing import Dict
7+
from typing import Callable, Dict, Optional
88

99
import aio_pika
1010
from aio_pika import DeliveryMode, ExchangeType
@@ -125,34 +125,68 @@ async def _shutdown(self) -> None:
125125
async def publish(
126126
self,
127127
exchange_name: str,
128-
routing: str,
128+
routing_key: str,
129129
payload: Dict,
130130
mandatory: bool = False,
131131
delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT,
132+
correlation_id: Optional[str] = None
132133
) -> None:
133134
if not self._is_ready:
134135
self._logger.warning(
135136
"Not connected to RabbitMQ, unable to publish message."
136137
)
137138
return
138139

139-
exchange = self._exchanges.get(exchange_name)
140-
if exchange is None:
141-
raise KeyError(f"Unknown exchange {exchange_name}.")
140+
exchange = self._get_exchange(exchange_name)
142141

143142
message = aio_pika.Message(
144-
json.dumps(payload).encode(), delivery_mode=delivery_mode
143+
json.dumps(payload).encode(),
144+
delivery_mode=delivery_mode,
145+
correlation_id=correlation_id,
145146
)
146147

147148
async with self._channel.transaction():
148149
await exchange.publish(
149150
message,
150-
routing_key=routing,
151-
mandatory=mandatory
151+
routing_key=routing_key,
152+
mandatory=mandatory,
152153
)
153154
self._logger.log(
154-
TRACE, "Published message %s to %s/%s", payload, exchange_name, routing
155+
TRACE, "Published message %s to %s/%s",
156+
payload,
157+
exchange_name,
158+
routing_key
159+
)
160+
161+
async def consume(
162+
self,
163+
exchange_name: str,
164+
routing_key: str,
165+
process_message: Callable[[aio_pika.IncomingMessage], None]
166+
) -> None:
167+
await self.initialize()
168+
if not self._is_ready:
169+
self._logger.warning(
170+
"Not connected to RabbitMQ, unable to declare queue."
155171
)
172+
return
173+
174+
exchange = self._get_exchange(exchange_name)
175+
queue = await self._channel.declare_queue(
176+
None,
177+
auto_delete=True,
178+
durable=False
179+
)
180+
181+
await queue.bind(exchange, routing_key)
182+
await queue.consume(process_message, exclusive=True)
183+
184+
def _get_exchange(self, exchange_name: str) -> aio_pika.Exchange:
185+
exchange = self._exchanges.get(exchange_name)
186+
if exchange is None:
187+
raise KeyError(f"Unknown exchange {exchange_name}.")
188+
189+
return exchange
156190

157191
@synchronizedmethod("initialization_lock")
158192
async def reconnect(self) -> None:

tests/integration_tests/conftest.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,20 @@ def mock_games():
3434

3535

3636
@pytest.fixture
37-
async def ladder_service(mocker, database, game_service):
37+
async def ladder_service(
38+
mocker,
39+
database,
40+
game_service,
41+
player_service,
42+
message_queue_service
43+
):
3844
mocker.patch("server.matchmaker.pop_timer.config.QUEUE_POP_TIME_MAX", 1)
39-
ladder_service = LadderService(database, game_service)
45+
ladder_service = LadderService(
46+
database,
47+
game_service,
48+
player_service,
49+
message_queue_service
50+
)
4051
await ladder_service.initialize()
4152
yield ladder_service
4253
await ladder_service.shutdown()
@@ -388,17 +399,22 @@ async def channel():
388399
await connection.close()
389400

390401

391-
async def connect_mq_consumer(server, channel, routing_key):
392-
"""
393-
Returns a subclass of Protocol that yields messages read from a rabbitmq
394-
exchange.
395-
"""
402+
async def connect_mq_queue(channel, routing_key):
396403
exchange = await channel.declare_exchange(
397404
config.MQ_EXCHANGE_NAME,
398405
aio_pika.ExchangeType.TOPIC
399406
)
400-
queue = await channel.declare_queue("", exclusive=True)
407+
queue = await channel.declare_queue(None, exclusive=True)
401408
await queue.bind(exchange, routing_key=routing_key)
409+
return queue
410+
411+
412+
async def connect_mq_consumer(channel, routing_key):
413+
"""
414+
Returns a subclass of Protocol that yields messages read from a rabbitmq
415+
exchange.
416+
"""
417+
queue = await connect_mq_queue(channel, routing_key)
402418
proto = AioQueueProtocol(queue)
403419
await proto.consume()
404420

0 commit comments

Comments
 (0)