Skip to content

Commit 95d2688

Browse files
committed
Add logic to handle EstablishedPeer messages
1 parent 0dceb02 commit 95d2688

6 files changed

Lines changed: 454 additions & 7 deletions

File tree

server/game_connection_matrix.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from collections import defaultdict
2+
3+
4+
class ConnectionMatrix:
5+
def __init__(self, established_peers: dict[int, set[int]]):
6+
self.established_peers = established_peers
7+
8+
def get_unconnected_peer_ids(self) -> set[int]:
9+
unconnected_peer_ids: set[int] = set()
10+
11+
# Group players by number of connected peers
12+
players_by_num_peers = defaultdict(list)
13+
for player_id, peer_ids in self.established_peers.items():
14+
players_by_num_peers[len(peer_ids)].append((player_id, peer_ids))
15+
16+
# Mark players with least number of connections as unconnected if they
17+
# don't meet the connection threshold. Each time a player is marked as
18+
# 'unconnected', remaining players need 1 less connection to be
19+
# considered connected.
20+
connected_peers = dict(self.established_peers)
21+
for num_connected, peers in sorted(players_by_num_peers.items()):
22+
if num_connected < len(connected_peers) - 1:
23+
for player_id, peer_ids in peers:
24+
unconnected_peer_ids.add(player_id)
25+
del connected_peers[player_id]
26+
27+
return unconnected_peer_ids

server/gameconnection.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import contextlib
77
import json
88
import logging
9-
from typing import Any
9+
from typing import Any, Optional
1010

1111
from sqlalchemy import select
1212

@@ -62,6 +62,10 @@ def __init__(
6262
self.player = player
6363
player.game_connection = self # Set up weak reference to self
6464
self.game = game
65+
# None if the EstablishedPeers message is not implemented by the game
66+
# version/mode used by the player. For instance, matchmaker might have
67+
# it, but custom games might not.
68+
self.established_peer_ids: Optional[set[int]] = None
6569

6670
self.setup_timeout = setup_timeout
6771

@@ -561,15 +565,21 @@ async def handle_established_peer(self, peer_id: str):
561565
- `peer_id`: The identifier of the peer that this connection received
562566
the message from
563567
"""
564-
pass
568+
if self.established_peer_ids is None:
569+
self.established_peer_ids = set()
570+
571+
self.established_peer_ids.add(int(peer_id))
565572

566573
async def handle_disconnected_peer(self, peer_id: str):
567574
"""
568575
Sent by the lobby when a player disconnects from a peer. This can happen
569576
when a peer is rejoining in which case that peer will have reported a
570577
"Rejoining" status, or if the peer has exited the game.
571578
"""
572-
pass
579+
if self.established_peer_ids is None:
580+
self.established_peer_ids = set()
581+
582+
self.established_peer_ids.discard(int(peer_id))
573583

574584
def _mark_dirty(self):
575585
if self.game:

server/games/game.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
game_stats,
1818
matchmaker_queue_game
1919
)
20+
from server.game_connection_matrix import ConnectionMatrix
2021
from server.games.game_results import (
2122
ArmyOutcome,
2223
ArmyReportedOutcome,
@@ -211,13 +212,41 @@ def players(self) -> list[Player]:
211212

212213
def get_connected_players(self) -> list[Player]:
213214
"""
214-
Get a collection of all players currently connected to the game.
215+
Get a collection of all players currently connected to the host.
215216
"""
216217
return [
217218
player for player in self._connections.keys()
218219
if player.id in self._configured_player_ids
219220
]
220221

222+
def get_unconnected_players_from_peer_matrix(
223+
self,
224+
) -> Optional[list[Player]]:
225+
"""
226+
Get a list of players who are not fully connected to the game based on
227+
the established peers matrix if possible. The EstablishedPeers messages
228+
might not be implemented by the game in which case this returns None.
229+
"""
230+
if any(
231+
conn.established_peer_ids is None
232+
for conn in self._connections.values()
233+
):
234+
return None
235+
236+
matrix = ConnectionMatrix(
237+
established_peers={
238+
player.id: conn.established_peer_ids
239+
for player, conn in self._connections.items()
240+
}
241+
)
242+
unconnected_peer_ids = matrix.get_unconnected_peer_ids()
243+
244+
return [
245+
player
246+
for player in self._connections.keys()
247+
if player.id in unconnected_peer_ids
248+
]
249+
221250
def _is_observer(self, player: Player) -> bool:
222251
army = self.get_player_option(player.id, "Army")
223252
return army is None or army < 0

server/ladder_service/ladder_service.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,12 @@ async def launch_match(
667667
try:
668668
await game.wait_launched(60 + 10 * len(guests))
669669
except asyncio.TimeoutError:
670+
unconnected_players = game.get_unconnected_players_from_peer_matrix()
671+
if unconnected_players is not None:
672+
raise NotConnectedError(unconnected_players)
673+
674+
# If the connection matrix was not available, fall back to looking
675+
# at who was connected to the host only.
670676
connected_players = game.get_connected_players()
671677
raise NotConnectedError([
672678
player for player in guests

tests/integration_tests/test_matchmaker_violations.py

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import asyncio
22
from datetime import datetime, timezone
33

4+
import pytest
5+
46
from tests.utils import fast_forward
57

68
from .conftest import connect_and_sign_in, read_until_command
7-
from .test_game import open_fa, queue_players_for_matchmaking, start_search
9+
from .test_game import (
10+
client_response,
11+
open_fa,
12+
queue_players_for_matchmaking,
13+
send_player_options,
14+
start_search
15+
)
816
from .test_parties import accept_party_invite, invite_to_party
17+
from .test_teammatchmaker import \
18+
queue_players_for_matchmaking as queue_players_for_matchmaking_2v2
919

1020

1121
@fast_forward(360)
@@ -18,8 +28,7 @@ async def test_violation_for_guest_timeout(mocker, lobby_server):
1828

1929
# The player that queued last will be the host
2030
async def launch_game_and_timeout_guest():
21-
await read_until_command(host, "game_launch")
22-
await open_fa(host)
31+
await client_response(host, timeout=60)
2332
await read_until_command(host, "game_info")
2433

2534
await read_until_command(guest, "game_launch")
@@ -110,6 +119,126 @@ async def launch_game_and_timeout_guest():
110119
}
111120

112121

122+
@fast_forward(360)
123+
async def test_violation_established_peer(mocker, lobby_server):
124+
mocker.patch(
125+
"server.ladder_service.violation_service.datetime_now",
126+
return_value=datetime(2022, 2, 5, tzinfo=timezone.utc)
127+
)
128+
protos, ids = await queue_players_for_matchmaking_2v2(lobby_server)
129+
host, guest1, guest2, guest3 = protos
130+
host_id, guest1_id, guest2_id, guest3_id = ids
131+
132+
# Connect all players to the host
133+
await asyncio.gather(*[
134+
client_response(proto, timeout=60)
135+
for proto in protos
136+
])
137+
await send_player_options(
138+
host,
139+
[host_id, "Color", 1],
140+
[guest1_id, "Color", 2],
141+
[guest2_id, "Color", 3],
142+
[guest3_id, "Color", 4],
143+
)
144+
145+
# Set up connection matrix
146+
for id in (guest1_id, guest2_id, guest3_id):
147+
await host.send_message({
148+
"target": "game",
149+
"command": "EstablishedPeer",
150+
"args": [id],
151+
})
152+
for id in (host_id, guest2_id):
153+
await guest1.send_message({
154+
"target": "game",
155+
"command": "EstablishedPeer",
156+
"args": [id],
157+
})
158+
for id in (host_id, guest1_id):
159+
await guest2.send_message({
160+
"target": "game",
161+
"command": "EstablishedPeer",
162+
"args": [id],
163+
})
164+
# Guest3 only connects to the host
165+
await guest3.send_message({
166+
"target": "game",
167+
"command": "EstablishedPeer",
168+
"args": [host_id],
169+
})
170+
171+
await read_until_command(host, "match_cancelled", timeout=120)
172+
msg = await read_until_command(guest3, "search_violation", timeout=10)
173+
assert msg == {
174+
"command": "search_violation",
175+
"count": 1,
176+
"time": "2022-02-05T00:00:00+00:00",
177+
}
178+
for proto in (host, guest1, guest2):
179+
with pytest.raises(asyncio.TimeoutError):
180+
await read_until_command(proto, "search_violation", timeout=10)
181+
182+
183+
@fast_forward(360)
184+
async def test_violation_established_peer_multiple(mocker, lobby_server):
185+
mocker.patch(
186+
"server.ladder_service.violation_service.datetime_now",
187+
return_value=datetime(2022, 2, 5, tzinfo=timezone.utc)
188+
)
189+
protos, ids = await queue_players_for_matchmaking_2v2(lobby_server)
190+
host, guest1, guest2, guest3 = protos
191+
host_id, guest1_id, guest2_id, guest3_id = ids
192+
193+
# Connect all players to the host
194+
await asyncio.gather(*[
195+
client_response(proto, timeout=60)
196+
for proto in protos
197+
])
198+
await send_player_options(
199+
host,
200+
[host_id, "Color", 1],
201+
[guest1_id, "Color", 2],
202+
[guest2_id, "Color", 3],
203+
[guest3_id, "Color", 4],
204+
)
205+
206+
# Set up connection matrix
207+
for id in (guest1_id, guest2_id, guest3_id):
208+
await host.send_message({
209+
"target": "game",
210+
"command": "EstablishedPeer",
211+
"args": [id],
212+
})
213+
# Guests only connect to the host
214+
await guest1.send_message({
215+
"target": "game",
216+
"command": "EstablishedPeer",
217+
"args": [host_id],
218+
})
219+
await guest2.send_message({
220+
"target": "game",
221+
"command": "EstablishedPeer",
222+
"args": [host_id],
223+
})
224+
await guest3.send_message({
225+
"target": "game",
226+
"command": "EstablishedPeer",
227+
"args": [host_id],
228+
})
229+
230+
await read_until_command(host, "match_cancelled", timeout=120)
231+
for proto in (guest1, guest2, guest3):
232+
msg = await read_until_command(proto, "search_violation", timeout=10)
233+
assert msg == {
234+
"command": "search_violation",
235+
"count": 1,
236+
"time": "2022-02-05T00:00:00+00:00",
237+
}
238+
with pytest.raises(asyncio.TimeoutError):
239+
await read_until_command(host, "search_violation", timeout=10)
240+
241+
113242
@fast_forward(360)
114243
async def test_violation_persisted_across_logins(mocker, lobby_server):
115244
mocker.patch(

0 commit comments

Comments
 (0)