Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement EstablishedPeers messages #1028

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions server/game_connection_matrix.py
Original file line number Diff line number Diff line change
@@ -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
86 changes: 61 additions & 25 deletions server/gameconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import contextlib
import json
import logging
from typing import Any
from typing import Any, Optional

from sqlalchemy import select

Expand Down Expand Up @@ -62,6 +62,10 @@
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

Expand Down Expand Up @@ -509,15 +513,20 @@

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

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

Expand Down Expand Up @@ -547,6 +556,31 @@
"""
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
"""
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):
"""
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.
"""
if self.established_peer_ids is None:
self.established_peer_ids = set()

Check warning on line 580 in server/gameconnection.py

View check run for this annotation

Codecov / codecov/patch

server/gameconnection.py#L580

Added line #L580 was not covered by tests

self.established_peer_ids.discard(int(peer_id))

Check warning on line 582 in server/gameconnection.py

View check run for this annotation

Codecov / codecov/patch

server/gameconnection.py#L582

Added line #L582 was not covered by tests

def _mark_dirty(self):
if self.game:
self.game_service.mark_dirty(self.game)
Expand Down Expand Up @@ -623,26 +657,28 @@


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
}
31 changes: 30 additions & 1 deletion server/games/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
game_stats,
matchmaker_queue_game
)
from server.game_connection_matrix import ConnectionMatrix
from server.games.game_results import (
ArmyOutcome,
ArmyReportedOutcome,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions server/ladder_service/ladder_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 132 additions & 3 deletions tests/integration_tests/test_matchmaker_violations.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -18,8 +28,7 @@

# 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")
Expand Down Expand Up @@ -110,6 +119,126 @@
}


@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):

Check warning on line 146 in tests/integration_tests/test_matchmaker_violations.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/integration_tests/test_matchmaker_violations.py#L146

Redefining built-in '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(
Expand Down
Loading
Loading