diff --git a/custom_components/quizify/analytics.py b/custom_components/quizify/analytics.py index faf6fc3..2a856ac 100644 --- a/custom_components/quizify/analytics.py +++ b/custom_components/quizify/analytics.py @@ -63,6 +63,16 @@ class PlayerStanding(TypedDict): total_score: int +class HeadToHead(TypedDict): + """The duel between two returning players, for the TV lobby (#613).""" + + left: str + right: str + left_wins: int + right_wins: int + games: int + + class EveningTally(TypedDict): """Tonight's running score across several games (#612).""" @@ -388,6 +398,54 @@ async def _prune_old_records(self) -> None: len(games), ) + def get_head_to_head(self, present: list[str]) -> HeadToHead | None: + """The duel between the two present players who have met most often. + + "Met" means both appeared in the same recorded game; the winner of that + meeting is whoever scored higher, which is not the same as the game's + overall winner — a duel is between these two, not against the room. + + Scope is the detailed game history, which prunes at RETENTION_DAYS / + MAX_DETAILED_RECORDS. So this is honestly "recent", not all-time, and + the caller labels it accordingly. A pairwise rollup in the unpruned map + would extend it, at a cost that grows quadratically with players — not + worth it for a lobby line. + + Returns ``None`` unless a pair has met at least twice: a single shared + game makes a "1–0" that reads like a record and is a coincidence. + """ + names = [n for n in dict.fromkeys(present) if n] + if len(names) < 2: + return None + + best: HeadToHead | None = None + for i, left in enumerate(names): + for right in names[i + 1 :]: + left_wins = right_wins = met = 0 + for game in self._data.get("games", []): + scores = game.get("player_scores") or {} + if left not in scores or right not in scores: + continue + met += 1 + if scores[left] > scores[right]: + left_wins += 1 + elif scores[right] > scores[left]: + right_wins += 1 + # A draw counts as a meeting and goes to nobody. + if met < 2: + continue + # Most-met pair wins; ties fall to the alphabetically first + # pair so the TV does not flicker between equals on rejoin. + if best is None or met > best["games"]: + best = { + "left": left, + "right": right, + "left_wins": left_wins, + "right_wins": right_wins, + "games": met, + } + return best + # More than this between two games and they belong to different evenings # (#612). A calendar day was the obvious alternative and is worse: it cuts # every party that runs past midnight, which is exactly the party this line diff --git a/custom_components/quizify/server/websocket.py b/custom_components/quizify/server/websocket.py index 1b019fe..db69e90 100644 --- a/custom_components/quizify/server/websocket.py +++ b/custom_components/quizify/server/websocket.py @@ -1493,6 +1493,7 @@ async def _flush_roster_after_window(self) -> None: # too, or the lobby keeps showing a team nobody is in. "teams": gs.team_registry.to_list(), }) + await self._send_head_to_head(gs) # A roster change that landed DURING the broadcast set _roster_dirty # again; _ensure_roster_flush won't start a new task while this one is @@ -1550,6 +1551,33 @@ def _cancel_progress_flush(self) -> None: self._progress_flush_task = None self._progress_dirty = False + async def _send_head_to_head(self, game_state: QuizifyGameState) -> None: + """Show the TV the duel between the two present regulars (#613). + + Lobby only: a rivalry line belongs before the game, and mid-game it + would compete with the question for the same screen. + + To the TV and admin, never to the phones — this is a deliberate + reversal of #371, which sends each player only their OWN standing. + Putting two people's record in front of the room is a different call, + made knowingly, and the phones stay out of it. + """ + if game_state.phase != GamePhase.LOBBY: + return + analytics = game_state.stats_service + if analytics is None: + return + duel = analytics.get_head_to_head( + [p.name for p in game_state.get_players()] + ) + if duel is None: + # Fewer than two present, or no pair has met twice. Silence beats a + # "1-0" that reads like a record and is a coincidence. + return + await self._conn.broadcast_to_admins_and_dashboards( + {"type": "head_to_head", **duel} + ) + def _cancel_roster_flush(self) -> None: """Cancel any pending roster-flush task (called on cleanup).""" if self._roster_flush_task is not None: diff --git a/custom_components/quizify/www/dashboard.html b/custom_components/quizify/www/dashboard.html index 058df10..3417e6b 100644 --- a/custom_components/quizify/www/dashboard.html +++ b/custom_components/quizify/www/dashboard.html @@ -154,6 +154,29 @@ margin-top: 4px; } + .dashboard-h2h { + margin-top: 14px; + text-align: center; + font-size: clamp(1rem, 1.7vw, 1.35rem); + color: var(--dash-text-white); + font-variant-numeric: tabular-nums; + } + + .dashboard-h2h .h2h-label { + display: block; + font-size: 0.72em; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--dash-text-muted); + margin-bottom: 3px; + } + + .dashboard-h2h .h2h-scope { + font-size: 0.68em; + color: var(--dash-text-muted); + margin-left: 8px; + } + .dashboard-evening-tally { font-size: clamp(1rem, 1.7vw, 1.4rem); color: var(--dash-text-white); @@ -1344,6 +1367,11 @@

+ + @@ -1502,6 +1530,7 @@

timerFill: document.getElementById('timer-fill'), answerProgress: document.getElementById('answer-progress'), eveningTally: document.getElementById('evening-tally'), + lobbyH2h: document.getElementById('lobby-h2h'), questionCategory: document.getElementById('question-category'), questionImage: document.getElementById('question-image'), questionMedia: document.getElementById('question-media'), @@ -1793,6 +1822,9 @@

case 'evening_tally': handleEveningTally(msg); break; + case 'head_to_head': + handleHeadToHead(msg); + break; case 'finale': case 'game_ended': handleFinale(msg); @@ -2114,6 +2146,25 @@

}).join(''); } + function handleHeadToHead(msg) { + if (!els.lobbyH2h) return; + if (!msg || !msg.left || !msg.right) { + els.lobbyH2h.classList.add('hidden'); + return; + } + var t = (window.QuizifyI18n && window.QuizifyI18n.t) + || function (k) { return k; }; + // "last 90 days" is stated, not implied: the detailed history + // prunes at RETENTION_DAYS, so calling this an all-time record + // would be a claim the data cannot support. + els.lobbyH2h.innerHTML = + '' + escapeHtml(t('dashboard.h2hLabel')) + '' + + escapeHtml(msg.left) + ' ' + msg.left_wins + + ' – ' + msg.right_wins + ' ' + escapeHtml(msg.right) + + '' + escapeHtml(t('dashboard.h2hRecent')) + ''; + els.lobbyH2h.classList.remove('hidden'); + } + function handleEveningTally(msg) { if (!els.eveningTally) return; var leaders = (msg && msg.leaders) || []; diff --git a/custom_components/quizify/www/i18n/de.json b/custom_components/quizify/www/i18n/de.json index 3fb0b39..41e50f0 100644 --- a/custom_components/quizify/www/i18n/de.json +++ b/custom_components/quizify/www/i18n/de.json @@ -554,6 +554,8 @@ "runnersUp": "Außerdem dabei", "leaderboardMore": "+{count} weitere", "tonightLabel": "Heute Abend", + "h2hLabel": "Duell", + "h2hRecent": "letzte 90 Tage", "tonightGames": "{games} Spiele", "tonightWins": "{wins} Siege", "tonightWinsOne": "1 Sieg" diff --git a/custom_components/quizify/www/i18n/en.json b/custom_components/quizify/www/i18n/en.json index 5749927..6c1aafb 100644 --- a/custom_components/quizify/www/i18n/en.json +++ b/custom_components/quizify/www/i18n/en.json @@ -554,6 +554,8 @@ "runnersUp": "Also playing", "leaderboardMore": "+{count} more", "tonightLabel": "Tonight", + "h2hLabel": "Head to head", + "h2hRecent": "last 90 days", "tonightGames": "{games} games", "tonightWins": "{wins} wins", "tonightWinsOne": "1 win" diff --git a/custom_components/quizify/www/i18n/es.json b/custom_components/quizify/www/i18n/es.json index 0c22fb9..500979d 100644 --- a/custom_components/quizify/www/i18n/es.json +++ b/custom_components/quizify/www/i18n/es.json @@ -554,6 +554,8 @@ "runnersUp": "También juegan", "leaderboardMore": "+{count} más", "tonightLabel": "Esta noche", + "h2hLabel": "Duelo", + "h2hRecent": "últimos 90 días", "tonightGames": "{games} partidas", "tonightWins": "{wins} victorias", "tonightWinsOne": "1 victoria" diff --git a/tests/test_head_to_head_613.py b/tests/test_head_to_head_613.py new file mode 100644 index 0000000..27b8580 --- /dev/null +++ b/tests/test_head_to_head_613.py @@ -0,0 +1,161 @@ +"""The duel between two returning players, on the TV lobby (issue #613). + +The code asked for this itself: `get_player_standing` justifies the lobby line +by saying it is "supposed to start a rivalry", and only the solo view existed. + +**This is a deliberate reversal of #371, not an extension of it.** That issue +sends each player only their OWN standing; #624 kept that posture this +afternoon. A head-to-head on the TV puts two people's record in front of the +whole room. Markus made that call explicitly — at home it is the fun, at a party +with colleagues "Ben 0–5" can be the moment someone stops playing. The phones +therefore stay out of it: TV and admin only. + +Scope is the detailed history, which prunes at RETENTION_DAYS / +MAX_DETAILED_RECORDS, so the line says "last 90 days" rather than implying an +all-time record it cannot support. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_CC = _REPO_ROOT / "custom_components" / "quizify" +_WWW = _CC / "www" + + +class _Analytics: + def __init__(self, games: list[dict]) -> None: + from custom_components.quizify.analytics import QuizifyAnalytics + + self._impl = QuizifyAnalytics.__new__(QuizifyAnalytics) + self._impl._data = {"games": games} + + def duel(self, present: list[str]): + return self._impl.get_head_to_head(present) + + +def _game(scores: dict[str, int]) -> dict: + return {"player_scores": scores} + + +def test_a_single_shared_game_is_not_a_record() -> None: + """"1–0" after one game reads like a record and is a coincidence.""" + games = [_game({"Anna": 10, "Ben": 8})] + + assert _Analytics(games).duel(["Anna", "Ben"]) is None + + +def test_two_meetings_produce_the_duel() -> None: + games = [ + _game({"Anna": 10, "Ben": 8}), + _game({"Anna": 5, "Ben": 9}), + _game({"Anna": 7, "Ben": 3}), + ] + + duel = _Analytics(games).duel(["Anna", "Ben"]) + + assert duel == { + "left": "Anna", + "right": "Ben", + "left_wins": 2, + "right_wins": 1, + "games": 3, + } + + +def test_the_winner_of_a_meeting_is_between_those_two() -> None: + """Not the game's overall winner. + + Cara topping the table does not settle anything between Anna and Ben. + """ + games = [ + _game({"Anna": 4, "Ben": 3, "Cara": 99}), + _game({"Anna": 6, "Ben": 2, "Cara": 99}), + ] + + duel = _Analytics(games).duel(["Anna", "Ben"]) + + assert duel is not None + assert (duel["left_wins"], duel["right_wins"]) == (2, 0) + + +def test_a_draw_counts_as_a_meeting_and_goes_to_nobody() -> None: + games = [ + _game({"Anna": 5, "Ben": 5}), + _game({"Anna": 7, "Ben": 3}), + ] + + duel = _Analytics(games).duel(["Anna", "Ben"]) + + assert duel is not None + assert duel["games"] == 2 + assert (duel["left_wins"], duel["right_wins"]) == (1, 0) + + +def test_the_most_met_pair_wins_when_several_are_present() -> None: + """Three regulars in the lobby is one duel, not three.""" + games = [ + _game({"Anna": 9, "Ben": 4}), + _game({"Anna": 8, "Ben": 5}), + _game({"Anna": 7, "Ben": 6}), + _game({"Anna": 3, "Cara": 9}), + _game({"Anna": 2, "Cara": 8}), + ] + + duel = _Analytics(games).duel(["Anna", "Ben", "Cara"]) + + assert duel is not None + assert {duel["left"], duel["right"]} == {"Anna", "Ben"} + assert duel["games"] == 3 + + +def test_games_only_one_of_them_played_are_ignored() -> None: + games = [ + _game({"Anna": 10}), + _game({"Ben": 10}), + _game({"Anna": 10, "Ben": 1}), + ] + + assert _Analytics(games).duel(["Anna", "Ben"]) is None + + +def test_a_lobby_of_one_has_no_duel() -> None: + assert _Analytics([_game({"Anna": 1, "Ben": 2})]).duel(["Anna"]) is None + + +def test_it_is_sent_in_the_lobby_only_and_never_to_phones() -> None: + """The reversal of #371 is bounded: the room sees it, the phones do not.""" + source = (_CC / "server" / "websocket.py").read_text("utf-8") + body = source.split("async def _send_head_to_head", 1)[1].split( + "\n def ", 1 + )[0] + body = re.sub(r'""".*?"""', "", body, flags=re.S) + + assert "phase != GamePhase.LOBBY" in body + assert "broadcast_to_admins_and_dashboards" in body + # A plain broadcast would reach every phone — the thing #371 avoided. + assert "self._conn.broadcast(" not in body + + +def test_the_tv_states_the_ninety_day_scope() -> None: + """The detailed history prunes, so calling it all-time would be a claim the + data cannot support.""" + html = (_WWW / "dashboard.html").read_text("utf-8") + + assert "dashboard.h2hRecent" in html + for code in ("de", "en", "es"): + bundle = json.loads((_WWW / "i18n" / f"{code}.json").read_text("utf-8")) + dash = bundle["dashboard"] + assert dash.get("h2hLabel") + assert "90" in dash["h2hRecent"] + + +def test_the_names_are_escaped() -> None: + html = (_WWW / "dashboard.html").read_text("utf-8") + body = html.split("function handleHeadToHead(", 1)[1].split("\n }", 1)[0] + + assert "escapeHtml(msg.left)" in body + assert "escapeHtml(msg.right)" in body