feat: overhaul hype score algorithm with 9 hockey-driven improvements
- 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:
206
app/games.py
206
app/games.py
@@ -10,9 +10,11 @@ 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]] = {}
|
||||
|
||||
# Maps (home_team_name, away_team_name) -> max score differential seen
|
||||
_comeback_tracker: dict[tuple[str, str], int] = {}
|
||||
|
||||
|
||||
def format_record(record):
|
||||
if record == "N/A":
|
||||
@@ -63,6 +65,7 @@ def parse_games(scoreboard_data):
|
||||
"matchup_bonus": priority_comps["matchup_bonus"],
|
||||
"closeness": priority_comps["closeness"],
|
||||
"power_play": priority_comps["power_play"],
|
||||
"empty_net": priority_comps["empty_net"],
|
||||
"comeback": comeback,
|
||||
"importance": importance_comps["total"],
|
||||
"importance_season_weight": importance_comps["season_weight"],
|
||||
@@ -100,10 +103,11 @@ def parse_games(scoreboard_data):
|
||||
|
||||
|
||||
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.
|
||||
"""Persistent comeback bonus that scales with deficit recovered.
|
||||
|
||||
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.
|
||||
"""
|
||||
if game["gameState"] not in ("LIVE", "CRIT"):
|
||||
return 0
|
||||
@@ -116,33 +120,25 @@ def get_comeback_bonus(game):
|
||||
|
||||
home_score = game["homeTeam"]["score"]
|
||||
away_score = game["awayTeam"]["score"]
|
||||
current_diff = abs(home_score - away_score)
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
|
||||
bonus = 0
|
||||
tracker_max = _comeback_tracker.get(key, 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
|
||||
|
||||
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
|
||||
tracker_max = max(tracker_max, prev_diff)
|
||||
_comeback_tracker[key] = tracker_max
|
||||
_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):
|
||||
@@ -196,6 +192,17 @@ def get_game_outcome(game, game_state):
|
||||
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):
|
||||
"""Return a dict of all priority components plus the final total."""
|
||||
_zero = {
|
||||
@@ -204,6 +211,7 @@ def _priority_components(game):
|
||||
"matchup_bonus": 0,
|
||||
"closeness": 0,
|
||||
"power_play": 0,
|
||||
"empty_net": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
@@ -238,21 +246,48 @@ def _priority_components(game):
|
||||
33 - away_standings["league_l10_sequence"]
|
||||
)
|
||||
# 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
|
||||
|
||||
# ── 4. Score-differential penalty ────────────────────────────────────
|
||||
if score_difference > 3:
|
||||
score_differential_adjustment = 500
|
||||
elif score_difference > 2:
|
||||
score_differential_adjustment = 350
|
||||
elif score_difference > 1:
|
||||
score_differential_adjustment = 100
|
||||
else:
|
||||
score_differential_adjustment = 0
|
||||
# ── Shootout: flat priority, no time component (rounds, not clock) ───
|
||||
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)
|
||||
return {
|
||||
"base": so_base,
|
||||
"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:
|
||||
score_differential_adjustment *= 2
|
||||
# ── 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
|
||||
|
||||
base_priority -= score_differential_adjustment
|
||||
|
||||
@@ -272,9 +307,14 @@ def _priority_components(game):
|
||||
# ── 6. Closeness bonus ───────────────────────────────────────────────
|
||||
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_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 ───────────────────────────────────────────────
|
||||
pp_bonus = 0
|
||||
@@ -282,29 +322,49 @@ def _priority_components(game):
|
||||
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
|
||||
if period >= 4:
|
||||
pp_bonus = 200
|
||||
pp_bonus = int(200 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 300:
|
||||
pp_bonus = 150
|
||||
pp_bonus = int(150 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 720:
|
||||
pp_bonus = 100
|
||||
pp_bonus = int(100 * advantage_mult)
|
||||
elif period == 3:
|
||||
pp_bonus = 50
|
||||
pp_bonus = int(50 * advantage_mult)
|
||||
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(
|
||||
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
|
||||
"closeness: %s, pp: %s",
|
||||
"closeness: %s, pp: %s, en: %s",
|
||||
base_priority,
|
||||
time_priority,
|
||||
matchup_bonus,
|
||||
closeness_bonus,
|
||||
pp_bonus,
|
||||
en_bonus,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -317,6 +377,7 @@ def _priority_components(game):
|
||||
"matchup_bonus": int(matchup_bonus),
|
||||
"closeness": closeness_bonus,
|
||||
"power_play": pp_bonus,
|
||||
"empty_net": en_bonus,
|
||||
"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):
|
||||
"""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}
|
||||
|
||||
if game.get("gameType", 2) != 2:
|
||||
return _zero
|
||||
if game["gameState"] in ("FINAL", "OFF"):
|
||||
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"])
|
||||
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||
|
||||
Reference in New Issue
Block a user