feat: overhaul hype scoring algorithm
All checks were successful
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s

- Period base: playoff OT escalates indefinitely (P4=600, P5=750…),
  reg season P4=5-min OT (600), P5=shootout (700)
- Time priority range increased (max ~300 vs old ~120), calibrated
  to period length so 5-min reg season OT reads correctly
- Matchup multiplier inverted: higher period = less weight (any OT
  is exciting regardless of teams)
- Replace unconditional score_total with closeness bonus: rewards
  tight games regardless of goal volume (5-4 == 1-0 at same diff)
- Power play bonus: 30 (P1/P2) → 50/100/150 (P3 by time) → 200 (OT)
- Comeback bonus: one-time pulse (+50/75/100 by period) when trailing
  team scores to within 2 goals; keyed on team names, clears after
  firing, skips intermission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:46:10 -04:00
parent 62afc1001e
commit e2d2c7dd97
3 changed files with 304 additions and 34 deletions

View File

@@ -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