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

Issue/#730 ignore score results #731

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 10 additions & 4 deletions server/games/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
matchmaker_queue_game
)
from server.games.game_results import (
ArmyOutcome,
ArmyReportedOutcome,
GameOutcome,
GameResolutionError,
GameResultReport,
Expand Down Expand Up @@ -293,9 +295,13 @@ async def add_result(
return

try:
outcome = GameOutcome(result_type.upper())
outcome = ArmyReportedOutcome(result_type.upper())
except ValueError:
outcome = GameOutcome.UNKNOWN
self._logger.debug(
"Ignoring result reported by %s for army %s: %s %s",
reporter, army, result_type, score
)
return

result = GameResultReport(reporter, army, outcome, score)
self._results.add(result)
Expand Down Expand Up @@ -826,10 +832,10 @@ async def mark_invalid(self, new_validity_state: ValidityState):
def get_army_score(self, army):
return self._results.score(army)

def get_player_outcome(self, player):
def get_player_outcome(self, player: Player) -> ArmyOutcome:
army = self.get_player_option(player.id, "Army")
if army is None:
return GameOutcome.UNKNOWN
return ArmyOutcome.UNKNOWN

return self._results.outcome(army)

Expand Down
123 changes: 79 additions & 44 deletions server/games/game_results.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,51 @@
import contextlib
from collections import Counter, defaultdict
from collections.abc import Mapping
from enum import Enum
from typing import List, NamedTuple, Set
from typing import Dict, Iterator, List, NamedTuple, Set

from server.decorators import with_logger


class GameOutcome(Enum):
class ArmyOutcome(Enum):
"""
The resolved outcome for an army. Each army has only one of these.
"""
VICTORY = "VICTORY"
DEFEAT = "DEFEAT"
DRAW = "DRAW"
MUTUAL_DRAW = "MUTUAL_DRAW"
UNKNOWN = "UNKNOWN"
CONFLICTING = "CONFLICTING"


class ArmyReportedOutcome(Enum):
"""
A reported outcome for an army. Each army may have several of these.
"""
VICTORY = "VICTORY"
DEFEAT = "DEFEAT"
DRAW = "DRAW"
# This doesn't seem to be reported by the game anymore
MUTUAL_DRAW = "MUTUAL_DRAW"

def to_resolved(self) -> ArmyOutcome:
"""
Convert this result to the resolved version. For when all reported
results agree.
"""
value = self.value
if value == "MUTUAL_DRAW":
value = "DRAW"
return ArmyOutcome(value)


class GameOutcome(Enum):
VICTORY = "VICTORY"
DEFEAT = "DEFEAT"
DRAW = "DRAW"
UNKNOWN = "UNKNOWN"


class GameResultReport(NamedTuple):
"""
These are sent from each player's FA when they quit the game. 'Score'
Expand All @@ -24,7 +55,7 @@ class GameResultReport(NamedTuple):

reporter: int
army: int
outcome: GameOutcome
outcome: ArmyReportedOutcome
score: int


Expand All @@ -36,59 +67,68 @@ class GameResultReports(Mapping):
for each army, but don't modify these.
"""

def __init__(self, game_id):
def __init__(self, game_id: int):
Mapping.__init__(self)
self._game_id = game_id # Just for logging
self._back = {}
self._back: Dict[int, List[GameResultReport]] = {}
# Outcome caching
self._outcomes: Dict[int, ArmyOutcome] = {}
self._dirty_armies: Set[int] = set()

def __getitem__(self, key: int):
def __getitem__(self, key: int) -> List[GameResultReport]:
return self._back[key]

def __iter__(self):
def __iter__(self) -> Iterator[int]:
return iter(self._back)

def __len__(self):
def __len__(self) -> int:
return len(self._back)

def add(self, result: GameResultReport):
def add(self, result: GameResultReport) -> None:
army_results = self._back.setdefault(result.army, [])
army_results.append(result)
self._dirty_armies.add(result.army)

def is_mutually_agreed_draw(self, player_armies):
def is_mutually_agreed_draw(self, player_armies) -> bool:
# Can't tell if we have no results
if not self:
return False
# Everyone has to agree to a mutual draw
for army in player_armies:
if army not in self:
continue
if any(r.outcome is not GameOutcome.MUTUAL_DRAW for r in self[army]):
if any(r.outcome is not ArmyReportedOutcome.MUTUAL_DRAW for r in self[army]):
return False
return True

def outcome(self, army: int) -> GameOutcome:
def outcome(self, army: int) -> ArmyOutcome:
"""
Determines what the game outcome was for a given army.
Determines what the outcome was for a given army.
Returns the unique reported outcome if all players agree,
or the majority outcome if only a few reports disagree.
Otherwise returns CONFLICTING if there is too much disagreement
or UNKNOWN if no reports were filed.
"""
if army not in self._outcomes or army in self._dirty_armies:
self._outcomes[army] = self._compute_outcome(army)
self._dirty_armies.discard(army)

return self._outcomes[army]

def _compute_outcome(self, army: int) -> ArmyOutcome:
if army not in self:
return GameOutcome.UNKNOWN
return ArmyOutcome.UNKNOWN

voters = defaultdict(set)
for report in filter(
lambda r: r.outcome is not GameOutcome.UNKNOWN, self[army]
):
for report in self[army]:
voters[report.outcome].add(report.reporter)

if len(voters) == 0:
return GameOutcome.UNKNOWN
return ArmyOutcome.UNKNOWN

if len(voters) == 1:
unique_outcome = voters.popitem()[0]
return unique_outcome
return unique_outcome.to_resolved()

sorted_outcomes = sorted(
voters.keys(),
Expand All @@ -99,17 +139,17 @@ def outcome(self, army: int) -> GameOutcome:
top_votes = len(voters[sorted_outcomes[0]])
runner_up_votes = len(voters[sorted_outcomes[1]])
if top_votes > 1 >= runner_up_votes or top_votes >= runner_up_votes + 3:
decision = sorted_outcomes[0]
decision = sorted_outcomes[0].to_resolved()
else:
decision = GameOutcome.CONFLICTING
decision = ArmyOutcome.CONFLICTING

self._logger.info(
"Multiple outcomes for game %s army %s resolved to %s. Reports are: %s",
self._game_id, army, decision, voters,
)
return decision

def score(self, army: int):
def score(self, army: int) -> int:
"""
Pick and return most frequently reported score for an army. If multiple
scores are most frequent, pick the largest one. Returns 0 if there are
Expand All @@ -128,14 +168,14 @@ def score(self, army: int):
score, _ = max(scores.items(), key=lambda kv: kv[::-1])
return score

def victory_only_score(self, army: int):
def victory_only_score(self, army: int) -> int:
"""
Calculate our own score depending *only* on victory.
"""
if army not in self:
return 0

if any(r.outcome is GameOutcome.VICTORY for r in self[army]):
if self.outcome(army) is ArmyOutcome.VICTORY:
return 1
else:
return 0
Expand All @@ -154,19 +194,20 @@ async def from_db(cls, database, game_id):
async for row in rows:
startspot, score = row[0], row[1]
# FIXME: Assertion about startspot == army
outcome = GameOutcome[row[2]]
result = GameResultReport(0, startspot, outcome, score)
results.add(result)
with contextlib.suppress(ValueError):
outcome = ArmyReportedOutcome(row[2])
result = GameResultReport(0, startspot, outcome, score)
results.add(result)
return results


class GameResolutionError(Exception):
pass


def resolve_game(team_outcomes: List[Set[GameOutcome]]) -> List[GameOutcome]:
def resolve_game(team_outcomes: List[Set[ArmyOutcome]]) -> List[GameOutcome]:
"""
Takes a list of length two containing sets of GameOutcomes
Takes a list of length two containing sets of ArmyOutcome
for individual players on a team
and converts a list of two GameOutcomes,
either VICTORY and DEFEAT or DRAW and DRAW.
Expand All @@ -179,8 +220,8 @@ def resolve_game(team_outcomes: List[Set[GameOutcome]]) -> List[GameOutcome]:
"Will not resolve game with other than two parties."
)

victory0 = GameOutcome.VICTORY in team_outcomes[0]
victory1 = GameOutcome.VICTORY in team_outcomes[1]
victory0 = ArmyOutcome.VICTORY in team_outcomes[0]
victory1 = ArmyOutcome.VICTORY in team_outcomes[1]
both_claim_victory = victory0 and victory1
someone_claims_victory = victory0 or victory1
if both_claim_victory:
Expand All @@ -191,36 +232,30 @@ def resolve_game(team_outcomes: List[Set[GameOutcome]]) -> List[GameOutcome]:
elif someone_claims_victory:
return [
GameOutcome.VICTORY
if GameOutcome.VICTORY in outcomes
if ArmyOutcome.VICTORY in outcomes
else GameOutcome.DEFEAT
for outcomes in team_outcomes
]

# Now know that no-one has GameOutcome.VICTORY
draw0 = (
GameOutcome.DRAW in team_outcomes[0]
or GameOutcome.MUTUAL_DRAW in team_outcomes[0]
)
draw1 = (
GameOutcome.DRAW in team_outcomes[1]
or GameOutcome.MUTUAL_DRAW in team_outcomes[1]
)
draw0 = ArmyOutcome.DRAW in team_outcomes[0]
draw1 = ArmyOutcome.DRAW in team_outcomes[1]
both_claim_draw = draw0 and draw1
someone_claims_draw = draw0 or draw1
if both_claim_draw:
return [GameOutcome.DRAW, GameOutcome.DRAW]
elif someone_claims_draw:
raise GameResolutionError(
"Cannot resolve game with unilateral draw. "
f" Team outcomes: {team_outcomes}"
f"Team outcomes: {team_outcomes}"
)

# Now know that the only results are DEFEAT or UNKNOWN/CONFLICTING
# Unrank if there are any players with unknown result
all_outcomes = team_outcomes[0] | team_outcomes[1]
if (
GameOutcome.UNKNOWN in all_outcomes
or GameOutcome.CONFLICTING in all_outcomes
ArmyOutcome.UNKNOWN in all_outcomes
or ArmyOutcome.CONFLICTING in all_outcomes
):
raise GameResolutionError(
"Cannot resolve game with ambiguous outcome. "
Expand Down
7 changes: 4 additions & 3 deletions server/games/ladder_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from server.players import Player
from server.rating import RatingType

from .game import Game, GameOutcome, GameType
from .game import Game, GameType
from .game_results import ArmyOutcome, GameOutcome
from .typedefs import FeaturedModType

logger = logging.getLogger(__name__)
Expand All @@ -27,8 +28,8 @@ def __init__(self, id_, *args, **kwargs):
new_kwargs.update(kwargs)
super().__init__(id_, *args, **new_kwargs)

def is_winner(self, player: Player):
return self.get_player_outcome(player) is GameOutcome.VICTORY
def is_winner(self, player: Player) -> bool:
return self.get_player_outcome(player) is ArmyOutcome.VICTORY

def get_army_score(self, army: int) -> int:
"""
Expand Down
6 changes: 3 additions & 3 deletions server/stats/game_stats_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from server.config import config
from server.core import Service
from server.games import FeaturedModType, Game
from server.games.game_results import GameOutcome
from server.games.game_results import ArmyOutcome
from server.players import Player
from server.stats.achievement_service import *
from server.stats.event_service import *
Expand Down Expand Up @@ -48,7 +48,7 @@ async def process_game_stats(self, player: Player, game: Game, army_stats_list:
return

army_result = game.get_player_outcome(player)
if army_result is GameOutcome.UNKNOWN:
if army_result is ArmyOutcome.UNKNOWN:
self._logger.warning("No army result available for player %s", player.login)
return

Expand All @@ -61,7 +61,7 @@ async def process_game_stats(self, player: Player, game: Game, army_stats_list:
e_queue = []
self._logger.debug("Army result for %s => %s ", player, army_result)

survived = army_result is GameOutcome.VICTORY
survived = army_result is ArmyOutcome.VICTORY
blueprint_stats = stats["blueprints"]
unit_stats = stats["units"]
scored_highest = highest_scorer == player.login
Expand Down