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

@@ -1,6 +1,7 @@
import app.games
from tests.conftest import make_game
from app.games import (
_get_man_advantage,
calculate_game_importance,
calculate_game_priority,
convert_game_state,
@@ -147,6 +148,146 @@ class TestGetPowerPlayInfo:
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:
def _live_game(
self,
@@ -239,14 +380,31 @@ class TestCalculateGamePriority:
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):
def test_shootout_ranks_below_late_ot(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)
ot = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
# 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):
mocker.patch(
@@ -382,6 +540,7 @@ class TestCalculateGamePriority:
class TestGetComebackBonus:
def setup_method(self):
app.games._score_cache.clear()
app.games._comeback_tracker.clear()
def test_returns_zero_on_first_call(self):
game = make_game(home_score=2, away_score=1)
@@ -392,50 +551,77 @@ class TestGetComebackBonus:
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
def test_no_bonus_for_one_goal_swing(self):
# 1-goal swings are normal hockey, no bonus
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
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._comeback_tracker[("Maple Leafs", "Bruins")] = 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):
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_bonus_persists_across_polls(self):
# Set up a 2-goal recovery, then call again — bonus stays
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)
first = get_comeback_bonus(game)
second = get_comeback_bonus(game)
assert first == second == 90
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_period_multiplier_p1_lower(self):
# P1 recovery is less dramatic: base=60, period_mult=0.6, 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=1)
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
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_ot_multiplier_higher(self):
# OT: base=60, period_mult=1.2, tie=30
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)
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)
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, 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)
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 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
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
def test_tracker_builds_max_deficit_over_time(self):
# Simulate progressive scoring: 0-1, 0-2, 1-2, 2-2
key = ("Maple Leafs", "Bruins")
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:
@@ -457,9 +643,36 @@ class TestCalculateGameImportance:
"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)
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):
game = make_game(game_state="OFF")