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 20 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
54 changes: 46 additions & 8 deletions server/games/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
import logging
import time
from collections import defaultdict
from typing import Any, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple
from typing import (
Any,
Dict,
FrozenSet,
Iterable,
List,
Optional,
OrderedDict,
Set,
Tuple
)

import pymysql
from sqlalchemy import and_, bindparam
Expand All @@ -19,6 +29,7 @@
from server.games.game_results import (
ArmyOutcome,
ArmyReportedOutcome,
ArmyResult,
GameOutcome,
GameResolutionError,
GameResultReport,
Expand Down Expand Up @@ -280,7 +291,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 +306,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 +325,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 +481,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:
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 +513,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 +851,15 @@ def get_player_outcome(self, player: Player) -> ArmyOutcome:

return self._results.outcome(army)

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

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,5 +1,5 @@
from enum import Enum, unique
from typing import Any, Dict, List, NamedTuple, Optional, Set
from typing import Any, Dict, List, NamedTuple, Optional, OrderedDict, Set

from server.games.game_results import 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[OrderedDict]


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[OrderedDict]],
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
) -> "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
1 change: 1 addition & 0 deletions tests/data/test-data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ INSERT INTO `coop_map` (`type`, `name`, `description`, `version`, `filename`) VA
insert into game_featuredMods (id, gamemod, name, description, publish, git_url, git_branch, file_extension, allow_override) values
(1, 'faf', 'FAF', 'Forged Alliance Forever', 1, 'https://github.com/FAForever/fa.git', 'deploy/faf', 'nx2', FALSE),
(6, 'ladder1v1', 'FAF', 'Ladder games', 1, 'https://github.com/FAForever/fa.git', 'deploy/faf', 'nx2', TRUE),
(24, 'gw', '', 'Galactic War', 0, NULL, NULL, NULL, NULL), -- GW unpublished because you can't start them from "Host game"
eoinnoble marked this conversation as resolved.
Show resolved Hide resolved
(25, 'coop', 'Coop', 'Multiplayer campaign games', 1, 'https://github.com/FAForever/fa-coop.git', 'master', 'cop', TRUE);

insert into game_stats (id, startTime, gameName, gameType, gameMod, host, mapId, validity) values
Expand Down
Loading