diff --git a/app/games.py b/app/games.py index e868dda..9ace49b 100644 --- a/app/games.py +++ b/app/games.py @@ -9,6 +9,10 @@ EASTERN = ZoneInfo("America/New_York") logger = logging.getLogger(__name__) +# Maps (home_team_name, away_team_name) -> (home_score, away_score) +# Used to detect when the trailing team just scored. +_score_cache: dict[tuple[str, str], tuple[int, int]] = {} + def format_record(record): if record == "N/A": @@ -39,6 +43,7 @@ def parse_games(scoreboard_data): "Home Logo": game["homeTeam"]["logo"], "Away Logo": game["awayTeam"]["logo"], "Game State": game_state, + "Game Type": game.get("gameType", 2), "Period": get_period(game), "Time Remaining": get_time_remaining(game), "Time Running": game["clock"]["running"] @@ -47,7 +52,7 @@ def parse_games(scoreboard_data): "Intermission": game["clock"]["inIntermission"] if game_state == "LIVE" else "N/A", - "Priority": calculate_game_priority(game), + "Priority": calculate_game_priority(game) + get_comeback_bonus(game), "Start Time": get_start_time(game), "Home Record": format_record(game["homeTeam"]["record"]) if game["gameState"] in ["PRE", "FUT"] @@ -75,6 +80,52 @@ def parse_games(scoreboard_data): return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True) +def get_comeback_bonus(game): + """ + Returns a one-time bonus when the trailing team just scored and the game + is still within reach (score_diff <= 2). Updates _score_cache. + Returns 0 for intermission, non-live games, or no cache entry yet. + """ + if game["gameState"] not in ("LIVE", "CRIT"): + return 0 + if game["clock"]["inIntermission"]: + return 0 + + home_name = game["homeTeam"]["name"]["default"] + away_name = game["awayTeam"]["name"]["default"] + key = (home_name, away_name) + + home_score = game["homeTeam"]["score"] + away_score = game["awayTeam"]["score"] + period = game.get("periodDescriptor", {}).get("number", 0) + + bonus = 0 + if key in _score_cache: + prev_home, prev_away = _score_cache[key] + prev_diff = abs(prev_home - prev_away) + new_diff = abs(home_score - away_score) + + trailing_scored = ( + new_diff < prev_diff + and new_diff <= 2 + and ( + (prev_home < prev_away and home_score > prev_home) + or (prev_away < prev_home and away_score > prev_away) + ) + ) + + if trailing_scored: + if period >= 4: + bonus = 100 + elif period == 3: + bonus = 75 + else: + bonus = 50 + + _score_cache[key] = (home_score, away_score) + return bonus + + def convert_game_state(game_state): state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"} return state_mapping.get(game_state, game_state) @@ -123,55 +174,59 @@ def get_game_outcome(game, game_state): def calculate_game_priority(game): - # Return 0 if game is in certain states if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]: return 0 - # Get period, time remaining, scores, and other relevant data period = game.get("periodDescriptor", {}).get("number", 0) time_remaining = game.get("clock", {}).get("secondsRemaining", 0) home_score = game["homeTeam"]["score"] away_score = game["awayTeam"]["score"] score_difference = abs(home_score - away_score) - score_total = (home_score + away_score) * 20 + is_playoff = game.get("gameType", 2) == 3 - # Get standings for home and away teams - home_team_standings = get_team_standings(game["homeTeam"]["name"]["default"]) - away_team_standings = get_team_standings(game["awayTeam"]["name"]["default"]) + # ── 1. Base priority by period ──────────────────────────────────────── + if is_playoff: + # Playoffs: P4+ are full 20-min OTs that escalate indefinitely + base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150) + else: + # Regular season: P4=5-min OT, P5=shootout + base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150) - # Calculate total values of leagueSequence + leagueL10Sequence for each team + # ── 2. Period length for time calculations ──────────────────────────── + if period >= 4: + period_length = 1200 if is_playoff else 300 + else: + period_length = 1200 + + # ── 3. Standings-quality matchup adjustment ─────────────────────────── + home_standings = get_team_standings(game["homeTeam"]["name"]["default"]) + away_standings = get_team_standings(game["awayTeam"]["name"]["default"]) home_total = ( - home_team_standings["league_sequence"] - + home_team_standings["league_l10_sequence"] + home_standings["league_sequence"] + home_standings["league_l10_sequence"] ) away_total = ( - away_team_standings["league_sequence"] - + away_team_standings["league_l10_sequence"] + away_standings["league_sequence"] + away_standings["league_l10_sequence"] ) - - # Calculate the matchup adjustment factor - matchup_multiplier = {5: 1, 4: 1, 3: 1.50, 2: 1.65, 1: 2}.get(period) + # Higher period = matchup matters less (any OT is exciting regardless of teams) + matchup_multiplier = {1: 2.0, 2: 1.65, 3: 1.50, 4: 1.0}.get(period, 1.0) matchup_adjustment = (home_total + away_total) * matchup_multiplier - # Calculate the base priority based on period - base_priority = {5: 650, 4: 600, 3: 300, 2: 200}.get(period, 150) - - # Adjust base priority based on score difference - score_differential_adjustment = 0 - + # ── 4. Score-differential penalty ──────────────────────────────────── if score_difference > 3: - score_differential_adjustment += 500 + score_differential_adjustment = 500 elif score_difference > 2: - score_differential_adjustment += 350 + score_differential_adjustment = 350 elif score_difference > 1: - score_differential_adjustment += 100 + score_differential_adjustment = 100 + else: + score_differential_adjustment = 0 if period == 3 and time_remaining <= 300: - score_differential_adjustment = score_differential_adjustment * 2 + score_differential_adjustment *= 2 base_priority -= score_differential_adjustment - # Adjust base priority based on certain conditions + # ── 5. Late-3rd urgency bonus ───────────────────────────────────────── if period == 3 and time_remaining <= 720: if score_difference == 0: base_priority += 100 @@ -184,24 +239,46 @@ def calculate_game_priority(game): elif score_difference == 1: base_priority += 30 - # Calculate time priority - time_multiplier = {4: 2, 3: 2, 2: 1.5}.get(period, 0.75) - time_priority = ((1200 - time_remaining) / 20) * time_multiplier + # ── 6. Closeness bonus (replaces unconditional score_total) ────────── + # Rewards tight games regardless of total goals scored + closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0) + + # ── 7. Time priority ───────────────────────────────────────────────── + # Calibrated to period length so deep-into-period signal is meaningful + # for both 5-min reg season OT and 20-min playoff OT + time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5) + time_priority = ((period_length - time_remaining) / 20) * time_multiplier + + # ── 8. Power play bonus ─────────────────────────────────────────────── + pp_bonus = 0 + situation = game.get("situation", {}) + if "PP" in situation.get("situationDescriptions", []): + if period >= 4: + pp_bonus = 200 + elif period == 3 and time_remaining <= 300: + pp_bonus = 150 + elif period == 3 and time_remaining <= 720: + pp_bonus = 100 + elif period == 3: + pp_bonus = 50 + else: + pp_bonus = 30 logger.debug( - "priority components — base: %s, time: %s, matchup: %s, score_total: %s", + "priority components — base: %s, time: %.0f, matchup: %.0f, " + "closeness: %s, pp: %s", base_priority, time_priority, matchup_adjustment, - score_total, + closeness_bonus, + pp_bonus, ) - # Calculate the final priority final_priority = int( - base_priority + time_priority - matchup_adjustment + score_total + base_priority + time_priority - matchup_adjustment + closeness_bonus + pp_bonus ) - # Pushes the games that are in intermission to the bottom, but retains their sort + # Pushes intermission games to the bottom, retains relative sort order if game["clock"]["inIntermission"]: return -2000 - time_remaining diff --git a/tests/conftest.py b/tests/conftest.py index 34f6ccf..6b39114 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ def make_game( start_time_utc="2024-04-10T23:00:00Z", home_record="40-25-10", away_record="38-27-09", + game_type=2, + situation=None, ): clock = { "timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}", @@ -43,6 +45,8 @@ def make_game( "record": away_record, }, "gameOutcome": {"lastPeriodType": "REG"}, + "gameType": game_type, + **({"situation": situation} if situation is not None else {}), } diff --git a/tests/test_games.py b/tests/test_games.py index 8bc350f..d36bbb7 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -1,8 +1,10 @@ +import app.games from tests.conftest import make_game from app.games import ( calculate_game_priority, convert_game_state, format_record, + get_comeback_bonus, get_game_outcome, get_period, get_power_play_info, @@ -224,3 +226,190 @@ class TestCalculateGamePriority: ) result = calculate_game_priority(game) assert isinstance(result, int) + + def test_playoff_ot_escalates_per_period(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + p4 = make_game(game_state="LIVE", period=4, seconds_remaining=600, game_type=3) + p5 = make_game(game_state="LIVE", period=5, seconds_remaining=600, game_type=3) + assert calculate_game_priority(p5) > calculate_game_priority(p4) + + def test_regular_season_shootout_p5_above_p4(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + p4 = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2) + p5 = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2) + assert calculate_game_priority(p5) > calculate_game_priority(p4) + + def test_playoff_p4_higher_than_regular_season_p4(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + reg = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2) + playoff = make_game( + game_state="LIVE", period=4, seconds_remaining=600, game_type=3 + ) + assert calculate_game_priority(playoff) > calculate_game_priority(reg) + + def test_closeness_bonus_tied_beats_one_goal(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + tied = self._live_game(home_score=2, away_score=2) + one_goal = self._live_game(home_score=2, away_score=1) + assert calculate_game_priority(tied) > calculate_game_priority(one_goal) + + def test_5_4_same_priority_as_1_0(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + high_scoring = self._live_game(home_score=5, away_score=4) + low_scoring = self._live_game(home_score=1, away_score=0) + assert calculate_game_priority(high_scoring) == calculate_game_priority( + low_scoring + ) + + def test_pp_in_ot_adds_200(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + base = make_game(game_state="LIVE", period=4, seconds_remaining=600) + with_pp = make_game( + game_state="LIVE", + period=4, + seconds_remaining=600, + situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"}, + ) + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200 + + def test_pp_late_p3_adds_150(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + base = self._live_game(period=3, seconds_remaining=240) + with_pp = make_game( + game_state="LIVE", + period=3, + seconds_remaining=240, + situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"}, + ) + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150 + + def test_pp_mid_p3_adds_100(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + base = self._live_game(period=3, seconds_remaining=600) + with_pp = make_game( + game_state="LIVE", + period=3, + seconds_remaining=600, + situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"}, + ) + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100 + + def test_pp_early_p3_adds_50(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + base = self._live_game(period=3, seconds_remaining=900) + with_pp = make_game( + game_state="LIVE", + period=3, + seconds_remaining=900, + situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"}, + ) + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50 + + def test_pp_p1_adds_30(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + base = self._live_game(period=1, seconds_remaining=600) + with_pp = make_game( + game_state="LIVE", + period=1, + seconds_remaining=600, + situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"}, + ) + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30 + + def test_time_priority_increases_as_clock_runs(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + early = self._live_game(period=3, seconds_remaining=1100) + late = self._live_game(period=3, seconds_remaining=200) + assert calculate_game_priority(late) > calculate_game_priority(early) + + +class TestGetComebackBonus: + def setup_method(self): + app.games._score_cache.clear() + + def test_returns_zero_on_first_call(self): + game = make_game(home_score=2, away_score=1) + assert get_comeback_bonus(game) == 0 + + def test_cache_populated_after_first_call(self): + game = make_game(home_score=2, away_score=1) + get_comeback_bonus(game) + assert ("Maple Leafs", "Bruins") in app.games._score_cache + + def test_no_bonus_when_leading_team_scores(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 1) + game = make_game(home_score=3, away_score=1) # leading home scored again + assert get_comeback_bonus(game) == 0 + + def test_bonus_when_trailing_home_scores_p3(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) + game = make_game(home_score=2, away_score=3, period=3) + assert get_comeback_bonus(game) == 75 + + def test_bonus_when_trailing_away_scores_p2(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (3, 1) + game = make_game(home_score=3, away_score=2, period=2) + assert get_comeback_bonus(game) == 50 + + def test_no_bonus_when_diff_still_above_2(self): + # 5-1 → 5-2: diff 4→3, still > 2, no bonus + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 5) + game = make_game(home_score=2, away_score=5) + assert get_comeback_bonus(game) == 0 + + def test_bonus_fires_once_then_clears(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) + game = make_game(home_score=2, away_score=3, period=3) + assert get_comeback_bonus(game) == 75 + # Same score on next call — cache now (2, 3), no change + assert get_comeback_bonus(game) == 0 + + def test_no_bonus_in_intermission(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) + game = make_game(home_score=2, away_score=3, in_intermission=True) + assert get_comeback_bonus(game) == 0 + # Cache should NOT be updated + assert app.games._score_cache[("Maple Leafs", "Bruins")] == (1, 3) + + def test_no_bonus_for_non_live_state(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) + game = make_game(game_state="OFF", home_score=2, away_score=3) + assert get_comeback_bonus(game) == 0 + + def test_ot_comeback_bonus_is_100(self): + app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) + game = make_game(home_score=2, away_score=3, period=4) + assert get_comeback_bonus(game) == 100