From e0db8f0859d1056b02cf415875ca7246ac97ac07 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 19 Apr 2026 11:26:07 -0400 Subject: [PATCH] refactor: recalibrate hype scoring to deflate gauge and add momentum signals Unify overlapping late-P3 bonuses into a single score-state lookup, add high-scoring and goal-spike signals, and tighten every component ceiling so filling the hype bar is reserved for genuinely rare moments. Co-Authored-By: Claude Opus 4.7 --- app/games.py | 310 ++++++++++++++++++++++++-------------------- tests/test_games.py | 89 +++++++------ 2 files changed, 220 insertions(+), 179 deletions(-) diff --git a/app/games.py b/app/games.py index 61c22a6..58b5c59 100644 --- a/app/games.py +++ b/app/games.py @@ -33,9 +33,11 @@ def parse_games(scoreboard_data): for game in scoreboard_data.get("games", []): game_state = convert_game_state(game["gameState"]) priority_comps = _priority_components(game) - comeback = get_comeback_bonus(game) + momentum = _momentum_components(game) importance_comps = _importance_components(game) - total_priority = priority_comps["total"] + comeback + importance_comps["total"] + total_priority = ( + priority_comps["total"] + momentum["total"] + importance_comps["total"] + ) extracted_info.append( { "Home Team": game["homeTeam"]["name"]["default"], @@ -63,10 +65,12 @@ def parse_games(scoreboard_data): "base": priority_comps["base"], "time": priority_comps["time"], "matchup_bonus": priority_comps["matchup_bonus"], - "closeness": priority_comps["closeness"], + "score_state": priority_comps["score_state"], + "high_scoring": priority_comps["high_scoring"], "power_play": priority_comps["power_play"], "empty_net": priority_comps["empty_net"], - "comeback": comeback, + "comeback": momentum["comeback"], + "goal_spike": momentum["goal_spike"], "importance": importance_comps["total"], "importance_season_weight": importance_comps["season_weight"], "importance_playoff_relevance": importance_comps[ @@ -109,17 +113,18 @@ def parse_games(scoreboard_data): return sorted(extracted_info, key=_sort_key) -def get_comeback_bonus(game): - """Persistent comeback bonus that scales with deficit recovered. +def _momentum_components(game): + """Detects comeback recovery and fresh-goal spikes in a single pass. - Tracks the maximum score differential seen in the game. A recovery of 2+ - goals earns a sustained bonus that persists as long as the game remains - close. One-goal swings are normal hockey and earn no bonus. + Updates both caches exactly once per call. Returns: + - comeback: persistent bonus while a 2+ goal deficit is being recovered + - goal_spike: one-tick bonus on the refresh where a goal just landed """ + zero = {"comeback": 0, "goal_spike": 0, "total": 0} if game["gameState"] not in ("LIVE", "CRIT"): - return 0 + return zero if game["clock"]["inIntermission"]: - return 0 + return zero home_name = game["homeTeam"]["name"]["default"] away_name = game["awayTeam"]["name"]["default"] @@ -127,25 +132,54 @@ def get_comeback_bonus(game): home_score = game["homeTeam"]["score"] away_score = game["awayTeam"]["score"] + current = (home_score, away_score) current_diff = abs(home_score - away_score) period = game.get("periodDescriptor", {}).get("number", 0) + time_remaining = game.get("clock", {}).get("secondsRemaining", 0) + # ── Goal spike (fires the single tick after a score changes) ───────── + previous = _score_cache.get(key) + goal_spike = 0 + if previous is not None and previous != current: + if period >= 4: + goal_spike = 100 + elif period == 3 and time_remaining <= 300: + goal_spike = 80 + elif period == 3 and time_remaining <= 720: + goal_spike = 60 + elif period == 3: + goal_spike = 40 + else: + goal_spike = 25 + + # ── Comeback tracking ──────────────────────────────────────────────── tracker_max = _comeback_tracker.get(key, 0) - if key in _score_cache: - prev_diff = abs(_score_cache[key][0] - _score_cache[key][1]) - tracker_max = max(tracker_max, prev_diff) + if previous is not None: + tracker_max = max(tracker_max, abs(previous[0] - previous[1])) _comeback_tracker[key] = tracker_max - _score_cache[key] = (home_score, away_score) + _score_cache[key] = current recovery = tracker_max - current_diff - if recovery < 2 or tracker_max < 2: - return 0 + comeback = 0 + if recovery >= 2 and tracker_max >= 2: + base = {2: 50, 3: 90}.get(recovery, 120) + period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2) + tie_bonus = 20 if current_diff == 0 else 0 + comeback = int(base * period_mult + tie_bonus) - base = {2: 60, 3: 120}.get(recovery, 160) - period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2) - tie_bonus = 30 if current_diff == 0 else 0 + return { + "comeback": comeback, + "goal_spike": goal_spike, + "total": comeback + goal_spike, + } - return int(base * period_mult + tie_bonus) + +def get_comeback_bonus(game): + return _momentum_components(game)["comeback"] + + +def get_goal_spike(game): + return _momentum_components(game)["goal_spike"] def convert_game_state(game_state): @@ -210,13 +244,42 @@ def _get_man_advantage(situation): return abs(home_total - away_total) +def _score_state_bonus(diff, period, time_remaining): + """Unified score-state contribution: closeness reward AND blowout penalty, + coherent across period and time remaining. Replaces closeness + diff + penalty + late-3rd urgency as a single signal.""" + if period >= 4: + # OT is always tied — flat tension bonus + return 60 + + if period <= 2: + return {0: 50, 1: 30, 2: 10, 3: -30, 4: -80}.get(diff, -120) + + # Period 3 — depends on time remaining + mins_left = time_remaining / 60 + if mins_left > 12: + return {0: 70, 1: 40, 2: 0, 3: -50, 4: -120}.get(diff, -160) + if mins_left > 6: + return {0: 120, 1: 80, 2: 20, 3: -80, 4: -180}.get(diff, -240) + if mins_left > 2: + # Goalie-pull zone — 2-goal deficit becomes interesting again + return {0: 180, 1: 140, 2: 50, 3: -80, 4: -200}.get(diff, -260) + # Final 2 min — peak tension + return {0: 220, 1: 180, 2: 80, 3: -50, 4: -220}.get(diff, -280) + + def _priority_components(game): - """Return a dict of all priority components plus the final total.""" + """Return a dict of all priority components plus the final total. + + Calibrated so typical late-P3 tied games land around 550-650 and only + genuinely rare moments (playoff OT + comeback + PP) exceed 900. + """ _zero = { "base": 0, "time": 0, "matchup_bonus": 0, - "closeness": 0, + "score_state": 0, + "high_scoring": 0, "power_play": 0, "empty_net": 0, "total": 0, @@ -225,25 +288,32 @@ def _priority_components(game): if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]: return _zero - period = game.get("periodDescriptor", {}).get("number", 0) + # Pushes intermission games to the bottom, retains relative sort order time_remaining = game.get("clock", {}).get("secondsRemaining", 0) + if game["clock"]["inIntermission"]: + return {**_zero, "total": -2000 - time_remaining} + + period = game.get("periodDescriptor", {}).get("number", 0) home_score = game["homeTeam"]["score"] away_score = game["awayTeam"]["score"] score_difference = abs(home_score - away_score) + total_goals = home_score + away_score is_playoff = game.get("gameType", 2) == 3 - # ── 1. Base priority by period ──────────────────────────────────────── + # ── 1. Base priority by period (tightened) ─────────────────────────── if is_playoff: - base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150) + # Multi-OT escalates aggressively (P4=500, P5=620, P6=760, P7=920…) + base_priority = {1: 120, 2: 180, 3: 280}.get( + period, 500 + (period - 4) * 120 + max(0, period - 5) * 20 + ) else: - base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150) + # Regular season: P4=5-min OT, P5=shootout + base_priority = {1: 80, 2: 120, 3: 200, 4: 400, 5: 380}.get(period, 80) - # ── 2. Period length for time calculations ──────────────────────────── + # ── 2. Period length for time calculations ─────────────────────────── period_length = (1200 if is_playoff else 300) if period >= 4 else 1200 - # ── 3. Standings-quality matchup bonus ─────────────────────────────── - # Invert rank so that #1 (best) contributes the most quality points. - # league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32. + # ── 3. Matchup bonus (minor contributor now) ───────────────────────── home_standings = get_team_standings(game["homeTeam"]["name"]["default"]) away_standings = get_team_standings(game["awayTeam"]["name"]["default"]) home_quality = (33 - home_standings["league_sequence"]) + ( @@ -252,115 +322,84 @@ def _priority_components(game): away_quality = (33 - away_standings["league_sequence"]) + ( 33 - away_standings["league_l10_sequence"] ) - # Higher period = matchup matters less (any OT is exciting regardless of teams) - matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0) - matchup_bonus = (home_quality + away_quality) * matchup_multiplier + # Max combined quality = 128. Divided by 5 → 0-25.6. Multiplier tightens further. + matchup_raw = (home_quality + away_quality) / 5 + matchup_multiplier = {1: 1.5, 2: 1.25, 3: 1.0, 4: 0.5}.get(period, 0.5) + matchup_bonus = int(matchup_raw * matchup_multiplier) - # ── Shootout: flat priority, no time component (rounds, not clock) ─── + # ── Shootout: flat skills-competition score, no time component ────── if period == 5 and not is_playoff: - so_base = 550 - so_closeness = 80 - so_matchup = (home_quality + away_quality) * 1.0 - so_total = int(so_base + so_closeness + so_matchup) + so_total = int(380 + 60 + matchup_bonus) return { - "base": so_base, + "base": 380, "time": 0, - "matchup_bonus": int(so_matchup), - "closeness": so_closeness, + "matchup_bonus": matchup_bonus, + "score_state": 60, + "high_scoring": 0, "power_play": 0, "empty_net": 0, "total": so_total, } - # ── 4. Score-differential penalty (period-aware) ─────────────────────── - score_differential_adjustment = 0 - if period <= 2: - adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350} - score_differential_adjustment = adj.get( - score_difference, 350 + (score_difference - 4) * 100 - ) - elif period == 3: - mins_left = time_remaining / 60 - if mins_left > 10: - adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400} - elif mins_left > 5: - adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500} - elif mins_left > 2: - # Goalie-pull zone: 2-goal penalty DECREASES - adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600} - else: - # Final 2 min: 2-goal deficit with active goalie pull is exciting - adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700} - score_differential_adjustment = adj.get( - score_difference, adj[4] + (score_difference - 4) * 100 - ) - # OT: always tied, no penalty needed + # ── 4. Unified score-state bonus ───────────────────────────────────── + score_state = _score_state_bonus(score_difference, period, time_remaining) - base_priority -= score_differential_adjustment - - # ── 5. Late-3rd urgency bonus ───────────────────────────────────────── - if period == 3 and time_remaining <= 720: - if score_difference == 0: - base_priority += 100 - elif score_difference == 1: - base_priority += 60 - - if period == 3 and time_remaining <= 360: - if score_difference == 0: - base_priority += 50 - elif score_difference == 1: - base_priority += 30 - - # ── 6. Closeness bonus ─────────────────────────────────────────────── - closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0) - - # ── 7. Time priority (non-linear — final minutes weighted more) ───── - time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5) + # ── 5. Time priority (cap per period, non-linear toward end) ───────── + time_priority_max = {1: 30, 2: 60, 3: 120}.get(period, 200 if is_playoff else 60) elapsed_fraction = ( max(0.0, (period_length - time_remaining) / period_length) if period_length else 0 ) - time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier + time_priority = (elapsed_fraction**1.6) * time_priority_max - # ── 8. Power play bonus ─────────────────────────────────────────────── + # ── 6. High-scoring bonus for close games with 6+ combined goals ───── + # Rewards the "shootout-y" vibe where next goal carries more weight. + high_scoring_bonus = 0 + if total_goals >= 6 and score_difference <= 2: + high_scoring_bonus = min((total_goals - 5) * 12, 60) + if period < 3: + high_scoring_bonus = int(high_scoring_bonus * 0.5) + + # ── 7. Power play bonus (tightened) ────────────────────────────────── pp_bonus = 0 situation = game.get("situation", {}) home_descs = situation.get("homeTeam", {}).get("situationDescriptions", []) away_descs = situation.get("awayTeam", {}).get("situationDescriptions", []) if "PP" in home_descs or "PP" in away_descs: man_advantage = _get_man_advantage(situation) - advantage_mult = 1.0 if man_advantage <= 1 else 1.6 + advantage_mult = 1.0 if man_advantage <= 1 else 1.5 if period >= 4: - pp_bonus = int(200 * advantage_mult) + pp_bonus = int(120 * advantage_mult) elif period == 3 and time_remaining <= 300: - pp_bonus = int(150 * advantage_mult) + pp_bonus = int(90 * advantage_mult) elif period == 3 and time_remaining <= 720: - pp_bonus = int(100 * advantage_mult) + pp_bonus = int(60 * advantage_mult) elif period == 3: - pp_bonus = int(50 * advantage_mult) + pp_bonus = int(35 * advantage_mult) else: - pp_bonus = int(30 * advantage_mult) + pp_bonus = int(20 * advantage_mult) - # ── 9. Empty net bonus ─────────────────────────────────────────────── + # ── 8. Empty net bonus (tightened) ─────────────────────────────────── en_bonus = 0 if "EN" in home_descs or "EN" in away_descs: if period >= 4: - en_bonus = 250 + en_bonus = 180 elif period == 3 and time_remaining <= 180: - en_bonus = 200 + en_bonus = 140 elif period == 3 and time_remaining <= 360: - en_bonus = 150 + en_bonus = 100 else: - en_bonus = 75 + en_bonus = 50 logger.debug( - "priority components — base: %s, time: %.0f, matchup_bonus: %.0f, " - "closeness: %s, pp: %s, en: %s", + "priority components — base: %s, time: %.0f, matchup: %s, " + "score_state: %s, high_scoring: %s, pp: %s, en: %s", base_priority, time_priority, matchup_bonus, - closeness_bonus, + score_state, + high_scoring_bonus, pp_bonus, en_bonus, ) @@ -369,20 +408,18 @@ def _priority_components(game): base_priority + time_priority + matchup_bonus - + closeness_bonus + + score_state + + high_scoring_bonus + pp_bonus + en_bonus ) - # Pushes intermission games to the bottom, retains relative sort order - if game["clock"]["inIntermission"]: - return {**_zero, "total": -2000 - time_remaining} - return { "base": base_priority, "time": int(time_priority), - "matchup_bonus": int(matchup_bonus), - "closeness": closeness_bonus, + "matchup_bonus": matchup_bonus, + "score_state": score_state, + "high_scoring": high_scoring_bonus, "power_play": pp_bonus, "empty_net": en_bonus, "total": final_priority, @@ -428,15 +465,14 @@ def get_team_standings(team_name): def _playoff_importance(game): - """Importance for playoff games based on series context and round.""" + """Importance for playoff games — round + series context. Max 150.""" series = game.get("seriesStatus", {}) if not series: - # No series data available — flat playoff bonus return { "season_weight": 1.0, "playoff_relevance": 0.50, "rivalry": 1.0, - "total": 100, + "total": 60, } round_num = series.get("round", 1) @@ -448,17 +484,17 @@ def _playoff_importance(game): round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0) if max_wins == 3 and min_wins == 3: - series_factor = 1.0 + series_factor = 1.0 # Game 7 elif max_wins == 3: - series_factor = 0.85 + series_factor = 0.90 # Elimination game elif max_wins == 2 and min_wins == 2: - series_factor = 0.70 + series_factor = 0.75 # Pivotal tied series elif max_wins == 2: - series_factor = 0.55 + series_factor = 0.60 else: - series_factor = 0.40 + series_factor = 0.45 - importance = min(int(series_factor * round_mult * 200), 200) + importance = min(int(series_factor * round_mult * 100), 150) return { "season_weight": round_mult, @@ -469,7 +505,7 @@ def _playoff_importance(game): def _importance_components(game): - """Return a dict of all importance components plus the final total.""" + """Regular-season importance — season_weight × stakes × rivalry. Max 100.""" _zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0} if game["gameState"] in ("FINAL", "OFF"): @@ -482,30 +518,29 @@ def _importance_components(game): home_st = get_team_standings(game["homeTeam"]["name"]["default"]) away_st = get_team_standings(game["awayTeam"]["name"]["default"]) - # Season weight — near-zero before game 30, sharp ramp 55-70, max at 82 + # Season weight — zero before game 30, linear ramp to 1.0 at game 70+ avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2 if avg_gp <= 30: - season_weight = 0.05 + season_weight = 0.0 else: - t = (avg_gp - 30) / (82 - 30) - season_weight = min(t**1.8, 1.0) + season_weight = min((avg_gp - 30) / 40, 1.0) - # Playoff relevance — peaks for bubble teams (wildcard rank ~17-19) + # Playoff stakes — peaks on the bubble, drops for locked-in or out best_wc = min( home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32 ) - if best_wc <= 12: - playoff_relevance = 0.60 + if best_wc <= 8: + stakes = 0.5 # locked in, pressure off elif best_wc <= 16: - playoff_relevance = 0.85 - elif best_wc <= 19: - playoff_relevance = 1.00 - elif best_wc <= 23: - playoff_relevance = 0.65 + stakes = 0.8 # comfortable, meaningful + elif best_wc <= 20: + stakes = 1.0 # bubble, every point critical + elif best_wc <= 24: + stakes = 0.5 # slipping else: - playoff_relevance = 0.15 + stakes = 0.2 # effectively out - # Division/conference rivalry multiplier + # Rivalry — division > conference > other home_div = home_st["division_abbrev"] away_div = away_st["division_abbrev"] home_conf = home_st["conference_abbrev"] @@ -517,21 +552,20 @@ def _importance_components(game): else: rivalry_multiplier = 1.0 - raw = season_weight * playoff_relevance * rivalry_multiplier - importance = max(0, min(int((raw / 1.4) * 150), 150)) + importance = int(season_weight * stakes * rivalry_multiplier * 70) + importance = max(0, min(importance, 100)) logger.debug( - "importance components — season_weight: %.3f, playoff_relevance: %.2f, " - "rivalry: %.1f, importance: %s", + "importance — season_weight: %.2f, stakes: %.2f, rivalry: %.1f, total: %s", season_weight, - playoff_relevance, + stakes, rivalry_multiplier, importance, ) return { "season_weight": round(season_weight, 3), - "playoff_relevance": playoff_relevance, + "playoff_relevance": stakes, "rivalry": rivalry_multiplier, "total": importance, } diff --git a/tests/test_games.py b/tests/test_games.py index 21b152b..8a7997f 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -307,9 +307,9 @@ class TestEmptyNetBonus: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200 + assert calculate_game_priority(with_en) - calculate_game_priority(base) == 140 - def test_en_mid_p3_adds_150(self, mocker): + def test_en_mid_p3_adds_100(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -325,9 +325,9 @@ class TestEmptyNetBonus: "timeRemaining": "5:00", }, ) - assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150 + assert calculate_game_priority(with_en) - calculate_game_priority(base) == 100 - def test_en_ot_adds_250(self, mocker): + def test_en_ot_adds_180(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -343,7 +343,7 @@ class TestEmptyNetBonus: "timeRemaining": "10:00", }, ) - assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250 + assert calculate_game_priority(with_en) - calculate_game_priority(base) == 180 def test_en_stacks_with_pp(self, mocker): mocker.patch( @@ -362,12 +362,12 @@ class TestEmptyNetBonus: }, ) delta = calculate_game_priority(with_both) - calculate_game_priority(base) - # PP late P3 = 150, EN late P3 = 200, total = 350 - assert delta == 350 + # PP late P3 = 90, EN late P3 = 140, total = 230 + assert delta == 230 class TestMultiManAdvantage: - def test_5v3_ot_pp_bonus_is_320(self, mocker): + def test_5v3_ot_pp_bonus_is_180(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -384,7 +384,8 @@ class TestMultiManAdvantage: "situationCode": "1351", }, ) - assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320 + # OT PP 5-on-3: 120 * 1.5 = 180 + assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 180 def test_standard_5v4_unchanged(self, mocker): mocker.patch( @@ -403,7 +404,8 @@ class TestMultiManAdvantage: "situationCode": "1451", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200 + # OT PP 5-on-4: 120 base, no advantage mult + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 120 class TestCalculateGamePriority: @@ -544,18 +546,19 @@ class TestCalculateGamePriority: 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): + def test_5_4_beats_1_0_via_high_scoring_bonus(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( + # Same 1-goal diff, but 9 total goals earns the high-scoring bonus + assert calculate_game_priority(high_scoring) > calculate_game_priority( low_scoring ) - def test_pp_in_ot_adds_200(self, mocker): + def test_pp_in_ot_adds_120(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -571,9 +574,9 @@ class TestCalculateGamePriority: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200 + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 120 - def test_pp_late_p3_adds_150(self, mocker): + def test_pp_late_p3_adds_90(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -589,9 +592,9 @@ class TestCalculateGamePriority: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150 + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 90 - def test_pp_mid_p3_adds_100(self, mocker): + def test_pp_mid_p3_adds_60(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -607,9 +610,9 @@ class TestCalculateGamePriority: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100 + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 60 - def test_pp_early_p3_adds_50(self, mocker): + def test_pp_early_p3_adds_35(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -625,9 +628,9 @@ class TestCalculateGamePriority: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50 + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 35 - def test_pp_p1_adds_30(self, mocker): + def test_pp_p1_adds_20(self, mocker): mocker.patch( "app.games.get_team_standings", return_value={"league_sequence": 0, "league_l10_sequence": 0}, @@ -643,7 +646,7 @@ class TestCalculateGamePriority: "timeRemaining": "1:30", }, ) - assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30 + assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 20 def test_time_priority_increases_as_clock_runs(self, mocker): mocker.patch( @@ -676,25 +679,25 @@ class TestGetComebackBonus: assert get_comeback_bonus(game) == 0 def test_two_goal_recovery_in_p3(self): - # Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30 + # Was 0-2, now 2-2: recovery=2, base=50, period_mult=1.0, tie=20 app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2 game = make_game(home_score=2, away_score=2, period=3) - assert get_comeback_bonus(game) == 90 # 60*1.0 + 30 + assert get_comeback_bonus(game) == 70 # 50*1.0 + 20 def test_three_goal_recovery_in_p3(self): - # Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30 + # Was 0-3, now 3-3: recovery=3, base=90, period_mult=1.0, tie=20 app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 3) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3 game = make_game(home_score=3, away_score=3, period=3) - assert get_comeback_bonus(game) == 150 # 120*1.0 + 30 + assert get_comeback_bonus(game) == 110 # 90*1.0 + 20 def test_partial_recovery_in_p3(self): - # Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie + # Was 0-3, now 2-3: recovery=2, base=50, period_mult=1.0, no tie app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3 game = make_game(home_score=2, away_score=3, period=3) - assert get_comeback_bonus(game) == 60 # 60*1.0 + assert get_comeback_bonus(game) == 50 # 50*1.0 def test_bonus_persists_across_polls(self): # Set up a 2-goal recovery, then call again — bonus stays @@ -703,21 +706,21 @@ class TestGetComebackBonus: game = make_game(home_score=2, away_score=2, period=3) first = get_comeback_bonus(game) second = get_comeback_bonus(game) - assert first == second == 90 + assert first == second == 70 def test_period_multiplier_p1_lower(self): - # P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30 + # P1 recovery is less dramatic: base=50, period_mult=0.6, tie=20 app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2 game = make_game(home_score=2, away_score=2, period=1) - assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30) + assert get_comeback_bonus(game) == 50 # int(50*0.6 + 20) def test_ot_multiplier_higher(self): - # OT: base=60, period_mult=1.2, tie=30 + # OT: base=50, period_mult=1.2, tie=20 app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2 game = make_game(home_score=2, away_score=2, period=4) - assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30) + assert get_comeback_bonus(game) == 80 # int(50*1.2 + 20) def test_no_bonus_in_intermission(self): app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2) @@ -739,7 +742,7 @@ class TestGetComebackBonus: get_comeback_bonus(make_game(home_score=1, away_score=2, period=2)) result = get_comeback_bonus(make_game(home_score=2, away_score=2, period=3)) assert app.games._comeback_tracker[key] == 2 - assert result == 90 # 60*1.0 + 30 + assert result == 70 # 50*1.0 + 20 class TestCalculateGameImportance: @@ -763,28 +766,31 @@ class TestCalculateGameImportance: def test_playoff_game_gets_fallback_importance(self): game = make_game(game_type=3) - assert calculate_game_importance(game) == 100 + assert calculate_game_importance(game) == 60 def test_playoff_game7_cup_final_is_max(self): game = make_game( game_type=3, series_status={"round": 4, "topSeedWins": 3, "bottomSeedWins": 3}, ) - assert calculate_game_importance(game) == 200 + # Game 7 Cup Final: series_factor 1.0 * round 1.5 * 100 = 150 + assert calculate_game_importance(game) == 150 def test_playoff_elimination_round1(self): game = make_game( game_type=3, series_status={"round": 1, "topSeedWins": 3, "bottomSeedWins": 2}, ) - assert calculate_game_importance(game) == 170 + # Elimination (3-x): 0.90 * 1.0 * 100 = 90 + assert calculate_game_importance(game) == 90 def test_playoff_game1_round1_lowest(self): game = make_game( game_type=3, series_status={"round": 1, "topSeedWins": 0, "bottomSeedWins": 0}, ) - assert calculate_game_importance(game) == 80 + # Series factor 0.45 * round 1.0 * 100 = 45 + assert calculate_game_importance(game) == 45 def test_playoff_later_rounds_more_important(self): series = {"topSeedWins": 2, "bottomSeedWins": 2} @@ -810,7 +816,8 @@ class TestCalculateGameImportance: return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"), ) game = make_game(game_state="FUT") - assert calculate_game_importance(game) == 150 + # season_weight 1.0 * stakes 1.0 * rivalry 1.4 * 70 = 98 + assert calculate_game_importance(game) == 98 def test_same_division_beats_same_conference(self, mocker): home_st = self._standings(gp=70, wc=18, div="ATL", conf="E") @@ -881,9 +888,9 @@ class TestCalculateGameImportance: assert isinstance(result, int) assert result >= 0 - def test_result_never_exceeds_150(self, mocker): + def test_result_never_exceeds_100(self, mocker): mocker.patch( "app.games.get_team_standings", return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"), ) - assert calculate_game_importance(make_game(game_state="FUT")) <= 150 + assert calculate_game_importance(make_game(game_state="FUT")) <= 100