feat: overhaul hype score algorithm with 9 hockey-driven improvements
All checks were successful
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 16s

- Empty net detection: pulled goalie triggers +150-250 bonus, stacks with PP
- Playoff series importance: Game 7 Cup Final = 200, elimination games scale
  by round; fallback of 100 when series data unavailable
- Period-aware score differential: 2-goal deficit penalty DECREASES in final
  2 min of P3 (goalie-pull zone), 3+ goal games get harsher penalties late
- Persistent comeback narrative: tracks max deficit, sustained bonus for 2+
  goal recoveries instead of one-shot spike (0-3 to 3-3 = 150 persistent)
- Shootout special handling: flat base 550 with no time component; ranks below
  dramatic close P3 games (skills competition, not hockey)
- Multi-man advantage: parses situationCode for 5v3/4v3, applies 1.6x PP mult
- Non-linear time priority: elapsed^1.5 curve weights final minutes more
- Matchup multiplier rebalance: P1/P2 from 2.0/1.65 to 1.5, tiebreaker not
  dominant factor
- Frontend gauge max raised from 700 to 1000 with adjusted color thresholds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 19:02:43 -04:00
parent 6c098850f5
commit 7784eaf9ce
4 changed files with 410 additions and 91 deletions

View File

@@ -10,9 +10,11 @@ EASTERN = ZoneInfo("America/New_York")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maps (home_team_name, away_team_name) -> (home_score, away_score) # 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]] = {} _score_cache: dict[tuple[str, str], tuple[int, int]] = {}
# Maps (home_team_name, away_team_name) -> max score differential seen
_comeback_tracker: dict[tuple[str, str], int] = {}
def format_record(record): def format_record(record):
if record == "N/A": if record == "N/A":
@@ -63,6 +65,7 @@ def parse_games(scoreboard_data):
"matchup_bonus": priority_comps["matchup_bonus"], "matchup_bonus": priority_comps["matchup_bonus"],
"closeness": priority_comps["closeness"], "closeness": priority_comps["closeness"],
"power_play": priority_comps["power_play"], "power_play": priority_comps["power_play"],
"empty_net": priority_comps["empty_net"],
"comeback": comeback, "comeback": comeback,
"importance": importance_comps["total"], "importance": importance_comps["total"],
"importance_season_weight": importance_comps["season_weight"], "importance_season_weight": importance_comps["season_weight"],
@@ -100,10 +103,11 @@ def parse_games(scoreboard_data):
def get_comeback_bonus(game): def get_comeback_bonus(game):
""" """Persistent comeback bonus that scales with deficit recovered.
Returns a one-time bonus when the trailing team just scored and the game
is still within reach (score_diff <= 2). Updates _score_cache. Tracks the maximum score differential seen in the game. A recovery of 2+
Returns 0 for intermission, non-live games, or no cache entry yet. goals earns a sustained bonus that persists as long as the game remains
close. One-goal swings are normal hockey and earn no bonus.
""" """
if game["gameState"] not in ("LIVE", "CRIT"): if game["gameState"] not in ("LIVE", "CRIT"):
return 0 return 0
@@ -116,33 +120,25 @@ def get_comeback_bonus(game):
home_score = game["homeTeam"]["score"] home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"] away_score = game["awayTeam"]["score"]
current_diff = abs(home_score - away_score)
period = game.get("periodDescriptor", {}).get("number", 0) period = game.get("periodDescriptor", {}).get("number", 0)
bonus = 0 tracker_max = _comeback_tracker.get(key, 0)
if key in _score_cache: if key in _score_cache:
prev_home, prev_away = _score_cache[key] prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
prev_diff = abs(prev_home - prev_away) tracker_max = max(tracker_max, prev_diff)
new_diff = abs(home_score - away_score) _comeback_tracker[key] = tracker_max
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) _score_cache[key] = (home_score, away_score)
return bonus
recovery = tracker_max - current_diff
if recovery < 2 or tracker_max < 2:
return 0
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 int(base * period_mult + tie_bonus)
def convert_game_state(game_state): def convert_game_state(game_state):
@@ -196,6 +192,17 @@ def get_game_outcome(game, game_state):
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A" return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
def _get_man_advantage(situation):
"""Parse situationCode for player count difference.
Format: [away_goalie][away_skaters][home_skaters][home_goalie]."""
code = situation.get("situationCode", "")
if len(code) != 4 or not code.isdigit():
return 1
away_total = int(code[0]) + int(code[1])
home_total = int(code[2]) + int(code[3])
return abs(home_total - away_total)
def _priority_components(game): 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."""
_zero = { _zero = {
@@ -204,6 +211,7 @@ def _priority_components(game):
"matchup_bonus": 0, "matchup_bonus": 0,
"closeness": 0, "closeness": 0,
"power_play": 0, "power_play": 0,
"empty_net": 0,
"total": 0, "total": 0,
} }
@@ -238,21 +246,48 @@ def _priority_components(game):
33 - away_standings["league_l10_sequence"] 33 - away_standings["league_l10_sequence"]
) )
# Higher period = matchup matters less (any OT is exciting regardless of teams) # 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_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 matchup_bonus = (home_quality + away_quality) * matchup_multiplier
# ── 4. Score-differential penalty ──────────────────────────────────── # ── Shootout: flat priority, no time component (rounds, not clock) ───
if score_difference > 3: if period == 5 and not is_playoff:
score_differential_adjustment = 500 so_base = 550
elif score_difference > 2: so_closeness = 80
score_differential_adjustment = 350 so_matchup = (home_quality + away_quality) * 1.0
elif score_difference > 1: so_total = int(so_base + so_closeness + so_matchup)
score_differential_adjustment = 100 return {
else: "base": so_base,
score_differential_adjustment = 0 "time": 0,
"matchup_bonus": int(so_matchup),
"closeness": so_closeness,
"power_play": 0,
"empty_net": 0,
"total": so_total,
}
if period == 3 and time_remaining <= 300: # ── 4. Score-differential penalty (period-aware) ───────────────────────
score_differential_adjustment *= 2 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
base_priority -= score_differential_adjustment base_priority -= score_differential_adjustment
@@ -272,9 +307,14 @@ def _priority_components(game):
# ── 6. Closeness bonus ─────────────────────────────────────────────── # ── 6. Closeness bonus ───────────────────────────────────────────────
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0) closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
# ── 7. Time priority ───────────────────────────────────────────────── # ── 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) 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 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
# ── 8. Power play bonus ─────────────────────────────────────────────── # ── 8. Power play bonus ───────────────────────────────────────────────
pp_bonus = 0 pp_bonus = 0
@@ -282,29 +322,49 @@ def _priority_components(game):
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", []) home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", []) away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
if "PP" in home_descs or "PP" in away_descs: 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
if period >= 4: if period >= 4:
pp_bonus = 200 pp_bonus = int(200 * advantage_mult)
elif period == 3 and time_remaining <= 300: elif period == 3 and time_remaining <= 300:
pp_bonus = 150 pp_bonus = int(150 * advantage_mult)
elif period == 3 and time_remaining <= 720: elif period == 3 and time_remaining <= 720:
pp_bonus = 100 pp_bonus = int(100 * advantage_mult)
elif period == 3: elif period == 3:
pp_bonus = 50 pp_bonus = int(50 * advantage_mult)
else: else:
pp_bonus = 30 pp_bonus = int(30 * advantage_mult)
# ── 9. Empty net bonus ───────────────────────────────────────────────
en_bonus = 0
if "EN" in home_descs or "EN" in away_descs:
if period >= 4:
en_bonus = 250
elif period == 3 and time_remaining <= 180:
en_bonus = 200
elif period == 3 and time_remaining <= 360:
en_bonus = 150
else:
en_bonus = 75
logger.debug( logger.debug(
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, " "priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
"closeness: %s, pp: %s", "closeness: %s, pp: %s, en: %s",
base_priority, base_priority,
time_priority, time_priority,
matchup_bonus, matchup_bonus,
closeness_bonus, closeness_bonus,
pp_bonus, pp_bonus,
en_bonus,
) )
final_priority = int( final_priority = int(
base_priority + time_priority + matchup_bonus + closeness_bonus + pp_bonus base_priority
+ time_priority
+ matchup_bonus
+ closeness_bonus
+ pp_bonus
+ en_bonus
) )
# Pushes intermission games to the bottom, retains relative sort order # Pushes intermission games to the bottom, retains relative sort order
@@ -317,6 +377,7 @@ def _priority_components(game):
"matchup_bonus": int(matchup_bonus), "matchup_bonus": int(matchup_bonus),
"closeness": closeness_bonus, "closeness": closeness_bonus,
"power_play": pp_bonus, "power_play": pp_bonus,
"empty_net": en_bonus,
"total": final_priority, "total": final_priority,
} }
@@ -359,14 +420,57 @@ def get_team_standings(team_name):
} }
def _playoff_importance(game):
"""Importance for playoff games based on series context and round."""
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,
}
round_num = series.get("round", 1)
top_wins = series.get("topSeedWins", 0)
bottom_wins = series.get("bottomSeedWins", 0)
max_wins = max(top_wins, bottom_wins)
min_wins = min(top_wins, bottom_wins)
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
elif max_wins == 3:
series_factor = 0.85
elif max_wins == 2 and min_wins == 2:
series_factor = 0.70
elif max_wins == 2:
series_factor = 0.55
else:
series_factor = 0.40
importance = min(int(series_factor * round_mult * 200), 200)
return {
"season_weight": round_mult,
"playoff_relevance": series_factor,
"rivalry": 1.0,
"total": importance,
}
def _importance_components(game): def _importance_components(game):
"""Return a dict of all importance components plus the final total.""" """Return a dict of all importance components plus the final total."""
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0} _zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
if game.get("gameType", 2) != 2:
return _zero
if game["gameState"] in ("FINAL", "OFF"): if game["gameState"] in ("FINAL", "OFF"):
return _zero return _zero
if game.get("gameType", 2) == 3:
return _playoff_importance(game)
if game.get("gameType", 2) != 2:
return _zero
home_st = get_team_standings(game["homeTeam"]["name"]["default"]) home_st = get_team_standings(game["homeTeam"]["name"]["default"])
away_st = get_team_standings(game["awayTeam"]["name"]["default"]) away_st = get_team_standings(game["awayTeam"]["name"]["default"])

View File

@@ -157,10 +157,10 @@ function ppIndicator(game) {
function updateGauges() { function updateGauges() {
document.querySelectorAll('.gauge').forEach(el => { document.querySelectorAll('.gauge').forEach(el => {
const score = Math.min(700, Math.max(0, parseInt(el.dataset.score, 10))); const score = Math.min(1000, Math.max(0, parseInt(el.dataset.score, 10)));
el.style.width = `${(score / 700) * 100}%`; el.style.width = `${(score / 1000) * 100}%`;
el.style.backgroundColor = score <= 300 ? '#4a90e2' el.style.backgroundColor = score <= 350 ? '#4a90e2'
: score <= 550 ? '#f97316' : score <= 650 ? '#f97316'
: '#ef4444'; : '#ef4444';
}); });
} }

View File

@@ -18,6 +18,7 @@ def make_game(
away_record="38-27-09", away_record="38-27-09",
game_type=2, game_type=2,
situation=None, situation=None,
series_status=None,
): ):
clock = { clock = {
"timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}", "timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}",
@@ -47,6 +48,7 @@ def make_game(
"gameOutcome": {"lastPeriodType": "REG"}, "gameOutcome": {"lastPeriodType": "REG"},
"gameType": game_type, "gameType": game_type,
**({"situation": situation} if situation is not None else {}), **({"situation": situation} if situation is not None else {}),
**({"seriesStatus": series_status} if series_status is not None else {}),
} }

View File

@@ -1,6 +1,7 @@
import app.games import app.games
from tests.conftest import make_game from tests.conftest import make_game
from app.games import ( from app.games import (
_get_man_advantage,
calculate_game_importance, calculate_game_importance,
calculate_game_priority, calculate_game_priority,
convert_game_state, convert_game_state,
@@ -147,6 +148,146 @@ class TestGetPowerPlayInfo:
assert get_power_play_info(game, "Maple Leafs") == "PP 0:45" assert get_power_play_info(game, "Maple Leafs") == "PP 0:45"
class TestGetManAdvantage:
def test_standard_5v4(self):
# 1451: away 1G+4S=5, home 5S+1G=6 → advantage=1
assert _get_man_advantage({"situationCode": "1451"}) == 1
def test_5v3(self):
# 1351: away 1G+3S=4, home 5S+1G=6 → advantage=2
assert _get_man_advantage({"situationCode": "1351"}) == 2
def test_4v3(self):
# 1341: away 1G+3S=4, home 4S+1G=5 → advantage=1
assert _get_man_advantage({"situationCode": "1341"}) == 1
def test_even_strength(self):
# 1551: away 1G+5S=6, home 5S+1G=6 → advantage=0
assert _get_man_advantage({"situationCode": "1551"}) == 0
def test_missing_code_defaults_to_1(self):
assert _get_man_advantage({}) == 1
def test_invalid_code_defaults_to_1(self):
assert _get_man_advantage({"situationCode": "abc"}) == 1
class TestEmptyNetBonus:
def test_en_late_p3_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=3, seconds_remaining=90)
with_en = make_game(
game_state="LIVE",
period=3,
seconds_remaining=90,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200
def test_en_mid_p3_adds_150(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=3, seconds_remaining=300)
with_en = make_game(
game_state="LIVE",
period=3,
seconds_remaining=300,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "5:00",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150
def test_en_ot_adds_250(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_en = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "10:00",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250
def test_en_stacks_with_pp(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=3, seconds_remaining=90)
with_both = make_game(
game_state="LIVE",
period=3,
seconds_remaining=90,
situation={
"homeTeam": {"situationDescriptions": ["PP", "EN"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
delta = calculate_game_priority(with_both) - calculate_game_priority(base)
# PP late P3 = 150, EN late P3 = 200, total = 350
assert delta == 350
class TestMultiManAdvantage:
def test_5v3_ot_pp_bonus_is_320(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_5v3 = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
"situationCode": "1351",
},
)
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320
def test_standard_5v4_unchanged(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={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
"situationCode": "1451",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
class TestCalculateGamePriority: class TestCalculateGamePriority:
def _live_game( def _live_game(
self, self,
@@ -239,14 +380,31 @@ class TestCalculateGamePriority:
p5 = make_game(game_state="LIVE", period=5, 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) assert calculate_game_priority(p5) > calculate_game_priority(p4)
def test_regular_season_shootout_p5_above_p4(self, mocker): def test_shootout_ranks_below_late_ot(self, mocker):
mocker.patch( mocker.patch(
"app.games.get_team_standings", "app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0}, return_value={"league_sequence": 0, "league_l10_sequence": 0},
) )
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2) ot = 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) so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
assert calculate_game_priority(p5) > calculate_game_priority(p4) # Sudden-death OT is more exciting than a skills competition
assert calculate_game_priority(ot) > calculate_game_priority(so)
def test_shootout_ranks_above_p2_blowout(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
blowout = make_game(
game_state="LIVE",
period=2,
seconds_remaining=600,
home_score=5,
away_score=1,
game_type=2,
)
assert calculate_game_priority(so) > calculate_game_priority(blowout)
def test_playoff_p4_higher_than_regular_season_p4(self, mocker): def test_playoff_p4_higher_than_regular_season_p4(self, mocker):
mocker.patch( mocker.patch(
@@ -382,6 +540,7 @@ class TestCalculateGamePriority:
class TestGetComebackBonus: class TestGetComebackBonus:
def setup_method(self): def setup_method(self):
app.games._score_cache.clear() app.games._score_cache.clear()
app.games._comeback_tracker.clear()
def test_returns_zero_on_first_call(self): def test_returns_zero_on_first_call(self):
game = make_game(home_score=2, away_score=1) game = make_game(home_score=2, away_score=1)
@@ -392,50 +551,77 @@ class TestGetComebackBonus:
get_comeback_bonus(game) get_comeback_bonus(game)
assert ("Maple Leafs", "Bruins") in app.games._score_cache assert ("Maple Leafs", "Bruins") in app.games._score_cache
def test_no_bonus_when_leading_team_scores(self): def test_no_bonus_for_one_goal_swing(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 1) # 1-goal swings are normal hockey, no bonus
game = make_game(home_score=3, away_score=1) # leading home scored again app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 1)
game = make_game(home_score=1, away_score=1, period=3)
assert get_comeback_bonus(game) == 0 assert get_comeback_bonus(game) == 0
def test_bonus_when_trailing_home_scores_p3(self): def test_two_goal_recovery_in_p3(self):
# Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30
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
def test_three_goal_recovery_in_p3(self):
# Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30
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
def test_partial_recovery_in_p3(self):
# Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) 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) game = make_game(home_score=2, away_score=3, period=3)
assert get_comeback_bonus(game) == 75 assert get_comeback_bonus(game) == 60 # 60*1.0
def test_bonus_when_trailing_away_scores_p2(self): def test_bonus_persists_across_polls(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (3, 1) # Set up a 2-goal recovery, then call again — bonus stays
game = make_game(home_score=3, away_score=2, period=2) app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
assert get_comeback_bonus(game) == 50 app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
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
def test_no_bonus_when_diff_still_above_2(self): def test_period_multiplier_p1_lower(self):
# 5-1 → 5-2: diff 4→3, still > 2, no bonus # P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 5) app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
game = make_game(home_score=2, away_score=5) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
assert get_comeback_bonus(game) == 0 game = make_game(home_score=2, away_score=2, period=1)
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
def test_bonus_fires_once_then_clears(self): def test_ot_multiplier_higher(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) # OT: base=60, period_mult=1.2, tie=30
game = make_game(home_score=2, away_score=3, period=3) app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2)
assert get_comeback_bonus(game) == 75 app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
# Same score on next call — cache now (2, 3), no change game = make_game(home_score=2, away_score=2, period=4)
assert get_comeback_bonus(game) == 0 assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30)
def test_no_bonus_in_intermission(self): def test_no_bonus_in_intermission(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
game = make_game(home_score=2, away_score=3, in_intermission=True) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, in_intermission=True)
assert get_comeback_bonus(game) == 0 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): def test_no_bonus_for_non_live_state(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 3)
game = make_game(game_state="OFF", home_score=2, away_score=3) app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
game = make_game(game_state="OFF", home_score=3, away_score=3)
assert get_comeback_bonus(game) == 0 assert get_comeback_bonus(game) == 0
def test_ot_comeback_bonus_is_100(self): def test_tracker_builds_max_deficit_over_time(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) # Simulate progressive scoring: 0-1, 0-2, 1-2, 2-2
game = make_game(home_score=2, away_score=3, period=4) key = ("Maple Leafs", "Bruins")
assert get_comeback_bonus(game) == 100 get_comeback_bonus(make_game(home_score=0, away_score=1, period=1))
get_comeback_bonus(make_game(home_score=0, away_score=2, period=1))
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
class TestCalculateGameImportance: class TestCalculateGameImportance:
@@ -457,9 +643,36 @@ class TestCalculateGameImportance:
"wildcard_sequence": wc, "wildcard_sequence": wc,
} }
def test_returns_zero_for_playoff_game(self): def test_playoff_game_gets_fallback_importance(self):
game = make_game(game_type=3) game = make_game(game_type=3)
assert calculate_game_importance(game) == 0 assert calculate_game_importance(game) == 100
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
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
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
def test_playoff_later_rounds_more_important(self):
series = {"topSeedWins": 2, "bottomSeedWins": 2}
r1 = make_game(game_type=3, series_status={**series, "round": 1})
r3 = make_game(game_type=3, series_status={**series, "round": 3})
assert calculate_game_importance(r3) > calculate_game_importance(r1)
def test_returns_zero_for_final_game(self): def test_returns_zero_for_final_game(self):
game = make_game(game_state="OFF") game = make_game(game_state="OFF")