diff --git a/server/games/game.py b/server/games/game.py index 2eddc60b6..badc2d3af 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -17,6 +17,8 @@ matchmaker_queue_game ) from server.games.game_results import ( + ArmyOutcome, + ArmyReportedOutcome, GameOutcome, GameResolutionError, GameResultReport, @@ -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) @@ -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) diff --git a/server/games/game_results.py b/server/games/game_results.py index 1b134ff97..33b6f082b 100644 --- a/server/games/game_results.py +++ b/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' @@ -24,7 +55,7 @@ class GameResultReport(NamedTuple): reporter: int army: int - outcome: GameOutcome + outcome: ArmyReportedOutcome score: int @@ -36,25 +67,29 @@ 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 @@ -62,33 +97,38 @@ def is_mutually_agreed_draw(self, player_armies): 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(), @@ -99,9 +139,9 @@ 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", @@ -109,7 +149,7 @@ def outcome(self, army: int) -> GameOutcome: ) 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 @@ -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 @@ -154,9 +194,10 @@ 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 @@ -164,9 +205,9 @@ 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. @@ -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: @@ -191,20 +232,14 @@ 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: @@ -212,15 +247,15 @@ def resolve_game(team_outcomes: List[Set[GameOutcome]]) -> List[GameOutcome]: 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. " diff --git a/server/games/ladder_game.py b/server/games/ladder_game.py index 0843ce1cf..2a2546647 100644 --- a/server/games/ladder_game.py +++ b/server/games/ladder_game.py @@ -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__) @@ -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: """ diff --git a/server/stats/game_stats_service.py b/server/stats/game_stats_service.py index 7a15e0038..895b5517b 100644 --- a/server/stats/game_stats_service.py +++ b/server/stats/game_stats_service.py @@ -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 * @@ -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 @@ -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 diff --git a/tests/integration_tests/test_game.py b/tests/integration_tests/test_game.py index 8feddf810..6875b018e 100644 --- a/tests/integration_tests/test_game.py +++ b/tests/integration_tests/test_game.py @@ -2,7 +2,10 @@ import gc import pytest +from sqlalchemy import select +from server.db.models import game_player_stats +from server.games.game_results import GameOutcome from server.protocol import Protocol from tests.utils import fast_forward @@ -336,6 +339,74 @@ async def test_partial_game_ended_rates_game(lobby_server, tmp_user): await read_until_command(host_proto, "player_info", timeout=10) +@fast_forward(15) +async def test_ladder_game_draw_bug(lobby_server, database): + """ + This simulates the infamous "draw bug" where a player could self destruct + their own ACU in order to kill the enemy ACU and be awarded a victory + instead of a draw. + """ + proto1, proto2 = await queue_players_for_matchmaking(lobby_server) + + msg1, msg2 = await asyncio.gather(*[ + client_response(proto) for proto in (proto1, proto2) + ]) + game_id = msg1["uid"] + army1 = msg1["map_position"] + army2 = msg2["map_position"] + + for proto in (proto1, proto2): + await proto.send_message({ + "target": "game", + "command": "GameState", + "args": ["Launching"] + }) + await read_until( + proto1, + lambda cmd: cmd["command"] == "game_info" and cmd["launched_at"] + ) + + # Player 1 ctrl-k's + for result in ( + [army1, "defeat -10"], + [army1, "score 1"], + [army2, "defeat -10"] + ): + for proto in (proto1, proto2): + await proto1.send_message({ + "target": "game", + "command": "GameResult", + "args": result + }) + + for proto in (proto1, proto2): + await proto.send_message({ + "target": "game", + "command": "GameEnded", + "args": [] + }) + + ratings = await get_player_ratings( + proto1, "ladder1", "ladder2", + rating_type="ladder_1v1" + ) + + # Both start at (1500, 500). + # When rated as a draw the means should barely change. + assert abs(ratings["ladder1"][0] - 1500.) < 1 + assert abs(ratings["ladder2"][0] - 1500.) < 1 + + async with database.acquire() as conn: + result = await conn.execute( + select([game_player_stats]).where( + game_player_stats.c.gameId == game_id + ) + ) + async for row in result: + assert row.result == GameOutcome.DEFEAT + assert row.score == 0 + + @fast_forward(15) async def test_ladder_game_not_joinable(lobby_server): """ @@ -348,16 +419,7 @@ async def test_ladder_game_not_joinable(lobby_server): await read_until_command(test_proto, "game_info") msg = await read_until_command(proto1, "game_launch") - await proto1.send_message({ - "command": "GameState", - "target": "game", - "args": ["Idle"] - }) - await proto1.send_message({ - "command": "GameState", - "target": "game", - "args": ["Lobby"] - }) + await open_fa(proto1) game_uid = msg["uid"] diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index 54abe99bc..aa5f63ce6 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -19,7 +19,7 @@ Victory, VisibilityState ) -from server.games.game_results import GameOutcome +from server.games.game_results import ArmyOutcome from server.rating import InclusiveRange, RatingType from tests.unit_tests.conftest import ( add_connected_player, @@ -135,7 +135,7 @@ async def test_add_result_unknown(game, game_add_players): players = game_add_players(game, 5, team=1) await game.launch() await game.add_result(0, 1, "something invalid", 5) - assert game.get_player_outcome(players[0]) is GameOutcome.UNKNOWN + assert game.get_player_outcome(players[0]) is ArmyOutcome.UNKNOWN async def test_ffa_not_rated(game, game_add_players): @@ -516,10 +516,10 @@ async def test_game_get_player_outcome_ignores_unknown_results( await game.add_result(0, 0, "defeat", 0) await game.add_result(0, 0, "score", 0) - assert game.get_player_outcome(players[0]) is GameOutcome.DEFEAT + assert game.get_player_outcome(players[0]) is ArmyOutcome.DEFEAT await game.add_result(0, 1, "score", 0) - assert game.get_player_outcome(players[1]) is GameOutcome.UNKNOWN + assert game.get_player_outcome(players[1]) is ArmyOutcome.UNKNOWN async def test_on_game_end_single_player_gives_unknown_result(game): @@ -659,9 +659,9 @@ async def test_persist_results_called_with_two_players(game, game_add_players): assert game.get_army_score(1) == 5 for player in game.players: if game.get_player_option(player.id, "Army") == 1: - assert game.get_player_outcome(player) is GameOutcome.VICTORY + assert game.get_player_outcome(player) is ArmyOutcome.VICTORY else: - assert game.get_player_outcome(player) is GameOutcome.UNKNOWN + assert game.get_player_outcome(player) is ArmyOutcome.UNKNOWN async def test_persist_results_called_for_unranked(game, game_add_players): @@ -677,17 +677,17 @@ async def test_persist_results_called_for_unranked(game, game_add_players): assert len(game.players) == 2 for player in game.players: if game.get_player_option(player.id, "Army") == 1: - assert game.get_player_outcome(player) is GameOutcome.VICTORY + assert game.get_player_outcome(player) is ArmyOutcome.VICTORY else: - assert game.get_player_outcome(player) is GameOutcome.UNKNOWN + assert game.get_player_outcome(player) is ArmyOutcome.UNKNOWN await game.load_results() assert game.get_army_score(1) == 5 for player in game.players: if game.get_player_option(player.id, "Army") == 1: - assert game.get_player_outcome(player) is GameOutcome.VICTORY + assert game.get_player_outcome(player) is ArmyOutcome.VICTORY else: - assert game.get_player_outcome(player) is GameOutcome.UNKNOWN + assert game.get_player_outcome(player) is ArmyOutcome.UNKNOWN async def test_get_army_score_conflicting_results_clear_winner( @@ -823,8 +823,8 @@ async def test_game_outcomes(game: Game, database, players): host_outcome = game.get_player_outcome(players.hosting) guest_outcome = game.get_player_outcome(players.joining) - assert host_outcome is GameOutcome.VICTORY - assert guest_outcome is GameOutcome.DEFEAT + assert host_outcome is ArmyOutcome.VICTORY + assert guest_outcome is ArmyOutcome.DEFEAT default_values_before_end = {(players.hosting.id, 0), (players.joining.id, 0)} assert await game_player_scores(database, game) == default_values_before_end @@ -845,8 +845,8 @@ async def test_game_outcomes_no_results(game: Game, database, players): host_outcome = game.get_player_outcome(players.hosting) guest_outcome = game.get_player_outcome(players.joining) - assert host_outcome is GameOutcome.UNKNOWN - assert guest_outcome is GameOutcome.UNKNOWN + assert host_outcome is ArmyOutcome.UNKNOWN + assert guest_outcome is ArmyOutcome.UNKNOWN await game.on_game_end() expected_scores = {(players.hosting.id, 0), (players.joining.id, 0)} @@ -868,8 +868,8 @@ async def test_game_outcomes_conflicting(game: Game, database, players): host_outcome = game.get_player_outcome(players.hosting) guest_outcome = game.get_player_outcome(players.joining) - assert host_outcome is GameOutcome.CONFLICTING - assert guest_outcome is GameOutcome.CONFLICTING + assert host_outcome is ArmyOutcome.CONFLICTING + assert guest_outcome is ArmyOutcome.CONFLICTING # No guarantees on scores for conflicting results. diff --git a/tests/unit_tests/test_game_rating.py b/tests/unit_tests/test_game_rating.py index d9832a402..c71fbb2b3 100644 --- a/tests/unit_tests/test_game_rating.py +++ b/tests/unit_tests/test_game_rating.py @@ -156,7 +156,6 @@ async def test_rating_summary_missing_team_raises_game_error(game, players): async def test_resolve_game_fails_if_not_launched(custom_game, players): - rating_service = custom_game.game_service._rating_service custom_game.state = GameState.LOBBY add_connected_players(custom_game, [players.hosting, players.joining]) custom_game.set_player_option(players.hosting.id, "Team", 2) diff --git a/tests/unit_tests/test_game_resolution.py b/tests/unit_tests/test_game_resolution.py index a9cd1b6b0..beab45784 100644 --- a/tests/unit_tests/test_game_resolution.py +++ b/tests/unit_tests/test_game_resolution.py @@ -1,6 +1,7 @@ import pytest from server.games.game_results import ( + ArmyOutcome, GameOutcome, GameResolutionError, resolve_game @@ -8,9 +9,13 @@ def test_only_rate_with_two_parties(): - one_party = [{GameOutcome.VICTORY}] - two_parties = [{GameOutcome.VICTORY}, {GameOutcome.DEFEAT}] - three_parties = [{GameOutcome.VICTORY}, {GameOutcome.DEFEAT}, {GameOutcome.DEFEAT}] + one_party = [{ArmyOutcome.VICTORY}] + two_parties = [{ArmyOutcome.VICTORY}, {ArmyOutcome.DEFEAT}] + three_parties = [ + {ArmyOutcome.VICTORY}, + {ArmyOutcome.DEFEAT}, + {ArmyOutcome.DEFEAT} + ] with pytest.raises(GameResolutionError): resolve_game(one_party) @@ -22,7 +27,7 @@ def test_only_rate_with_two_parties(): def testresolve(): - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.DEFEAT}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.DEFEAT}] ranks = resolve_game(team_outcomes) @@ -33,97 +38,73 @@ def test_ranks_all_1v1_possibilities(): """ Document expectations for all outcomes of 1v1 games. Assumes that the order of teams doesn't matter. - With six possible outcomes there are 21 possibilities. + With five possible outcomes there are 15 possibilities. """ - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.VICTORY}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.VICTORY}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.DEFEAT}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.DEFEAT}] ranks = resolve_game(team_outcomes) assert ranks == [GameOutcome.VICTORY, GameOutcome.DEFEAT] - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.DRAW}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.DRAW}] resolve_game(team_outcomes) assert ranks == [GameOutcome.VICTORY, GameOutcome.DEFEAT] - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.MUTUAL_DRAW}] - ranks = resolve_game(team_outcomes) - assert ranks == [GameOutcome.VICTORY, GameOutcome.DEFEAT] - - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.UNKNOWN}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.UNKNOWN}] ranks = resolve_game(team_outcomes) assert ranks == [GameOutcome.VICTORY, GameOutcome.DEFEAT] - team_outcomes = [{GameOutcome.VICTORY}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.VICTORY}, {ArmyOutcome.CONFLICTING}] ranks = resolve_game(team_outcomes) assert ranks == [GameOutcome.VICTORY, GameOutcome.DEFEAT] - team_outcomes = [{GameOutcome.DEFEAT}, {GameOutcome.DEFEAT}] + team_outcomes = [{ArmyOutcome.DEFEAT}, {ArmyOutcome.DEFEAT}] ranks = resolve_game(team_outcomes) assert ranks == [GameOutcome.DRAW, GameOutcome.DRAW] - team_outcomes = [{GameOutcome.DEFEAT}, {GameOutcome.DRAW}] - with pytest.raises(GameResolutionError): - resolve_game(team_outcomes) - - team_outcomes = [{GameOutcome.DEFEAT}, {GameOutcome.MUTUAL_DRAW}] - with pytest.raises(GameResolutionError): - resolve_game(team_outcomes) - - team_outcomes = [{GameOutcome.DEFEAT}, {GameOutcome.UNKNOWN}] - with pytest.raises(GameResolutionError): - resolve_game(team_outcomes) - - team_outcomes = [{GameOutcome.DEFEAT}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.DEFEAT}, {ArmyOutcome.DRAW}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.DRAW}, {GameOutcome.DRAW}] - ranks = resolve_game(team_outcomes) - assert ranks == [GameOutcome.DRAW, GameOutcome.DRAW] - - team_outcomes = [{GameOutcome.DRAW}, {GameOutcome.MUTUAL_DRAW}] - ranks = resolve_game(team_outcomes) - assert ranks == [GameOutcome.DRAW, GameOutcome.DRAW] - - team_outcomes = [{GameOutcome.DRAW}, {GameOutcome.UNKNOWN}] + team_outcomes = [{ArmyOutcome.DEFEAT}, {ArmyOutcome.UNKNOWN}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.DRAW}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.DEFEAT}, {ArmyOutcome.CONFLICTING}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.MUTUAL_DRAW}, {GameOutcome.MUTUAL_DRAW}] + team_outcomes = [{ArmyOutcome.DRAW}, {ArmyOutcome.DRAW}] ranks = resolve_game(team_outcomes) assert ranks == [GameOutcome.DRAW, GameOutcome.DRAW] - team_outcomes = [{GameOutcome.MUTUAL_DRAW}, {GameOutcome.UNKNOWN}] + team_outcomes = [{ArmyOutcome.DRAW}, {ArmyOutcome.UNKNOWN}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.MUTUAL_DRAW}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.DRAW}, {ArmyOutcome.CONFLICTING}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.UNKNOWN}, {GameOutcome.UNKNOWN}] + team_outcomes = [{ArmyOutcome.UNKNOWN}, {ArmyOutcome.UNKNOWN}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.UNKNOWN}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.UNKNOWN}, {ArmyOutcome.CONFLICTING}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) - team_outcomes = [{GameOutcome.CONFLICTING}, {GameOutcome.CONFLICTING}] + team_outcomes = [{ArmyOutcome.CONFLICTING}, {ArmyOutcome.CONFLICTING}] with pytest.raises(GameResolutionError): resolve_game(team_outcomes) def test_team_outcome_ignores_unknown(): team_outcomes = [ - {GameOutcome.VICTORY, GameOutcome.UNKNOWN}, - {GameOutcome.DEFEAT, GameOutcome.UNKNOWN}, + {ArmyOutcome.VICTORY, ArmyOutcome.UNKNOWN}, + {ArmyOutcome.DEFEAT, ArmyOutcome.UNKNOWN}, ] ranks = resolve_game(team_outcomes) @@ -132,18 +113,8 @@ def test_team_outcome_ignores_unknown(): def test_team_outcome_throws_if_unilateral_draw(): team_outcomes = [ - {GameOutcome.DRAW, GameOutcome.DEFEAT}, - {GameOutcome.DEFEAT, GameOutcome.UNKNOWN}, - ] - - with pytest.raises(GameResolutionError): - resolve_game(team_outcomes) - - -def test_team_outcome_throws_if_unilateral_mutual_draw(): - team_outcomes = [ - {GameOutcome.MUTUAL_DRAW, GameOutcome.DEFEAT}, - {GameOutcome.DEFEAT, GameOutcome.UNKNOWN}, + {ArmyOutcome.DRAW, ArmyOutcome.DEFEAT}, + {ArmyOutcome.DEFEAT, ArmyOutcome.UNKNOWN}, ] with pytest.raises(GameResolutionError): @@ -152,8 +123,8 @@ def test_team_outcome_throws_if_unilateral_mutual_draw(): def test_team_outcome_victory_has_priority_over_defeat(): team_outcomes = [ - {GameOutcome.VICTORY, GameOutcome.DEFEAT}, - {GameOutcome.DEFEAT, GameOutcome.DEFEAT}, + {ArmyOutcome.VICTORY, ArmyOutcome.DEFEAT}, + {ArmyOutcome.DEFEAT, ArmyOutcome.DEFEAT}, ] ranks = resolve_game(team_outcomes) @@ -163,8 +134,8 @@ def test_team_outcome_victory_has_priority_over_defeat(): def test_team_outcome_victory_has_priority_over_draw(): team_outcomes = [ - {GameOutcome.VICTORY, GameOutcome.DRAW}, - {GameOutcome.DRAW, GameOutcome.DEFEAT}, + {ArmyOutcome.VICTORY, ArmyOutcome.DRAW}, + {ArmyOutcome.DRAW, ArmyOutcome.DEFEAT}, ] ranks = resolve_game(team_outcomes) @@ -174,8 +145,8 @@ def test_team_outcome_victory_has_priority_over_draw(): def test_team_outcome_no_double_victory(): team_outcomes = [ - {GameOutcome.VICTORY, GameOutcome.VICTORY}, - {GameOutcome.VICTORY, GameOutcome.DEFEAT}, + {ArmyOutcome.VICTORY, ArmyOutcome.VICTORY}, + {ArmyOutcome.VICTORY, ArmyOutcome.DEFEAT}, ] with pytest.raises(GameResolutionError): @@ -184,8 +155,8 @@ def test_team_outcome_no_double_victory(): def test_team_outcome_unranked_if_ambiguous(): team_outcomes = [ - {GameOutcome.UNKNOWN, GameOutcome.DEFEAT}, - {GameOutcome.DEFEAT, GameOutcome.DEFEAT}, + {ArmyOutcome.UNKNOWN, ArmyOutcome.DEFEAT}, + {ArmyOutcome.DEFEAT, ArmyOutcome.DEFEAT}, ] with pytest.raises(GameResolutionError): diff --git a/tests/unit_tests/test_game_results.py b/tests/unit_tests/test_game_results.py new file mode 100644 index 000000000..c72d96c37 --- /dev/null +++ b/tests/unit_tests/test_game_results.py @@ -0,0 +1,44 @@ +import mock +import pytest + +from server.games.game_results import ( + ArmyOutcome, + ArmyReportedOutcome, + GameResultReport, + GameResultReports +) + + +@pytest.fixture +def game_results(): + return GameResultReports(game_id=42) + + +def test_reported_result_to_resolved(): + assert ArmyReportedOutcome.VICTORY.to_resolved() is ArmyOutcome.VICTORY + assert ArmyReportedOutcome.DEFEAT.to_resolved() is ArmyOutcome.DEFEAT + assert ArmyReportedOutcome.DRAW.to_resolved() is ArmyOutcome.DRAW + assert ArmyReportedOutcome.MUTUAL_DRAW.to_resolved() is ArmyOutcome.DRAW + + # Make sure our test hits every enum variant in case new ones are added + for variant in ArmyReportedOutcome: + assert variant.to_resolved() in ArmyOutcome + + +def test_outcome_cache(game_results): + game_results._compute_outcome = mock.Mock( + side_effect=game_results._compute_outcome + ) + game_results.add(GameResultReport(1, 1, ArmyReportedOutcome.DEFEAT, -10)) + + assert game_results.outcome(1) is ArmyOutcome.DEFEAT + game_results._compute_outcome.assert_called_once_with(1) + assert game_results.outcome(1) is ArmyOutcome.DEFEAT + game_results._compute_outcome.assert_called_once_with(1) + game_results._compute_outcome.reset_mock() + + game_results.add(GameResultReport(1, 1, ArmyReportedOutcome.VICTORY, -10)) + assert game_results.outcome(1) is ArmyOutcome.CONFLICTING + game_results._compute_outcome.assert_called_once_with(1) + assert game_results.outcome(1) is ArmyOutcome.CONFLICTING + game_results._compute_outcome.assert_called_once_with(1) diff --git a/tests/unit_tests/test_game_stats_service.py b/tests/unit_tests/test_game_stats_service.py index e69b1eb39..9d58ba74d 100644 --- a/tests/unit_tests/test_game_stats_service.py +++ b/tests/unit_tests/test_game_stats_service.py @@ -8,7 +8,7 @@ from server.factions import Faction from server.games import Game from server.games.game_results import ( - GameOutcome, + ArmyReportedOutcome, GameResultReport, GameResultReports ) @@ -49,7 +49,7 @@ def game(database, game_stats_service, player): game = Game(1, database, Mock(), game_stats_service) game._player_options[player.id] = {"Army": 1} game._results = GameResultReports(1) - game._results.add(GameResultReport(1, 1, GameOutcome.VICTORY, 0)) + game._results.add(GameResultReport(1, 1, ArmyReportedOutcome.VICTORY, 0)) return game diff --git a/tests/unit_tests/test_laddergame.py b/tests/unit_tests/test_laddergame.py index 57efb2a49..806950ce4 100644 --- a/tests/unit_tests/test_laddergame.py +++ b/tests/unit_tests/test_laddergame.py @@ -5,6 +5,7 @@ from server.db.models import leaderboard_rating from server.games import GameState, LadderGame, ValidityState +from server.games.game_results import GameOutcome from server.rating import RatingType from tests.unit_tests.test_game import add_connected_players @@ -71,6 +72,23 @@ async def test_is_winner_on_draw(laddergame, players): assert not laddergame.is_winner(players.joining) +async def test_game_result_draw_bug(laddergame, players): + laddergame.state = GameState.LOBBY + add_connected_players(laddergame, [players.hosting, players.joining]) + await laddergame.launch() + + await laddergame.add_result(players.hosting.id, 0, "defeat", -10) + await laddergame.add_result(players.hosting.id, 0, "score", 1) + await laddergame.add_result(players.joining.id, 1, "defeat", -10) + + assert not laddergame.is_winner(players.hosting) + assert not laddergame.is_winner(players.joining) + + results = await laddergame.resolve_game_results() + for summary in results.team_summaries: + assert summary.outcome is GameOutcome.DRAW + + async def test_rate_game(laddergame: LadderGame, database, game_add_players): laddergame.state = GameState.LOBBY players = game_add_players(laddergame, 2)