From 69097cb2584a6de27d5c269fa414fa9f7bc960e1 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 16 Jun 2026 08:12:58 -0700 Subject: [PATCH] feat(worldcup): enhance goal tracking and scoring details in ESPNClient and WorldCupLiveService - Updated ESPNClient to include a detailed list of scoring plays in the live match state, capturing clock, scorer, team ID, own goal, and penalty information. - Modified WorldCupLiveService to track and announce goals, including formatting for new scorers and handling multiple goals since the last poll. - Added unit tests to validate goal tracking functionality and ensure accurate announcements during matches. --- modules/clients/espn_client.py | 24 ++++++++++-- modules/service_plugins/worldcup_service.py | 35 ++++++++++++++++-- tests/test_worldcup_service.py | 41 ++++++++++++++++++++- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/modules/clients/espn_client.py b/modules/clients/espn_client.py index 9ec7c94..a21b783 100644 --- a/modules/clients/espn_client.py +++ b/modules/clients/espn_client.py @@ -157,9 +157,12 @@ class ESPNClient: """Return current per-match live state for the scoreboard (today's matches). Each item: {id, home_id, away_id, home_name, away_name, home_score, away_score, - status, clock, home_pen, away_pen}. Names are full team display names. Penalty - fields are None unless a shootout score is present. Used by the live-score service - to detect kickoff / goal / half-time / full-time transitions. Returns [] on error. + status, clock, home_pen, away_pen, goals}. Names are full team display names. + Penalty fields are None unless a shootout score is present. ``goals`` is the + chronological list of scoring plays, each {clock, scorer, team_id, own_goal, + penalty} (penalty shootout kicks excluded). Used by the live-score service to + detect kickoff / goal / half-time / full-time transitions and name scorers. + Returns [] on error. cache_bust appends a unique query param to bypass ESPN's edge cache, used when a fastcast push signals a change so the REST snapshot reflects it immediately. @@ -182,6 +185,20 @@ class ESPNClient: home = next((c for c in competitors if c.get('homeAway') == 'home'), competitors[0]) away = next((c for c in competitors if c.get('homeAway') == 'away'), competitors[1]) status_obj = competition.get('status', event.get('status', {})) + + goals = [] + for det in competition.get('details', []): + if not det.get('scoringPlay') or det.get('shootout'): + continue + athletes = det.get('athletesInvolved') or [] + goals.append({ + 'clock': det.get('clock', {}).get('displayValue', ''), + 'scorer': athletes[0].get('displayName', '') if athletes else '', + 'team_id': str(det.get('team', {}).get('id', '')), + 'own_goal': bool(det.get('ownGoal')), + 'penalty': bool(det.get('penaltyKick')), + }) + states.append({ 'id': str(event.get('id', '')), 'home_id': str(home.get('team', {}).get('id', '')), @@ -194,6 +211,7 @@ class ESPNClient: 'clock': status_obj.get('displayClock', ''), 'home_pen': self.extract_shootout_score(home), 'away_pen': self.extract_shootout_score(away), + 'goals': goals, }) return states except Exception as e: diff --git a/modules/service_plugins/worldcup_service.py b/modules/service_plugins/worldcup_service.py index 91b0f1e..6bda5da 100644 --- a/modules/service_plugins/worldcup_service.py +++ b/modules/service_plugins/worldcup_service.py @@ -189,7 +189,7 @@ class WorldCupLiveService(BaseServicePlugin): changed = False for m in matches: eid = m["id"] - cur = {"h": m["home_score"], "a": m["away_score"], "s": m["status"]} + cur = {"h": m["home_score"], "a": m["away_score"], "s": m["status"], "g": len(m.get("goals") or [])} prev = self._state.get(eid) if prev is None: # First sight: seed silently so in-progress matches aren't back-announced. @@ -226,11 +226,38 @@ class WorldCupLiveService(BaseServicePlugin): return self._score_line(label, m, cur, "full-time") if self.announce_halftime and cs == HT_STATUS and ps != HT_STATUS: return self._score_line(label, m, cur, "half-time") - if self.announce_goals and (cur["h"], cur["a"]) != (prev["h"], prev["a"]) and cs in PLAYING_STATUSES: - clock = (m.get("clock") or "").strip() - return self._score_line(label, m, cur, clock or None) + if ( + self.announce_goals + and (cur["h"] + cur["a"]) > (prev["h"] + prev["a"]) # a goal was added (not a VAR removal) + and cs in PLAYING_STATUSES + ): + return self._score_line(label, m, cur, self._goal_tag(prev, m)) return None + def _goal_tag(self, prev: dict, m: dict) -> Optional[str]: + """Build the parenthetical tag for a goal: scorer(s) since last poll, else the clock.""" + goals = m.get("goals") or [] + # Only name scorers when we have a known prior count to diff against; otherwise the + # whole list would look "new" (e.g. on the first poll after an upgrade). + if "g" in prev and len(goals) >= prev["g"]: + new_goals = goals[prev["g"]:] + if new_goals: + return "; ".join(self._fmt_goal(g) for g in new_goals) + clock = (m.get("clock") or "").strip() + return clock or None + + @staticmethod + def _fmt_goal(goal: dict) -> str: + """Format one scoring play, e.g. \"64' Mohammad Mohebbi\" or \"7' Elijah Just, pen\".""" + clock = (goal.get("clock") or "").strip() + name = goal.get("scorer") or "?" + text = f"{clock} {name}".strip() + if goal.get("own_goal"): + text += ", OG" + elif goal.get("penalty"): + text += ", pen" + return text + def _score_line(self, label: str, m: dict, cur: dict, tag: Optional[str]) -> str: line = f"{label}: {m['home_name']} {cur['h']}, {m['away_name']} {cur['a']}" if tag == "full-time" and m.get("status") == "STATUS_FINAL_PEN" and m.get("home_pen") is not None: diff --git a/tests/test_worldcup_service.py b/tests/test_worldcup_service.py index 3df4547..22874e6 100644 --- a/tests/test_worldcup_service.py +++ b/tests/test_worldcup_service.py @@ -35,14 +35,18 @@ def _svc(**overrides): def _match(eid="1", h=0, a=0, status="STATUS_SCHEDULED", clock="", hp=None, ap=None, - hid="100", aid="200", hn="Côte d'Ivoire", an="Ecuador"): + hid="100", aid="200", hn="Côte d'Ivoire", an="Ecuador", goals=None): return { "id": eid, "home_id": hid, "away_id": aid, "home_name": hn, "away_name": an, "home_score": h, "away_score": a, "status": status, "clock": clock, - "home_pen": hp, "away_pen": ap, + "home_pen": hp, "away_pen": ap, "goals": goals or [], } +def _goal(clock, scorer, own_goal=False, penalty=False, team_id="100"): + return {"clock": clock, "scorer": scorer, "team_id": team_id, "own_goal": own_goal, "penalty": penalty} + + GROUPS = {"100": "Group E", "200": "Group E"} @@ -63,6 +67,39 @@ class TestDetect: m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="23'") assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (23')" + def test_goal_names_new_scorer(self): + svc = _svc() + prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0} + m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="7'", + goals=[_goal("7'", "Elijah Just")]) + assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (7' Elijah Just)" + + def test_goal_penalty_annotation(self): + svc = _svc() + prev = {"h": 1, "a": 0, "s": "STATUS_SECOND_HALF", "g": 1} + m = _match(h=1, a=1, status="STATUS_SECOND_HALF", clock="64'", + goals=[_goal("7'", "Elijah Just"), _goal("64'", "Ramin Rezaeian", penalty=True, team_id="200")]) + assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 1 (64' Ramin Rezaeian, pen)" + + def test_goal_multiple_since_last_poll(self): + svc = _svc() + prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0} + m = _match(h=2, a=0, status="STATUS_FIRST_HALF", clock="20'", + goals=[_goal("7'", "A. One"), _goal("20'", "B. Two")]) + assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 2, Ecuador 0 (7' A. One; 20' B. Two)" + + def test_goal_falls_back_to_clock_without_details(self): + svc = _svc() + prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF", "g": 0} + m = _match(h=1, a=0, status="STATUS_FIRST_HALF", clock="12'") # no goals payload + assert self._detect(svc, prev, m) == "Group E: Côte d'Ivoire 1, Ecuador 0 (12')" + + def test_var_removed_goal_not_announced(self): + svc = _svc() + prev = {"h": 1, "a": 0, "s": "STATUS_FIRST_HALF", "g": 1} + m = _match(h=0, a=0, status="STATUS_FIRST_HALF", clock="15'", goals=[]) + assert self._detect(svc, prev, m) is None + def test_halftime(self): svc = _svc() prev = {"h": 0, "a": 0, "s": "STATUS_FIRST_HALF"}