From c6585dda90cc71a396e4198de31f2575a8679bce Mon Sep 17 00:00:00 2001 From: "(Jip) Willem Wijnia" Date: Sat, 26 Oct 2024 15:58:09 +0200 Subject: [PATCH 1/2] Improve documentation of messages --- server/gameconnection.py | 74 +++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/server/gameconnection.py b/server/gameconnection.py index 6d2e10c87..16b4334fd 100644 --- a/server/gameconnection.py +++ b/server/gameconnection.py @@ -509,8 +509,11 @@ async def handle_rehost(self, *args: list[Any]): async def handle_launch_status(self, status: str): """ - Currently is sent with status `Rejected` if a matchmaker game failed - to start due to players using differing game settings. + Represents the launch status of a peer. + + # Params + - `status`: One of "Unknown" | "Connecting" | "Missing local peers" | + "Rejoining" | "Ready" | "Ejected" | "Rejected" | "Failed" """ pass @@ -518,6 +521,8 @@ async def handle_bottleneck(self, *args: list[Any]): """ Not sure what this command means. This is currently unused but included for documentation purposes. + + Example args: ["ack", "23381", "232191", "64218.4"] """ pass @@ -547,6 +552,25 @@ async def handle_game_full(self): """ pass + async def handle_established_peer(self, peer_id: str): + """ + Sent by the lobby when the player connectes to another peer. Can be + send multiple times. + + # Params + - `peer_id`: The identifier of the peer that this connection received + the message from + """ + pass + + async def handle_disconnected_peer(self, peer_id: str): + """ + Sent by the lobby when a player disconnects from a peer. This can happen + 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 + def _mark_dirty(self): if self.game: self.game_service.mark_dirty(self.game) @@ -623,26 +647,28 @@ def __str__(self): COMMAND_HANDLERS = { - "AIOption": GameConnection.handle_ai_option, - "Bottleneck": GameConnection.handle_bottleneck, - "BottleneckCleared": GameConnection.handle_bottleneck_cleared, - "Chat": GameConnection.handle_chat, - "ClearSlot": GameConnection.handle_clear_slot, - "Desync": GameConnection.handle_desync, - "Disconnected": GameConnection.handle_disconnected, - "EnforceRating": GameConnection.handle_enforce_rating, - "GameEnded": GameConnection.handle_game_ended, - "GameFull": GameConnection.handle_game_full, - "GameMods": GameConnection.handle_game_mods, - "GameOption": GameConnection.handle_game_option, - "GameResult": GameConnection.handle_game_result, - "GameState": GameConnection.handle_game_state, - "IceMsg": GameConnection.handle_ice_message, - "JsonStats": GameConnection.handle_json_stats, - "LaunchStatus": GameConnection.handle_launch_status, - "OperationComplete": GameConnection.handle_operation_complete, - "PlayerOption": GameConnection.handle_player_option, - "Rehost": GameConnection.handle_rehost, - "TeamkillHappened": GameConnection.handle_teamkill_happened, - "TeamkillReport": GameConnection.handle_teamkill_report, + "AIOption": GameConnection.handle_ai_option, # Lobby message + "Bottleneck": GameConnection.handle_bottleneck, # Lobby/game message + "BottleneckCleared": GameConnection.handle_bottleneck_cleared, # Lobby/game message + "Chat": GameConnection.handle_chat, # Lobby message + "ClearSlot": GameConnection.handle_clear_slot, # Lobby message + "Desync": GameConnection.handle_desync, # Game message + "Disconnected": GameConnection.handle_disconnected, # Lobby message + "EnforceRating": GameConnection.handle_enforce_rating, # Game message + "EstablishedPeer": GameConnection.handle_established_peer, # Lobby message + "DisconnectedPeer": GameConnection.handle_disconnected_peer, # Lobby message + "GameEnded": GameConnection.handle_game_ended, # Game message + "GameFull": GameConnection.handle_game_full, # Lobby message + "GameMods": GameConnection.handle_game_mods, # Lobby message + "GameOption": GameConnection.handle_game_option, # Lobby message + "GameResult": GameConnection.handle_game_result, # Game message + "GameState": GameConnection.handle_game_state, # Lobby/game message + "IceMsg": GameConnection.handle_ice_message, # Lobby/Game message + "JsonStats": GameConnection.handle_json_stats, # Game message + "LaunchStatus": GameConnection.handle_launch_status, # Lobby message + "OperationComplete": GameConnection.handle_operation_complete, # Coop message + "PlayerOption": GameConnection.handle_player_option, # Lobby message + "Rehost": GameConnection.handle_rehost, # Game message + "TeamkillHappened": GameConnection.handle_teamkill_happened, # Game message + "TeamkillReport": GameConnection.handle_teamkill_report, # Game message } From 55c09a497b252cc028d7790be9dc7b179a1897f4 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 26 Oct 2024 15:10:22 -0400 Subject: [PATCH 2/2] 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 + .../test_matchmaker_violations.py | 135 +++++++++- tests/unit_tests/test_connection_matrix.py | 246 ++++++++++++++++++ 6 files changed, 454 insertions(+), 7 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/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}