Skip to content

Commit

Permalink
Issue/#730 ignore score results (#731)
Browse files Browse the repository at this point in the history
* Add tests for draw bug

* Refactor GameOutcome enum into several enums

We can now distinguish between results in their various stages:
1. Results reported by an individual for a specific army
2. Accepted result for an army based on the individual reports
3. Accepted result for the whole team based on the accepted army results

For instance, it doesn't make sense for a player's game to report the 
status
of a particular result as "conflicting" since conflicts only arise when
multiple players report different results for the same army. The Enum's 
now
reflect that, and you can no longer report a "conflicting" or "unknown"
result directly (as you could have before).

As a consequence, any result string that is not present in the
ArmyReportedOutcome will be ignored. Hence, the ACU kills reported via 
"score"
results will no longer affect the army score calculation (previously 
these
could turn a -10 score from a defeat into a positive number if the 
player got
any acu kills).
  • Loading branch information
Askaholic committed Mar 6, 2021
1 parent 2cd034c commit 4ff6d60
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 149 deletions.
14 changes: 10 additions & 4 deletions server/games/game.py
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
@@ -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
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
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

0 comments on commit 4ff6d60

Please sign in to comment.