From 03944d3d63ea20b49eb2d8df517070a985a37015 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 26 Oct 2024 15:10:22 -0400 Subject: [PATCH] Add logic to handle EstablishedPeer messages --- server/game_connection_matrix.py | 27 +++ server/gameconnection.py | 16 +- server/games/game.py | 31 ++- server/ladder_service/ladder_service.py | 6 + tests/unit_tests/test_connection_matrix.py | 246 +++++++++++++++++++++ 5 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 server/game_connection_matrix.py create mode 100644 tests/unit_tests/test_connection_matrix.py 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/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}