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

Feature/#747 save additional metadata in game results #782

Merged
Show file tree
Hide file tree
Changes from 13 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
10 changes: 7 additions & 3 deletions server/gameconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,15 @@ async def handle_clear_slot(self, slot: Any):
async def handle_game_result(self, army: Any, result: Any):
army = int(army)
result = str(result).lower()

try:
label, score = result.split(" ")[-2:]
await self.game.add_result(self.player.id, army, label, int(score))
except (KeyError, ValueError): # pragma: no cover
*metadata, result_type, score = result.split()
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
except ValueError:
self._logger.warning("Invalid result for %s reported: %s", army, result)
else:
await self.game.add_result(
self.player.id, army, result_type, int(score), frozenset(metadata)
)

async def handle_operation_complete(
self, primary: Any, secondary: Any, delta: str
Expand Down
44 changes: 36 additions & 8 deletions server/games/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from server.games.game_results import (
ArmyOutcome,
ArmyReportedOutcome,
ArmyResult,
GameOutcome,
GameResolutionError,
GameResultReport,
Expand Down Expand Up @@ -280,7 +281,12 @@ async def wait_launched(self, timeout: float):
)

async def add_result(
self, reporter: int, army: int, result_type: str, score: int
self,
reporter: int,
army: int,
result_type: str,
score: int,
result_metadata: FrozenSet[str] = frozenset(),
):
"""
As computed by the game.
Expand All @@ -290,6 +296,8 @@ async def add_result(
- `army`: the army number being reported for
- `result_type`: a string representing the result
- `score`: an arbitrary number assigned with the result
- `result_metadata`: everything preceding the `result_type` in the
result message from the game, one or more words, optional
"""
if army not in self.armies:
self._logger.debug(
Expand All @@ -307,7 +315,7 @@ async def add_result(
)
return

result = GameResultReport(reporter, army, outcome, score)
result = GameResultReport(reporter, army, outcome, score, result_metadata)
self._results.add(result)
self._logger.info(
"%s reported result for army %s: %s %s", reporter, army,
Expand Down Expand Up @@ -463,14 +471,21 @@ async def resolve_game_results(self) -> EndedGameInfo:
await self._run_pre_rate_validity_checks()

basic_info = self.get_basic_info()

team_army_results = [
[self.get_army_results(player) for player in team]
for team in basic_info.teams
]

team_outcomes = [GameOutcome.UNKNOWN for _ in basic_info.teams]

if self.validity is ValidityState.VALID:
if self.validity == ValidityState.VALID:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So looks like you are gonna keep this in for now?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought this might be better as a separate PR as it looked like I might have to alter the logic of game_results.resolve_game as well and we might want to be able to roll that back easily without undoing the changes in this PR?

team_player_partial_outcomes = [
{self.get_player_outcome(player) for player in team}
for team in basic_info.teams
]

try:
team_player_partial_outcomes = [
{self.get_player_outcome(player) for player in team}
for team in basic_info.teams
]
# TODO: Remove override once game result messages are reliable
team_outcomes = (
self._outcome_override_hook()
Expand All @@ -488,7 +503,11 @@ async def resolve_game_results(self) -> EndedGameInfo:
commander_kills = {}

return EndedGameInfo.from_basic(
basic_info, self.validity, team_outcomes, commander_kills
basic_info,
self.validity,
team_outcomes,
commander_kills,
team_army_results,
)

def _outcome_override_hook(self) -> Optional[List[GameOutcome]]:
Expand Down Expand Up @@ -822,6 +841,15 @@ def get_player_outcome(self, player: Player) -> ArmyOutcome:

return self._results.outcome(army)

def get_army_results(self, player: Player) -> ArmyResult:
army = self.get_player_option(player.id, "Army")
return ArmyResult(
player.id,
army,
self.get_player_outcome(player).name,
self._results.metadata(army),
)

def report_army_stats(self, stats_json):
self._army_stats_list = json.loads(stats_json)["stats"]
self._process_pending_army_stats()
Expand Down
45 changes: 44 additions & 1 deletion server/games/game_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from collections import Counter, defaultdict
from collections.abc import Mapping
from enum import Enum
from typing import Dict, Iterator, List, NamedTuple, Set
from typing import Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Set

from server.decorators import with_logger

Expand Down Expand Up @@ -39,6 +39,16 @@ def to_resolved(self) -> ArmyOutcome:
return ArmyOutcome(value)


class ArmyResult(NamedTuple):
"""
Broadcast in the end of game rabbitmq message.
"""
player_id: int
army: Optional[int]
army_result: str
metadata: List[str]


class GameOutcome(Enum):
VICTORY = "VICTORY"
DEFEAT = "DEFEAT"
Expand All @@ -57,6 +67,7 @@ class GameResultReport(NamedTuple):
army: int
outcome: ArmyReportedOutcome
score: int
metadata: FrozenSet[str] = frozenset()


@with_logger
Expand Down Expand Up @@ -149,6 +160,38 @@ def _compute_outcome(self, army: int) -> ArmyOutcome:
)
return decision

def metadata(self, army: int) -> List[str]:
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
"""
If any users have sent metadata tags in their messages about this army
this function will compare those tags across all messages trying to find
common ones to return.
"""
if army not in self:
return []

all_metadata = [report.metadata for report in self[army]]
metadata_count = Counter(all_metadata).most_common()

if len(metadata_count) == 1:
# Everyone agrees!
return sorted(list(metadata_count[0][0]))

most_common, next_most_common, *_ = metadata_count
if most_common[1] > next_most_common[1]:
resolved_to = sorted(list(most_common[0]))
self._logger.info(
"Conflicting metadata for game %s army %s resolved to %s. Reports are: %s",
self._game_id, army, resolved_to, all_metadata,
)
return resolved_to

# We have a tie
self._logger.info(
"Conflicting metadata for game %s army %s, unable to resolve. Reports are: %s",
self._game_id, army, all_metadata,
)
return []

def score(self, army: int) -> int:
"""
Pick and return most frequently reported score for an army. If multiple
Expand Down
10 changes: 7 additions & 3 deletions server/games/typedefs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from enum import Enum, unique
from typing import Any, Dict, List, NamedTuple, Optional, Set

from server.games.game_results import GameOutcome
from server.games.game_results import ArmyResult, GameOutcome
from server.players import Player


Expand Down Expand Up @@ -123,6 +123,7 @@ class BasicGameInfo(NamedTuple):
class TeamRatingSummary(NamedTuple):
outcome: GameOutcome
player_ids: Set[int]
army_results: List[ArmyResult]


class EndedGameInfo(NamedTuple):
Expand Down Expand Up @@ -154,6 +155,7 @@ def from_basic(
validity: ValidityState,
team_outcomes: List[GameOutcome],
commander_kills: Dict[str, int],
team_army_results: List[List[ArmyResult]],
) -> "EndedGameInfo":
if len(basic_info.teams) != len(team_outcomes):
raise ValueError(
Expand All @@ -170,8 +172,9 @@ def from_basic(
commander_kills,
validity,
[
TeamRatingSummary(outcome, set(player.id for player in team))
for outcome, team in zip(team_outcomes, basic_info.teams)
TeamRatingSummary(outcome, set(player.id for player in team), army_results)
for outcome, team, army_results
in zip(team_outcomes, basic_info.teams, team_army_results)
],
)

Expand All @@ -190,6 +193,7 @@ def to_dict(self):
{
"outcome": team_summary.outcome.name,
"player_ids": list(team_summary.player_ids),
"army_results": team_summary.army_results,
}
for team_summary in self.team_summaries
],
Expand Down
4 changes: 3 additions & 1 deletion server/rating_service/typedefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def from_game_info_dict(cls, game_info: Dict) -> "GameRatingSummary":
game_info["rating_type"],
[
TeamRatingSummary(
getattr(GameOutcome, summary["outcome"]), set(summary["player_ids"])
GameOutcome(summary["outcome"]),
set(summary["player_ids"]),
summary["army_results"],
)
for summary in game_info["teams"]
],
Expand Down
113 changes: 112 additions & 1 deletion tests/integration_tests/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
from server.protocol import Protocol
from tests.utils import fast_forward

from .conftest import connect_and_sign_in, read_until, read_until_command
from .conftest import (
connect_and_sign_in,
connect_mq_consumer,
read_until,
read_until_command
)

# All test coroutines will be treated as marked.
pytestmark = pytest.mark.asyncio
Expand Down Expand Up @@ -532,3 +537,109 @@ async def test_gamestate_ended_clears_references(

assert test.lobby_connection.game_connection is None
assert rhiza.lobby_connection.game_connection is None


@pytest.mark.rabbitmq
@fast_forward(30)
async def test_game_ended_broadcasts_army_results(lobby_server, channel):
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
mq_proto_all = await connect_mq_consumer(
lobby_server,
channel,
"success.gameResults.create"
)

host_id, _, host_proto = await connect_and_sign_in(
("test", "test_password"), lobby_server
)
guest_id, _, guest_proto = await connect_and_sign_in(
("Rhiza", "puff_the_magic_dragon"), lobby_server
)
await read_until_command(guest_proto, "game_info")
ratings = await get_player_ratings(host_proto, "test", "Rhiza")

# Set up the game
game_id = await host_game(host_proto)
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
await join_game(guest_proto, game_id)
# Set player options
await send_player_options(
host_proto,
[host_id, "Army", 1],
[host_id, "Team", 1],
[host_id, "StartSpot", 0],
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
[host_id, "Faction", 1],
[host_id, "Color", 1],
[guest_id, "Army", 2],
[guest_id, "Team", 1],
[guest_id, "StartSpot", 1],
[guest_id, "Faction", 1],
[guest_id, "Color", 2],
)

# Launch game
await host_proto.send_message({
"target": "game",
"command": "GameState",
"args": ["Launching"]
})

await read_until(
host_proto,
lambda cmd: cmd["command"] == "game_info" and cmd["launched_at"]
)
await host_proto.send_message({
"target": "game",
"command": "EnforceRating",
"args": []
})

# End the game
# Reports results
for proto in (host_proto, guest_proto):
await proto.send_message({
"target": "game",
"command": "GameResult",
"args": [1, "victory 10"]
})
await proto.send_message({
"target": "game",
"command": "GameResult",
"args": [2, "recall defeat -5"]
})
# Report GameEnded
for proto in (host_proto, guest_proto):
await proto.send_message({
"target": "game",
"command": "GameEnded",
"args": []
})

# Check that the ratings were updated
new_ratings = await get_player_ratings(host_proto, "test", "Rhiza")

assert ratings["test"][0] < new_ratings["test"][0]
assert ratings["Rhiza"][0] > new_ratings["Rhiza"][0]
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved

# Now disconnect both players
for proto in (host_proto, guest_proto):
await proto.send_message({
"target": "game",
"command": "GameState",
"args": ["Ended"]
})

expected_army_results = [
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
[1, 1, "VICTORY", []],
[3, 2, "DEFEAT", ["recall"]]
]
messages = []

await read_until(
mq_proto_all, lambda message: "teams" in message and not messages.append(message)
)

eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
broadcast_army_results = []
for message in messages:
for team in message["teams"]:
broadcast_army_results.append(*team["army_results"])

assert broadcast_army_results == expected_army_results
2 changes: 1 addition & 1 deletion tests/integration_tests/test_teammatchmaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ async def test_ratings_initialized_based_on_global_persisted(
proto3,
lambda msg: msg["command"] == "player_info"
and any(player["id"] == test_id for player in msg["players"]),
timeout=10
timeout=15
)

async with database.acquire() as conn:
Expand Down
2 changes: 1 addition & 1 deletion tests/unit_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def add_connected_players(game: Game, players):
"""
Utility to add players with army and StartSpot indexed by a list
"""
for army, player in enumerate(players):
for army, player in enumerate(players, start=len(game.players)):
add_connected_player(game, player)
game.set_player_option(player.id, "Army", army)
game.set_player_option(player.id, "StartSpot", army)
Expand Down
Loading