Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions custom_components/quizify/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions custom_components/quizify/server/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions custom_components/quizify/www/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1344,6 +1367,11 @@ <h1 class="wordmark" style="font-size:3rem;">
</div>
</div>
<div id="lobby-players" class="dashboard-player-list"></div>
<!-- #613: the duel between the two present regulars. Lobby only,
TV only — #371 sends each player their OWN standing, and
putting two people's record in front of the room is a
deliberate reversal of that, not an extension. -->
<div id="lobby-h2h" class="dashboard-h2h hidden"></div>
</div>
</div>

Expand Down Expand Up @@ -1502,6 +1530,7 @@ <h1 class="wordmark" style="font-size:3rem;">
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'),
Expand Down Expand Up @@ -1793,6 +1822,9 @@ <h1 class="wordmark" style="font-size:3rem;">
case 'evening_tally':
handleEveningTally(msg);
break;
case 'head_to_head':
handleHeadToHead(msg);
break;
case 'finale':
case 'game_ended':
handleFinale(msg);
Expand Down Expand Up @@ -2114,6 +2146,25 @@ <h1 class="wordmark" style="font-size:3rem;">
}).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 =
'<span class="h2h-label">' + escapeHtml(t('dashboard.h2hLabel')) + '</span>'
+ escapeHtml(msg.left) + ' ' + msg.left_wins
+ ' – ' + msg.right_wins + ' ' + escapeHtml(msg.right)
+ '<span class="h2h-scope">' + escapeHtml(t('dashboard.h2hRecent')) + '</span>';
els.lobbyH2h.classList.remove('hidden');
}

function handleEveningTally(msg) {
if (!els.eveningTally) return;
var leaders = (msg && msg.leaders) || [];
Expand Down
2 changes: 2 additions & 0 deletions custom_components/quizify/www/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions custom_components/quizify/www/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions custom_components/quizify/www/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
161 changes: 161 additions & 0 deletions tests/test_head_to_head_613.py
Original file line number Diff line number Diff line change
@@ -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
Loading