diff --git a/server/game_connection_matrix.py b/server/game_connection_matrix.py new file mode 100644 index 000000000..4bd5e4849 --- /dev/null +++ b/server/game_connection_matrix.py @@ -0,0 +1,22 @@ +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() + + 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)) + + 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 acaffc2f7..f72b9496e 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 @@ -568,7 +572,16 @@ async def handle_established_peers( established a connection to. Is a list stored in a string, separated by spaces. As an example: "1321 22 33 43221 2132" """ - pass + if int(peer_id) != self.player.id: + # TODO: We could capture all reported peer statuses here and + # reconcile them similar to how we do with game results in order to + # detect discrepancies between what players are reporting. + return + + self.established_peer_ids = set( + int(peer_id) + for peer_id in peer_connected_to.split(" ") + ) 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/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}