diff --git a/server/game_connection_matrix.py b/server/game_connection_matrix.py new file mode 100644 index 000000000..1af49b211 --- /dev/null +++ b/server/game_connection_matrix.py @@ -0,0 +1,27 @@ +from collections import defaultdict + + +class ConnectionMatrix: + def __init__(self, established_peers: dict[int, set[int]]): + self.established_peers = established_peers + + def get_unconnected_peer_ids(self) -> set[int]: + unconnected_peer_ids: set[int] = set() + + # Group players by number of connected peers + players_by_num_peers = defaultdict(list) + for player_id, peer_ids in self.established_peers.items(): + players_by_num_peers[len(peer_ids)].append((player_id, peer_ids)) + + # Mark players with least number of connections as unconnected if they + # don't meet the connection threshold. Each time a player is marked as + # 'unconnected', remaining players need 1 less connection to be + # considered connected. + connected_peers = dict(self.established_peers) + for num_connected, peers in sorted(players_by_num_peers.items()): + if num_connected < len(connected_peers) - 1: + for player_id, peer_ids in peers: + unconnected_peer_ids.add(player_id) + del connected_peers[player_id] + + return unconnected_peer_ids diff --git a/server/gameconnection.py b/server/gameconnection.py index 16b4334fd..cf077776b 100644 --- a/server/gameconnection.py +++ b/server/gameconnection.py @@ -6,7 +6,7 @@ import contextlib import json import logging -from typing import Any +from typing import Any, Optional from sqlalchemy import select @@ -62,6 +62,10 @@ def __init__( self.player = player player.game_connection = self # Set up weak reference to self self.game = game + # None if the EstablishedPeers message is not implemented by the game + # version/mode used by the player. For instance, matchmaker might have + # it, but custom games might not. + self.established_peer_ids: Optional[set[int]] = None self.setup_timeout = setup_timeout @@ -561,7 +565,10 @@ async def handle_established_peer(self, peer_id: str): - `peer_id`: The identifier of the peer that this connection received the message from """ - pass + if self.established_peer_ids is None: + self.established_peer_ids = set() + + self.established_peer_ids.add(int(peer_id)) async def handle_disconnected_peer(self, peer_id: str): """ @@ -569,7 +576,10 @@ async def handle_disconnected_peer(self, peer_id: str): when a peer is rejoining in which case that peer will have reported a "Rejoining" status, or if the peer has exited the game. """ - pass + if self.established_peer_ids is None: + self.established_peer_ids = set() + + self.established_peer_ids.discard(int(peer_id)) def _mark_dirty(self): if self.game: diff --git a/server/games/game.py b/server/games/game.py index d337034b2..29ca4a440 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -17,6 +17,7 @@ game_stats, matchmaker_queue_game ) +from server.game_connection_matrix import ConnectionMatrix from server.games.game_results import ( ArmyOutcome, ArmyReportedOutcome, @@ -211,13 +212,41 @@ def players(self) -> list[Player]: def get_connected_players(self) -> list[Player]: """ - Get a collection of all players currently connected to the game. + Get a collection of all players currently connected to the host. """ return [ player for player in self._connections.keys() if player.id in self._configured_player_ids ] + def get_unconnected_players_from_peer_matrix( + self, + ) -> Optional[list[Player]]: + """ + Get a list of players who are not fully connected to the game based on + the established peers matrix if possible. The EstablishedPeers messages + might not be implemented by the game in which case this returns None. + """ + if any( + conn.established_peer_ids is None + for conn in self._connections.values() + ): + return None + + matrix = ConnectionMatrix( + established_peers={ + player.id: conn.established_peer_ids + for player, conn in self._connections.items() + } + ) + unconnected_peer_ids = matrix.get_unconnected_peer_ids() + + return [ + player + for player in self._connections.keys() + if player.id in unconnected_peer_ids + ] + def _is_observer(self, player: Player) -> bool: army = self.get_player_option(player.id, "Army") return army is None or army < 0 diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 39cda2709..6c1d2b9eb 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -667,6 +667,12 @@ async def launch_match( try: await game.wait_launched(60 + 10 * len(guests)) except asyncio.TimeoutError: + unconnected_players = game.get_unconnected_players_from_peer_matrix() + if unconnected_players is not None: + raise NotConnectedError(unconnected_players) + + # If the connection matrix was not available, fall back to looking + # at who was connected to the host only. connected_players = game.get_connected_players() raise NotConnectedError([ player for player in guests diff --git a/tests/integration_tests/test_matchmaker_violations.py b/tests/integration_tests/test_matchmaker_violations.py index 76e491e68..55b51462c 100644 --- a/tests/integration_tests/test_matchmaker_violations.py +++ b/tests/integration_tests/test_matchmaker_violations.py @@ -1,11 +1,21 @@ import asyncio from datetime import datetime, timezone +import pytest + from tests.utils import fast_forward from .conftest import connect_and_sign_in, read_until_command -from .test_game import open_fa, queue_players_for_matchmaking, start_search +from .test_game import ( + client_response, + open_fa, + queue_players_for_matchmaking, + send_player_options, + start_search +) from .test_parties import accept_party_invite, invite_to_party +from .test_teammatchmaker import \ + queue_players_for_matchmaking as queue_players_for_matchmaking_2v2 @fast_forward(360) @@ -18,8 +28,7 @@ async def test_violation_for_guest_timeout(mocker, lobby_server): # The player that queued last will be the host async def launch_game_and_timeout_guest(): - await read_until_command(host, "game_launch") - await open_fa(host) + await client_response(host, timeout=60) await read_until_command(host, "game_info") await read_until_command(guest, "game_launch") @@ -110,6 +119,126 @@ async def launch_game_and_timeout_guest(): } +@fast_forward(360) +async def test_violation_established_peer(mocker, lobby_server): + mocker.patch( + "server.ladder_service.violation_service.datetime_now", + return_value=datetime(2022, 2, 5, tzinfo=timezone.utc) + ) + protos, ids = await queue_players_for_matchmaking_2v2(lobby_server) + host, guest1, guest2, guest3 = protos + host_id, guest1_id, guest2_id, guest3_id = ids + + # Connect all players to the host + await asyncio.gather(*[ + client_response(proto, timeout=60) + for proto in protos + ]) + await send_player_options( + host, + [host_id, "Color", 1], + [guest1_id, "Color", 2], + [guest2_id, "Color", 3], + [guest3_id, "Color", 4], + ) + + # Set up connection matrix + for id in (guest1_id, guest2_id, guest3_id): + await host.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [id], + }) + for id in (host_id, guest2_id): + await guest1.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [id], + }) + for id in (host_id, guest1_id): + await guest2.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [id], + }) + # Guest3 only connects to the host + await guest3.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [host_id], + }) + + await read_until_command(host, "match_cancelled", timeout=120) + msg = await read_until_command(guest3, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 1, + "time": "2022-02-05T00:00:00+00:00", + } + for proto in (host, guest1, guest2): + with pytest.raises(asyncio.TimeoutError): + await read_until_command(proto, "search_violation", timeout=10) + + +@fast_forward(360) +async def test_violation_established_peer_multiple(mocker, lobby_server): + mocker.patch( + "server.ladder_service.violation_service.datetime_now", + return_value=datetime(2022, 2, 5, tzinfo=timezone.utc) + ) + protos, ids = await queue_players_for_matchmaking_2v2(lobby_server) + host, guest1, guest2, guest3 = protos + host_id, guest1_id, guest2_id, guest3_id = ids + + # Connect all players to the host + await asyncio.gather(*[ + client_response(proto, timeout=60) + for proto in protos + ]) + await send_player_options( + host, + [host_id, "Color", 1], + [guest1_id, "Color", 2], + [guest2_id, "Color", 3], + [guest3_id, "Color", 4], + ) + + # Set up connection matrix + for id in (guest1_id, guest2_id, guest3_id): + await host.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [id], + }) + # Guests only connect to the host + await guest1.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [host_id], + }) + await guest2.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [host_id], + }) + await guest3.send_message({ + "target": "game", + "command": "EstablishedPeer", + "args": [host_id], + }) + + await read_until_command(host, "match_cancelled", timeout=120) + for proto in (guest1, guest2, guest3): + msg = await read_until_command(proto, "search_violation", timeout=10) + assert msg == { + "command": "search_violation", + "count": 1, + "time": "2022-02-05T00:00:00+00:00", + } + with pytest.raises(asyncio.TimeoutError): + await read_until_command(host, "search_violation", timeout=10) + + @fast_forward(360) async def test_violation_persisted_across_logins(mocker, lobby_server): mocker.patch( diff --git a/tests/unit_tests/test_connection_matrix.py b/tests/unit_tests/test_connection_matrix.py new file mode 100644 index 000000000..7c6d67e06 --- /dev/null +++ b/tests/unit_tests/test_connection_matrix.py @@ -0,0 +1,246 @@ +from server.game_connection_matrix import ConnectionMatrix + + +def test_all_connected(): + # One by hand example + matrix = ConnectionMatrix( + established_peers={ + 0: {1, 2, 3}, + 1: {0, 2, 3}, + 2: {0, 1, 3}, + 3: {0, 1, 2}, + }, + ) + assert matrix.get_unconnected_peer_ids() == set() + + # Check every fully connected grid, including the empty grid + for num_players in range(0, 16 + 1): + matrix = ConnectionMatrix( + established_peers={ + player_id: { + peer_id + for peer_id in range(num_players) + if peer_id != player_id + } + for player_id in range(num_players) + }, + ) + assert matrix.get_unconnected_peer_ids() == set() + + +def test_1v1_not_connected(): + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1} + + +def test_2v2_one_player_not_connected(): + # 0 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: {2, 3}, + 2: {1, 3}, + 3: {1, 2}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0} + + +def test_2v2_two_players_not_connected(): + # 0 is not connected to anyone + # 1 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: {3}, + 3: {2}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1} + + +def test_2v2_not_connected(): + # Not possible for only 3 players to be completely disconnected in a 4 + # player game. Either 1, 2, or all can be disconnected. + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: set(), + 3: set(), + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2, 3} + + +def test_2v2_one_pair_not_connected(): + # 0 and 1 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: {2, 3}, + 1: {2, 3}, + 2: {0, 1, 3}, + 3: {0, 1, 2}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1} + + +def test_2v2_two_pairs_not_connected(): + # 0 and 1 are not connected to each other + # 1 and 2 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: {2, 3}, + 1: {3}, + 2: {0, 3}, + 3: {0, 1, 2}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {1} + + +def test_2v2_two_disjoint_pairs_not_connected(): + # 0 and 1 are not connected to each other + # 2 and 3 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: {2, 3}, + 1: {2, 3}, + 2: {0, 1}, + 3: {0, 1}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2, 3} + + +def test_2v2_three_pairs_not_connected(): + # 0 and 1 are not connected to each other + # 1 and 2 are not connected to each other + # 2 and 3 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: {2, 3}, + 1: {3}, + 2: {0}, + 3: {0, 1}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {1, 2} + + +def test_3v3_one_player_not_connected(): + # 0 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: {2, 3, 4, 5}, + 2: {1, 3, 4, 5}, + 3: {1, 2, 4, 5}, + 4: {1, 2, 3, 5}, + 5: {1, 2, 3, 4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0} + + +def test_3v3_two_players_not_connected(): + # 0 is not connected to anyone + # 1 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: {3, 4, 5}, + 3: {2, 4, 5}, + 4: {2, 3, 5}, + 5: {2, 3, 4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1} + + +def test_3v3_three_players_not_connected(): + # 0 is not connected to anyone + # 1 is not connected to anyone + # 2 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: set(), + 3: {4, 5}, + 4: {3, 5}, + 5: {3, 4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2} + + +def test_3v3_four_players_not_connected(): + # 0 is not connected to anyone + # 1 is not connected to anyone + # 2 is not connected to anyone + # 3 is not connected to anyone + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: set(), + 3: set(), + 4: {5}, + 5: {4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2, 3} + + +def test_3v3_not_connected(): + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: set(), + 2: set(), + 3: set(), + 4: set(), + 5: set(), + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2, 3, 4, 5} + + +def test_3v3_one_pair_not_connected(): + # 0 and 1 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: {2, 3, 4, 5}, + 1: {2, 3, 4, 5}, + 2: {0, 1, 3, 4, 5}, + 3: {0, 1, 2, 4, 5}, + 4: {0, 1, 2, 3, 5}, + 5: {0, 1, 2, 3, 4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1} + + +def test_3v3_one_player_and_one_pair_not_connected(): + # 0 is not connected to anyone + # 1 and 2 are not connected to each other + matrix = ConnectionMatrix( + established_peers={ + 0: set(), + 1: {3, 4, 5}, + 2: {3, 4, 5}, + 3: {1, 2, 4, 5}, + 4: {1, 2, 3, 5}, + 5: {1, 2, 3, 4}, + }, + ) + assert matrix.get_unconnected_peer_ids() == {0, 1, 2}